Skip to content

Settle fee, execution units and change with a Scalus balancer - #851

Closed
nau wants to merge 4 commits into
MeshJS:mainfrom
nau:pr2/scalus-balancer
Closed

nau wants to merge 4 commits into
MeshJS:mainfrom
nau:pr2/scalus-balancer

Conversation

@nau

@nau nau commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

MeshTxBuilder evaluates scripts before coin selection adds the change output. A script sees the
whole transaction, so its execution units can be wrong for the transaction finally built, and the
fee and the change both depend on those units. This adds an opt-in balancer that settles the three
together.

  • ITxBalancer in @meshsdk/common: balanceTx(tx, utxos, params, changeOutputIndex). Coin
    selection and change placement stay with the builder. A balancer edits only the change output's
    lovelace, the fee and the redeemers.
  • ScalusTxBalancer in @meshsdk/core-cst, through Scalus's balancer.balanceTx. Keys a native
    script needs, which cannot be inferred from the inputs, go in extraSigners.
  • new MeshTxBuilder({ balancer }) runs it at the end of complete(). Without one, nothing
    changes.

Builds on #850 (Scalus 1.3.0).

Tests

@meshsdk/core-cst         179 + 8
@meshsdk/scalus-emulator  24
test:scalus-packages      ESM, CJS, .d.ts, browser
@meshsdk/transaction      same results as main

The balancer tests start from a draft with the fee set to zero, so the fee they check is one the
balancer computed. The MeshTxBuilder test checks what complete() hands the balancer: the change
output's index, and the selected UTxO with its address and value.

Node

Scalus is loaded with import(), so the CJS build works on Node 18. Checked there: the CJS build
loads and the balancer reaches Scalus.

nau and others added 2 commits October 2, 2026 18:13
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.
@lisicky

lisicky commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Hi @nau ! Thanks for you contribution! Could you clarify did you have issues with ex units recalculation in mesh ?
In the current design tx balancing should happen on same level with coin selection including recalculation of ex units. At least it was initial intention. I'll recheck current codebase.

@nau

nau commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Hi @lisicky, thanks, and you're right: computeMinimumCost already re-evaluates the redeemers with the change outputs present, so this PR's description was wrong on that point.

I agree that balancing belongs in coin selection. Looking closer, two gaps remain there:

  1. The skeleton is evaluated with a placeholder fee of 1 000 ADA, and its change still holds the fee. A script that reads the fee or the change output sees different values than on chain.
  2. Nothing evaluates the final transaction, so nothing checks that its units, fee and change agree.

Repro: a V3 validator whose cost depends on the fee. Mesh declares 5 322 mem, while the final transaction needs 2 311 524, so it fails phase 2.

#861 fixes this inside MeshTxBuilder, on top of #849, with no separate balancer and no Scalus balanceTx. After coin selection it evaluates the final transaction, sets the fee, moves the difference into the change, and repeats until nothing changes. The same rule could serve as the balancing step of the part builders you are adding in #854–#857, and I'm happy to help there.

Closing this one in favour of #861.

@nau nau closed this Oct 24, 2026
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.

3 participants