Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tx-size-exclude-isvalid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

Transaction size is now measured the way the ledger measures it: `toCBORForSizeComputation`'s 3-element array `[body, witnessSet, auxiliaryData]`, with the `isValid` flag omitted. The new `Transaction.toCBORBytesForSizeComputation` produces those bytes, and both `calculateTransactionSize` (builder fee estimation and the `maxTxSize` check) and `FeeValidation.validateTransactionFee` use them. Measuring the full 4-element encoding overstated the size by exactly one byte — the boolean — so every built transaction overpaid the minimum fee by `minFeeCoefficient` lovelace (44 lovelace at the usual coefficient), and fee validation could reject a transaction whose fee was exactly the ledger minimum.
8 changes: 6 additions & 2 deletions packages/evolution/src/FeeValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ export const validateTransactionFee = (
})
: transaction

// 3. Serialize transaction to CBOR to get exact size
const cborBytes = Transaction.toCBORBytes(txToMeasure)
// 3. Serialize transaction the way the ledger computes its size
// (toCBORForSizeComputation: [body, witnessSet, auxiliaryData], the
// isValid flag omitted) to get the exact size the ledger's fee rule
// uses. The full 4-element encoding is one byte longer, which would
// overstate the minimum fee by minFeeCoefficient lovelace.
const cborBytes = Transaction.toCBORBytesForSizeComputation(txToMeasure)
const txSizeBytes = cborBytes.length

// 4. Calculate minimum required fee using Cardano protocol formula
Expand Down
34 changes: 34 additions & 0 deletions packages/evolution/src/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,40 @@ const gainsLowerKey = (witnessSet: ReadonlyMap<bigint, CBOR.CBOR>, format: CBOR.
)
}

/**
* CBOR bytes of the transaction as the ledger computes its size: a
* 3-element array `[body, witnessSet, auxiliaryData]` with the `isValid`
* flag omitted (`toCBORForSizeComputation` in the ledger's Alonzo Tx
* module). The ledger's one size — `tx ^. sizeTxF` — feeds both the
* minimum fee and the `maxTxSize` check, so anything measuring a
* transaction for fee or size purposes must measure these bytes, not the
* full 4-element encoding: the full encoding is exactly one byte longer
* (the boolean; the array header is one byte at both lengths).
*
* Each component is written as {@link toCBORBytes} writes it under the
* given options, joined exactly as the full encoding joins them.
*
* @since 2.0.0
* @category encoding
*/
export const toCBORBytesForSizeComputation = (
data: Transaction,
options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS
): Uint8Array => {
const txOptions = CBOR.toTxCodecOptions(options)
const { ledger } = txOptions
return encodeArray(
[
TransactionBody.toCBORBytes(data.body, txOptions),
TransactionWitnessSet.toCBORBytes(data.witnessSet, txOptions),
data.auxiliaryData === null
? CBOR.toCBORBytes(null, ledger)
: AuxiliaryData.toCBORBytes(data.auxiliaryData, ledger)
],
ledger
)
}

/**
* Convert a Transaction to CBOR bytes using an explicit root format tree.
*
Expand Down
11 changes: 9 additions & 2 deletions packages/evolution/src/sdk/builders/internal/txBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1035,15 +1035,22 @@ export const assembleTransaction = (

/**
* Calculate the size of a transaction in bytes for fee estimation.
* Uses CBOR serialization to get accurate size, with `options` when given.
*
* This is the ledger's size (`toCBORForSizeComputation`): the
* 3-element `[body, witnessSet, auxiliaryData]` serialization with the
* `isValid` flag omitted. The ledger uses that one size for both the
* minimum fee and the `maxTxSize` check, so measuring the full
* 4-element encoding would overstate the size by one byte and the fee
* by `minFeeCoefficient` lovelace on every transaction. The size is
* measured with `options` when given, as the full encoding would be.
*
* @since 2.0.0
* @category fee-calculation
*/
export const calculateTransactionSize = (
transaction: Transaction.Transaction,
options?: CBOR.TxCodecOptions | CBOR.CodecOptions
): number => Transaction.toCBORBytes(transaction, options).length
): number => Transaction.toCBORBytesForSizeComputation(transaction, options).length

