From db92912288812e79b9832301ba8f65b158291642 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Wed, 14 Oct 2026 23:26:01 +0000 Subject: [PATCH] fix(evolution): keep map-form outputs in map form on re-encode (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TxOut schema encoder picks the legacy array form whenever an output has no script ref and its 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 was dropped for that output: the body bytes changed, the transaction id changed, and witnesses already collected stopped verifying. The captured format now decides the form. TxOut.withCapturedForm rebuilds an array-encoded output as a map when its format node is a map — a bare datum hash is wrapped as the datum_option [0, hash] — and TransactionBody.alignOutputsWithFormat applies it to the outputs (key 1) and the collateral return (key 16), matched positionally the way the encoder consumes format entries. Transaction.toCBORBytesWithFormat aligns the body inside the transaction tuple the same way. With no map format node nothing changes: array-form outputs, outputs with an inline datum or script ref, and freshly built transactions encode as before. Tests: 9 new checks in Transaction-with-format.test.ts — definite and indefinite map-form round trips via WithFormat and the cached default path, a map-form output with a datum hash, map and array outputs side by side, a map-form collateral return, CML body-hash equality, addVKeyWitnessesHex body stability, and a TransactionBody-level round trip. Full suite: 1687 passed / 75 skipped / 0 failed. --- .changeset/txout-map-form-preserved.md | 5 ++ packages/evolution/src/Transaction.ts | 11 ++- packages/evolution/src/TransactionBody.ts | 51 +++++++++++- packages/evolution/src/TxOut.ts | 31 +++++++ .../test/Transaction-with-format.test.ts | 80 +++++++++++++++++++ 5 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 .changeset/txout-map-form-preserved.md diff --git a/.changeset/txout-map-form-preserved.md b/.changeset/txout-map-form-preserved.md new file mode 100644 index 00000000..ceaf5d1a --- /dev/null +++ b/.changeset/txout-map-form-preserved.md @@ -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. diff --git a/packages/evolution/src/Transaction.ts b/packages/evolution/src/Transaction.ts index 4014343d..ee7f6501 100644 --- a/packages/evolution/src/Transaction.ts +++ b/packages/evolution/src/Transaction.ts @@ -320,6 +320,13 @@ 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, 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 ( @@ -327,14 +334,14 @@ export const toCBORBytesWithFormat = ( (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] }) diff --git a/packages/evolution/src/TransactionBody.ts b/packages/evolution/src/TransactionBody.ts index ee2e322a..dde28983 100644 --- a/packages/evolution/src/TransactionBody.ts +++ b/packages/evolution/src/TransactionBody.ts @@ -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, + format: CBOR.CBORFormat +): Map => { + if (format._tag !== "map") return cborMap + let aligned: Map | 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. * @@ -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) } /** @@ -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) } // ============================================================================ diff --git a/packages/evolution/src/TxOut.ts b/packages/evolution/src/TxOut.ts index 7729797b..f0cf9cd5 100644 --- a/packages/evolution/src/TxOut.ts +++ b/packages/evolution/src/TxOut.ts @@ -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() + 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` diff --git a/packages/evolution/test/Transaction-with-format.test.ts b/packages/evolution/test/Transaction-with-format.test.ts index 87d64b2d..37800420 100644 --- a/packages/evolution/test/Transaction-with-format.test.ts +++ b/packages/evolution/test/Transaction-with-format.test.ts @@ -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" @@ -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 // ---------------------------------------------------------------------------