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/txout-map-form-preserved.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@evolution-sdk/evolution": patch
---

Outputs decoded in the post-Alonzo map form no longer come back in the legacy array form on re-encode. The captured format now decides the form: when the format node for an output (or the collateral return) is a map, the encoded output is rebuilt as a map — with a bare datum hash wrapped as the datum_option `[0, hash]` — so the format replays and a plain round trip reproduces the input bytes exactly, leaving the transaction id unchanged. Array-form outputs, outputs with an inline datum or script ref, and freshly built transactions are unchanged.
11 changes: 9 additions & 2 deletions packages/evolution/src/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,21 +320,28 @@ export const toCBORBytesWithFormat = (
const cborTuple = Schema.encodeSync(FromCDDL)(data)
const arrayFormat: CBOR.CBORFormat.Array = format._tag === "array" ? format : { _tag: "array", children: [] }
const [bodyFormat = { _tag: "simple" }, witnessSetFormat = { _tag: "simple" }, ...rest] = arrayFormat.children
// Outputs the decoder read in map form are re-expressed in that form, so
// their captured format nodes replay instead of being dropped (#577).
const body =
cborTuple[0] instanceof Map
? TransactionBody.alignOutputsWithFormat(cborTuple[0] as Map<bigint, CBOR.CBOR>, bodyFormat)
: cborTuple[0]
const alignedTuple = [body, ...cborTuple.slice(1)] as typeof cborTuple
// A witness set without datums or redeemers replays in the tuple as it is,
// unless a key it gains may go ahead of its decoded keys
if (
(data.witnessSet.plutusData ?? []).length === 0 &&
(data.witnessSet.redeemers?.size ?? 0) === 0 &&
!gainsLowerKey(cborTuple[1], witnessSetFormat)
) {
return CBOR.toCBORBytesWithFormat(cborTuple as unknown as CBOR.CBOR, format)
return CBOR.toCBORBytesWithFormat(alignedTuple as unknown as CBOR.CBOR, format)
}
// The witness set's bytes, decoded with their format, replay exactly in
// the tuple. A part the format does not cover is written with the defaults.
const witnessSet = CBOR.fromCBORBytesWithFormat(
TransactionWitnessSet.toCBORBytesWithFormat(data.witnessSet, witnessSetFormat)
)
return CBOR.toCBORBytesWithFormat([cborTuple[0], witnessSet.value, ...cborTuple.slice(2)], {
return CBOR.toCBORBytesWithFormat([body, witnessSet.value, ...cborTuple.slice(2)], {
...arrayFormat,
children: [bodyFormat, witnessSet.format, ...rest]
})
Expand Down
51 changes: 49 additions & 2 deletions packages/evolution/src/TransactionBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,53 @@ export const fromCBORHexWithFormat = (
return { value, format: decoded.format }
}

/**
* Align the encoded outputs (key 1) and collateral return (key 16) with
* their captured format nodes.
*
* Entries are matched positionally, the same way the format-guided encoder
* consumes them. An output the decoder read in the post-Alonzo map form has
* a map node in the format tree, but the schema encoder writes the legacy
* array form whenever the output has no script ref — the shape mismatch
* drops the format and changes the body bytes (#577). Outputs whose format
* node is a map are rebuilt in map form via {@link TxOut.withCapturedForm};
* every other entry is returned untouched, and the input map is returned
* as-is when nothing changed.
*
* @since 2.0.0
* @category conversion
*/
export const alignOutputsWithFormat = (
cborMap: Map<bigint, CBOR.CBOR>,
format: CBOR.CBORFormat
): Map<bigint, CBOR.CBOR> => {
if (format._tag !== "map") return cborMap
let aligned: Map<bigint, CBOR.CBOR> | undefined
let index = 0
for (const [key, value] of cborMap) {
const entry = format.entries[index]
index += 1
if (entry === undefined) break
const valueFormat = entry[1]
let next: CBOR.CBOR | undefined
if (key === 1n && valueFormat._tag === "array" && Array.isArray(value)) {
const outputs = value.map((output, i) => {
const child = valueFormat.children[i]
return child !== undefined && Array.isArray(output) ? TxOut.withCapturedForm(output, child) : output
})
if (outputs.some((output, i) => output !== value[i])) next = outputs
} else if (key === 16n && Array.isArray(value)) {
const converted = TxOut.withCapturedForm(value, valueFormat)
if (converted !== value) next = converted
}
if (next !== undefined) {
aligned ??= new Map(cborMap)
aligned.set(key, next)
}
}
return aligned ?? cborMap
}

/**
* Convert a TransactionBody to CBOR bytes using an explicit root format tree.
*
Expand All @@ -633,7 +680,7 @@ export const toCBORBytesWithFormat = (
format: CBOR.CBORFormat
): Uint8Array => {
const cborMap = Schema.encodeSync(FromCDDL)(data)
return CBOR.toCBORBytesWithFormat(cborMap, format)
return CBOR.toCBORBytesWithFormat(alignOutputsWithFormat(cborMap, format), format)
}

/**
Expand All @@ -647,7 +694,7 @@ export const toCBORHexWithFormat = (
format: CBOR.CBORFormat
): string => {
const cborMap = Schema.encodeSync(FromCDDL)(data)
return CBOR.toCBORHexWithFormat(cborMap, format)
return CBOR.toCBORHexWithFormat(alignOutputsWithFormat(cborMap, format), format)
}

// ============================================================================
Expand Down
31 changes: 31 additions & 0 deletions packages/evolution/src/TxOut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,37 @@ export const makeFromCDDL = (plutusData: CBOR.CodecOptions) => {
*/
export const FromCDDL = makeFromCDDL(PlutusData.DEFAULT_CBOR_OPTIONS)

/**
* Re-express an encoded output in the form its captured format records.
*
* The schema encoder above picks the legacy array form whenever there is no
* script ref and the datum is absent or a hash — even when the output was
* decoded from the post-Alonzo map form. The captured format node says map
* while the encoder produces an array, so the format is dropped for that
* output and the re-encoded body bytes (and transaction id) change (#577).
* When the format node for an output is a map and the encoded form is an
* array, rebuild the map form so the format replays: keys 0 and 1 take the
* address and value as they are, and a bare datum hash becomes the
* datum_option encoding `[0, hash]`. Any other combination — an array
* format, an already-map encoding, a script ref or inline datum added by
* an edit — is returned unchanged, so freshly built transactions and edits
* that move to the map form keep today's behaviour.
*
* @since 2.0.0
* @category transformation
*/
export const withCapturedForm = (encoded: CBOR.CBOR, format: CBOR.CBORFormat): CBOR.CBOR => {
if (format._tag !== "map" || !Array.isArray(encoded)) return encoded
if (encoded.length < 2 || encoded.length > 3) return encoded
const outputMap = new Map<bigint, CBOR.CBOR>()
outputMap.set(0n, encoded[0])
outputMap.set(1n, encoded[1])
if (encoded.length === 3) {
outputMap.set(2n, [0n, encoded[2]])
}
return outputMap
}

/**
* CBOR bytes transformation schema for TransactionOutput. A new inline datum
* is written with the `plutusData` options, the rest with the `ledger`
Expand Down
80 changes: 80 additions & 0 deletions packages/evolution/test/Transaction-with-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"
import * as CBOR from "../src/CBOR.js"
import * as Ed25519Signature from "../src/Ed25519Signature.js"
import * as Transaction from "../src/Transaction.js"
import * as TransactionBody from "../src/TransactionBody.js"
import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js"
import * as VKey from "../src/VKey.js"

Expand Down Expand Up @@ -102,6 +103,85 @@ describe("Transaction WithFormat — property", () => {
})
})

// ---------------------------------------------------------------------------
// Map-form outputs (#577): an output written in the post-Alonzo map form
// must keep that form on re-encode, or the body bytes and tx id change.
// ---------------------------------------------------------------------------

// One input in a tagged set, fee 172_864, one output of 1_000_000 lovelace
// to a base address (header 0x00, payment hash 0x11…, stake hash 0x22…).
const MAP_FORM_TX_HEX =
"84a300d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000181a2005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f4240021a0002a300a0f5f6"
const MAP_FORM_INDEFINITE_TX_HEX =
"84a300d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000181bf005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f4240ff021a0002a300a0f5f6"
// Map form with a datum hash: key 2 carries the datum_option [0, hash].
const MAP_FORM_DATUM_TX_HEX =
"84a300d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000181a3005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f424002820058203333333333333333333333333333333333333333333333333333333333333333021a0002a300a0f5f6"
const MAP_FORM_DATUM_BODY_HEX =
"a300d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000181a3005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f424002820058203333333333333333333333333333333333333333333333333333333333333333021a0002a300"
// Two outputs: the first in map form, the second in legacy array form.
const MIXED_FORM_TX_HEX =
"84a300d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000182a2005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f42408258390011111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222221a000f4240021a0002a300a0f5f6"
// Array-form output plus a map-form collateral return (body key 16).
const COLLATERAL_RETURN_TX_HEX =
"84a500d9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0001818258390011111111111111111111111111111111111111111111111111111111222222222222222222222222222222222222222222222222222222221a000f4240021a0002a3000dd9010281825820aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa0010a2005839001111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222222222222011a000f4240a0f5f6"

describe("Transaction WithFormat — map-form outputs (#577)", () => {
it("round-trips a definite map-form output exactly (WithFormat)", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(MAP_FORM_TX_HEX)
expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(MAP_FORM_TX_HEX)
})

it("round-trips a definite map-form output exactly (cached default path)", () => {
const tx = Transaction.fromCBORHex(MAP_FORM_TX_HEX)
expect(Transaction.toCBORHex(tx)).toBe(MAP_FORM_TX_HEX)
})

it("round-trips an indefinite map-form output exactly", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(MAP_FORM_INDEFINITE_TX_HEX)
expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(MAP_FORM_INDEFINITE_TX_HEX)
})

it("round-trips a map-form output with a datum hash exactly", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(MAP_FORM_DATUM_TX_HEX)
expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(MAP_FORM_DATUM_TX_HEX)
})

it("round-trips map-form and array-form outputs side by side", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(MIXED_FORM_TX_HEX)
expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(MIXED_FORM_TX_HEX)
})

it("round-trips a map-form collateral return exactly", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(COLLATERAL_RETURN_TX_HEX)
expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(COLLATERAL_RETURN_TX_HEX)
})

it("preserves the body hash of a map-form transaction — verified via CML", () => {
const { format, value: tx } = Transaction.fromCBORHexWithFormat(MAP_FORM_DATUM_TX_HEX)
const reEncoded = Transaction.toCBORHexWithFormat(tx, format)

const hashBefore = CML.hash_transaction(CML.Transaction.from_cbor_hex(MAP_FORM_DATUM_TX_HEX).body()).to_hex()
const hashAfter = CML.hash_transaction(CML.Transaction.from_cbor_hex(reEncoded).body()).to_hex()
expect(hashAfter).toBe(hashBefore)
})

it("addVKeyWitnessesHex leaves a map-form body unchanged", () => {
const signedHex = Transaction.addVKeyWitnessesHex(MAP_FORM_TX_HEX, buildWalletWitnessHex())
const body = MAP_FORM_TX_HEX.slice(2, -6)
expect(signedHex).toContain(body)

const hashBefore = CML.hash_transaction(CML.Transaction.from_cbor_hex(MAP_FORM_TX_HEX).body()).to_hex()
const hashAfter = CML.hash_transaction(CML.Transaction.from_cbor_hex(signedHex).body()).to_hex()
expect(hashAfter).toBe(hashBefore)
})

it("round-trips a map-form output at the TransactionBody level", () => {
const { format, value: body } = TransactionBody.fromCBORHexWithFormat(MAP_FORM_DATUM_BODY_HEX)
expect(TransactionBody.toCBORHexWithFormat(body, format)).toBe(MAP_FORM_DATUM_BODY_HEX)
})
})

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down