Skip to content

fix(builder): compute exact min UTxO with Babbage/Conway formula#169

Merged
solidsnakedev merged 6 commits into
mainfrom
fix/min-utxo-scriptref-issue-167
Mar 2, 2026
Merged

fix(builder): compute exact min UTxO with Babbage/Conway formula#169
solidsnakedev merged 6 commits into
mainfrom
fix/min-utxo-scriptref-issue-167

Conversation

@solidsnakedev
Copy link
Copy Markdown
Collaborator

Summary

`calculateMinimumUtxoLovelace` was underestimating the minimum UTxO for outputs containing a `scriptRef`. Two compounding bugs:

  1. Missing 160-byte overhead — the Babbage/Conway formula requires `coinsPerUtxoByte × (160 + serializedSize)`, but the mandatory UTxO entry overhead was absent.
  2. CBOR width instability — seeding with `0n` lovelace (1 CBOR byte) then computing ~900K+ (5 CBOR bytes) in one pass produced an underestimate of ~17K lovelace.

Fix

Replaced single-pass with a fixed-point solver:

  • Seed at `0n`, iterate: `required = coinsPerUtxoByte × (160n + cborSize(output with lovelace=seed))`
  • Stop when `seed === required`; converges in 2–3 iterations

Added `UTXO_ENTRY_OVERHEAD_BYTES = 160n` constant.

Tests

  • New `TxBuilder.MinUtxoLovelace.test.ts`: 4 cases (160-byte overhead, convergence, scriptRef, CBOR width)
  • Updated fixtures in 5 existing test files (higher lovelace amounts + adjusted fee/change expectations)

Other changes

  • Root `vitest.config.ts`: exclude `.direnv/` from test runner
  • `packages/evolution-devnet/vitest.config.ts`: new config — higher timeouts, `singleFork: true`, `retry: 0` (prevents Docker parallel resource contention)
  • `.gitignore`: add `.github/skills/`

Closes #167

- Add UTXO_ENTRY_OVERHEAD_BYTES = 160n constant for mandatory entry overhead
- Replace single-pass calculation with fixed-point solver (MAX_MIN_UTXO_ITERATIONS=10)
- Seed iteration at 0n lovelace; converges in 2-3 iterations
- Resolves underestimation for scriptRef outputs where CBOR width varies
- Add TxBuilder.MinUtxoLovelace.test.ts with 4 targeted cases:
  160-byte overhead, stable convergence, scriptRef sufficiency, CBOR width fix
- Update existing test fixtures: increase lovelace amounts to satisfy higher
  (correct) minimums; adjust fee/change expectations accordingly
…itignore skills

- vitest.config.ts: add .direnv to exclude list
- evolution-devnet/vitest.config.ts: new file — higher timeouts, singleFork,
  retry=0 to prevent Docker parallel resource contention in CI
- .gitignore: add .github/skills/ to exclude local skill files
Copilot AI review requested due to automatic review settings March 2, 2026 00:00
@solidsnakedev solidsnakedev added the bug Something isn't working label Mar 2, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes calculateMinimumUtxoLovelace underestimation for outputs that include scriptRef by applying the correct Babbage/Conway min-UTxO formula (including the 160-byte ledger overhead) and using a fixed-point iteration to stabilize CBOR sizing.

Changes:

  • Update min-UTxO computation to coinsPerUtxoByte * (160 + serializedOutputSize) with a fixed-point solver to avoid CBOR width underestimates.
  • Add a dedicated test suite for min-UTxO edge cases (overhead, convergence, scriptRef).
  • Update multiple TxBuilder integration tests and Vitest configs (including devnet serialization/timeouts).

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
vitest.config.ts Excludes .direnv/ from the root Vitest runner.
packages/evolution/src/sdk/builders/TxBuilderImpl.ts Implements 160-byte overhead + fixed-point iteration for exact min-UTxO sizing.
packages/evolution/test/TxBuilder.MinUtxoLovelace.test.ts New tests covering overhead, CBOR-width stability, scriptRef sizing, and fixed-point behavior.
packages/evolution/test/TxBuilder.UnfrackMinUTxO.test.ts Adjusts initial lovelace fixture to satisfy corrected change min-UTxO.
packages/evolution/test/TxBuilder.UnfrackDrain.test.ts Updates deterministic expectations after corrected min-UTxO changes behavior (subdivision fallback).
packages/evolution/test/TxBuilder.UnfrackChangeHandling.test.ts Updates fixtures/expectations for corrected min-UTxO and reselection behavior.
packages/evolution/test/TxBuilder.Reselection.test.ts Updates reselection expectations and balance-check logic after corrected min-UTxO.
packages/evolution/test/TxBuilder.EdgeCases.test.ts Updates boundary/oscillation fixtures and min-UTxO thresholds for corrected formula.
packages/evolution-devnet/vitest.config.ts Adds devnet-specific Vitest config (timeouts, single-fork execution, no retries).
.gitignore Ignores .github/skills/.
.changeset/fix-min-utxo-scriptref-issue-167.md Adds changeset entry for the patch release.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1581 to +1587
if (requiredLovelace === currentLovelace) {
return requiredLovelace
}
currentLovelace = requiredLovelace
}

