Skip to content

fix(tx-builder): evaluate the final transaction and settle fee and change to a fixed point - #861

Merged
lisicky merged 2 commits into
MeshJS:feat/tx-builder-fetch-efficiencyfrom
nau:pr-b/exact-balancing
Nov 1, 2026
Merged

lisicky merged 2 commits into
MeshJS:feat/tx-builder-fetch-efficiencyfrom
nau:pr-b/exact-balancing

Conversation

@nau

@nau nau commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Builds on #849: this PR targets its branch, so it shows only its own two commits, and should merge after it. Supersedes #851.

Two points first

  • Cost: one more evaluator call per complete() with Plutus scripts. It comes on top of perf: cache provider calls and reduce fetcher and evaluator calls in MeshTxBuilder #849, which reduces calls elsewhere. For a remote evaluator (Blockfrost, Ogmios), that is one more HTTP call. Builds without an evaluator or without Plutus scripts make no extra call. When the selection has already priced the final transaction (the common case: scripts that do not read the fee or the change), the check is exactly that one call.
  • Why after selection, not inside it. Selection prices a skeleton: the cardano-sdk selector asks for the cost before a fee exists, so the skeleton carries a 1 000 ADA placeholder fee and change that still holds the fee. Only the built transaction has the real fee and change, so only the built transaction can be checked. The same rule (evaluate, then fee, then change, until nothing changes) also fits the assembly step of the new part builder in feat: add transaction part builders for inputs, collateral and outputs #854–feat: add vote and proposal part builders #857, and we can help there.

Problem

As @lisicky said on #851, balancing belongs in coin selection, and computeMinimumCost already re-evaluates the redeemers on a cloned builder with the change outputs present. Two gaps remain:

  1. The skeleton is evaluated with the placeholder fee, and its change still holds the fee. A script that reads the fee or the change output runs against other values than on chain.
  2. Nothing evaluates the final body, so nothing checks that its redeemer units, fee and change agree.

Repro: a V3 spending validator \ctx -> if txInfoFee < 100 ADA then loop 1000 else (), spent with MeshTxBuilder + ScalusEmulator as the evaluator:

selector declared ex-units ex-units the final tx needs fee min fee of the final tx
CardanoSdkInputSelector (default), before 5 322 / 1 670 632 2 311 524 / 599 741 014 175 301 175 301
LargestFirstInputSelector, before 7 000 000 / 3 000 000 000 (defaults) 2 311 524 / 599 741 014 175 301 795 161
both, after 2 311 524 / 599 741 014 2 311 524 / 599 741 014 351 578 351 578

Before the change, the default selector's tx fails phase 2: the fee matches the declared units, but those units are about 400 times too low. The largest-first selector never returned the redeemers it evaluated, so its transactions declared the default ex-units with a fee priced for the evaluated ones. The first commit fixes both rows.

Commits

1. fix(tx-builder): evaluate the final transaction and settle fee and change to a fixed point

  • complete(): after updateByTxPrototype(..., true), settleFeeAndChange evaluates the final body and recomputes the fee (getActualFee). It moves the difference into a change output and repeats until one more pass changes neither the fee nor the units. It stops after 20 passes with an error, and never returns a transaction it could not settle. Evaluation errors carry the same "Evaluate redeemers failed: ..." prefix as in selection.
  • Fee rule: when there is a change output, the first pass sets the fee to the minimum of the final transaction, so a selection estimate that was too high goes back to the change. After the first pass the fee only rises. Without a change output the fee never goes below the selection's estimate, because there is nothing to refund it to.
  • Why the fee only rises: Scalus's TxBuilder loop floors the fee at the initial fee. That floor is not enough here. A script that is cheaper with a higher fee (fee < X ? expensive : cheap, with X between the two fees) would flip between two fees until the cap. A fee that only rises after the first pass settles it, at the cost of a fee a little above the minimum in that case.
  • When the selection splits the change (a token bundle over maxValSize), the change output with the most lovelace takes the difference. The cardano-sdk selector puts the excess lovelace in the first split output, largest-first in the last one; the others hold their minimum.
  • setFee is kept: only the units are refreshed.
  • If the change output cannot absorb the difference (below min UTxO, or no change output while the fee must rise), it throws with the amounts. A retry of the selection would rebuild the same skeleton, because computeMinimumCost cannot see the final units.
  • After settling, the collateral is checked against the ledger rule collateral * 100 >= fee * collateralPercent. The collateral is totalCollateral when it is set, else the lovelace of the collateral inputs. It throws with the numbers.
  • CardanoSdkInputSelector: the placeholder stays, with a comment. For the size estimate it is exact: it encodes in 5 bytes, like any fee from 0.065 to 4 294 ADA.