/**
* Calculate minimum transaction fee based on protocol parameters.
Expand Down
79 changes: 79 additions & 0 deletions packages/evolution/test/Transaction.SizeComputation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest"

import * as FeeValidation from "../src/FeeValidation.js"
import { calculateTransactionSize } from "../src/sdk/builders/internal/txBuilder.js"
import * as Transaction from "../src/Transaction.js"

// ---------------------------------------------------------------------------
// Ledger size computation (#179)
//
// The ledger measures a transaction with `toCBORForSizeComputation`
// (Cardano.Ledger.Alonzo.Tx): a 3-element array [body, witnessSet,
// auxiliaryData] — the `isValid` flag is omitted. That one size feeds both
// the minimum fee (FeeTooSmallUTxO) and the maxTxSize check
// (validateMaxTxSizeUTxO reads `tx ^. sizeTxF`), so measuring the full
// 4-element encoding overstates the size by exactly one byte (the bool;
// the array header is one byte at both lengths) and the fee by
// 1 × minFeeCoefficient.
// ---------------------------------------------------------------------------

// body = {0: #6.258([]), 1: [], 2: 200000}, witness set = {}, aux = null
const NO_AUX_TX_HEX = "84a300d90102800180021a00030d40a0f5f6"
const NO_AUX_SIZE_HEX = "83a300d90102800180021a00030d40a0f6"

// same body, aux = metadata {0: 0}
const AUX_TX_HEX = "84a300d90102800180021a00030d40a0f5a10000"
const AUX_SIZE_HEX = "83a300d90102800180021a00030d40a0a10000"

const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex")

describe("Transaction size computation (ledger, #179)", () => {
it("encodes a 3-element array without isValid when there is no auxiliary data", () => {
const tx = Transaction.fromCBORHex(NO_AUX_TX_HEX)
expect(hex(Transaction.toCBORBytesForSizeComputation(tx))).toBe(NO_AUX_SIZE_HEX)
})

it("encodes a 3-element array without isValid when auxiliary data is present", () => {
const tx = Transaction.fromCBORHex(AUX_TX_HEX)
expect(hex(Transaction.toCBORBytesForSizeComputation(tx))).toBe(AUX_SIZE_HEX)
})

it("does not depend on the isValid flag value", () => {
const tx = Transaction.fromCBORHex(NO_AUX_TX_HEX)
const invalid = new Transaction.Transaction({
body: tx.body,
witnessSet: tx.witnessSet,
isValid: false,
auxiliaryData: tx.auxiliaryData
})
expect(hex(Transaction.toCBORBytesForSizeComputation(invalid))).toBe(NO_AUX_SIZE_HEX)
})

it("is exactly one byte shorter than the full encoding", () => {
const tx = Transaction.fromCBORHex(AUX_TX_HEX)
const full = Transaction.toCBORBytes(tx)
const size = Transaction.toCBORBytesForSizeComputation(tx)
expect(full.length - size.length).toBe(1)
expect(size[0]).toBe(0x83)
})

it("calculateTransactionSize returns the ledger size", () => {
const tx = Transaction.fromCBORHex(NO_AUX_TX_HEX)
expect(calculateTransactionSize(tx)).toBe(17)
})

it("validateTransactionFee accepts a fee at the ledger minimum", () => {
const tx = Transaction.fromCBORHex(NO_AUX_TX_HEX)
// fee is 200000; with the ledger size of 17 bytes and a coefficient of
// 44, the constant that makes the fee exactly the minimum is
// 200000 - 44 * 17 = 199252. Measuring the full 18-byte encoding would
// demand 200044 and reject this transaction.
const result = FeeValidation.validateTransactionFee(tx, {
minFeeCoefficient: 44n,
minFeeConstant: 199_252n
})
expect(result.txSizeBytes).toBe(17)
expect(result.minRequiredFee).toBe(200_000n)
expect(result.isValid).toBe(true)
})
})
10 changes: 7 additions & 3 deletions packages/evolution/test/TxBuilder.CodecOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ import {
walletUtxos
} from "./utils/codec-build-helpers.js"

// The goldens were recorded before the build option existed
// The goldens were recorded before the build option existed.
// Re-recorded with the ledger size computation (#179): every fee is
// 44 lovelace lower, because the size now excludes the isValid flag.
const CASES = makeCases(585, golden.length)
const PLUTUS_CASES = CASES.filter((c) => c.spend !== undefined || c.mints.length > 0)

Expand Down Expand Up @@ -90,7 +92,9 @@ const concat = (...parts: ReadonlyArray<Uint8Array>) => {
/**
* Checks bytes written for a canonical build: they are the encoding of their
* own content, the body hashes cover the written witness set and auxiliary
* data, and the fee covers the size of `sizedBytes`.
* data, and the fee covers the ledger size of `sizedBytes` — its size
* computation (`toCBORForSizeComputation`, #179), which is the full
* encoding without the one-byte `isValid` flag.
*/
const checkWritten = (bytes: Uint8Array, options: CBOR.TxCodecOptions | CBOR.CodecOptions, sizedBytes = bytes) => {
const decoded = Transaction.fromCBORBytes(bytes, CBOR.CANONICAL_OPTIONS)
Expand Down Expand Up @@ -130,7 +134,7 @@ const checkWritten = (bytes: Uint8Array, options: CBOR.TxCodecOptions | CBOR.Cod
steps += Number(r.exUnits.steps)
}
const minFee =
FULL_PP.minFeeA * sizedBytes.length +
FULL_PP.minFeeA * (sizedBytes.length - 1) +
FULL_PP.minFeeB +
Math.ceil(FULL_PP.priceMem * mem + FULL_PP.priceStep * steps)
expect(body.fee >= BigInt(minFee)).toBe(true)
Expand Down
12 changes: 6 additions & 6 deletions packages/evolution/test/TxBuilder.EdgeCases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,12 +252,12 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => {

// Change ADA must meet minUTxO (corrected Babbage/Conway formula with 160-byte overhead)
const changeAda = changeOutput.assets.lovelace
expect(changeAda).toBe(1_148_515n)
expect(changeAda).toBe(1_148_559n) // 44 lovelace higher: the fee fell by 44 with the ledger size (#179)
}

// Fee validation
const fee = tx.body.fee
expect(fee).toBe(171_485n)
expect(fee).toBe(171_441n) // 44 lovelace lower: ledger size without isValid (#179)
})