return currentLovelace
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

calculateMinimumUtxoLovelace silently returns the last iteration’s value if the fixed-point loop doesn’t converge within MAX_MIN_UTXO_ITERATIONS. That can mask a sizing bug and potentially return a non–fixed-point value without any signal. Consider failing with a TransactionBuilderError (or at least logging) when the iteration cap is hit, and/or assert monotonic convergence (e.g., required never decreases) to guarantee correctness.

Suggested change
if (requiredLovelace === currentLovelace) {
return requiredLovelace
}
currentLovelace = requiredLovelace
}
return currentLovelace
// Ensure monotonic convergence: required lovelace should never decrease.
if (requiredLovelace < currentLovelace) {
return yield* Effect.fail(
new TransactionBuilderError({
message:
"Minimum UTxO calculation decreased across iterations; expected monotonic convergence"
})
)
}
if (requiredLovelace === currentLovelace) {
return requiredLovelace
}
currentLovelace = requiredLovelace
}
return yield* Effect.fail(
new TransactionBuilderError({
message:
"Minimum UTxO calculation did not converge within MAX_MIN_UTXO_ITERATIONS"
})
)

Copilot uses AI. Check for mistakes.
// All outputs should meet minimum UTxO (~965K lovelace for ADA-only)
tx.body.outputs.forEach((output) => {
expect(output.assets.lovelace).toBeGreaterThanOrEqual(actualMinUtxo)
expect(output.assets.lovelace).toBeGreaterThanOrEqual(900_000n)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

This test claims the minimum UTxO is ~965K lovelace, but it only asserts >= 900_000n, which would allow outputs below the stated minimum to pass. Please either assert against the actual computed min-UTxO (e.g., via calculateMinimumUtxoLovelace using the same protocol params) or update the threshold to match the documented minimum.

Suggested change
expect(output.assets.lovelace).toBeGreaterThanOrEqual(900_000n)
expect(output.assets.lovelace).toBeGreaterThanOrEqual(965_000n)

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +34
// An ADA-only output CBOR is roughly 50-70 bytes, so result should be > 200
// Without the 160-byte overhead, it would be < 100
expect(result).toBeGreaterThan(200n)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

The overhead regression test uses a heuristic expect(result).toBeGreaterThan(200n), which is potentially brittle if the CBOR-encoded output size changes (e.g., address encoding changes) and the total lands at exactly 200. A more robust assertion would directly verify that result equals 160n + TxOut.toCBORBytes(output).length when coinsPerUtxoByte=1n, or at least >= 160n plus a computed/observed CBOR size in the same test.

Suggested change
// An ADA-only output CBOR is roughly 50-70 bytes, so result should be > 200
// Without the 160-byte overhead, it would be < 100
expect(result).toBeGreaterThan(200n)
// An ADA-only output CBOR is roughly 50-70 bytes, so result should be >= 200
// Without the 160-byte overhead, it would be < 100
expect(result).toBeGreaterThanOrEqual(200n)

Copilot uses AI. Check for mistakes.
Comment on lines +166 to +175
// Verify change output has correct amount after fee convergence
// Input: 2,500,000, Payment: 100,000, Fee: ~175K
// Expected change: 2,500,000 - 100,000 - fee
const changeOutput = tx.body.outputs[1]
expect(changeOutput.assets.lovelace).toBe(1_226_447n)
expect(changeOutput.assets.lovelace).toBeGreaterThan(2_000_000n)

// Verify fee is correct for single-output transaction
expect(tx.body.fee).toBe(173_553n)
// Verify fee is reasonable for single-output transaction
expect(tx.body.fee).toBeGreaterThan(155_381n)
expect(tx.body.fee).toBeLessThan(200_000n)

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

This case used to assert exact fee/change values, but now only checks broad ranges (fee < 200_000n, change > 2_000_000n). Since you already assert the balance equation, tightening these expectations (e.g., exact fee derived from size, or an assertFeeValid-style helper if available) would better protect against regressions in fee/min-UTxO convergence and coin selection behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +445 to +447
// Balance equation must hold after reselection attempts
const totalInput =
inputCount === 3 ? 2_300_000n + 2_100_000n + 250_000n : 2_300_000n + 2_100_000n + 250_000n + 250_000n
inputCount === 3 ? 2_300_000n + 2_100_000n + 500_000n : 2_300_000n + 2_100_000n + 500_000n + 500_000n
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

totalInput is hard-coded based on inputCount (assuming the 3rd/4th selected inputs are the 500K UTxOs). That makes the balance assertion fragile if the selection algorithm ever chooses a different combination (e.g., one of the 400K UTxOs). Consider computing totalInput by summing lovelace for the actual tx.body.inputs by matching them back to the known utxos set (similar to the approach used in the later test in this file).

Copilot uses AI. Check for mistakes.
@solidsnakedev solidsnakedev merged commit 3c8296a into main Mar 2, 2026
9 checks passed
@solidsnakedev solidsnakedev deleted the fix/min-utxo-scriptref-issue-167 branch March 2, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

calculateMinimumUtxoLovelace underestimates min ADA for scriptRef outputs

2 participants