2. perf(tx-builder): price largest-first selection passes with the computed fee

  • Passes after the first now price the skeleton with the fee they already computed, whose change already pays it, and the selector returns the redeemers it evaluated. The first commit already makes the units right; this one saves evaluations (3 instead of 4 in the test).

Known limitations

#849 interaction

#849 caches fetcher results (UTxOs by tx hash, cost models per epoch) and skips evaluation without Plutus witnesses. It does not memoize evaluation results, so the final evaluation sees the final body.

Tests

packages/mesh-transaction/test/mesh-tx-builder/exact-balancing.test.ts, with two hand-written V3 validators that read the fee (UPLC source next to the flat hex). 13 of its 14 tests fail on #849 without this PR:

  • for both selectors, the declared units equal an independent evaluation of the final tx hex, the fee equals the min fee computed from the signed tx, inputs = outputs + fee, and the emulator accepts the tx;
  • for both selectors, change split over several outputs: the fee rise goes to the output with the lovelace (a "first output" or "last output" rule fails one of the two);
  • for both selectors, a fee-threshold validator that is cheaper with a higher fee: the tx settles with a fee above the threshold and declares the units it needs;
  • a selection estimate that is too high goes back to the change: the fee equals the min fee;
  • a fee-independent script costs exactly one extra evaluation; largest-first costs 3 evaluations in total (commit 2);
  • an evaluator whose units never settle makes complete() throw;
  • clear errors when the change cannot pay the higher fee, and when the collateral (totalCollateral or the collateral inputs) does not cover the fee.

The 14th test is a guard and passes on #849 too: a manual fee is kept and the units are exact.

npm test -w packages/mesh-transaction: 180 tests, 176 passed, 4 failed. The same 4 fail on #849 without this PR (166 tests, 162 passed): 3 in tx.test.ts ("Cannot find module 'scalus'") and 1 in provider-calls.test.ts ("fetches missing script bytes for a locally known Plutus reference UTxO"). npm test -w packages/mesh-core-cst: 6 + 179 passed.

nau added 2 commits October 23, 2026 15:06
…ange to a fixed point

Coin selection prices redeemers on a skeleton with a 1 000 ADA placeholder fee and a
change output that still holds the fee, so a script that reads the fee or the change
can need other units on chain than the ones declared. complete() now evaluates the
final body, recomputes the fee, moves the difference into the change output with the
most lovelace and repeats until one more pass changes nothing (at most 20 passes,
then it throws). With a change output, the first pass sets the minimum fee; after that
the fee only rises, so a script that gets cheaper with a higher fee cannot flip
between two fees. The collateral is then checked against the settled fee.

Without an evaluator or Plutus scripts nothing changes; a manual fee is kept.
…ted fee

The largest-first selector priced every pass with the 1 000 ADA placeholder fee and
dropped the redeemers it evaluated, so complete() always had to re-evaluate the final
transaction at least twice. Passes after the first now use the fee already computed,
whose change already pays it, and the selector returns its redeemers. When that pass
priced the final transaction, the final check costs one evaluation.

@lisicky lisicky left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, merging. Ran it together with #862 and #866 on top of the fetch-efficiency branch, everything green. Good catch on LargestFirst dropping the redeemers.

@lisicky
lisicky merged commit 5b4626b into MeshJS:feat/tx-builder-fetch-efficiency Nov 1, 2026
@nau
nau deleted the pr-b/exact-balancing branch November 2, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants