diff --git a/.changeset/plutus-data-presets.md b/.changeset/plutus-data-presets.md new file mode 100644 index 000000000..bba9f052a --- /dev/null +++ b/.changeset/plutus-data-presets.md @@ -0,0 +1,11 @@ +--- +"@evolution-sdk/evolution": minor +--- + +The new `CBOR.PLUTUS_DATA_OPTIONS` preset encodes Plutus data in the layout the node writes. It writes non-empty lists and constructor fields with indefinite length, maps with definite length, the empty list as `80`, and the empty map as `a0`. + +`UPLC.applyParamsToScript` and `UPLC.dataConstant` now encode parameters with `PLUTUS_DATA_OPTIONS` by default, so a map parameter gives the same bytes and script hash as `aiken blueprint apply`. Before, a map parameter became a list of pairs, which is a different value and gives a different script hash. Scripts applied with map parameters therefore get a new hash. + +`CBOR.AIKEN_DEFAULT_OPTIONS` now matches Aiken `cbor.serialise()`, which writes `Pairs` as a definite map, and is deprecated in favor of `PLUTUS_DATA_OPTIONS`. The new `CBOR.CML_DATA_DEFINITE_OPTIONS` writes lists and maps with definite length, like CML `PlutusData.to_cbor_hex()`. `CBOR.CARDANO_NODE_DATA_OPTIONS` is deprecated in favor of it, since the node does not write that layout. + +The default for `Data` encoding stays `CBOR.CML_DATA_DEFAULT_OPTIONS`, and transaction encoding is unchanged. diff --git a/docs/content/docs/encoding/cbor.mdx b/docs/content/docs/encoding/cbor.mdx index 8ed0b8e0f..6026a71bc 100644 --- a/docs/content/docs/encoding/cbor.mdx +++ b/docs/content/docs/encoding/cbor.mdx @@ -123,9 +123,11 @@ The plain `toCBORHex`/`toCBORBytes` APIs accept a `CodecOptions` argument that c |--------|----------| | `CML_DEFAULT_OPTIONS` *(default)* | General Cardano use — definite lengths, minimal integer encoding | | `CANONICAL_OPTIONS` | RFC 8949 canonical: sorted keys, minimal encoding | -| `CML_DATA_DEFAULT_OPTIONS` | Plutus data with indefinite arrays/maps | -| `AIKEN_DEFAULT_OPTIONS` | Aiken `cbor.serialise()` — indefinite arrays, maps as pairs | -| `CARDANO_NODE_DATA_OPTIONS` | Definite Plutus data (tooling compatibility) | +| `PLUTUS_DATA_OPTIONS` | Plutus data in the node layout: indefinite lists and constructor fields, definite maps. The default for `UPLC.applyParamsToScript` and `UPLC.dataConstant`; matches `cardano-cli`, Aiken `cbor.serialise()`, and `aiken blueprint apply` except for constructor indices above 127 and the integer -2^64 | +| `CML_DATA_DEFAULT_OPTIONS` | Matches CML `PlutusData.to_cardano_node_format()`: indefinite lists and maps. CML also sorts map keys; this preset keeps insertion order. The default for `Data` encoding | +| `CML_DATA_DEFINITE_OPTIONS` | Matches CML `PlutusData.to_cbor_hex()` on new data: definite lists and maps | +| `AIKEN_DEFAULT_OPTIONS` | Deprecated alias of `PLUTUS_DATA_OPTIONS` | +| `CARDANO_NODE_DATA_OPTIONS` | Deprecated alias of `CML_DATA_DEFINITE_OPTIONS` | ```typescript twoslash import { CBOR } from "@evolution-sdk/evolution" diff --git a/docs/content/docs/encoding/uplc.mdx b/docs/content/docs/encoding/uplc.mdx index 63beb1260..e9c5dc40d 100644 --- a/docs/content/docs/encoding/uplc.mdx +++ b/docs/content/docs/encoding/uplc.mdx @@ -114,7 +114,7 @@ import { UPLC, Data } from "@evolution-sdk/evolution" // Create a data constant from PlutusData const term = UPLC.dataConstant(Data.int(42n)) -// With custom CBOR options (default is Aiken-compatible) +// The default options match the node and Aiken (PLUTUS_DATA_OPTIONS) const aikenTerm = UPLC.dataConstant( Data.constr(0n, [Data.int(1n)]), ) diff --git a/docs/content/docs/introduction/important-defaults.mdx b/docs/content/docs/introduction/important-defaults.mdx index bf64414fb..ded8b1b38 100644 --- a/docs/content/docs/introduction/important-defaults.mdx +++ b/docs/content/docs/introduction/important-defaults.mdx @@ -78,13 +78,13 @@ const tx2 = await template.build() // No contamination from tx1 ### CBOR: Aiken-Compatible by Default -`UPLC.applyParamsToScript()` uses Aiken-compatible CBOR encoding (indefinite-length arrays/maps) by default. If you're using a different Plutus toolchain, you may need different options. +`UPLC.applyParamsToScript()` encodes parameters with `CBOR.PLUTUS_DATA_OPTIONS` by default: indefinite-length lists and constructor fields, definite-length maps. This is the layout the node, `cardano-cli`, and `aiken blueprint apply` use. If you're using a different Plutus toolchain, you may need different options. ```typescript -// Default: Aiken encoding +// Default: node layout, same as aiken blueprint apply UPLC.applyParamsToScript(script, params) -// For CML-compatible encoding: +// Indefinite lists and maps, like CML to_cardano_node_format() (map keys keep insertion order): UPLC.applyParamsToScript(script, params, CBOR.CML_DATA_DEFAULT_OPTIONS) ``` diff --git a/docs/content/docs/smart-contracts/apply-params.mdx b/docs/content/docs/smart-contracts/apply-params.mdx index f3d1609a5..2f26266ff 100644 --- a/docs/content/docs/smart-contracts/apply-params.mdx +++ b/docs/content/docs/smart-contracts/apply-params.mdx @@ -122,19 +122,19 @@ await signed.submit() ## CBOR Encoding Options -By default, `applyParamsToScript` uses Aiken-compatible encoding (indefinite-length arrays and maps). If your script was compiled with a different tool, you can pass a different CBOR preset: +By default, `applyParamsToScript` uses `CBOR.PLUTUS_DATA_OPTIONS`: indefinite-length lists and constructor fields, definite-length maps, the same bytes as `aiken blueprint apply`. If your script was compiled with a different tool, you can pass a different CBOR preset: ```typescript twoslash import { CBOR, Data, UPLC } from "@evolution-sdk/evolution" declare const compiledScript: string -// With default Aiken encoding (indefinite-length arrays/maps) +// With the default node layout (same as aiken blueprint apply) const applied = UPLC.applyParamsToScript(compiledScript, [ Data.int(42n), ]) -// With CML-compatible encoding (definite-length) +// With indefinite lists and maps, like CML to_cardano_node_format() (map keys keep insertion order) const appliedCml = UPLC.applyParamsToScript( compiledScript, [Data.int(42n)], diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 3d9b5734f..eb7b3279e 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -168,6 +168,13 @@ export type CodecOptions = | { readonly mode: "canonical" readonly mapsAsObjects?: boolean + /** + * Encode every map as an array of `[key, value]` pairs. + * + * @deprecated This turns a map into a list, which is a different value + * with a different hash. No Plutus data encoder writes maps this way, and + * no option replaces it: leave it unset so maps stay maps. + */ readonly encodeMapAsPairs?: boolean } | { @@ -178,6 +185,13 @@ export type CodecOptions = readonly sortMapKeys: boolean readonly useMinimalEncoding: boolean readonly mapsAsObjects?: boolean + /** + * Encode every map as an array of `[key, value]` pairs. + * + * @deprecated This turns a map into a list, which is a different value + * with a different hash. No Plutus data encoder writes maps this way, and + * no option replaces it: leave it unset so maps stay maps. + */ readonly encodeMapAsPairs?: boolean } @@ -208,19 +222,29 @@ export const CML_DEFAULT_OPTIONS: CodecOptions = { } as const /** - * Default CBOR encoding options for PlutusData. + * CBOR encoding options for PlutusData in the layout the Cardano node writes. * - * Uses indefinite-length arrays and maps. The `bounded_bytes` constraint - * (Conway CDDL: byte strings ≤ 64 bytes) is enforced at the data-type layer - * via the `BoundedBytes` CBOR node, independent of these codec options. + * - Non-empty lists and constructor fields: indefinite-length (`9f...ff`) + * - Maps: definite-length + * - Empty list: `80`; empty map: `a0` * - * @since 1.0.0 + * This matches `encodeData` in the Haskell `PlutusCore.Data` module, + * `cardano-cli hash-script-data`, Aiken `cbor.serialise()`, and + * `aiken blueprint apply`. Two cases still differ from the node: a + * constructor index above 127 (tag 102) writes its `[index, fields]` pair + * indefinite where the node writes `82`, and the integer -2^64 is written as a + * negative bignum where the node writes `3bffffffffffffffff`. The + * `bounded_bytes` constraint (Conway CDDL: byte strings of at most 64 bytes) + * is enforced at the data-type layer via the `BoundedBytes` CBOR node, + * independent of these codec options. + * + * @since 2.0.0 * @category constants */ -export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = { +export const PLUTUS_DATA_OPTIONS: CodecOptions = { mode: "custom", useIndefiniteArrays: true, - useIndefiniteMaps: true, + useIndefiniteMaps: false, useDefiniteForEmpty: true, sortMapKeys: false, useMinimalEncoding: true, @@ -228,31 +252,44 @@ export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = { } as const /** - * Aiken-compatible CBOR encoding options. - * - * Matches the encoding produced by `cbor.serialise()` in Aiken: - * - Indefinite-length arrays (`9f...ff`) - * - Maps encoded as arrays of pairs (not CBOR maps) - * - Strings as byte arrays (major type 2, not 3) - * - Constructor tags: 121–127 for indices 0–6, then 1280+ for 7+ + * CBOR encoding options for PlutusData matching CML + * `PlutusData.to_cardano_node_format()`, except that CML also sorts map keys + * and this preset keeps insertion order. This is the current default for + * `Data` encoding. * - * PlutusData byte strings are chunked per the Conway `bounded_bytes` rule - * via the `BoundedBytes` CBOR node, independent of these codec options. + * Uses indefinite-length lists, constructor fields, and maps. It differs from + * the node layout ({@link PLUTUS_DATA_OPTIONS}) by writing non-empty maps + * indefinite. The `bounded_bytes` constraint (Conway CDDL: byte strings of at + * most 64 bytes) is enforced at the data-type layer via the `BoundedBytes` + * CBOR node, independent of these codec options. * - * @since 2.0.0 + * @since 1.0.0 * @category constants */ -export const AIKEN_DEFAULT_OPTIONS: CodecOptions = { +export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = { mode: "custom", useIndefiniteArrays: true, useIndefiniteMaps: true, - useDefiniteForEmpty: false, + useDefiniteForEmpty: true, sortMapKeys: false, useMinimalEncoding: true, - mapsAsObjects: false, - encodeMapAsPairs: true + mapsAsObjects: false } as const +/** + * Aiken `cbor.serialise()` encoding options. Same object as + * {@link PLUTUS_DATA_OPTIONS}. + * + * Aiken writes data in the node layout: non-empty lists and constructor + * fields indefinite, `Pairs` as definite maps. Tuples are lists, so they + * encode as indefinite arrays. + * + * @deprecated Use {@link PLUTUS_DATA_OPTIONS}. + * @since 2.0.0 + * @category constants + */ +export const AIKEN_DEFAULT_OPTIONS: CodecOptions = PLUTUS_DATA_OPTIONS + /** * CBOR encoding options that return objects instead of Maps for Schema.Struct compatibility * @@ -270,19 +307,17 @@ export const STRUCT_FRIENDLY_OPTIONS: CodecOptions = { } as const /** - * Cardano Node compatible CBOR encoding options for PlutusData - * - * Uses definite-length encoding for arrays and maps, matching the format - * produced by CML's `to_cardano_node_format().to_cbor_hex()`. + * CBOR encoding options for PlutusData matching CML `PlutusData.to_cbor_hex()` + * on freshly built data. * - * Note: The on-chain format uses indefinite-length (AIKEN_DEFAULT_OPTIONS), - * but this option is useful for testing compatibility with tools that - * expect definite-length encoding. + * Uses definite-length lists, constructor fields, and maps. It differs from + * the node layout ({@link PLUTUS_DATA_OPTIONS}) by writing lists and + * constructor fields definite. * * @since 2.0.0 * @category constants */ -export const CARDANO_NODE_DATA_OPTIONS: CodecOptions = { +export const CML_DATA_DEFINITE_OPTIONS: CodecOptions = { mode: "custom", useIndefiniteArrays: false, useIndefiniteMaps: false, @@ -292,6 +327,19 @@ export const CARDANO_NODE_DATA_OPTIONS: CodecOptions = { mapsAsObjects: false } as const +/** + * Definite-length PlutusData encoding options. Same object as + * {@link CML_DATA_DEFINITE_OPTIONS}. + * + * The name is wrong: the Cardano node writes non-empty lists and constructor + * fields indefinite. For the node layout use {@link PLUTUS_DATA_OPTIONS}. + * + * @deprecated Use {@link CML_DATA_DEFINITE_OPTIONS}. + * @since 2.0.0 + * @category constants + */ +export const CARDANO_NODE_DATA_OPTIONS: CodecOptions = CML_DATA_DEFINITE_OPTIONS + const DEFAULT_OPTIONS: CodecOptions = { mode: "custom", useIndefiniteArrays: false, @@ -1362,7 +1410,7 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions, const encodeAsPairs = !mapFmt && (options.mode === "canonical" || options.mode === "custom") && options.encodeMapAsPairs === true - // If encoding as array of pairs (Aiken/Plutus style), delegate to array encoding + // If encoding as array of pairs (deprecated encodeMapAsPairs), delegate to array encoding if (encodeAsPairs) { const pairArrays = pairs.map(([k, v]) => [k, v] as CBOR) return encodeArraySync(pairArrays, options) diff --git a/packages/evolution/src/UPLC.ts b/packages/evolution/src/UPLC.ts index 5dcb5c33e..760f9ef8e 100644 --- a/packages/evolution/src/UPLC.ts +++ b/packages/evolution/src/UPLC.ts @@ -1507,14 +1507,15 @@ const encodeFlatToDoubleCbor = (bytes: Uint8Array): string => { * Create a UPLC Constant term from PlutusData. * The data is CBOR-encoded and stored as a constant of type Data. * - * Uses Aiken-compatible encoding (indefinite-length arrays/maps) by default, - * which matches the on-chain format. An optional `options` parameter allows - * customizing the CBOR encoding for testing or compatibility purposes. + * Encodes with PLUTUS_DATA_OPTIONS by default: the node layout, with + * indefinite-length lists and constructor fields and definite-length maps. + * This matches Aiken `cbor.serialise()`. An optional `options` parameter + * allows customizing the CBOR encoding for testing or compatibility purposes. * * @since 2.0.0 * @category constructors */ -export const dataConstant = (data: Data.Data, options: CBOR.CodecOptions = CBOR.AIKEN_DEFAULT_OPTIONS): Term => ({ +export const dataConstant = (data: Data.Data, options: CBOR.CodecOptions = CBOR.PLUTUS_DATA_OPTIONS): Term => ({ type: "Constant", valueType: "Data", value: Data.toCBORBytes(data, options) @@ -1527,8 +1528,11 @@ export const dataConstant = (data: Data.Data, options: CBOR.CodecOptions = CBOR. * the script body in a series of Application nodes, where each parameter is * converted to a UPLC Constant of type Data. * - * Uses Aiken-compatible encoding (indefinite-length arrays/maps) by default. - * Pass custom CBOR options for compatibility with other encoding formats. + * Encodes parameters with PLUTUS_DATA_OPTIONS by default: the node layout, + * with indefinite-length lists and constructor fields and definite-length + * maps. This matches `aiken blueprint apply`, so the applied script hash + * agrees. Pass custom CBOR options for compatibility with other encoding + * formats. * * @since 2.0.0 * @category script @@ -1536,7 +1540,7 @@ export const dataConstant = (data: Data.Data, options: CBOR.CodecOptions = CBOR. export const applyParamsToScript = ( plutusScript: string, params: ReadonlyArray, - options: CBOR.CodecOptions = CBOR.AIKEN_DEFAULT_OPTIONS + options: CBOR.CodecOptions = CBOR.PLUTUS_DATA_OPTIONS ): string => { // Decode the script to a UPLC Program const flatBytes = decodeDoubleCborHexToFlat(plutusScript) diff --git a/packages/evolution/test/CBOR.Aiken.test.ts b/packages/evolution/test/CBOR.Aiken.test.ts index e445e6778..ba25cb589 100644 --- a/packages/evolution/test/CBOR.Aiken.test.ts +++ b/packages/evolution/test/CBOR.Aiken.test.ts @@ -112,41 +112,52 @@ describe("Aiken CBOR Encoding Compatibility", () => { expect(encoded).toBe("9f9f0102ff9f0304ffff") }) - // Test #16: encode_map_empty - it("encode_map_empty: should encode empty map", () => { - const value = Data.map([]) + // Test #16: encode_tuples_empty + it("encode_tuples_empty: should encode empty list of tuples", () => { + const value = Data.list([]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("80") }) - // Test #17: encode_map_single_entry - it("encode_map_single_entry: should encode single entry map", () => { - const value = Data.map([[1n, Bytes.fromHex("ff")]]) + // Test #17: encode_tuples_single_entry + it("encode_tuples_single_entry: should encode [(1, #ff)] as a list of lists", () => { + const value = Data.list([Data.list([1n, Bytes.fromHex("ff")])]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f0141ffffff") }) - // Test #18: encode_map_multiple_entries - it("encode_map_multiple_entries: should encode map with multiple entries", () => { - const value = Data.map([ - [Bytes.fromHex("01"), 1n], - [Bytes.fromHex("02"), 2n], - [Bytes.fromHex("03"), 3n] + // Test #18: encode_tuples_multiple_entries + it("encode_tuples_multiple_entries: should encode list of tuples with multiple entries", () => { + const value = Data.list([ + Data.list([Bytes.fromHex("01"), 1n]), + Data.list([Bytes.fromHex("02"), 2n]), + Data.list([Bytes.fromHex("03"), 3n]) ]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f410101ff9f410202ff9f410303ffff") }) - // Test #19: encode_map_int_keys - it("encode_map_int_keys: should encode map with int keys", () => { - const value = Data.map([ - [1n, 100n], - [2n, 200n] - ]) + // Test #19: encode_tuples_int_keys + it("encode_tuples_int_keys: should encode list of tuples with int keys", () => { + const value = Data.list([Data.list([1n, 100n]), Data.list([2n, 200n])]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f011864ff9f0218c8ffff") }) + // encode_pairs_single: Aiken Pairs encode as a definite-length CBOR map + it("encode_pairs_single: should encode [Pair(1, #ff)] as a definite map", () => { + const value = Data.map([[1n, Bytes.fromHex("ff")]]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("a10141ff") + }) + + // encode_pairs_empty + it("encode_pairs_empty: should encode empty Pairs as an empty map", () => { + const value = Data.map([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("a0") + }) + // Test #20: encode_option_some it("encode_option_some: should encode Some(42)", () => { const OptionInt = TSchema.UndefinedOr(TSchema.Integer) @@ -326,23 +337,20 @@ describe("Aiken CBOR Encoding Compatibility", () => { expect(encoded).toBe("d8799f9f010203ffff") }) - // Test #35: encode_map_with_option_values - it("encode_map_with_option_values: should encode map with option values", () => { + // Test #35: encode_tuples_with_option_values + it("encode_tuples_with_option_values: should encode list of tuples with option values", () => { const OptionInt = TSchema.UndefinedOr(TSchema.Integer) const some100 = Data.withSchema(OptionInt).toData(100n) const none = Data.withSchema(OptionInt).toData(undefined) - const value = Data.map([ - [1n, some100], - [2n, none] - ]) + const value = Data.list([Data.list([1n, some100]), Data.list([2n, none])]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f01d8799f1864ffff9f02d87a80ffff") }) - // Test #36: encode_map_nested_as_value - it("encode_map_nested_as_value: should encode map with nested map value", () => { - const innerMap = Data.map([[2n, 3n]]) - const value = Data.map([[1n, innerMap]]) + // Test #36: encode_tuples_nested_as_value + it("encode_tuples_nested_as_value: should encode list of tuples with a nested list of tuples", () => { + const inner = Data.list([Data.list([2n, 3n])]) + const value = Data.list([Data.list([1n, inner])]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f019f9f0203ffffffff") }) diff --git a/packages/evolution/test/CBOR.PlutusDataPresets.test.ts b/packages/evolution/test/CBOR.PlutusDataPresets.test.ts new file mode 100644 index 000000000..85e0e90d3 --- /dev/null +++ b/packages/evolution/test/CBOR.PlutusDataPresets.test.ts @@ -0,0 +1,140 @@ +import * as CML from "@dcspark/cardano-multiplatform-lib-nodejs" +import { blake2b } from "@noble/hashes/blake2.js" +import { describe, expect, it } from "vitest" + +import * as Bytes from "../src/Bytes.js" +import * as CBOR from "../src/CBOR.js" +import * as Data from "../src/Data.js" + +// Oracle vectors from `cardano-cli 10.5.1 hash-script-data`: the bytes are the +// node encoding of each value, and the hash is blake2b-256 of those bytes. +const oracles: ReadonlyArray<{ name: string; data: Data.Data; bytes: string; hash: string }> = [ + { + name: "list [1, 2]", + data: Data.list([1n, 2n]), + bytes: "9f0102ff", + hash: "ed33125018c5cbc9ae1b242a3ff8f3db2e108e4a63866d0b5238a34502c723ed" + }, + { + name: "map {1: 2}", + data: Data.map([[1n, 2n]]), + bytes: "a10102", + hash: "83eeb4193576c3f615697a300067662b2154b6753a3c6596eda5822a2d658dc0" + }, + { + name: "constr 0 [1]", + data: Data.constr(0n, [1n]), + bytes: "d8799f01ff", + hash: "58b85f4b6b8f3d8e62f406ee77f09afc99a9e1b959389367969bcce3c485c6ad" + }, + { + name: "constr 0 [1, [2]]", + data: Data.constr(0n, [1n, Data.list([2n])]), + bytes: "d8799f019f02ffff", + hash: "568f77b14e62b9cbe3528ad268f33ec71a3929809c38905f8a8ce1933568ad28" + }, + { + // Also equals Aiken v1.1.24 blake2b_256(serialise_data(datum)) inside a validator. + name: "datum constr 0 [h'aa', {h'01': 5, h'02': 7}, [[h'01', 5], [h'02', 7]]]", + data: Data.constr(0n, [ + Bytes.fromHex("aa"), + Data.map([ + [Bytes.fromHex("01"), 5n], + [Bytes.fromHex("02"), 7n] + ]), + Data.list([Data.list([Bytes.fromHex("01"), 5n]), Data.list([Bytes.fromHex("02"), 7n])]) + ]), + bytes: "d8799f41aaa24101054102079f9f410105ff9f410207ffffff", + hash: "609906c41d57c561f14f46944f4cd699ee8c316054583decedd5489df8395084" + } +] + +describe("PLUTUS_DATA_OPTIONS matches the node encoding", () => { + for (const { bytes, data, hash, name } of oracles) { + it(`${name}: encodes as ${bytes}`, () => { + expect(Data.toCBORHex(data, CBOR.PLUTUS_DATA_OPTIONS)).toBe(bytes) + }) + + it(`${name}: hash of the encoding matches cardano-cli`, () => { + const encoded = Data.toCBORBytes(data, CBOR.PLUTUS_DATA_OPTIONS) + expect(Bytes.toHex(blake2b(encoded, { dkLen: 32 }))).toBe(hash) + }) + } + + it("encodes the empty list as 80 and the empty map as a0", () => { + expect(Data.toCBORHex(Data.list([]), CBOR.PLUTUS_DATA_OPTIONS)).toBe("80") + expect(Data.toCBORHex(Data.map([]), CBOR.PLUTUS_DATA_OPTIONS)).toBe("a0") + }) + + it("keeps the deprecated aliases pointing at the new presets", () => { + expect(CBOR.AIKEN_DEFAULT_OPTIONS).toBe(CBOR.PLUTUS_DATA_OPTIONS) + expect(CBOR.CARDANO_NODE_DATA_OPTIONS).toBe(CBOR.CML_DATA_DEFINITE_OPTIONS) + }) + + it("leaves the Data default on CML_DATA_DEFAULT_OPTIONS", () => { + expect(Data.toCBORHex(Data.map([[1n, 2n]]))).toBe("bf0102ff") + }) +}) + +describe("CML data presets match CML", () => { + const cmlInt = (n: number) => CML.PlutusData.new_integer(CML.BigInteger.from_str(String(n))) + const cmlBytes = (hex: string) => CML.PlutusData.new_bytes(Bytes.fromHex(hex)) + const cmlList = (items: ReadonlyArray) => { + const list = CML.PlutusDataList.new() + for (const item of items) list.add(item) + return CML.PlutusData.new_list(list) + } + const cmlMap = (entries: ReadonlyArray) => { + const map = CML.PlutusMap.new() + for (const [k, v] of entries) map.set(k, v) + return CML.PlutusData.new_map(map) + } + const cmlConstr = (index: bigint, fields: ReadonlyArray) => { + const list = CML.PlutusDataList.new() + for (const field of fields) list.add(field) + return CML.PlutusData.new_constr_plutus_data(CML.ConstrPlutusData.new(index, list)) + } + + // Each case is built fresh in both libraries, so neither side carries a + // decoded encoding. Map keys are already in sorted order, because + // to_cardano_node_format() sorts map keys and CML_DATA_DEFAULT_OPTIONS keeps + // insertion order. + const cases: ReadonlyArray<{ name: string; build: () => [Data.Data, CML.PlutusData] }> = [ + { name: "list [1, 2]", build: () => [Data.list([1n, 2n]), cmlList([cmlInt(1), cmlInt(2)])] }, + { name: "map {1: 2}", build: () => [Data.map([[1n, 2n]]), cmlMap([[cmlInt(1), cmlInt(2)]])] }, + { name: "constr 0 [1]", build: () => [Data.constr(0n, [1n]), cmlConstr(0n, [cmlInt(1)])] }, + { + name: "datum constr 0 [h'aa', {h'01': 5, h'02': 7}, [[h'01', 5], [h'02', 7]]]", + build: () => [ + Data.constr(0n, [ + Bytes.fromHex("aa"), + Data.map([ + [Bytes.fromHex("01"), 5n], + [Bytes.fromHex("02"), 7n] + ]), + Data.list([Data.list([Bytes.fromHex("01"), 5n]), Data.list([Bytes.fromHex("02"), 7n])]) + ]), + cmlConstr(0n, [ + cmlBytes("aa"), + cmlMap([ + [cmlBytes("01"), cmlInt(5)], + [cmlBytes("02"), cmlInt(7)] + ]), + cmlList([cmlList([cmlBytes("01"), cmlInt(5)]), cmlList([cmlBytes("02"), cmlInt(7)])]) + ]) + ] + } + ] + + for (const { build, name } of cases) { + it(`${name}: CML_DATA_DEFAULT_OPTIONS equals to_cardano_node_format().to_cbor_hex()`, () => { + const [data, cml] = build() + expect(Data.toCBORHex(data, CBOR.CML_DATA_DEFAULT_OPTIONS)).toBe(cml.to_cardano_node_format().to_cbor_hex()) + }) + + it(`${name}: CML_DATA_DEFINITE_OPTIONS equals to_cbor_hex()`, () => { + const [data, cml] = build() + expect(Data.toCBORHex(data, CBOR.CML_DATA_DEFINITE_OPTIONS)).toBe(cml.to_cbor_hex()) + }) + } +}) diff --git a/packages/evolution/test/UPLC.test.ts b/packages/evolution/test/UPLC.test.ts index b628c0f5a..349ea76fa 100644 --- a/packages/evolution/test/UPLC.test.ts +++ b/packages/evolution/test/UPLC.test.ts @@ -1,6 +1,8 @@ +import { blake2b } from "@noble/hashes/blake2.js" import { FastCheck, Schema } from "effect" import { describe, expect, it } from "vitest" +import * as Bytes from "../src/Bytes.js" import * as CBOR from "../src/CBOR.js" import * as Data from "../src/Data.js" import { PlutusV2 } from "../src/PlutusV2.js" @@ -298,7 +300,7 @@ describe("UPLC Module", () => { const helloParam = "58b00100003232323232322322322232253330093232533300b3371e6eb8c008c034dd500280388008a5032330010013758601e60206020602060206020602060206020601a6ea8c008c034dd50019129998078008a50132533300d3371e6eb8c04400802c5288998018018009808800918070008a4c26caca66600e66e1d20003008375400226464a666018601c0042930b1bae300c001300937540022c6eb8004dd7000ab9a5573aaae7955cfaba157441" - // Expected result from lucid-evolution (single CBOR encoded, uses CARDANO_NODE_DATA_OPTIONS encoding) + // Expected result from lucid-evolution (single CBOR encoded, definite-length data encoding) const helloAppliedValid = "58e5010000333232323232322322322232253330093232533300b3371e6eb8c008c034dd500280388008a5032330010013758601e60206020602060206020602060206020601a6ea8c008c034dd50019129998078008a50132533300d3371e6eb8c04400802c5288998018018009808800918070008a4c26caca66600e66e1d20003008375400226464a666018601c0042930b1bae300c001300937540022c6eb8004dd7000ab9a5573aaae7955cfaba157449811e581ce6849315a2984aadcd1e42d9628f6d6cc071685bef02bb52502f86c9004c010e4d48656c6c6f2c20576f726c64210001" @@ -313,7 +315,7 @@ describe("UPLC Module", () => { // msg: "Hello, World!" as hex Data.bytearray("48656c6c6f2c20576f726c6421") ], - CBOR.CARDANO_NODE_DATA_OPTIONS // Use definite-length encoding to match lucid-evolution + CBOR.CML_DATA_DEFINITE_OPTIONS // Use definite-length encoding to match lucid-evolution ) // Strip one CBOR layer to get single-encoded result for comparison @@ -325,7 +327,7 @@ describe("UPLC Module", () => { }) it("should apply byte array parameters to script", () => { - // Default uses AIKEN_DEFAULT_OPTIONS (indefinite-length) which is the on-chain format + // Default uses PLUTUS_DATA_OPTIONS (the node layout, same as aiken blueprint apply) const helloApplied = UPLC.applyParamsToScript(UPLC.applyDoubleCborEncoding(helloParam), [ Data.bytearray("e6849315a2984aadcd1e42d9628f6d6cc071685bef02bb52502f86c9"), Data.bytearray("48656c6c6f2c20576f726c6421") @@ -353,15 +355,15 @@ describe("UPLC Module", () => { expect(roundtripped).toBe(helloApplied) }) - it("should produce byte-exact output with CARDANO_NODE_DATA_OPTIONS", () => { - // Use CARDANO_NODE_DATA_OPTIONS to match lucid-evolution's expected output exactly + it("should produce byte-exact output with CML_DATA_DEFINITE_OPTIONS", () => { + // Use CML_DATA_DEFINITE_OPTIONS to match lucid-evolution's expected output exactly const helloApplied = UPLC.applyParamsToScript( UPLC.applyDoubleCborEncoding(helloParam), [ Data.bytearray("e6849315a2984aadcd1e42d9628f6d6cc071685bef02bb52502f86c9"), Data.bytearray("48656c6c6f2c20576f726c6421") ], - CBOR.CARDANO_NODE_DATA_OPTIONS + CBOR.CML_DATA_DEFINITE_OPTIONS ) // Verify it decodes correctly @@ -370,6 +372,55 @@ describe("UPLC Module", () => { expect(decoded.body.type).toBe("Apply") }) + describe("map parameter matches aiken blueprint apply", () => { + // Produced with Aiken v1.1.24 from this validator: + // + // validator limits(table: Pairs) { + // spend(_d: Option, _r: Data, _o: Data, _t: Data) { + // when table is { + // [Pair(_k, v), ..] -> v > 0 + // [] -> False + // } + // } + // else(_) { fail } + // } + // + // limitsUnapplied is the compiledCode from `aiken build`. limitsAikenApplied + // is the compiledCode from `aiken blueprint apply` with the parameter + // a1410105 (Pairs [Pair(#"01", 5)]). Its script hash, blake2b-224 of + // 0x03 || compiledCode, is 1bb0e306732e07ca5495991af8b47a14e2bda09eb2235d1c264c6354. + const limitsUnapplied = + "5876010100229800aba2aba1aab9faab9eaab9dab9a9bab002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc896600201314a113371090001bad300c300e009403460180026018601a00260126ea800a2c8038601000260086ea802229344d9590021" + const limitsAikenApplied = + "587f0101003229800aba2aba1aab9faab9eaab9dab9a9bab002488888896600264653001300800198041804800cc0200092225980099b8748008c020dd500144c8cc896600201314a113371090001bad300c300e009403460180026018601a00260126ea800a2c8038601000260086ea802229344d959002130104a14101050001" + const limitsAikenHash = "1bb0e306732e07ca5495991af8b47a14e2bda09eb2235d1c264c6354" + + const applyLimits = () => + UPLC.applyParamsToScript(UPLC.applyDoubleCborEncoding(limitsUnapplied), [ + Data.map([[Bytes.fromHex("01"), 5n]]) + ]) + + // applyParamsToScript returns double CBOR; strip one byte-string layer to + // get the compiledCode form that aiken writes. + const unwrapOnce = (hex: string): string => { + const inner = CBOR.fromCBORHex(hex) + if (!(inner instanceof Uint8Array)) throw new Error("expected a CBOR byte string") + return Bytes.toHex(inner) + } + + it("default options produce the aiken applied script byte for byte", () => { + expect(unwrapOnce(applyLimits())).toBe(limitsAikenApplied) + }) + + it("default options produce the aiken script hash", () => { + const compiledCode = Bytes.fromHex(unwrapOnce(applyLimits())) + const preimage = new Uint8Array(compiledCode.length + 1) + preimage[0] = 0x03 + preimage.set(compiledCode, 1) + expect(Bytes.toHex(blake2b(preimage, { dkLen: 28 }))).toBe(limitsAikenHash) + }) + }) + it("should handle double CBOR encoding", () => { const encoded = UPLC.applyDoubleCborEncoding(helloParam) diff --git a/packages/evolution/test/spec/README.md b/packages/evolution/test/spec/README.md index 05bfe2e14..30a4f5c7c 100644 --- a/packages/evolution/test/spec/README.md +++ b/packages/evolution/test/spec/README.md @@ -57,7 +57,8 @@ The `lib/cbor_encoding_spec.ak` file contains comprehensive tests to document Ai - **Primitives**: Int, ByteArray, Bool - **Lists**: Empty, single-item, multi-item, nested, mixed types - **Tuples**: Pairs, triples, nested structures -- **Maps**: Empty, single-entry, multi-entry +- **Lists of tuples**: Empty, single-entry, multi-entry +- **Pairs (maps)**: Empty, single-entry - **Options**: Some/None with constructor tags - **Custom Types**: Multi-constructor types with fields - **Edge Cases**: Deeply nested structures, large values diff --git a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak index 65a655d02..01950ecd2 100644 --- a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak +++ b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak @@ -45,7 +45,8 @@ test encode_bytearray_long() { // ============================================================================ test encode_list_empty() { - cbor.serialise([]) == #"80" + let empty: List = [] + cbor.serialise(empty) == #"80" } test encode_list_single() { @@ -83,29 +84,43 @@ test encode_nested_pairs() { } // ============================================================================ -// Maps (Dictionaries) +// Lists of Tuples // ============================================================================ -test encode_map_empty() { - // Empty list of pairs encodes as empty array - let pairs: List<(Int, ByteArray)> = [] - cbor.serialise(pairs) == #"80" +test encode_tuples_empty() { + // An empty list of tuples encodes as an empty array + let tuples: List<(Int, ByteArray)> = [] + cbor.serialise(tuples) == #"80" } -test encode_map_single_entry() { - // Aiken encodes maps as indefinite arrays of indefinite pairs +test encode_tuples_single_entry() { + // A list of tuples is a list of lists: indefinite arrays of indefinite arrays cbor.serialise([(1, #"ff")]) == #"9f9f0141ffffff" } -test encode_map_multiple_entries() { - // Maps in Aiken are represented as lists of pairs +test encode_tuples_multiple_entries() { cbor.serialise([(#"01", 1), (#"02", 2), (#"03", 3)]) == #"9f9f410101ff9f410202ff9f410303ffff" } -test encode_map_int_keys() { +test encode_tuples_int_keys() { cbor.serialise([(1, 100), (2, 200)]) == #"9f9f011864ff9f0218c8ffff" } +// ============================================================================ +// Pairs (Maps) +// ============================================================================ + +test encode_pairs_single() { + // Pairs encode as a definite-length CBOR map + let pairs: Pairs = [Pair(1, #"ff")] + cbor.serialise(pairs) == #"a10141ff" +} + +test encode_pairs_empty() { + let pairs: Pairs = [] + cbor.serialise(pairs) == #"a0" +} + // ============================================================================ // Option Types (Constructors 0 and 1) // ============================================================================ @@ -215,12 +230,12 @@ test encode_option_of_list() { cbor.serialise(Some([1, 2, 3])) == #"d8799f9f010203ffff" } -test encode_map_with_option_values() { +test encode_tuples_with_option_values() { cbor.serialise([(1, Some(100)), (2, None)]) == #"9f9f01d8799f1864ffff9f02d87a80ffff" } -test encode_map_nested_as_value() { - // Map containing another map as value (list of pairs as value) +test encode_tuples_nested_as_value() { + // List of tuples with another list of tuples as a value cbor.serialise([(1, [(2, 3)])]) == #"9f9f019f9f0203ffffffff" }