it("maximum asset name lengths at minUTxO boundary", async () => {
Expand Down Expand Up @@ -319,7 +319,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => {

// Strict ADA validation: exact value from deterministic transaction
const changeAda = changeOutput.assets.lovelace
expect(changeAda).toBe(3_825_655n) // 6M - 2M - 174,345 fee (431 bytes with Shelley format)
expect(changeAda).toBe(3_825_699n) // 6M - 2M - 174,301 fee (430 bytes, ledger size without isValid, #179)

// Strict asset validation
expect(changeOutput.assets.multiAsset).toBeDefined()
Expand All @@ -342,7 +342,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => {

// Fee validation
const fee = tx.body.fee
expect(fee).toBe(174_345n) // 431 bytes * 44 + 155_381 (Shelley format saves 4 bytes)
expect(fee).toBe(174_301n) // 430 bytes * 44 + 155_381 (ledger size without isValid, #179)
})

it("fee oscillation through 3 reselection attempts", async () => {
Expand Down Expand Up @@ -416,7 +416,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => {

// Change must be >= minUTxO (corrected Babbage/Conway with native asset)
const changeAda = changeOutput.assets.lovelace
expect(changeAda).toBe(1_225_391n)
expect(changeAda).toBe(1_225_435n) // 44 lovelace higher: the fee fell by 44 with the ledger size (#179)

// Change should have the native asset token
expect(changeOutput.assets.multiAsset).toBeDefined()
Expand All @@ -438,7 +438,7 @@ describe("TxBuilder P0 Edge Cases - MinUTxO Boundary Precision", () => {

// Fee validation
const fee = tx.body.fee
expect(fee).toBe(174_609n)
expect(fee).toBe(174_565n) // 44 lovelace lower: ledger size without isValid (#179)

// Balance equation must hold after reselection attempts
const utxoMap = new Map(utxos.map((u) => [u.transactionId, u.assets.lovelace]))
Expand Down
32 changes: 16 additions & 16 deletions packages/evolution/test/TxBuilder.InsufficientChange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,13 @@ describe("Fallback Tier 3: onInsufficientChange Strategy", () => {
// Verify: Transaction balances (inputs = outputs + fee)
// Strict deterministic values
expect(tx.body.outputs[0].assets.lovelace).toBe(2_000_000n) // Payment amount
expect(tx.body.fee).toBe(165_281n) // Deterministic fee (uses efficient Shelley format)
expect(tx.body.fee).toBe(165_237n) // Deterministic fee (ledger size without isValid, #179)

// Verify leftover is burned
const inputTotal = utxo.assets.lovelace // 2_170_000n
const outputTotal = tx.body.outputs[0].assets.lovelace // 2_000_000n
const leftover = inputTotal - outputTotal - tx.body.fee // 2_170_000 - 2_000_000 - 165_281 = 4_719
expect(leftover).toBe(4_719n) // Exact leftover amount that was "burned" (becomes excess)
const leftover = inputTotal - outputTotal - tx.body.fee // 2_170_000 - 2_000_000 - 165_237 = 4_763
expect(leftover).toBe(4_763n) // Exact leftover amount that was "burned" (becomes excess)
})
})

Expand Down Expand Up @@ -162,12 +162,12 @@ describe("Fallback Precedence: drainTo before onInsufficientChange", () => {
// Assert: Transaction succeeds using drainTo
expect(tx.body.inputs.length).toBe(1)
expect(tx.body.outputs.length).toBe(1) // Leftover merged into payment output
expect(tx.body.fee).toBe(165_281n) // Deterministic fee (uses efficient Shelley format)
expect(tx.body.fee).toBe(165_237n) // Deterministic fee (ledger size without isValid, #179)

// Verify: Leftover was drained into first output (not burned)
const firstOutput = tx.body.outputs[0]
// Payment (2M) + drained leftover (4_719) = 2_004_719
expect(firstOutput.assets.lovelace).toBe(2_004_719n)
// Payment (2M) + drained leftover (4_763) = 2_004_763
expect(firstOutput.assets.lovelace).toBe(2_004_763n)
})
})

Expand Down Expand Up @@ -197,16 +197,16 @@ describe("Normal Path: Sufficient Change (No Fallbacks)", () => {
// Assert: Change output created successfully
expect(tx.body.inputs.length).toBe(1)
expect(tx.body.outputs.length).toBe(2) // Payment + change
expect(tx.body.fee).toBe(168_141n) // Deterministic fee (2 outputs with Shelley format)
expect(tx.body.fee).toBe(168_097n) // Deterministic fee (ledger size without isValid, #179)

// Verify outputs
const paymentOutput = tx.body.outputs[0]
const changeOutput = tx.body.outputs[1]

expect(paymentOutput.assets.lovelace).toBe(10_000_000n)
expect(Address.toBech32(changeOutput.address)).toBe(CHANGE_ADDRESS)
// Change: 100M - 10M payment - 168_141 fee = 89_831_859
expect(changeOutput.assets.lovelace).toBe(89_831_859n)
// Change: 100M - 10M payment - 168_097 fee = 89_831_903
expect(changeOutput.assets.lovelace).toBe(89_831_903n)
})

it("should handle exact amount with drainTo without triggering fallbacks", async () => {
Expand All @@ -233,12 +233,12 @@ describe("Normal Path: Sufficient Change (No Fallbacks)", () => {
// Assert: Single output with drained leftover
expect(tx.body.inputs.length).toBe(1)
expect(tx.body.outputs.length).toBe(1) // Payment with drained change
expect(tx.body.fee).toBe(165_281n) // Deterministic fee (uses efficient Shelley format)
expect(tx.body.fee).toBe(165_237n) // Deterministic fee (ledger size without isValid, #179)

// Verify: Output has payment + leftover
const output = tx.body.outputs[0]
// 2_170_000 - 165_281 = 2_004_719
expect(output.assets.lovelace).toBe(2_004_719n)
// 2_170_000 - 165_237 = 2_004_763
expect(output.assets.lovelace).toBe(2_004_763n)
})
})

Expand Down Expand Up @@ -282,11 +282,11 @@ describe("Edge Cases", () => {
// Assert: Transaction built successfully with drainTo
expect(tx.body.inputs.length).toBe(2)
expect(tx.body.outputs.length).toBe(1) // Drained into output[0]
expect(tx.body.fee).toBe(166_865n) // Deterministic fee for 2 inputs, 1 output (Shelley format)
expect(tx.body.fee).toBe(166_821n) // Deterministic fee for 2 inputs, 1 output (ledger size, #179)

// Verify the output has the drained amount
// Total: 2_200_000 - 166_865 fee = 2_033_135
expect(tx.body.outputs[0].assets.lovelace).toBe(2_033_135n)
// Total: 2_200_000 - 166_821 fee = 2_033_179
expect(tx.body.outputs[0].assets.lovelace).toBe(2_033_179n)
})

it("should respect burn strategy with very small leftover amounts", async () => {
Expand Down Expand Up @@ -316,7 +316,7 @@ describe("Edge Cases", () => {
expect(tx.body.outputs[0].assets.lovelace).toBe(2_000_000n)

// Verify: Fee is deterministic
expect(tx.body.fee).toBe(165_281n) // Uses efficient Shelley format
expect(tx.body.fee).toBe(165_237n) // Ledger size without isValid (#179)
})
})

Expand Down
Loading