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 // ---------------------------------------------------------------------------