diff --git a/.changeset/script-data-hash-datum-options.md b/.changeset/script-data-hash-datum-options.md new file mode 100644 index 00000000..457987d9 --- /dev/null +++ b/.changeset/script-data-hash-datum-options.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +`Redeemers.toScriptDataHash` now encodes datums with the codec options it is given. Before, it applied the options to the redeemers only and always wrote datums in the default Plutus data encoding. A hash requested with `CBOR.CANONICAL_OPTIONS` then did not match a witness set holding canonical datums, and the node rejected the transaction. Calls without options produce the same hashes as before. diff --git a/packages/evolution/src/Redeemers.ts b/packages/evolution/src/Redeemers.ts index 8d25bde0..3a7a755b 100644 --- a/packages/evolution/src/Redeemers.ts +++ b/packages/evolution/src/Redeemers.ts @@ -589,6 +589,8 @@ export const toScriptDataHash = ( options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS ): ScriptDataHash.ScriptDataHash => { const hasDatums = Array.isArray(datums) && datums.length > 0 + // Datums keep their own default encoding unless the caller chose options + const datumOptions = options === CBOR.CML_DEFAULT_OPTIONS ? CBOR.CML_DATA_DEFAULT_OPTIONS : options // Language views encoding (handles PlutusV1 indefinite-length quirk per spec) const langViewsBytes = CostModel.languageViewsEncoding(costModels) @@ -597,7 +599,7 @@ export const toScriptDataHash = ( if (hasDatums && redeemers.size === 0) { // Special case (CDDL): [ A0 | tag(258) datums | A0 ] - const datumsBytes = encodeDatumsTaggedSet(datums) + const datumsBytes = encodeDatumsTaggedSet(datums, datumOptions) payload = concatBytes( new Uint8Array([0xa0]), // Empty map datumsBytes, @@ -609,7 +611,7 @@ export const toScriptDataHash = ( redeemers._tag === "RedeemerMap" ? toCBORBytesMap(redeemers, options) : toCBORBytes(redeemers as RedeemerArray, options) - const datumsBytes = hasDatums ? encodeDatumsTaggedSet(datums) : undefined + const datumsBytes = hasDatums ? encodeDatumsTaggedSet(datums, datumOptions) : undefined payload = datumsBytes ? concatBytes(redeemersBytes, datumsBytes, langViewsBytes) diff --git a/packages/evolution/test/UtilsHash.CML.test.ts b/packages/evolution/test/UtilsHash.CML.test.ts index d1e3fb1c..1b966132 100644 --- a/packages/evolution/test/UtilsHash.CML.test.ts +++ b/packages/evolution/test/UtilsHash.CML.test.ts @@ -1,9 +1,11 @@ import * as CML from "@dcspark/cardano-multiplatform-lib-nodejs" +import { blake2b } from "@noble/hashes/blake2.js" import { FastCheck, Schema } from "effect" import { describe, expect, it } from "vitest" import * as AuxiliaryData from "../src/AuxiliaryData.js" import * as AuxiliaryDataHash from "../src/AuxiliaryDataHash.js" +import * as Bytes from "../src/Bytes.js" import * as CBOR from "../src/CBOR.js" import * as CostModel from "../src/CostModel.js" import * as Data from "../src/Data.js" @@ -277,4 +279,65 @@ describe("UtilsHash helpers CML parity", () => { expect(evolutionHex).toBe(cmlHex) }) + + describe("codec options reach the datums", () => { + const datumHex = "d8799f019f02ffff" // Constr(0, [1, [2]]) with indefinite lists + const datum = Data.fromCBORHex(datumHex) + const canonicalDatumHex = Data.toCBORHex(datum, CBOR.CANONICAL_OPTIONS) + const noCostModels = new CostModel.CostModels({ + PlutusV1: new CostModel.CostModel({ costs: [] }), + PlutusV2: new CostModel.CostModel({ costs: [] }), + PlutusV3: new CostModel.CostModel({ costs: [] }) + }) + const datumList = (hex: string) => { + const list = CML.PlutusDataList.new() + list.add(CML.PlutusData.from_cbor_hex(hex)) + return list + } + + it("hashes canonical datums when there are no redeemers", () => { + expect(canonicalDatumHex).toBe("d87982018102") + const redeemers = new Redeemers.RedeemerArray({ value: [] }) + const evolutionHex = ScriptDataHash.toHex( + Redeemers.toScriptDataHash(redeemers, noCostModels, [datum], CBOR.CANONICAL_OPTIONS) + ) + const cmlHex = CML.hash_script_data( + CML.Redeemers.from_cbor_hex("80"), + CML.CostModels.from_cbor_hex("a0"), + datumList(canonicalDatumHex) + ).to_hex() + expect(evolutionHex).toBe(cmlHex) + expect(evolutionHex).toBe(toHex(blake2b(Bytes.fromHex(`a0d9010281${canonicalDatumHex}a0`), { dkLen: 32 }))) + }) + + it("hashes canonical datums next to redeemers", () => { + const redeemer = new Redeemer.Redeemer({ + tag: "spend", + index: 0n, + data: Data.constr(0n, []), + exUnits: new Redeemer.ExUnits({ mem: 1n, steps: 1n }) + }) + const redeemers = new Redeemers.RedeemerArray({ value: [redeemer] }) + const evolutionHex = ScriptDataHash.toHex( + Redeemers.toScriptDataHash(redeemers, noCostModels, [datum], CBOR.CANONICAL_OPTIONS) + ) + const cmlHex = CML.hash_script_data( + CML.Redeemers.from_cbor_hex(Redeemers.toCBORHex(redeemers, CBOR.CANONICAL_OPTIONS)), + CML.CostModels.from_cbor_hex("a0"), + datumList(canonicalDatumHex) + ).to_hex() + expect(evolutionHex).toBe(cmlHex) + }) + + it("keeps the default datum encoding when no options are given", () => { + const redeemers = new Redeemers.RedeemerArray({ value: [] }) + const evolutionHex = ScriptDataHash.toHex(Redeemers.toScriptDataHash(redeemers, noCostModels, [datum])) + const cmlHex = CML.hash_script_data( + CML.Redeemers.from_cbor_hex("80"), + CML.CostModels.from_cbor_hex("a0"), + datumList(datumHex) + ).to_hex() + expect(evolutionHex).toBe(cmlHex) + }) + }) })