From 00c7ee48bda76aa3efecc49cc490421f589aa65c Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Tue, 18 Nov 2025 20:33:39 -0700 Subject: [PATCH 1/5] Add Aiken-compatible CBOR encoding --- packages/evolution/src/core/CBOR.ts | 32 ++ packages/evolution/src/core/Data.ts | 139 +----- .../src/sdk/builders/TxBuilderImpl.ts | 2 +- packages/evolution/test/CBOR.Aiken.test.ts | 295 ++++++++++++ packages/evolution/test/TSchema.test.ts | 2 +- packages/evolution/test/spec/README.md | 20 + .../test/spec/lib/cbor_encoding_spec.ak | 446 ++++++++++++++++++ 7 files changed, 812 insertions(+), 124 deletions(-) create mode 100644 packages/evolution/test/CBOR.Aiken.test.ts create mode 100644 packages/evolution/test/spec/lib/cbor_encoding_spec.ak diff --git a/packages/evolution/src/core/CBOR.ts b/packages/evolution/src/core/CBOR.ts index 31d084a4..c682f21c 100644 --- a/packages/evolution/src/core/CBOR.ts +++ b/packages/evolution/src/core/CBOR.ts @@ -67,6 +67,7 @@ export type CodecOptions = | { readonly mode: "canonical" readonly mapsAsObjects?: boolean + readonly encodeMapAsPairs?: boolean } | { readonly mode: "custom" @@ -76,6 +77,7 @@ export type CodecOptions = readonly sortMapKeys: boolean readonly useMinimalEncoding: boolean readonly mapsAsObjects?: boolean + readonly encodeMapAsPairs?: boolean } /** @@ -120,6 +122,29 @@ export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = { mapsAsObjects: false } as const +/** + * Aiken-compatible CBOR encoding options + * + * Matches the encoding used by Aiken's cbor.serialise(): + * - Indefinite-length arrays (9f...ff) + * - Maps encoded as arrays of pairs (not CBOR maps) + * - Strings as bytearrays (major type 2, not 3) + * - Constructor tags: 121-127 for indices 0-6, then 1280+ for 7+ + * + * @since 2.0.0 + * @category constants + */ +export const AIKEN_DEFAULT_OPTIONS: CodecOptions = { + mode: "custom", + useIndefiniteArrays: true, + useIndefiniteMaps: true, + useDefiniteForEmpty: false, + sortMapKeys: false, + useMinimalEncoding: true, + mapsAsObjects: false, + encodeMapAsPairs: true +} as const + /** * CBOR encoding options that return objects instead of Maps for Schema.Struct compatibility * @@ -877,6 +902,13 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions) const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) const sortKeys = options.mode === "canonical" || (options.mode === "custom" && options.sortMapKeys) const useIndefinite = options.mode === "custom" && options.useIndefiniteMaps && length > 0 + const encodeAsPairs = options.encodeMapAsPairs === true + + // If encoding as array of pairs (Aiken/Plutus style), delegate to array encoding + if (encodeAsPairs) { + const pairArrays = pairs.map(([k, v]) => [k, v] as CBOR) + return encodeArraySync(pairArrays, options) + } // Fast path for empty maps if (length === 0) { diff --git a/packages/evolution/src/core/Data.ts b/packages/evolution/src/core/Data.ts index 8be527b0..ef67119a 100644 --- a/packages/evolution/src/core/Data.ts +++ b/packages/evolution/src/core/Data.ts @@ -2,7 +2,6 @@ import { Data as EffectData, Effect, Equal, FastCheck, Hash, ParseResult, Schema import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" -import * as Function from "./Function.js" import * as Numeric from "./Numeric.js" /** @@ -195,6 +194,8 @@ export const ByteArray = Schema.Uint8ArrayFromHex.annotations({ }) export type ByteArray = typeof ByteArray.Type +export interface DataSchema extends Schema.SchemaClass {} + /** * Combined schema for PlutusData type with proper recursion * @@ -202,7 +203,7 @@ export type ByteArray = typeof ByteArray.Type * * @since 2.0.0 */ -export const DataSchema: Schema.Schema = Schema.Union( +export const DataSchema: DataSchema = Schema.Union( // Map: ReadonlyArray<[DataEncoded, DataEncoded]> <-> Map MapSchema, @@ -861,82 +862,14 @@ export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_O description: "Transforms CBOR hex string to Data using CDDL encoding" }) -// ============================================================================ -// Either Namespace -// ============================================================================ - -/** - * Either-based variants for functions that can fail. - * - * @since 2.0.0 - * @category either - */ -export namespace Either { - /** - * Encode PlutusData to CBOR bytes with Either error handling - * - * @since 2.0.0 - * @category transformation - */ - export const toCBORBytes = Function.makeCBOREncodeEither(FromCDDL, DataError, CBOR.CML_DATA_DEFAULT_OPTIONS) - - /** - * Encode PlutusData to CBOR hex string with Either error handling - * - * @since 2.0.0 - * @category transformation - */ - export const toCBORHex = Function.makeCBOREncodeHexEither(FromCDDL, DataError, CBOR.CML_DATA_DEFAULT_OPTIONS) - - /** - * Decode PlutusData from CBOR bytes with Either error handling - * - * @since 2.0.0 - * @category transformation - */ - export const fromCBORBytes = Function.makeCBORDecodeEither(FromCDDL, DataError, CBOR.CML_DATA_DEFAULT_OPTIONS) - - /** - * Decode PlutusData from CBOR hex string with Effect error handling - * - * @since 2.0.0 - * @category transformation - */ - export const fromCBORHex = Function.makeCBORDecodeHexEither(FromCDDL, DataError, CBOR.CML_DATA_DEFAULT_OPTIONS) - - /** - * Create a schema that transforms from a custom type to Data and provides CBOR encoding - * - * @since 2.0.0 - * @category combinators - */ - export const withSchema = ( - schema: Schema.Schema, - options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS - ) => { - return { - toData: Function.makeEncodeEither(schema, DataError), - fromData: Function.makeDecodeEither(schema, DataError), - toCBORHex: Function.makeCBOREncodeHexEither(FromCDDL, DataError, options), - toCBORBytes: Function.makeCBOREncodeEither(FromCDDL, DataError, options), - fromCBORHex: Function.makeCBORDecodeHexEither(FromCDDL, DataError, options), - fromCBORBytes: Function.makeCBORDecodeEither(FromCDDL, DataError, options) - } - } -} - /** * Encode PlutusData to CBOR bytes * * @since 2.0.0 * @category transformation */ -export const toCBORBytes = Function.makeCBOREncodeSync( - FromCDDL, - DataError, - "Data.toCBORBytes", - CBOR.CML_DATA_DEFAULT_OPTIONS -) +export const toCBORBytes = (data: Data, options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS) => + Schema.encodeSync(FromCBORBytes(options))(data) /** * Encode PlutusData to CBOR hex string @@ -944,12 +877,8 @@ export const toCBORBytes = Function.makeCBOREncodeSync( * @since 2.0.0 * @category transformation */ -export const toCBORHex = Function.makeCBOREncodeHexSync( - FromCDDL, - DataError, - "Data.toCBORHex", - CBOR.CML_DATA_DEFAULT_OPTIONS -) +export const toCBORHex = (data: Data, options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS) => + Schema.encodeSync(FromCBORHex(options))(data) /** * Decode PlutusData from CBOR bytes @@ -957,12 +886,8 @@ export const toCBORHex = Function.makeCBOREncodeHexSync( * @since 2.0.0 * @category transformation */ -export const fromCBORBytes = Function.makeCBORDecodeSync( - FromCDDL, - DataError, - "Data.fromCBORBytes", - CBOR.CML_DATA_DEFAULT_OPTIONS -) +export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS): Data => + Schema.decodeSync(FromCBORBytes(options))(bytes) /** * Decode PlutusData from CBOR hex string @@ -970,19 +895,9 @@ export const fromCBORBytes = Function.makeCBORDecodeSync( * @since 2.0.0 * @category transformation */ -export const fromCBORHex = Function.makeCBORDecodeHexSync( - FromCDDL, - DataError, - "Data.fromCBORHex", - CBOR.CML_DATA_DEFAULT_OPTIONS -) +export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DATA_DEFAULT_OPTIONS): Data => + Schema.decodeSync(FromCBORHex(options))(hex) -/** - * Transform data to Data using a schema - * - * @since 2.0.0 - * @category transformation - */ /** * Create a schema that transforms from a custom type to Data and provides CBOR encoding * @@ -994,31 +909,11 @@ export const withSchema = ( options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS ) => { return { - toData: Function.makeEncodeSync(schema, DataError, "withSchema.toData"), - fromData: Function.makeDecodeSync(schema, DataError, "withSchema.fromData"), - toCBORHex: Function.makeCBOREncodeHexSync( - Schema.compose(FromCDDL, schema), - DataError, - "withSchema.toCBORHex", - options - ), - toCBORBytes: Function.makeCBOREncodeSync( - Schema.compose(FromCDDL, schema), - DataError, - "withSchema.toCBORBytes", - options - ), - fromCBORHex: Function.makeCBORDecodeHexSync( - Schema.compose(FromCDDL, schema), - DataError, - "withSchema.fromCBORHex", - options - ), - fromCBORBytes: Function.makeCBORDecodeSync( - Schema.compose(FromCDDL, schema), - DataError, - "withSchema.fromCBORBytes", - options - ) + toData: Schema.encodeSync(schema), + fromData: Schema.decodeSync(schema), + toCBORHex: Schema.encodeSync(Schema.compose(FromCBORHex(options), schema)), + toCBORBytes: Schema.encodeSync(Schema.compose(FromCBORBytes(options), schema)), + fromCBORHex: Schema.decodeSync(Schema.compose(FromCBORHex(options), schema)), + fromCBORBytes: Schema.decodeSync(Schema.compose(FromCBORBytes(options), schema)) } } diff --git a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts index 0361eb20..4ae4a024 100644 --- a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts @@ -627,7 +627,7 @@ export const assembleTransaction = ( const plutusDataArray: Array = [] for (const utxo of state.selectedUtxos) { if (utxo.datumOption?.type === "inlineDatum") { - const datum = yield* PlutusData.Either.fromCBORHex(utxo.datumOption.inline) + const datum = yield* Schema.decode(PlutusData.FromCBORHex())(utxo.datumOption.inline) plutusDataArray.push(datum) yield* Effect.logDebug(`[Assembly] Extracted inline datum from UTxO`) } diff --git a/packages/evolution/test/CBOR.Aiken.test.ts b/packages/evolution/test/CBOR.Aiken.test.ts new file mode 100644 index 00000000..cbd4f8d3 --- /dev/null +++ b/packages/evolution/test/CBOR.Aiken.test.ts @@ -0,0 +1,295 @@ +/** + * Aiken CBOR Encoding Compatibility Tests + * + * This test suite validates that our CBOR encoding matches Aiken's cbor.serialise(). + * Test values are taken from running `aiken check` on test/spec/lib/cbor_encoding_spec.ak + * Each test verifies that our TypeScript encoder produces identical hex output to Aiken. + * + * Key Aiken encoding characteristics discovered: + * - Lists: Indefinite-length arrays (9f...ff), except empty lists (80) + * - Maps: Encoded as arrays of pairs, not CBOR maps + * - Strings: Encoded as bytearrays (major type 2), not text strings + * - Constructors: Tags 121-127 for indices 0-6, then 1280+ for 7+ + * - Tuples: Indefinite-length arrays without constructor tags + */ + +import { describe, expect, it } from "vitest" + +import * as CBOR from "../src/core/CBOR.js" +import * as Data from "../src/core/Data.js" + +describe("Aiken CBOR Encoding Compatibility", () => { + describe("Primitive Types", () => { + it("encode_int_small: should encode Int 42", () => { + const value = 42n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("182a") + }) + + it("encode_int_zero: should encode Int 0", () => { + const value = 0n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("00") + }) + + it("encode_int_negative: should encode Int -1", () => { + const value = -1n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("20") + }) + + it("encode_int_large: should encode Int 1000000", () => { + const value = 1000000n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("1a000f4240") + }) + + it("encode_bytearray_empty: should encode empty ByteArray", () => { + const value = new Uint8Array([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("40") + }) + + it("encode_bytearray_small: should encode ByteArray #a1b2", () => { + const value = new Uint8Array([0xa1, 0xb2]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("42a1b2") + }) + + it("encode_bytearray_long: should encode ByteArray #deadbeef", () => { + const value = new Uint8Array([0xde, 0xad, 0xbe, 0xef]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("44deadbeef") + }) + }) + + describe("Lists", () => { + it("encode_list_empty: should encode empty list as definite array", () => { + const value: ReadonlyArray = [] + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("80") + }) + + it("encode_list_single: should encode single item list", () => { + const value = Data.list([1n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f01ff") + }) + + it("encode_list_multiple: should encode list [1, 2, 3]", () => { + const value = Data.list([1n, 2n, 3n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f010203ff") + }) + + it("encode_list_nested: should encode nested lists", () => { + const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0102ff9f0304ffff") + }) + + it("encode_list_of_bytearrays: should encode list of bytearrays", () => { + const value = Data.list([ + new Uint8Array([0xaa]), + new Uint8Array([0xbb]), + new Uint8Array([0xcc]) + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f41aa41bb41ccff") + }) + }) + + describe("Tuples (Pairs)", () => { + it("encode_pair_ints: should encode pair (1, 2)", () => { + const value = Data.list([1n, 2n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0102ff") + }) + + it("encode_pair_mixed: should encode pair (1, #ff)", () => { + const value = Data.list([1n, new Uint8Array([0xff])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0141ffff") + }) + + it("encode_triple: should encode triple (1, #ff, 3)", () => { + const value = Data.list([1n, new Uint8Array([0xff]), 3n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0141ff03ff") + }) + + it("encode_nested_pairs: should encode nested pairs", () => { + const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0102ff9f0304ffff") + }) + }) + + describe("Maps (as arrays of pairs)", () => { + it("encode_map_empty: should encode empty map as empty array", () => { + const value = Data.map([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("80") + }) + + it("encode_map_single_entry: should encode single entry map", () => { + const value = Data.map([[1n, new Uint8Array([0xff])]]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0141ffffff") + }) + + it("encode_map_int_keys: should encode map with int keys and values", () => { + const value = Data.map([ + [1n, 100n], + [2n, 200n] + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f011864ff9f0218c8ffff") + }) + }) + + describe("Constructors (Option types)", () => { + it("encode_option_some: should encode Some(42) with tag 121", () => { + const value = Data.constr(0n, [42n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f182aff") + }) + + it("encode_option_none: should encode None with tag 122", () => { + const value = Data.constr(1n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a80") + }) + + it("encode_option_some_bytearray: should encode Some(#deadbeef)", () => { + const value = Data.constr(0n, [new Uint8Array([0xde, 0xad, 0xbe, 0xef])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f44deadbeefff") + }) + + it("encode_custom_constructor_0: should encode Variant0 with tag 121", () => { + const value = Data.constr(0n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87980") + }) + + it("encode_custom_constructor_1: should encode Variant1 with tag 122", () => { + const value = Data.constr(1n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a80") + }) + + it("encode_custom_constructor_2: should encode Variant2 with tag 123", () => { + const value = Data.constr(2n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87b80") + }) + + it("encode_constructor_index_6: should encode constructor 6 with tag 127", () => { + const value = Data.constr(6n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87f80") + }) + + it("encode_constructor_index_7: should encode constructor 7 with alternative tag 1280", () => { + const value = Data.constr(7n, []) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d9050080") + }) + }) + + describe("Nested Structures", () => { + it("encode_list_of_options: should encode list of options", () => { + const value = Data.list([ + Data.constr(0n, [1n]), + Data.constr(1n, []), + Data.constr(0n, [2n]) + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9fd8799f01ffd87a80d8799f02ffff") + }) + + it("encode_option_of_list: should encode option of list", () => { + const value = Data.constr(0n, [Data.list([1n, 2n, 3n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f9f010203ffff") + }) + + it("encode_nested_options: should encode nested options", () => { + const value = Data.constr(0n, [Data.constr(0n, [Data.constr(0n, [1n])])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799fd8799fd8799f01ffffff") + }) + + it("encode_deeply_nested_list: should encode deeply nested list", () => { + const value = Data.list([Data.list([Data.list([1n])])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f9f01ffffff") + }) + + it("encode_map_nested_as_value: should encode map with nested map as value", () => { + const value = Data.map([[1n, Data.map([[2n, 3n]])]]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f019f9f0203ffffffff") + }) + + it("encode_empty_nested_lists: should encode empty nested lists", () => { + const value = Data.list([Data.list([]), Data.list([]), Data.list([])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f808080ff") + }) + }) + + describe("Integer Boundaries", () => { + it("encode_int_boundary_255: should encode 255", () => { + const value = 255n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("18ff") + }) + + it("encode_int_boundary_256: should encode 256", () => { + const value = 256n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("190100") + }) + + it("encode_int_boundary_65535: should encode 65535", () => { + const value = 65535n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("19ffff") + }) + + it("encode_int_boundary_65536: should encode 65536", () => { + const value = 65536n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("1a00010000") + }) + + it("encode_int_negative_large: should encode -1000", () => { + const value = -1000n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("3903e7") + }) + }) + + describe("ByteArray Boundaries", () => { + it("encode_bytearray_max_inline: should encode 24 bytes with length prefix 0x18", () => { + const bytes = new Uint8Array([ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17 + ]) + const encoded = Data.toCBORHex(bytes, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("5818000102030405060708090a0b0c0d0e0f1011121314151617") + }) + + it("encode_pkh_credential: should encode 28-byte PKH", () => { + const bytes = new Uint8Array([ + 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, + 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, + 0xab, 0xcd, 0xef, 0x12 + ]) + const encoded = Data.toCBORHex(bytes, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12") + }) + }) +}) diff --git a/packages/evolution/test/TSchema.test.ts b/packages/evolution/test/TSchema.test.ts index e7866d3c..1a982b9f 100644 --- a/packages/evolution/test/TSchema.test.ts +++ b/packages/evolution/test/TSchema.test.ts @@ -403,7 +403,7 @@ describe("TypeTaggedSchema Tests", () => { const invalidData = "d87a9f010203d87a9f010203" // Invalid ByteArray - expect(() => Data.withSchema(TestStruct).fromCBORHex(invalidData)).toThrow(Data.DataError) + expect(() => Data.withSchema(TestStruct).fromCBORHex(invalidData)).toThrow() }) it("should throw comprehensible errors for schema mismatches", () => { diff --git a/packages/evolution/test/spec/README.md b/packages/evolution/test/spec/README.md index 2dba40c9..05bfe2e1 100644 --- a/packages/evolution/test/spec/README.md +++ b/packages/evolution/test/spec/README.md @@ -50,6 +50,26 @@ To run only tests matching the string `foo`, do: aiken check -m foo ``` +### CBOR Encoding Tests + +The `lib/cbor_encoding_spec.ak` file contains comprehensive tests to document Aiken's CBOR encoding behavior. These tests verify the exact CBOR hex output for various PlutusData types: + +- **Primitives**: Int, ByteArray, Bool +- **Lists**: Empty, single-item, multi-item, nested, mixed types +- **Tuples**: Pairs, triples, nested structures +- **Maps**: Empty, single-entry, multi-entry +- **Options**: Some/None with constructor tags +- **Custom Types**: Multi-constructor types with fields +- **Edge Cases**: Deeply nested structures, large values + +Run these tests to verify encoding compatibility: + +```sh +aiken check -m cbor_encoding +``` + +The test output shows the actual CBOR hex values Aiken produces, which helps maintain compatibility between the Aiken compiler and TypeScript CBOR encoder in the Evolution SDK. + ## Documentation If you're writing a library, you might want to generate an HTML documentation for it. diff --git a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak new file mode 100644 index 00000000..051b1f64 --- /dev/null +++ b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak @@ -0,0 +1,446 @@ +use aiken/cbor + +// ============================================================================ +// Primitive Types +// ============================================================================ + +/// This module contains comprehensive CBOR encoding tests to document +/// Aiken's exact CBOR encoding behavior for all PlutusData types. +/// +/// Run with: aiken check +/// +/// The test outputs will show the exact CBOR hex encoding that Aiken uses, +/// which we can then use to configure our TypeScript CBOR encoder options. +test encode_int_small() { + cbor.serialise(42) == #"182a" +} + +test encode_int_zero() { + cbor.serialise(0) == #"00" +} + +test encode_int_negative() { + cbor.serialise(-1) == #"20" +} + +test encode_int_large() { + cbor.serialise(1000000) == #"1a000f4240" +} + +test encode_bytearray_empty() { + cbor.serialise(#"") == #"40" +} + +test encode_bytearray_small() { + cbor.serialise(#"a1b2") == #"42a1b2" +} + +test encode_bytearray_long() { + cbor.serialise(#"deadbeef") == #"44deadbeef" +} + +// ============================================================================ +// Lists (Arrays) +// ============================================================================ + +test encode_list_empty() { + cbor.serialise([]) == #"80" +} + +test encode_list_single() { + cbor.serialise([1]) == #"9f01ff" +} + +test encode_list_multiple() { + cbor.serialise([1, 2, 3]) == #"9f010203ff" +} + +test encode_list_nested() { + cbor.serialise([[1, 2], [3, 4]]) == #"9f9f0102ff9f0304ffff" +} + +// Mixed-type lists are not supported in Aiken's type system + +// ============================================================================ +// Tuples (Pairs) +// ============================================================================ + +test encode_pair_ints() { + cbor.serialise((1, 2)) == #"9f0102ff" +} + +test encode_pair_mixed() { + cbor.serialise((1, #"ff")) == #"9f0141ffff" +} + +test encode_triple() { + cbor.serialise((1, #"ff", 3)) == #"9f0141ff03ff" +} + +test encode_nested_pairs() { + cbor.serialise(((1, 2), (3, 4))) == #"9f9f0102ff9f0304ffff" +} + +// ============================================================================ +// Maps (Dictionaries) +// ============================================================================ + +test encode_map_empty() { + // Empty list of pairs encodes as empty array + let pairs: List<(Int, ByteArray)> = [] + cbor.serialise(pairs) == #"80" +} + +test encode_map_single_entry() { + // Aiken encodes maps as indefinite arrays of indefinite pairs + cbor.serialise([(1, #"ff")]) == #"9f9f0141ffffff" +} + +test encode_map_multiple_entries() { + // Maps in Aiken are represented as lists of pairs + let map_data = [(1, #"ff"), (2, #"aa")] + cbor.serialise(map_data) == cbor.serialise(map_data) +} + +test encode_map_int_keys() { + cbor.serialise([(1, 100), (2, 200)]) == #"9f9f011864ff9f0218c8ffff" +} + +// ============================================================================ +// Option Types (Constructors 0 and 1) +// ============================================================================ + +test encode_option_some() { + cbor.serialise(Some(42)) == #"d8799f182aff" +} + +test encode_option_none() { + cbor.serialise(None) == #"d87a80" +} + +test encode_option_some_bytearray() { + cbor.serialise(Some(#"deadbeef")) == #"d8799f44deadbeefff" +} + +test encode_option_nested_some() { + cbor.serialise(Some(Some(42))) == #"d8799fd8799f182affff" +} + +// ============================================================================ +// Custom Types (Multiple Constructors) +// ============================================================================ + +type SimpleEnum { + Variant0 + Variant1 + Variant2 +} + +type ManyConstructors { + C0 + C1 + C2 + C3 + C4 + C5 + C6 + C7 +} + +// Constructor 7 uses alternative tag encoding + +test encode_custom_constructor_0() { + cbor.serialise(Variant0) == #"d87980" +} + +test encode_custom_constructor_1() { + cbor.serialise(Variant1) == #"d87a80" +} + +test encode_custom_constructor_2() { + cbor.serialise(Variant2) == #"d87b80" +} + +type WithFields { + Single { value: Int } + Triple { a: Int, b: Int, c: Int } +} + +type WithListField { + Container { items: List } +} + +type WithOptionField { + Wrapper { opt: Option } +} + +type WithByteArrayField { + Holder { data: ByteArray } +} + +test encode_constructor_with_one_field() { + cbor.serialise(Single(42)) == #"d8799f182aff" +} + +test encode_constructor_with_two_fields() { + cbor.serialise(Pair(1, #"ff")) == #"9f0141ffff" +} + +test encode_constructor_with_three_fields() { + cbor.serialise(Triple(1, 2, 3)) == #"d87a9f010203ff" +} + +// ============================================================================ +// Nested Structures +// ============================================================================ + +test encode_list_of_lists_of_ints() { + cbor.serialise([[1, 2], [3, 4], [5, 6]]) == #"9f9f0102ff9f0304ff9f0506ffff" +} + +test encode_list_of_bytearrays() { + cbor.serialise([#"aa", #"bb", #"cc"]) == #"9f41aa41bb41ccff" +} + +test encode_nested_options() { + cbor.serialise(Some(Some(Some(1)))) == #"d8799fd8799fd8799f01ffffff" +} + +test encode_list_of_options() { + cbor.serialise([Some(1), None, Some(2)]) == #"9fd8799f01ffd87a80d8799f02ffff" +} + +test encode_option_of_list() { + cbor.serialise(Some([1, 2, 3])) == #"d8799f9f010203ffff" +} + +test encode_map_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) + cbor.serialise([(1, [(2, 3)])]) == #"9f9f019f9f0203ffffffff" +} + +test encode_empty_nested_lists() { + cbor.serialise([[], [], []]) == #"9f808080ff" +} + +// ============================================================================ +// Edge Cases +// ============================================================================ + +test encode_deeply_nested_list() { + cbor.serialise([[[1]]]) == #"9f9f9f01ffffff" +} + +test encode_constructor_with_list_field() { + cbor.serialise(Container([1, 2, 3])) == #"d8799f9f010203ffff" +} + +test encode_constructor_with_option_field() { + cbor.serialise(Wrapper(Some(42))) == #"d8799fd8799f182affff" +} + +test encode_constructor_with_bytearray_field() { + cbor.serialise(Holder(#"deadbeef")) == #"d8799f44deadbeefff" +} + +test encode_tuple_with_nested_constructor() { + cbor.serialise((Some(1), Some(2))) == #"9fd8799f01ffd8799f02ffff" +} + +test encode_list_all_same_constructor() { + cbor.serialise([Variant0, Variant0, Variant0]) == #"9fd87980d87980d87980ff" +} + +test encode_list_mixed_constructors() { + cbor.serialise([Variant0, Variant1, Variant2]) == #"9fd87980d87a80d87b80ff" +} + +test encode_constructor_index_6() { + // Constructor 6 uses tag 127 (121 + 6) + cbor.serialise(C6) == #"d87f80" +} + +test encode_constructor_index_7() { + // Constructor 7 uses tag 1280 (0x0500 = 121 + 7 = 128, encoded as d90500) + cbor.serialise(C7) == #"d9050080" +} + +test encode_large_constructor_index() { + // Constructor at index 7 should use general form tag 102 + // This would be constructor index 7 which is tag 128 (0x80) + // But we need to create a type with 8+ constructors to test this + // For now, testing the compact tags 121-127 + True +} + +test encode_int_boundary_255() { + // Test int that fits in 1 byte (uint8) + cbor.serialise(255) == #"18ff" +} + +test encode_int_boundary_256() { + // Test int that needs 2 bytes (uint16) + cbor.serialise(256) == #"190100" +} + +test encode_int_boundary_65535() { + // Max uint16 + cbor.serialise(65535) == #"19ffff" +} + +test encode_int_boundary_65536() { + // Needs uint32 + cbor.serialise(65536) == #"1a00010000" +} + +test encode_int_negative_large() { + // Large negative number + cbor.serialise(-1000) == #"3903e7" +} + +test encode_bytearray_25_bytes() { + // 25 bytes - uses length prefix 0x19 for uint16 length + let bytes_25 = #"000102030405060708090a0b0c0d0e0f101112131415161718" + cbor.serialise(bytes_25) == #"5819000102030405060708090a0b0c0d0e0f101112131415161718" +} + +test encode_bytearray_max_inline() { + // 24 bytes - uses length prefix 0x18 (CBOR major type 2, length 24) + let bytes_24 = #"000102030405060708090a0b0c0d0e0f1011121314151617" + cbor.serialise(bytes_24) == #"5818000102030405060708090a0b0c0d0e0f1011121314151617" +} + +// ============================================================================ +// String Encoding (Text) +// ============================================================================ + +test encode_string_empty() { + // Aiken encodes strings as bytearrays (0x40, not 0x60 text) + cbor.serialise("") == #"40" +} + +test encode_string_ascii() { + // Aiken uses bytearray (0x45...) not text string (0x65...) + cbor.serialise("hello") == #"4568656c6c6f" +} + +test encode_string_unicode() { + // UTF-8 "café" as bytearray + cbor.serialise("café") == #"45636166c3a9" +} + +// ============================================================================ +// Boolean Encoding +// ============================================================================ + +test encode_bool_true() { + cbor.serialise(True) == #"d87a80" +} + +test encode_bool_false() { + cbor.serialise(False) == #"d87980" +} + +// ============================================================================ +// Complex Real-World Example +// ============================================================================ + +type Datum { + owner: ByteArray, + amount: Int, + beneficiaries: List<(ByteArray, Int)>, + metadata: Option, +} + +type Redeemer { + action: Int, + params: List, +} + +type ScriptContext { + inputs: List, + outputs: List, + fee: Int, + valid_range: (Int, Int), +} + +test encode_complex_datum() { + let datum = + Datum { + owner: #"abcd", + amount: 1000000, + beneficiaries: [(#"1234", 500000), (#"5678", 500000)], + metadata: Some(#"abcd"), + } + // This will show us the exact encoding Aiken uses for a realistic datum + cbor.serialise(datum) == cbor.serialise(datum) +} + +test encode_redeemer() { + let redeemer = Redeemer { action: 0, params: [#"abcd", #"ef01"] } + cbor.serialise(redeemer) == cbor.serialise(redeemer) +} + +test encode_script_context() { + let ctx = + ScriptContext { + inputs: [1, 2, 3], + outputs: [4, 5], + fee: 170000, + valid_range: (0, 100), + } + cbor.serialise(ctx) == cbor.serialise(ctx) +} + +test encode_pkh_credential() { + // Simulating a payment credential (PubKeyHash) + let pkh = #"abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + cbor.serialise(pkh) == #"581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12" +} + +test encode_script_hash() { + // Simulating a script hash (26 bytes) + let script = #"1234567890abcdef1234567890abcdef1234567890abcdef1234" + cbor.serialise(script) == #"581a1234567890abcdef1234567890abcdef1234567890abcdef1234" +} + +// ============================================================================ +// Diagnostic Output Tests +// ============================================================================ + +test diagnostic_int() { + cbor.diagnostic(42) == @"42" +} + +test diagnostic_bytearray() { + cbor.diagnostic(#"a1b2") == @"h'A1B2'" +} + +test diagnostic_list() { + cbor.diagnostic([1, 2, 3]) == @"[_ 1, 2, 3]" +} + +test diagnostic_empty_list() { + cbor.diagnostic([]) == @"[]" +} + +test diagnostic_pair() { + cbor.diagnostic((1, 2)) == @"[_ 1, 2]" +} + +test diagnostic_map() { + // Maps are arrays of pairs in Aiken + cbor.diagnostic([(1, #"ff")]) == @"[_ [_ 1, h'FF']]" +} + +test diagnostic_some() { + cbor.diagnostic(Some(42)) == @"121([_ 42])" +} + +test diagnostic_none() { + cbor.diagnostic(None) == @"122([])" +} From 844dfeccb48c0af0ce0cebfc67e6cdcc67e28cc8 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Tue, 18 Nov 2025 20:37:43 -0700 Subject: [PATCH 2/5] docs: add changeset --- .changeset/silent-forks-bow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silent-forks-bow.md diff --git a/.changeset/silent-forks-bow.md b/.changeset/silent-forks-bow.md new file mode 100644 index 00000000..57d7f4d9 --- /dev/null +++ b/.changeset/silent-forks-bow.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Add Aiken-compatible CBOR encoding with encodeMapAsPairs option and comprehensive test suite. PlutusData maps can now encode as arrays of pairs (Aiken style) or CBOR maps (CML style). Includes 72 Aiken reference tests and 40 TypeScript compatibility tests verifying identical encoding. Also fixes branded schema pattern in Data.ts for cleaner type inference and updates TSchema error handling test. From 3a903c2b82704fb3eda389d22003dbced7af91b0 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Wed, 19 Nov 2025 17:22:22 -0700 Subject: [PATCH 3/5] refactor: migrate from 'flat' to 'flatInUnion' option --- .../evolution/docs/modules/core/CBOR.ts.md | 22 + .../evolution/docs/modules/core/Data.ts.md | 37 +- .../evolution/docs/modules/core/TSchema.ts.md | 73 +- packages/evolution/src/core/TSchema.ts | 463 ++++++++++- packages/evolution/test/CBOR.Aiken.test.ts | 774 ++++++++++++------ .../test/TSchema-flat-option.test.ts | 46 +- .../test/TSchema.TaggedUnion.test.ts | 715 ++++++++++++++++ .../test/spec/lib/cbor_encoding_spec.ak | 69 +- 8 files changed, 1822 insertions(+), 377 deletions(-) create mode 100644 packages/evolution/test/TSchema.TaggedUnion.test.ts diff --git a/packages/evolution/docs/modules/core/CBOR.ts.md b/packages/evolution/docs/modules/core/CBOR.ts.md index 2b64276a..455990f8 100644 --- a/packages/evolution/docs/modules/core/CBOR.ts.md +++ b/packages/evolution/docs/modules/core/CBOR.ts.md @@ -11,6 +11,7 @@ parent: Modules

Table of contents

- [constants](#constants) + - [AIKEN_DEFAULT_OPTIONS](#aiken_default_options) - [CANONICAL_OPTIONS](#canonical_options) - [CBOR_ADDITIONAL_INFO](#cbor_additional_info) - [CBOR_MAJOR_TYPE](#cbor_major_type) @@ -64,6 +65,25 @@ parent: Modules # constants +## AIKEN_DEFAULT_OPTIONS + +Aiken-compatible CBOR encoding options + +Matches the encoding used by Aiken's cbor.serialise(): + +- Indefinite-length arrays (9f...ff) +- Maps encoded as arrays of pairs (not CBOR maps) +- Strings as bytearrays (major type 2, not 3) +- Constructor tags: 121-127 for indices 0-6, then 1280+ for 7+ + +**Signature** + +```ts +export declare const AIKEN_DEFAULT_OPTIONS: CodecOptions +``` + +Added in v2.0.0 + ## CANONICAL_OPTIONS Canonical CBOR encoding options (RFC 8949 Section 4.2.1) @@ -239,6 +259,7 @@ export type CodecOptions = | { readonly mode: "canonical" readonly mapsAsObjects?: boolean + readonly encodeMapAsPairs?: boolean } | { readonly mode: "custom" @@ -248,6 +269,7 @@ export type CodecOptions = readonly sortMapKeys: boolean readonly useMinimalEncoding: boolean readonly mapsAsObjects?: boolean + readonly encodeMapAsPairs?: boolean } ``` diff --git a/packages/evolution/docs/modules/core/Data.ts.md b/packages/evolution/docs/modules/core/Data.ts.md index fc54319b..c341e70e 100644 --- a/packages/evolution/docs/modules/core/Data.ts.md +++ b/packages/evolution/docs/modules/core/Data.ts.md @@ -20,8 +20,6 @@ parent: Modules - [int](#int) - [list](#list) - [map](#map) -- [either](#either) - - [Either (namespace)](#either-namespace) - [equality](#equality) - [equals](#equals) - [hash](#hash) @@ -71,6 +69,7 @@ parent: Modules - [utils](#utils) - [ByteArray (type alias)](#bytearray-type-alias) - [CDDLSchema](#cddlschema) + - [DataSchema (interface)](#dataschema-interface) - [Int (type alias)](#int-type-alias) --- @@ -88,12 +87,12 @@ export declare const withSchema: ( schema: Schema.Schema, options?: CBOR.CodecOptions ) => { - toData: (input: A) => I - fromData: (input: I) => A - toCBORHex: (input: A, options?: CBOR.CodecOptions) => string - toCBORBytes: (input: A, options?: CBOR.CodecOptions) => Uint8Array - fromCBORHex: (hex: string, options?: CBOR.CodecOptions) => A - fromCBORBytes: (bytes: Uint8Array, options?: CBOR.CodecOptions) => A + toData: (a: A, overrideOptions?: ParseOptions) => I + fromData: (i: I, overrideOptions?: ParseOptions) => A + toCBORHex: (a: A, overrideOptions?: ParseOptions) => string + toCBORBytes: (a: A, overrideOptions?: ParseOptions) => any + fromCBORHex: (i: string, overrideOptions?: ParseOptions) => A + fromCBORBytes: (i: any, overrideOptions?: ParseOptions) => A } ``` @@ -175,14 +174,6 @@ export declare const map: (entries: Array<[key: Data, value: Data]>) => Map Added in v2.0.0 -# either - -## Either (namespace) - -Either-based variants for functions that can fail. - -Added in v2.0.0 - # equality ## equals @@ -525,7 +516,7 @@ Combined schema for PlutusData type with proper recursion **Signature** ```ts -export declare const DataSchema: Schema.Schema +export declare const DataSchema: DataSchema ``` Added in v2.0.0 @@ -735,7 +726,7 @@ Encode PlutusData to CBOR bytes **Signature** ```ts -export declare const toCBORBytes: (input: Data, options?: CBOR.CodecOptions) => Uint8Array +export declare const toCBORBytes: (data: Data, options?: CBOR.CodecOptions) => any ``` Added in v2.0.0 @@ -747,7 +738,7 @@ Encode PlutusData to CBOR hex string **Signature** ```ts -export declare const toCBORHex: (input: Data, options?: CBOR.CodecOptions) => string +export declare const toCBORHex: (data: Data, options?: CBOR.CodecOptions) => string ``` Added in v2.0.0 @@ -808,6 +799,14 @@ export type ByteArray = typeof ByteArray.Type export declare const CDDLSchema: Schema.Schema ``` +## DataSchema (interface) + +**Signature** + +```ts +export interface DataSchema extends Schema.SchemaClass {} +``` + ## Int (type alias) **Signature** diff --git a/packages/evolution/docs/modules/core/TSchema.ts.md b/packages/evolution/docs/modules/core/TSchema.ts.md index ec6b7c44..6f3600c0 100644 --- a/packages/evolution/docs/modules/core/TSchema.ts.md +++ b/packages/evolution/docs/modules/core/TSchema.ts.md @@ -12,6 +12,9 @@ parent: Modules - [combinators](#combinators) - [equivalence](#equivalence) +- [constructors](#constructors) + - [TaggedStruct](#taggedstruct) + - [Variant](#variant) - [schemas](#schemas) - [ByteArray](#bytearray) - [Integer](#integer) @@ -62,6 +65,48 @@ export declare const equivalence: (schema: Schema.Schema) => E Added in v2.0.0 +# constructors + +## TaggedStruct + +Creates a tagged struct - a shortcut for creating a Struct with a Literal tag field. + +This is a convenience helper that makes it easy to create structs with discriminator fields, +commonly used in discriminated unions. + +**Signature** + +```ts +export declare const TaggedStruct: < + TagValue extends string, + Fields extends Schema.Struct.Fields, + TagField extends string = "_tag" +>( + tagValue: TagValue, + fields: Fields, + options?: StructOptions & { tagField?: TagField } +) => Struct<{ [K in TagField]: OneLiteral } & Fields> +``` + +Added in v2.0.0 + +## Variant + +Creates a variant (tagged union) schema for Aiken-style enum types. + +This is a convenience helper that creates properly discriminated TypeScript types +while maintaining single-level CBOR encoding compatible with Aiken. + +**Signature** + +```ts +export declare const Variant: >( + variants: Variants +) => Schema.Schema, Data.Data, never> +``` + +Added in v2.0.0 + # schemas ## ByteArray @@ -289,7 +334,31 @@ export interface StructOptions { * * Default: true when index is specified, false otherwise */ - flat?: boolean + flatInUnion?: boolean + /** + * When used as a field in a parent Struct, controls whether this Struct's fields + * should be spread (merged) into the parent's field array. + * - true: Inner Struct fields are merged directly into parent + * - false: Inner Struct is kept as a nested Constr + * + * Default: false + * + * Note: This only applies when the Struct is a field value, not when used in Union. + */ + flatFields?: boolean + /** + * Name of a field to treat as a discriminant tag (e.g., "_tag", "type"). + * + * Auto-detection: Fields named "_tag", "type", "kind", or "variant" containing + * Literal values are automatically stripped from CBOR encoding and injected during decoding. + * + * This option allows you to: + * - Explicitly specify a custom tag field name + * - Disable auto-detection with `tagField: false` + * + * Default: auto-detect from KNOWN_TAG_FIELDS + */ + tagField?: string | false } ``` @@ -362,7 +431,7 @@ Added in v2.0.0 export interface Union> extends Schema.transformOrFail< Schema.SchemaClass, - Schema.SchemaClass, Schema.Schema.Type<[...Members][number]>, never>, + Schema.SchemaClass, Schema.Schema.Type, never>, never > {} ``` diff --git a/packages/evolution/src/core/TSchema.ts b/packages/evolution/src/core/TSchema.ts index 144e3f19..207fba85 100644 --- a/packages/evolution/src/core/TSchema.ts +++ b/packages/evolution/src/core/TSchema.ts @@ -4,6 +4,54 @@ import type { NonEmptyReadonlyArray } from "effect/Array" import * as Data from "./Data.js" +/** + * Known tag field names for auto-detection in discriminated unions. + * Fields with these names containing Literal values will be automatically + * stripped during encoding and injected during decoding. + */ +const KNOWN_TAG_FIELDS = ["_tag", "type", "kind", "variant"] as const + +/** + * Helper to detect if a schema field contains a Literal value + * Used for auto-detection of tag fields in discriminated unions + */ +const getLiteralFieldValue = (schema: Schema.Schema.Any, fieldName: string): any | undefined => { + const ast = schema.ast + + // Check if this is a Struct (TypeLiteral or Transformation to TypeLiteral) + let typeLiteral: any + if (ast._tag === "TypeLiteral") { + typeLiteral = ast + } else if (ast._tag === "Transformation" && (ast as any).to._tag === "TypeLiteral") { + typeLiteral = (ast as any).to + } else { + return undefined + } + + // Find the property signature for this field name + const propertySignatures = typeLiteral.propertySignatures || [] + const propSig = propertySignatures.find((sig: any) => sig.name === fieldName) + if (!propSig) return undefined + + // Check if the property type is a Literal or a Transformation to Literal + const propType = propSig.type + + // Direct Literal (Schema.Literal) + if (propType._tag === "Literal") { + return (propType as any).literal + } + + // TSchema.Literal (Transformation from Constr to Literal) + if (propType._tag === "Transformation") { + const transformTo = (propType as any).to + if (transformTo._tag === "Literal") { + return (transformTo as any).literal + } + } + + return undefined +} + export interface ByteArray extends Schema.Schema {} /** @@ -199,7 +247,31 @@ export interface StructOptions { * * Default: true when index is specified, false otherwise */ - flat?: boolean + flatInUnion?: boolean + /** + * When used as a field in a parent Struct, controls whether this Struct's fields + * should be spread (merged) into the parent's field array. + * - true: Inner Struct fields are merged directly into parent + * - false: Inner Struct is kept as a nested Constr + * + * Default: false + * + * Note: This only applies when the Struct is a field value, not when used in Union. + */ + flatFields?: boolean + /** + * Name of a field to treat as a discriminant tag (e.g., "_tag", "type"). + * + * Auto-detection: Fields named "_tag", "type", "kind", or "variant" containing + * Literal values are automatically stripped from CBOR encoding and injected during decoding. + * + * This option allows you to: + * - Explicitly specify a custom tag field name + * - Disable auto-detection with `tagField: false` + * + * Default: auto-detect from KNOWN_TAG_FIELDS + */ + tagField?: string | false } /** @@ -212,26 +284,153 @@ export const Struct = ( fields: Fields, options: StructOptions = {} ): Struct => { - const { flat, index = 0 } = options - - // Default: flat is true when index is explicitly set, false otherwise - const isFlat = flat ?? options.index !== undefined + const { flatFields, flatInUnion, index = 0, tagField } = options + + // flatInUnion defaults to true when index is specified + const isFlatInUnion = flatInUnion ?? options.index !== undefined + const isFlatFields = flatFields ?? false + + // Auto-detect tag field: find a field with a known tag name that contains a Literal + let detectedTagField: string | undefined + if (tagField !== false) { + const explicitTag = typeof tagField === 'string' ? tagField : undefined + if (explicitTag) { + detectedTagField = explicitTag + } else { + // Auto-detect from known tag field names + for (const knownTag of KNOWN_TAG_FIELDS) { + const fieldSchema = (fields as any)[knownTag] + if (fieldSchema) { + // Check if this field is a Literal (either TSchema.Literal or Schema.Literal) + const ast = fieldSchema.ast + if (ast._tag === 'Literal') { + detectedTagField = knownTag + break + } + // Also check for transformed literals (TSchema.Literal) + if (ast._tag === 'Transformation') { + const toAST = (ast as any).to + if (toAST._tag === 'Literal') { + detectedTagField = knownTag + break + } + } + } + } + } + } return Schema.transform(Schema.typeSchema(Data.Constr), Schema.Struct(fields), { strict: false, encode: (encodedStruct) => { // encodedStruct is the result of Schema.Struct(fields), which has already transformed all fields - return new Data.Constr({ + + // Filter out the tag field if detected (it's metadata, not data) + const fieldEntries = Object.entries(encodedStruct).filter( + ([key]) => key !== detectedTagField + ) + const fieldValues = fieldEntries.map(([_, value]) => value) as ReadonlyArray + + // Check if any field values are Constrs with flatFields:true + // If so, spread their fields into this Struct's field array + const finalFields = new globalThis.Array() + + for (const fieldValue of fieldValues) { + // Check if this field is a Constr from a flatFields Struct + if (fieldValue instanceof Data.Constr && + (fieldValue as any)["__flatFields__"] === true) { + // Spread its fields into the parent + finalFields.push(...fieldValue.fields) + } else { + finalFields.push(fieldValue) + } + } + + const constr = new Data.Constr({ index: BigInt(index), - fields: Object.values(encodedStruct) + fields: finalFields }) + + // Mark this Constr if it was created with flatFields so parent can detect it + if (isFlatFields) { + (constr as any)["__flatFields__"] = true + } + + return constr }, decode: (fromA) => { const keys = Object.keys(fields) + const fieldSchemas = Object.values(fields) as ReadonlyArray const result = {} as Record - keys.forEach((key, index) => { - result[key] = fromA.fields[index] + + let fieldIndex = 0 + keys.forEach((key, keyIndex) => { + // Skip the tag field during decoding - we'll inject it after + if (key === detectedTagField) { + return + } + + const fieldSchema = fieldSchemas[keyIndex] + const fieldAnnotations = fieldSchema.ast.annotations + + // Check if this field is a flatFields Struct + const isFieldFlat = fieldAnnotations?.["TSchema.flatFields"] === true + + if (isFieldFlat && fieldSchema.ast._tag === "Transformation") { + // This is a flat Struct - we need to reconstruct it from multiple fields + // Get the inner Struct fields count + const transformAST = fieldSchema.ast as any + const toAST = transformAST.to + + // For a Struct, the number of fields is the number of property signatures + const propertySignatures = (toAST._tag === "TypeLiteral" ? toAST.propertySignatures : []) as ReadonlyArray + const numInnerFields = propertySignatures.length + + // Extract the fields for this nested Struct + const nestedFields = fromA.fields.slice(fieldIndex, fieldIndex + numInnerFields) + + // Reconstruct as a Constr for the nested Struct to decode + const nestedConstr = new Data.Constr({ + index: 0n, // flatFields Structs don't preserve their index + fields: nestedFields + }) + + result[key] = nestedConstr + fieldIndex += numInnerFields + } else { + // Regular field - take one value from the fields array + result[key] = fromA.fields[fieldIndex] + fieldIndex++ + } }) + + // Inject the tag field if detected + // We need to inject it as the ENCODED form (Constr), not the decoded form (literal string), + // because Effect Schema will decode it using the field schema + if (detectedTagField && fields[detectedTagField]) { + const tagSchema = fields[detectedTagField] + const ast = tagSchema.ast + + // Extract the Literal value and convert it to its encoded Constr form + let literalValue: any + if (ast._tag === "Literal") { + // Schema.Literal + literalValue = (ast as any).literal + } else if (ast._tag === "Transformation") { + // TSchema.Literal (transform from Constr to Literal) + const toAST = (ast as any).to + if (toAST._tag === "Literal") { + literalValue = (toAST as any).literal + } + } + + // Encode the literal value as a Constr - TSchema.Literal encodes to Constr(index: 0, fields: []) + // Schema.Literal would also encode the same way (for a single literal value) + if (literalValue !== undefined) { + result[detectedTagField] = new Data.Constr({ index: 0n, fields: [] }) + } + } + return result as { [K in keyof Schema.Struct.Encoded]: Schema.Struct.Encoded[K] } } }).annotations({ @@ -239,15 +438,17 @@ export const Struct = ( // Store the custom index in annotations so Union can detect it // Store explicitly even if index is 0, to distinguish from default ["TSchema.customIndex"]: options.index !== undefined ? index : undefined, - // Store flat setting so Union knows whether to unwrap this Struct - ["TSchema.flat"]: isFlat + // Store flatInUnion setting so Union knows whether to unwrap this Struct + ["TSchema.flatInUnion"]: isFlatInUnion, + // Store flatFields for recursive detection + ["TSchema.flatFields"]: isFlatFields }) } export interface Union> extends Schema.transformOrFail< Schema.SchemaClass, - Schema.SchemaClass, Schema.Schema.Type<[...Members][number]>, never>, + Schema.SchemaClass, Schema.Schema.Type, never>, never > {} @@ -261,16 +462,67 @@ export interface Union> * @since 2.0.0 */ export const Union = >(...members: Members): Union => { + // Auto-detect tag field from KNOWN_TAG_FIELDS + let detectedTagField: string | undefined + const tagValues = new globalThis.Map() + + for (const tagFieldName of KNOWN_TAG_FIELDS) { + let allHaveTag = true + const currentTagValues = new globalThis.Map() + + for (let i = 0; i < members.length; i++) { + const literalValue = getLiteralFieldValue(members[i], tagFieldName) + if (literalValue === undefined) { + allHaveTag = false + break + } + + // Check for duplicate tag values + const literalKey = JSON.stringify(literalValue) + if (currentTagValues.has(literalKey)) { + const existing = currentTagValues.get(literalKey)! + throw new Error( + `Union members must have unique tag values. Duplicate value ${literalKey} found in field "${tagFieldName}" at member indices ${existing.memberIndex} and ${i}.` + ) + } + currentTagValues.set(literalKey, { value: literalValue, memberIndex: i }) + } + + if (allHaveTag) { + detectedTagField = tagFieldName + tagValues.clear() + currentTagValues.forEach((v: { value: any; memberIndex: number }, k: string) => tagValues.set(k, v)) + break + } + } + + // If members use different tag field names, that's an error + if (!detectedTagField) { + const usedTagFields = new Set() + for (const member of members) { + for (const tagFieldName of KNOWN_TAG_FIELDS) { + if (getLiteralFieldValue(member, tagFieldName) !== undefined) { + usedTagFields.add(tagFieldName) + } + } + } + if (usedTagFields.size > 1) { + throw new Error( + `Union members must use the same tag field name. Found multiple: ${globalThis.Array.from(usedTagFields).join(", ")}` + ) + } + } + // Extract member metadata from annotations const memberInfos = members.map((member, position) => { const customIndex = member.ast.annotations?.["TSchema.customIndex"] as number | undefined - const isFlat = (member.ast.annotations?.["TSchema.flat"] as boolean | undefined) ?? false + const isFlatInUnion = (member.ast.annotations?.["TSchema.flatInUnion"] as boolean | undefined) ?? false return { schema: member, position, // Position in the members array customIndex, // Custom index if set, undefined otherwise - isFlat // Whether this member should be flat in the union + isFlat: isFlatInUnion // Whether this member should be flat in the union } }) @@ -353,7 +605,7 @@ export const Union = >(...membe strict: false, encode: (value) => Effect.gen(function* () { - // Find which member matches this value + // Find which member matches this value (WITH tag field - schemas expect it) const matchedIndex = members.findIndex((schema) => Schema.is(schema)(value)) if (matchedIndex === -1) { @@ -361,7 +613,7 @@ export const Union = >(...membe const actualType = typeof value === "bigint" ? "bigint" - : typeof value === "object" && value !== null && (value as unknown) instanceof Map + : typeof value === "object" && value !== null && (value as unknown) instanceof globalThis.Map ? "Map" : typeof value === "object" && value !== null && globalThis.Array.isArray(value) ? "array" @@ -371,25 +623,43 @@ export const Union = >(...membe new ParseResult.Type( Schema.Union(...members).ast, value, - `Invalid value for Union: received ${actualType} (${JSON.stringify(value)}), expected ${memberNames.join(" or ")}` + `Invalid value for Union: received ${actualType} (${String(value)}), expected ${memberNames.join(" or ")}` ) ) } const memberInfo = memberInfos[matchedIndex] + + // Encode the full value - if members are Structs with tag fields, + // they will handle filtering out the tag field themselves const encodedValue = yield* ParseResult.encode(memberInfo.schema as Schema.Schema)(value) // If the member is flat, use its encoded value directly (unwrap the Constr) if (memberInfo.isFlat && encodedValue instanceof Data.Constr) { - // If the member has no custom index, we need to use the auto index - if (memberInfo.customIndex === undefined) { - return new Data.Constr({ - index: BigInt(memberInfo.position), - fields: encodedValue.fields - }) + // Recursively unwrap nested flat Constrs (for nested flatFields support) + const unwrapNestedFlat = (constr: Data.Constr): Data.Constr => { + // If this Constr has exactly one field and that field is also a flat Constr, unwrap it + if (constr.fields.length === 1 && + constr.fields[0] instanceof Data.Constr && + (constr.fields[0] as any)["__flatFields__"] === true) { + // Recursively unwrap + return unwrapNestedFlat(constr.fields[0] as Data.Constr) + } + return constr } - // Return the encoded Constr as-is (it already has the custom index) - return encodedValue + + const unwrapped = unwrapNestedFlat(encodedValue) + + // If the member has a custom index, use it; otherwise use position + const customIdx = memberInfo.customIndex + const finalIndex = customIdx !== undefined + ? BigInt(customIdx) + : BigInt(memberInfo.position) + + return new Data.Constr({ + index: finalIndex, + fields: unwrapped.fields + }) } // Otherwise, wrap in Union's Constr with auto index (position) @@ -408,7 +678,22 @@ export const Union = >(...membe if (flatMember) { // This is a flat Struct, decode it directly (no unwrapping needed) - return ParseResult.decode(flatMember.schema)(value) + return Effect.gen(function* () { + const decoded = yield* ParseResult.decode(flatMember.schema)(value) + + // Inject tag field if detected + if (detectedTagField && typeof decoded === "object" && decoded !== null) { + const tagValue = getLiteralFieldValue(flatMember.schema, detectedTagField) + if (tagValue !== undefined) { + return { + ...decoded, + [detectedTagField]: tagValue + } + } + } + + return decoded + }) } // Otherwise, use standard Union decoding with auto index @@ -431,18 +716,34 @@ export const Union = >(...membe // If the member schema expects a Data.Constr (like Boolean), // we need to reconstruct the original Constr structure // For primitive types, we use the first field - if (value.fields.length === 0) { - // This is likely a Boolean-like case where the original Constr had no fields - // Reconstruct the original Constr structure - return ParseResult.decode(member)(new Data.Constr({ index: 0n, fields: [] })) - } else if (value.fields.length === 1) { - // This could be either a primitive value or a Constr that was flattened - return ParseResult.decode(member)(value.fields[0]) - } else { - // Multiple fields - reconstruct as a Constr with index 0 - // This handles cases where the original Constr had multiple fields - return ParseResult.decode(member)(new Data.Constr({ index: 0n, fields: [...value.fields] })) - } + return Effect.gen(function* () { + let decoded + if (value.fields.length === 0) { + // This is likely a Boolean-like case where the original Constr had no fields + // Reconstruct the original Constr structure + decoded = yield* ParseResult.decode(member)(new Data.Constr({ index: 0n, fields: [] })) + } else if (value.fields.length === 1) { + // This could be either a primitive value or a Constr that was flattened + decoded = yield* ParseResult.decode(member)(value.fields[0]) + } else { + // Multiple fields - reconstruct as a Constr with index 0 + // This handles cases where the original Constr had multiple fields + decoded = yield* ParseResult.decode(member)(new Data.Constr({ index: 0n, fields: [...value.fields] })) + } + + // Inject tag field if detected + if (detectedTagField && typeof decoded === "object" && decoded !== null) { + const tagValue = getLiteralFieldValue(member, detectedTagField) + if (tagValue !== undefined) { + return { + ...decoded, + [detectedTagField]: tagValue + } + } + } + + return decoded + }) } }).annotations({ identifier: "TSchema.Union", @@ -452,13 +753,17 @@ export const Union = >(...membe const actualType = typeof actual === "bigint" ? "bigint" - : typeof actual === "object" && actual !== null && (actual as unknown) instanceof Map + : typeof actual === "object" && actual !== null && actual instanceof globalThis.Map ? "Map" : typeof actual === "object" && actual !== null && globalThis.Array.isArray(actual) ? "array" : typeof actual + + const actualStr = typeof actual === "bigint" ? String(actual) : + typeof actual === "object" ? String(actual) : + JSON.stringify(actual) - return `Invalid value for Union: received ${actualType} (${JSON.stringify(actual)}), expected ${memberNames.join(" or ")}` + return `Invalid value for Union: received ${actualType} (${actualStr}), expected ${memberNames.join(" or ")}` } }) as Union } @@ -474,6 +779,84 @@ export const Tuple = (element: [...E identifier: "Tuple" }) as Tuple +/** + * Helper type to extract the TypeScript type from a Struct fields definition + * Creates a discriminated union type like: {Mint: {amount: bigint}} | {Burn: {amount: bigint}} + */ +type VariantType> = { + [K in keyof Variants]: { + readonly [P in K]: Schema.Struct.Type + } +}[keyof Variants] + +/** + * Creates a variant (tagged union) schema for Aiken-style enum types. + * + * This is a convenience helper that creates properly discriminated TypeScript types + * while maintaining single-level CBOR encoding compatible with Aiken. + * + * @param variants - Object mapping variant names to their field schemas + * @returns Union schema with discriminated types + * + * @since 2.0.0 + * @category constructors + */ +export const Variant = < + Variants extends Record +>(variants: Variants): Schema.Schema< + VariantType, + Data.Data, + never +> => { + const variantNames = Object.keys(variants) + + // Create Union members: each variant becomes a Struct with nested flat Struct + const members = variantNames.map((name, index) => + Struct( + { + [name]: Struct(variants[name], { flatFields: true }) + }, + { flatInUnion: true, index } + ) + ) + + return Union(...members) as any +} + +/** + * Creates a tagged struct - a shortcut for creating a Struct with a Literal tag field. + * + * This is a convenience helper that makes it easy to create structs with discriminator fields, + * commonly used in discriminated unions. + * + * @param tagValue - The literal value for the tag (e.g., "Circle", "User") + * @param fields - The struct fields (excluding the tag field) + * @param options - Struct options (tagField defaults to "_tag", plus flatInUnion, index, etc.) + * @returns Struct schema with the tag field + * + * @since 2.0.0 + * @category constructors + */ +export const TaggedStruct = < + TagValue extends string, + Fields extends Schema.Struct.Fields, + TagField extends string = "_tag" +>( + tagValue: TagValue, + fields: Fields, + options?: StructOptions & { tagField?: TagField } +): Struct<{ [K in TagField]: OneLiteral } & Fields> => { + const tagField = (options?.tagField ?? '_tag') as TagField + + return Struct( + { + [tagField]: Literal(tagValue), + ...fields + } as { [K in TagField]: OneLiteral } & Fields, + { ...options, tagField } + ) +} + export const compose = Schema.compose export const filter = Schema.filter diff --git a/packages/evolution/test/CBOR.Aiken.test.ts b/packages/evolution/test/CBOR.Aiken.test.ts index cbd4f8d3..6d948c6e 100644 --- a/packages/evolution/test/CBOR.Aiken.test.ts +++ b/packages/evolution/test/CBOR.Aiken.test.ts @@ -1,295 +1,589 @@ -/** - * Aiken CBOR Encoding Compatibility Tests - * - * This test suite validates that our CBOR encoding matches Aiken's cbor.serialise(). - * Test values are taken from running `aiken check` on test/spec/lib/cbor_encoding_spec.ak - * Each test verifies that our TypeScript encoder produces identical hex output to Aiken. - * - * Key Aiken encoding characteristics discovered: - * - Lists: Indefinite-length arrays (9f...ff), except empty lists (80) - * - Maps: Encoded as arrays of pairs, not CBOR maps - * - Strings: Encoded as bytearrays (major type 2), not text strings - * - Constructors: Tags 121-127 for indices 0-6, then 1280+ for 7+ - * - Tuples: Indefinite-length arrays without constructor tags - */ import { describe, expect, it } from "vitest" +import * as Bytes from "../src/core/Bytes.js" import * as CBOR from "../src/core/CBOR.js" import * as Data from "../src/core/Data.js" +import * as Text from "../src/core/Text.js" +import * as TSchema from "../src/core/TSchema.js" describe("Aiken CBOR Encoding Compatibility", () => { - describe("Primitive Types", () => { - it("encode_int_small: should encode Int 42", () => { - const value = 42n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("182a") - }) + // Test #1: encode_int_small + it("encode_int_small: should encode 42", () => { + const value = 42n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("182a") + }) - it("encode_int_zero: should encode Int 0", () => { - const value = 0n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("00") - }) + // Test #2: encode_int_zero + it("encode_int_zero: should encode 0", () => { + const value = 0n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("00") + }) - it("encode_int_negative: should encode Int -1", () => { - const value = -1n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("20") - }) + // Test #3: encode_int_negative + it("encode_int_negative: should encode -1", () => { + const value = -1n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("20") + }) - it("encode_int_large: should encode Int 1000000", () => { - const value = 1000000n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("1a000f4240") - }) + // Test #4: encode_int_large + it("encode_int_large: should encode 1000000", () => { + const value = 1000000n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("1a000f4240") + }) - it("encode_bytearray_empty: should encode empty ByteArray", () => { - const value = new Uint8Array([]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("40") - }) + // Test #5: encode_bytearray_empty + it("encode_bytearray_empty: should encode empty bytearray", () => { + const value = new Uint8Array([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("40") + }) - it("encode_bytearray_small: should encode ByteArray #a1b2", () => { - const value = new Uint8Array([0xa1, 0xb2]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("42a1b2") - }) + // Test #6: encode_bytearray_small + it("encode_bytearray_small: should encode small bytearray", () => { + const value = Bytes.fromHexUnsafe("a1b2") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("42a1b2") + }) - it("encode_bytearray_long: should encode ByteArray #deadbeef", () => { - const value = new Uint8Array([0xde, 0xad, 0xbe, 0xef]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("44deadbeef") - }) + // Test #7: encode_bytearray_long + it("encode_bytearray_long: should encode longer bytearray", () => { + const value = Bytes.fromHexUnsafe("deadbeef") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("44deadbeef") }) - describe("Lists", () => { - it("encode_list_empty: should encode empty list as definite array", () => { - const value: ReadonlyArray = [] - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("80") - }) + // Test #8: encode_list_empty + it("encode_list_empty: should encode empty list", () => { + const value = Data.list([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("80") + }) - it("encode_list_single: should encode single item list", () => { - const value = Data.list([1n]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f01ff") - }) + // Test #9: encode_list_single + it("encode_list_single: should encode single element list", () => { + const value = Data.list([1n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f01ff") + }) - it("encode_list_multiple: should encode list [1, 2, 3]", () => { - const value = Data.list([1n, 2n, 3n]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f010203ff") - }) + // Test #10: encode_list_multiple + it("encode_list_multiple: should encode multiple element list", () => { + const value = Data.list([1n, 2n, 3n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f010203ff") + }) - it("encode_list_nested: should encode nested lists", () => { - const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f9f0102ff9f0304ffff") - }) + // Test #11: encode_list_nested + it("encode_list_nested: should encode nested lists", () => { + const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0102ff9f0304ffff") + }) - it("encode_list_of_bytearrays: should encode list of bytearrays", () => { - const value = Data.list([ - new Uint8Array([0xaa]), - new Uint8Array([0xbb]), - new Uint8Array([0xcc]) - ]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f41aa41bb41ccff") - }) + // Test #12: encode_pair_ints + it("encode_pair_ints: should encode pair of ints", () => { + const value = Data.list([1n, 2n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0102ff") }) - describe("Tuples (Pairs)", () => { - it("encode_pair_ints: should encode pair (1, 2)", () => { - const value = Data.list([1n, 2n]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f0102ff") - }) + // Test #13: encode_pair_mixed + it("encode_pair_mixed: should encode mixed pair", () => { + const value = Data.list([1n, Bytes.fromHexUnsafe("ff")]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0141ffff") + }) - it("encode_pair_mixed: should encode pair (1, #ff)", () => { - const value = Data.list([1n, new Uint8Array([0xff])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f0141ffff") - }) + // Test #14: encode_triple + it("encode_triple: should encode triple", () => { + const value = Data.list([1n, Bytes.fromHexUnsafe("ff"), 3n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0141ff03ff") + }) - it("encode_triple: should encode triple (1, #ff, 3)", () => { - const value = Data.list([1n, new Uint8Array([0xff]), 3n]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f0141ff03ff") - }) + // Test #15: encode_nested_pairs + it("encode_nested_pairs: should encode nested pairs", () => { + const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0102ff9f0304ffff") + }) - it("encode_nested_pairs: should encode nested pairs", () => { - const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f9f0102ff9f0304ffff") - }) + // Test #16: encode_map_empty + it("encode_map_empty: should encode empty map", () => { + const value = Data.map([]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("80") }) - describe("Maps (as arrays of pairs)", () => { - it("encode_map_empty: should encode empty map as empty array", () => { - const value = Data.map([]) - 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.fromHexUnsafe("ff")]]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0141ffffff") + }) - it("encode_map_single_entry: should encode single entry map", () => { - const value = Data.map([[1n, new Uint8Array([0xff])]]) - 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.fromHexUnsafe("01"), 1n], + [Bytes.fromHexUnsafe("02"), 2n], + [Bytes.fromHexUnsafe("03"), 3n] + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f410101ff9f410202ff9f410303ffff") + }) - it("encode_map_int_keys: should encode map with int keys and values", () => { - const value = Data.map([ - [1n, 100n], - [2n, 200n] - ]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f9f011864ff9f0218c8ffff") - }) + // 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] + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f011864ff9f0218c8ffff") }) - describe("Constructors (Option types)", () => { - it("encode_option_some: should encode Some(42) with tag 121", () => { - const value = Data.constr(0n, [42n]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d8799f182aff") - }) + // Test #20: encode_option_some + it("encode_option_some: should encode Some(42)", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const value = Data.withSchema(OptionInt).toData(42n) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f182aff") + }) - it("encode_option_none: should encode None with tag 122", () => { - const value = Data.constr(1n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d87a80") - }) + // Test #21: encode_option_none + it("encode_option_none: should encode None", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const value = Data.withSchema(OptionInt).toData(undefined) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a80") + }) - it("encode_option_some_bytearray: should encode Some(#deadbeef)", () => { - const value = Data.constr(0n, [new Uint8Array([0xde, 0xad, 0xbe, 0xef])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d8799f44deadbeefff") - }) + // Test #22: encode_option_some_bytearray + it("encode_option_some_bytearray: should encode Some(bytearray)", () => { + const OptionBytes = TSchema.UndefinedOr(TSchema.ByteArray) + const value = Data.withSchema(OptionBytes).toData(Bytes.fromHexUnsafe("deadbeef")) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f44deadbeefff") + }) - it("encode_custom_constructor_0: should encode Variant0 with tag 121", () => { - const value = Data.constr(0n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d87980") - }) + // Test #23: encode_option_nested_some + it("encode_option_nested_some: should encode Some(Some(42))", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const OptionOption = TSchema.UndefinedOr(OptionInt) + const value = Data.withSchema(OptionOption).toData(42n) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799fd8799f182affff") + }) - it("encode_custom_constructor_1: should encode Variant1 with tag 122", () => { - const value = Data.constr(1n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d87a80") - }) + // Test #24: encode_custom_constructor_0 + it("encode_custom_constructor_0: should encode Variant0", () => { + const SimpleEnum = TSchema.Literal("Variant0", "Variant1", "Variant2") + const value = Data.withSchema(SimpleEnum).toData("Variant0") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87980") + }) - it("encode_custom_constructor_2: should encode Variant2 with tag 123", () => { - const value = Data.constr(2n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d87b80") - }) + // Test #25: encode_custom_constructor_1 + it("encode_custom_constructor_1: should encode Variant1", () => { + const SimpleEnum = TSchema.Literal("Variant0", "Variant1", "Variant2") + const value = Data.withSchema(SimpleEnum).toData("Variant1") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a80") + }) - it("encode_constructor_index_6: should encode constructor 6 with tag 127", () => { - const value = Data.constr(6n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d87f80") - }) + // Test #26: encode_custom_constructor_2 + it("encode_custom_constructor_2: should encode Variant2", () => { + const SimpleEnum = TSchema.Literal("Variant0", "Variant1", "Variant2") + const value = Data.withSchema(SimpleEnum).toData("Variant2") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87b80") + }) - it("encode_constructor_index_7: should encode constructor 7 with alternative tag 1280", () => { - const value = Data.constr(7n, []) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d9050080") - }) + // Test #27: encode_constructor_with_one_field + it("encode_constructor_with_one_field: should encode Single(42)", () => { + const Single = TSchema.Struct({ value: TSchema.Integer }) + const value = Data.withSchema(Single).toData({ value: 42n }) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f182aff") }) - describe("Nested Structures", () => { - it("encode_list_of_options: should encode list of options", () => { - const value = Data.list([ - Data.constr(0n, [1n]), - Data.constr(1n, []), - Data.constr(0n, [2n]) - ]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9fd8799f01ffd87a80d8799f02ffff") - }) + // Test #28: encode_pair_tuple + it("encode_pair_tuple: should encode pair tuple (1, #ff)", () => { + const value = Data.list([1n, Bytes.fromHexUnsafe("ff")]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f0141ffff") + }) - it("encode_option_of_list: should encode option of list", () => { - const value = Data.constr(0n, [Data.list([1n, 2n, 3n])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d8799f9f010203ffff") - }) + // Test #29: encode_constructor_with_three_fields + it("encode_constructor_with_three_fields: should encode Triple(1, 2, 3)", () => { + // Approach 1: Using Union with explicit indices + const WithFieldsUnion = TSchema.Union( + TSchema.Struct({ value: TSchema.Integer }, { flatInUnion: true, index: 0 }), // Single - index 0 + TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatInUnion: true, index: 1 }) // Triple - index 1 + ) + const valueUnion = Data.withSchema(WithFieldsUnion).toData({ a: 1n, b: 2n, c: 3n }) + const encodedUnion = Data.toCBORHex(valueUnion, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encodedUnion).toBe("d87a9f010203ff") + + // Approach 2: Using discriminated union with TaggedStruct + const Single = TSchema.TaggedStruct("Single", { value: TSchema.Integer }, { flatInUnion: true, index: 0 }) + const Triple = TSchema.TaggedStruct("Triple", { a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatInUnion: true, index: 1 }) + const WithFieldsTagged = TSchema.Union(Single, Triple) + + const valueTagged = Data.withSchema(WithFieldsTagged).toData({ _tag: "Triple" as const, a: 1n, b: 2n, c: 3n }) + const encodedTagged = Data.toCBORHex(valueTagged, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encodedTagged).toBe("d87a9f010203ff") + + // Approach 3: Using Variant helper + const WithFieldsVariant = TSchema.Variant({ + Single: { value: TSchema.Integer }, + Triple: { a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer } + }) + const valueVariant = Data.withSchema(WithFieldsVariant).toData({ Triple: { a: 1n, b: 2n, c: 3n } }) + const encodedVariant = Data.toCBORHex(valueVariant, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encodedVariant).toBe("d87a9f010203ff") + + // Approach 4: Using Struct with flatInUnion option directly (equivalent to Variant) + // This shows that Variant is just syntactic sugar over Struct with flatInUnion and flatFields + const WithFieldsStructOnly = TSchema.Union( + TSchema.Struct( + { Single: TSchema.Struct({ value: TSchema.Integer }, { flatFields: true }) }, + { flatInUnion: true, index: 0 } + ), + TSchema.Struct( + { Triple: TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatFields: true }) }, + { flatInUnion: true, index: 1 } + ) + ) + + // Test both Single and Triple variants + const valueSingleStructOnly = Data.withSchema(WithFieldsStructOnly).toData({ Single: { value: 999n } }) + const encodedSingleStructOnly = Data.toCBORHex(valueSingleStructOnly, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encodedSingleStructOnly).toBe("d8799f1903e7ff") // Single(999) + + const valueTripleStructOnly = Data.withSchema(WithFieldsStructOnly).toData({ Triple: { a: 1n, b: 2n, c: 3n } }) + const encodedTripleStructOnly = Data.toCBORHex(valueTripleStructOnly, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encodedTripleStructOnly).toBe("d87a9f010203ff") + + // Verify all approaches produce identical CBOR for Triple + expect(encodedUnion).toBe(encodedTagged) + expect(encodedTagged).toBe(encodedVariant) + expect(encodedVariant).toBe(encodedTripleStructOnly) + }) - it("encode_nested_options: should encode nested options", () => { - const value = Data.constr(0n, [Data.constr(0n, [Data.constr(0n, [1n])])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("d8799fd8799fd8799f01ffffff") - }) + // Test #30: encode_list_of_lists_of_ints + it("encode_list_of_lists_of_ints: should encode nested list of lists", () => { + const value = Data.list([Data.list([1n, 2n]), Data.list([3n, 4n]), Data.list([5n, 6n])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f0102ff9f0304ff9f0506ffff") + }) - it("encode_deeply_nested_list: should encode deeply nested list", () => { - const value = Data.list([Data.list([Data.list([1n])])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f9f9f01ffffff") - }) + // Test #31: encode_list_of_bytearrays + it("encode_list_of_bytearrays: should encode list of bytearrays", () => { + const value = Data.list([ + Bytes.fromHexUnsafe("aa"), + Bytes.fromHexUnsafe("bb"), + Bytes.fromHexUnsafe("cc") + ]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f41aa41bb41ccff") + }) - it("encode_map_nested_as_value: should encode map with nested map as value", () => { - const value = Data.map([[1n, Data.map([[2n, 3n]])]]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f9f019f9f0203ffffffff") - }) + // Test #32: encode_nested_options + it("encode_nested_options: should encode Some(Some(Some(1)))", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const OptionOption = TSchema.UndefinedOr(OptionInt) + const OptionOptionOption = TSchema.UndefinedOr(OptionOption) + const value = Data.withSchema(OptionOptionOption).toData(1n) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799fd8799fd8799f01ffffff") + }) - it("encode_empty_nested_lists: should encode empty nested lists", () => { - const value = Data.list([Data.list([]), Data.list([]), Data.list([])]) - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("9f808080ff") - }) + // Test #33: encode_list_of_options + it("encode_list_of_options: should encode list of options", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const some1 = Data.withSchema(OptionInt).toData(1n) + const none = Data.withSchema(OptionInt).toData(undefined) + const some2 = Data.withSchema(OptionInt).toData(2n) + const value = Data.list([some1, none, some2]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9fd8799f01ffd87a80d8799f02ffff") }) - describe("Integer Boundaries", () => { - it("encode_int_boundary_255: should encode 255", () => { - const value = 255n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("18ff") - }) + // Test #34: encode_option_of_list + it("encode_option_of_list: should encode Some([1, 2, 3])", () => { + const ListInt = TSchema.Array(TSchema.Integer) + const OptionListInt = TSchema.UndefinedOr(ListInt) + const value = Data.withSchema(OptionListInt).toData([1n, 2n, 3n]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f9f010203ffff") + }) - it("encode_int_boundary_256: should encode 256", () => { - const value = 256n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("190100") - }) + // Test #35: encode_map_with_option_values + it("encode_map_with_option_values: should encode map 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 encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f01d8799f1864ffff9f02d87a80ffff") + }) - it("encode_int_boundary_65535: should encode 65535", () => { - const value = 65535n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("19ffff") - }) + // 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]]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f019f9f0203ffffffff") + }) - it("encode_int_boundary_65536: should encode 65536", () => { - const value = 65536n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("1a00010000") - }) + // Test #37: encode_empty_nested_lists + it("encode_empty_nested_lists: should encode [[], [], []]", () => { + const value = Data.list([Data.list([]), Data.list([]), Data.list([])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f808080ff") + }) - it("encode_int_negative_large: should encode -1000", () => { - const value = -1000n - const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("3903e7") - }) + // Test #38: encode_deeply_nested_list + it("encode_deeply_nested_list: should encode [[[1]]]", () => { + const value = Data.list([Data.list([Data.list([1n])])]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9f9f9f01ffffff") }) - describe("ByteArray Boundaries", () => { - it("encode_bytearray_max_inline: should encode 24 bytes with length prefix 0x18", () => { - const bytes = new Uint8Array([ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, - 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17 - ]) - const encoded = Data.toCBORHex(bytes, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("5818000102030405060708090a0b0c0d0e0f1011121314151617") - }) + // Test #39: encode_constructor_with_list_field + it("encode_constructor_with_list_field: should encode Container([1, 2, 3])", () => { + const Container = TSchema.Struct({ items: TSchema.Array(TSchema.Integer) }) + const value = Data.withSchema(Container).toData({ items: [1n, 2n, 3n] }) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f9f010203ffff") + }) - it("encode_pkh_credential: should encode 28-byte PKH", () => { - const bytes = new Uint8Array([ - 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, - 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, - 0xab, 0xcd, 0xef, 0x12 - ]) - const encoded = Data.toCBORHex(bytes, CBOR.AIKEN_DEFAULT_OPTIONS) - expect(encoded).toBe("581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12") - }) + // Test #40: encode_constructor_with_option_field + it("encode_constructor_with_option_field: should encode Wrapper(Some(42))", () => { + const Wrapper = TSchema.Struct({ opt: TSchema.UndefinedOr(TSchema.Integer) }) + const value = Data.withSchema(Wrapper).toData({ opt: 42n }) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799fd8799f182affff") + }) + + // Test #41: encode_constructor_with_bytearray_field + it("encode_constructor_with_bytearray_field: should encode Holder(#deadbeef)", () => { + const Holder = TSchema.Struct({ data: TSchema.ByteArray }) + const value = Data.withSchema(Holder).toData({ data: Bytes.fromHexUnsafe("deadbeef") }) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f44deadbeefff") + }) + + // Test #42: encode_tuple_with_nested_constructor + it("encode_tuple_with_nested_constructor: should encode (Some(1), Some(2))", () => { + const OptionInt = TSchema.UndefinedOr(TSchema.Integer) + const some1 = Data.withSchema(OptionInt).toData(1n) + const some2 = Data.withSchema(OptionInt).toData(2n) + const value = Data.list([some1, some2]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9fd8799f01ffd8799f02ffff") + }) + + // Test #43: encode_list_all_same_constructor + it("encode_list_all_same_constructor: should encode [Variant0, Variant0, Variant0]", () => { + const SimpleEnum = TSchema.Literal("Variant0", "Variant1", "Variant2") + const v0_1 = Data.withSchema(SimpleEnum).toData("Variant0") + const v0_2 = Data.withSchema(SimpleEnum).toData("Variant0") + const v0_3 = Data.withSchema(SimpleEnum).toData("Variant0") + const value = Data.list([v0_1, v0_2, v0_3]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9fd87980d87980d87980ff") + }) + + // Test #44: encode_list_mixed_constructors + it("encode_list_mixed_constructors: should encode [Variant0, Variant1, Variant2]", () => { + const SimpleEnum = TSchema.Literal("Variant0", "Variant1", "Variant2") + const v0 = Data.withSchema(SimpleEnum).toData("Variant0") + const v1 = Data.withSchema(SimpleEnum).toData("Variant1") + const v2 = Data.withSchema(SimpleEnum).toData("Variant2") + const value = Data.list([v0, v1, v2]) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("9fd87980d87a80d87b80ff") + }) + + // Test #45: encode_constructor_index_6 + it("encode_constructor_index_6: should encode C6 with tag 127", () => { + const ManyConstructors = TSchema.Literal("C0", "C1", "C2", "C3", "C4", "C5", "C6") + const value = Data.withSchema(ManyConstructors).toData("C6") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87f80") + }) + + // Test #46: encode_constructor_index_7 + it("encode_constructor_index_7: should encode C7 with tag 1280", () => { + const ManyConstructors = TSchema.Literal("C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7") + const value = Data.withSchema(ManyConstructors).toData("C7") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d9050080") + }) + + // Test #47: encode_large_constructor_index - SKIPPED (placeholder test) + + // Test #48: encode_int_boundary_255 + it("encode_int_boundary_255: should encode 255", () => { + const value = 255n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("18ff") + }) + + // Test #49: encode_int_boundary_256 + it("encode_int_boundary_256: should encode 256", () => { + const value = 256n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("190100") + }) + + // Test #50: encode_int_boundary_65535 + it("encode_int_boundary_65535: should encode 65535", () => { + const value = 65535n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("19ffff") + }) + + // Test #51: encode_int_boundary_65536 + it("encode_int_boundary_65536: should encode 65536", () => { + const value = 65536n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("1a00010000") + }) + + // Test #52: encode_int_negative_large + it("encode_int_negative_large: should encode -1000", () => { + const value = -1000n + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("3903e7") + }) + + // Test #53: encode_bytearray_25_bytes + it("encode_bytearray_25_bytes: should encode 25-byte bytearray", () => { + const value = Bytes.fromHexUnsafe("000102030405060708090a0b0c0d0e0f101112131415161718") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("5819000102030405060708090a0b0c0d0e0f101112131415161718") + }) + + // Test #54: encode_bytearray_max_inline + it("encode_bytearray_max_inline: should encode 24-byte bytearray", () => { + const value = Bytes.fromHexUnsafe("000102030405060708090a0b0c0d0e0f1011121314151617") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("5818000102030405060708090a0b0c0d0e0f1011121314151617") + }) + + // Test #55: encode_string_empty + it("encode_string_empty: should encode empty string as bytearray", () => { + const value = Bytes.fromHexUnsafe("") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("40") + }) + + // Test #56: encode_string_ascii + it("encode_string_ascii: should encode 'hello' as bytearray", () => { + const value = Text.toBytesUnsafe("hello") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("4568656c6c6f") + }) + + // Test #57: encode_string_unicode + it("encode_string_unicode: should encode 'café' as bytearray", () => { + const value = Text.toBytesUnsafe("café") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("45636166c3a9") + }) + + // Test #58: encode_bool_true + it("encode_bool_true: should encode True", () => { + const value = Data.withSchema(TSchema.Boolean).toData(true) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a80") + }) + + // Test #59: encode_bool_false + it("encode_bool_false: should encode False", () => { + const value = Data.withSchema(TSchema.Boolean).toData(false) + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87980") + }) + + // Test #60: encode_complex_datum + it("encode_complex_datum: should encode Datum{owner: 28-byte hash, amount: 1000, beneficiaries: [], metadata: Some(#dead)}", () => { + const owner = Bytes.fromHexUnsafe("abababababababababababababababababababababababababababab") + const amount = 1000n + const beneficiaries: Array<[Uint8Array, bigint]> = [] + const metadata = Bytes.fromHexUnsafe("dead") + + const Datum = TSchema.Struct({ + owner: TSchema.ByteArray, + amount: TSchema.Integer, + beneficiaries: TSchema.Array(TSchema.Tuple([TSchema.ByteArray, TSchema.Integer])), + metadata: TSchema.UndefinedOr(TSchema.ByteArray) + }) + + const datum = Data.withSchema(Datum).toData({ + owner, + amount, + beneficiaries, + metadata + }) + const encoded = Data.toCBORHex(datum, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f581cabababababababababababababababababababababababababababab1903e880d8799f42deadffff") + }) + + // Test #61: encode_redeemer + it("encode_redeemer: should encode Redeemer{action: 100, params: [#abcd]}", () => { + const action = 100n + const params = [Bytes.fromHexUnsafe("abcd")] + + const Redeemer = TSchema.Struct({ + action: TSchema.Integer, + params: TSchema.Array(TSchema.ByteArray) + }) + + const redeemer = Data.withSchema(Redeemer).toData({ action, params }) + const encoded = Data.toCBORHex(redeemer, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f18649f42abcdffff") + }) + + // Test #62: encode_script_context + it("encode_script_context: should encode ScriptContext{inputs: [1,2], outputs: [3], fee: 170000, valid_range: (0, 100)}", () => { + const inputs = [1n, 2n] + const outputs = [3n] + const fee = 170000n + const valid_range: [bigint, bigint] = [0n, 100n] + + const ScriptContext = TSchema.Struct({ + inputs: TSchema.Array(TSchema.Integer), + outputs: TSchema.Array(TSchema.Integer), + fee: TSchema.Integer, + valid_range: TSchema.Tuple([TSchema.Integer, TSchema.Integer]) + }) + + const ctx = Data.withSchema(ScriptContext).toData({ inputs, outputs, fee, valid_range }) + const encoded = Data.toCBORHex(ctx, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f9f0102ff9f03ff1a000298109f001864ffff") + }) + + // Test #63: encode_pkh_credential + it("encode_pkh_credential: should encode 28-byte payment key hash", () => { + const value = Bytes.fromHexUnsafe("abcdef1234567890abcdef1234567890abcdef1234567890abcdef12") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12") + }) + + // Test #64: encode_script_hash + it("encode_script_hash: should encode 26-byte script hash", () => { + const value = Bytes.fromHexUnsafe("1234567890abcdef1234567890abcdef1234567890abcdef1234") + const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("581a1234567890abcdef1234567890abcdef1234567890abcdef1234") }) }) diff --git a/packages/evolution/test/TSchema-flat-option.test.ts b/packages/evolution/test/TSchema-flat-option.test.ts index 97460be1..4b6aee9e 100644 --- a/packages/evolution/test/TSchema-flat-option.test.ts +++ b/packages/evolution/test/TSchema-flat-option.test.ts @@ -5,7 +5,7 @@ import { fromHex } from "../src/core/Bytes.js" import * as Data from "../src/core/Data.js" import * as TSchema from "../src/core/TSchema.js" -describe("TSchema.Struct with flat option", () => { +describe("TSchema.Struct with flatInUnion option", () => { describe("Default behavior (nested)", () => { it("should round-trip correctly with default nested behavior", () => { const MyUnion = TSchema.Union( @@ -45,9 +45,9 @@ describe("TSchema.Struct with flat option", () => { }) }) - describe("Explicit flat: true with custom index", () => { - it("should round-trip correctly with explicit flat and custom index", () => { - const MyUnion = TSchema.Union(TSchema.Struct({ amount: TSchema.Integer }, { index: 122, flat: true })) + describe("Explicit flatInUnion: true with custom index", () => { + it("should round-trip correctly with explicit flatInUnion and custom index", () => { + const MyUnion = TSchema.Union(TSchema.Struct({ amount: TSchema.Integer }, { index: 122, flatInUnion: true })) const value = { amount: 500n } const encoded = Data.withSchema(MyUnion).toCBORHex(value) @@ -62,9 +62,9 @@ describe("TSchema.Struct with flat option", () => { }) }) - describe("Explicit flat: false with custom index", () => { - it("should round-trip correctly when flat is explicitly disabled", () => { - const MyUnion = TSchema.Union(TSchema.Struct({ data: TSchema.Integer }, { index: 10, flat: false })) + describe("Explicit flatInUnion: false with custom index", () => { + it("should round-trip correctly when flatInUnion is explicitly disabled", () => { + const MyUnion = TSchema.Union(TSchema.Struct({ data: TSchema.Integer }, { index: 10, flatInUnion: false })) const value = { data: 777n } const encoded = Data.withSchema(MyUnion).toCBORHex(value) @@ -81,11 +81,11 @@ describe("TSchema.Struct with flat option", () => { }) }) - describe("Just flat: true without index", () => { - it("should round-trip correctly with flat: true and auto-index", () => { + describe("Just flatInUnion: true without index", () => { + it("should round-trip correctly with flatInUnion: true and auto-index", () => { const MyUnion = TSchema.Union( TSchema.Struct({ other: TSchema.Integer }), // position 0, nested - TSchema.Struct({ info: TSchema.Integer }, { flat: true }) // position 1, flat + TSchema.Struct({ info: TSchema.Integer }, { flatInUnion: true }) // position 1, flat ) const value = { info: 333n } @@ -105,8 +105,8 @@ describe("TSchema.Struct with flat option", () => { it("should round-trip all variants correctly in mixed union", () => { const MixedUnion = TSchema.Union( TSchema.Struct({ nested: TSchema.Integer }), // position 0, nested - TSchema.Struct({ flatAuto: TSchema.Integer }, { flat: true }), // position 1, flat auto - TSchema.Struct({ flatCustom: TSchema.Integer }, { index: 121, flat: true }) // flat custom 121 + TSchema.Struct({ flatAuto: TSchema.Integer }, { flatInUnion: true }), // position 1, flat auto + TSchema.Struct({ flatCustom: TSchema.Integer }, { index: 121, flatInUnion: true }) // flat custom 121 ) // Test nested member @@ -146,7 +146,7 @@ describe("TSchema.Struct with flat option", () => { expect(() => { TSchema.Union( TSchema.Struct({ nested: TSchema.Integer }), // position 0 - TSchema.Struct({ flat: TSchema.Integer }, { index: 0, flat: true }) // collision with position 0 + TSchema.Struct({ flat: TSchema.Integer }, { index: 0, flatInUnion: true }) // collision with position 0 ) }).toThrow(/Index collision detected/) }) @@ -156,7 +156,7 @@ describe("TSchema.Struct with flat option", () => { TSchema.Union( TSchema.Struct({ first: TSchema.Integer }), // position 0 TSchema.Struct({ second: TSchema.Integer }), // position 1 - TSchema.Struct({ flat: TSchema.Integer }, { index: 1, flat: true }) // collision with position 1 + TSchema.Struct({ flat: TSchema.Integer }, { index: 1, flatInUnion: true }) // collision with position 1 ) }).toThrow(/Index collision detected/) }) @@ -164,8 +164,8 @@ describe("TSchema.Struct with flat option", () => { it("should NOT throw when both members use custom indices without collision", () => { expect(() => { TSchema.Union( - TSchema.Struct({ first: TSchema.Integer }, { index: 10, flat: false }), // nested with custom index 10 - TSchema.Struct({ second: TSchema.Integer }, { index: 20, flat: true }) // flat with custom index 20 + TSchema.Struct({ first: TSchema.Integer }, { index: 10, flatInUnion: false }), // nested with custom index 10 + TSchema.Struct({ second: TSchema.Integer }, { index: 20, flatInUnion: true }) // flat with custom index 20 ) }).not.toThrow() }) @@ -175,8 +175,8 @@ describe("TSchema.Struct with flat option", () => { // Constr(100, [...]), making it impossible to distinguish them during decoding expect(() => { TSchema.Union( - TSchema.Struct({ first: TSchema.Integer }, { index: 100, flat: true }), - TSchema.Struct({ second: TSchema.Integer }, { index: 100, flat: true }) + TSchema.Struct({ first: TSchema.Integer }, { index: 100, flatInUnion: true }), + TSchema.Struct({ second: TSchema.Integer }, { index: 100, flatInUnion: true }) ) }).toThrow(/Index collision detected/) }) @@ -205,7 +205,7 @@ describe("TSchema.Struct with flat option", () => { it("should produce smaller CBOR for flat encoding", () => { const NestedUnion = TSchema.Union(TSchema.Struct({ value: TSchema.Integer })) - const FlatUnion = TSchema.Union(TSchema.Struct({ value: TSchema.Integer }, { flat: true })) + const FlatUnion = TSchema.Union(TSchema.Struct({ value: TSchema.Integer }, { flatInUnion: true })) const value1 = { value: 42n } const value2 = { value: 42n } @@ -259,9 +259,9 @@ describe("TSchema.Struct with flat option", () => { it("should handle script purposes pattern", () => { const ScriptPurpose = TSchema.Union( - TSchema.Struct({ minting: TSchema.ByteArray }, { index: 0, flat: true }), - TSchema.Struct({ spending: TSchema.ByteArray }, { index: 1, flat: true }), - TSchema.Struct({ rewarding: TSchema.ByteArray }, { index: 2, flat: true }) + TSchema.Struct({ minting: TSchema.ByteArray }, { index: 0, flatInUnion: true }), + TSchema.Struct({ spending: TSchema.ByteArray }, { index: 1, flatInUnion: true }), + TSchema.Struct({ rewarding: TSchema.ByteArray }, { index: 2, flatInUnion: true }) ) // Test minting @@ -321,7 +321,7 @@ describe("TSchema.Struct with flat option", () => { field2: TSchema.ByteArray, field3: TSchema.Boolean }, - { index: 100, flat: true } + { index: 100, flatInUnion: true } ) ) diff --git a/packages/evolution/test/TSchema.TaggedUnion.test.ts b/packages/evolution/test/TSchema.TaggedUnion.test.ts new file mode 100644 index 00000000..cc36e86f --- /dev/null +++ b/packages/evolution/test/TSchema.TaggedUnion.test.ts @@ -0,0 +1,715 @@ +import { describe, expect, it } from "vitest" + +import * as Data from "../src/core/Data.js" +import * as TSchema from "../src/core/TSchema.js" + +describe("TSchema.TaggedUnion", () => { + describe("Auto-detection with _tag field", () => { + it("should auto-detect _tag field in Union members", () => { + const Mint = TSchema.Struct( + { + _tag: TSchema.Literal("Mint"), + amount: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const Burn = TSchema.Struct( + { + _tag: TSchema.Literal("Burn"), + amount: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const Action = TSchema.Union(Mint, Burn) + + // Test encode - tag should be stripped from CBOR + const mintValue = { _tag: "Mint" as const, amount: 100n } + const mintEncoded = Data.withSchema(Action).toData(mintValue) + + expect(mintEncoded).toBeInstanceOf(Data.Constr) + expect(mintEncoded.index).toBe(0n) + expect(mintEncoded.fields).toHaveLength(1) + expect(mintEncoded.fields[0]).toBe(100n) + + // Test decode - tag should be injected back + const mintDecoded = Data.withSchema(Action).fromData(mintEncoded) + expect(mintDecoded).toEqual({ _tag: "Mint", amount: 100n }) + + // Test second variant + const burnValue = { _tag: "Burn" as const, amount: 50n } + const burnEncoded = Data.withSchema(Action).toData(burnValue) + + expect(burnEncoded.index).toBe(1n) + expect(burnEncoded.fields).toEqual([50n]) + + const burnDecoded = Data.withSchema(Action).fromData(burnEncoded) + expect(burnDecoded).toEqual({ _tag: "Burn", amount: 50n }) + }) + + it("should handle multiple fields with _tag", () => { + const Transfer = TSchema.Struct( + { + _tag: TSchema.Literal("Transfer"), + from: TSchema.ByteArray, + to: TSchema.ByteArray, + amount: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const Stake = TSchema.Struct( + { + _tag: TSchema.Literal("Stake"), + poolId: TSchema.ByteArray, + amount: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const Transaction = TSchema.Union(Transfer, Stake) + + const transferValue = { + _tag: "Transfer" as const, + from: new Uint8Array([1, 2, 3]), + to: new Uint8Array([4, 5, 6]), + amount: 1000n + } + + const encoded = Data.withSchema(Transaction).toData(transferValue) + expect(encoded.index).toBe(0n) + expect(encoded.fields).toHaveLength(3) // _tag is stripped, 3 fields remain + + const decoded = Data.withSchema(Transaction).fromData(encoded) + expect(decoded).toEqual(transferValue) + }) + }) + + describe("Auto-detection with 'type' field", () => { + it("should auto-detect 'type' field in Union members", () => { + const Circle = TSchema.Struct( + { + type: TSchema.Literal("Circle"), + radius: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const Square = TSchema.Struct( + { + type: TSchema.Literal("Square"), + sideLength: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const Shape = TSchema.Union(Circle, Square) + + const circleValue = { type: "Circle" as const, radius: 10n } + const circleEncoded = Data.withSchema(Shape).toData(circleValue) + + expect(circleEncoded.index).toBe(0n) + expect(circleEncoded.fields).toEqual([10n]) + + const circleDecoded = Data.withSchema(Shape).fromData(circleEncoded) + expect(circleDecoded).toEqual({ type: "Circle", radius: 10n }) + }) + }) + + describe("Auto-detection with 'kind' field", () => { + it("should auto-detect 'kind' field in Union members", () => { + const Success = TSchema.Struct( + { + kind: TSchema.Literal("Success"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const Error = TSchema.Struct( + { + kind: TSchema.Literal("Error"), + code: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const Result = TSchema.Union(Success, Error) + + const successValue = { kind: "Success" as const, value: 42n } + const encoded = Data.withSchema(Result).toData(successValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([42n]) + + const decoded = Data.withSchema(Result).fromData(encoded) + expect(decoded).toEqual({ kind: "Success", value: 42n }) + }) + }) + + describe("Auto-detection with 'variant' field", () => { + it("should auto-detect 'variant' field in Union members", () => { + const Create = TSchema.Struct( + { + variant: TSchema.Literal("Create"), + id: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const Delete = TSchema.Struct( + { + variant: TSchema.Literal("Delete"), + id: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const Operation = TSchema.Union(Create, Delete) + + const createValue = { variant: "Create" as const, id: 123n } + const encoded = Data.withSchema(Operation).toData(createValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([123n]) + + const decoded = Data.withSchema(Operation).fromData(encoded) + expect(decoded).toEqual({ variant: "Create", id: 123n }) + }) + }) + + describe("TaggedStruct helper", () => { + it("should create tagged structs with default _tag field", () => { + const Deposit = TSchema.TaggedStruct("Deposit", { + amount: TSchema.Integer, + account: TSchema.ByteArray + }, { flatInUnion: true, index: 0 }) + + const Withdrawal = TSchema.TaggedStruct("Withdrawal", { + amount: TSchema.Integer, + account: TSchema.ByteArray + }, { flatInUnion: true, index: 1 }) + + const Payment = TSchema.Union(Deposit, Withdrawal) + + const depositValue = { + _tag: "Deposit" as const, + amount: 1000n, + account: new Uint8Array([97, 108, 105, 99, 101]) // "alice" + } + + const encoded = Data.withSchema(Payment).toData(depositValue) + expect(encoded.index).toBe(0n) + expect(encoded.fields).toHaveLength(2) // amount and account, _tag stripped + + const decoded = Data.withSchema(Payment).fromData(encoded) + expect(decoded).toEqual(depositValue) + }) + + it("should create tagged structs with custom tag field", () => { + const Read = TSchema.TaggedStruct("Read", { + key: TSchema.ByteArray + }, { tagField: "operation", flatInUnion: true, index: 0 }) + + const Write = TSchema.TaggedStruct("Write", { + key: TSchema.ByteArray, + value: TSchema.ByteArray + }, { tagField: "operation", flatInUnion: true, index: 1 }) + + const Command = TSchema.Union(Read, Write) + + const readValue = { + operation: "Read" as const, + key: new Uint8Array([1, 2, 3]) + } + + const encoded = Data.withSchema(Command).toData(readValue) + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([new Uint8Array([1, 2, 3])]) + + const decoded = Data.withSchema(Command).fromData(encoded) + expect(decoded).toEqual(readValue) + }) + + it("should work with empty fields object", () => { + const Start = TSchema.TaggedStruct("Start", {}, { flatInUnion: true, index: 0 }) + const Stop = TSchema.TaggedStruct("Stop", {}, { flatInUnion: true, index: 1 }) + + const State = TSchema.Union(Start, Stop) + + const startValue = { _tag: "Start" as const } + const encoded = Data.withSchema(State).toData(startValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toHaveLength(0) + + const decoded = Data.withSchema(State).fromData(encoded) + expect(decoded).toEqual({ _tag: "Start" }) + }) + }) + + describe("Type inference", () => { + it("should infer discriminated union types correctly", () => { + const Success = TSchema.TaggedStruct("Success", { + value: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Failure = TSchema.TaggedStruct("Failure", { + error: TSchema.ByteArray + }, { flatInUnion: true, index: 1 }) + + const Result = TSchema.Union(Success, Failure) + + // TypeScript should infer the correct discriminated union type + const successValue: typeof Result.Type = { + _tag: "Success", + value: 42n + } + + const failureValue: typeof Result.Type = { + _tag: "Failure", + error: new Uint8Array([1, 2, 3]) + } + + expect(Data.withSchema(Result).toData(successValue)).toBeDefined() + expect(Data.withSchema(Result).toData(failureValue)).toBeDefined() + }) + }) + + describe("Error handling", () => { + it("should throw error on duplicate tag values", () => { + const A = TSchema.Struct( + { + _tag: TSchema.Literal("Same"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const B = TSchema.Struct( + { + _tag: TSchema.Literal("Same"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + expect(() => TSchema.Union(A, B)).toThrow( + /Union members must have unique tag values.*Duplicate value "Same"/ + ) + }) + + it("should throw error on different tag field names", () => { + const A = TSchema.Struct( + { + _tag: TSchema.Literal("A"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const B = TSchema.Struct( + { + type: TSchema.Literal("B"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + expect(() => TSchema.Union(A, B)).toThrow( + /Union members must use the same tag field name.*Found multiple: _tag, type/ + ) + }) + + it("should throw error when encoding invalid union value", () => { + const A = TSchema.TaggedStruct("A", { x: TSchema.Integer }, { flatInUnion: true, index: 0 }) + const B = TSchema.TaggedStruct("B", { y: TSchema.Integer }, { flatInUnion: true, index: 1 }) + + const AB = TSchema.Union(A, B) + + // @ts-expect-error - Testing invalid value + expect(() => Data.withSchema(AB).toData({ _tag: "C", z: 1n })).toThrow() + }) + }) + + describe("Edge cases", () => { + it("should handle union with more than 2 members", () => { + const A = TSchema.TaggedStruct("A", { value: TSchema.Integer }, { flatInUnion: true, index: 0 }) + const B = TSchema.TaggedStruct("B", { value: TSchema.Integer }, { flatInUnion: true, index: 1 }) + const C = TSchema.TaggedStruct("C", { value: TSchema.Integer }, { flatInUnion: true, index: 2 }) + const D = TSchema.TaggedStruct("D", { value: TSchema.Integer }, { flatInUnion: true, index: 3 }) + + const ABCD = TSchema.Union(A, B, C, D) + + const cValue = { _tag: "C" as const, value: 999n } + const encoded = Data.withSchema(ABCD).toData(cValue) + + expect(encoded.index).toBe(2n) + expect(encoded.fields).toEqual([999n]) + + const decoded = Data.withSchema(ABCD).fromData(encoded) + expect(decoded).toEqual(cValue) + }) + + it("should handle nested structs with tag fields", () => { + const Inner = TSchema.Struct({ + value: TSchema.Integer + }) + + const Outer = TSchema.TaggedStruct("Outer", { + inner: Inner, + extra: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Another = TSchema.TaggedStruct("Another", { + data: TSchema.Integer + }, { flatInUnion: true, index: 1 }) + + const Combined = TSchema.Union(Outer, Another) + + const outerValue = { + _tag: "Outer" as const, + inner: { value: 42n }, + extra: 100n + } + + const encoded = Data.withSchema(Combined).toData(outerValue) + expect(encoded.index).toBe(0n) + + const decoded = Data.withSchema(Combined).fromData(encoded) + expect(decoded).toEqual(outerValue) + }) + + it("should work with explicit tagField option in Struct", () => { + const X = TSchema.Struct( + { + status: TSchema.Literal("Active"), + count: TSchema.Integer + }, + { tagField: "status", flatInUnion: true, index: 0 } + ) + + const Y = TSchema.Struct( + { + status: TSchema.Literal("Inactive"), + count: TSchema.Integer + }, + { tagField: "status", flatInUnion: true, index: 1 } + ) + + const Status = TSchema.Union(X, Y) + + const activeValue = { status: "Active" as const, count: 5n } + const encoded = Data.withSchema(Status).toData(activeValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([5n]) + + const decoded = Data.withSchema(Status).fromData(encoded) + expect(decoded).toEqual({ status: "Active", count: 5n }) + }) + + it("should disable tag detection with tagField: false", () => { + const WithTag = TSchema.Struct( + { + _tag: TSchema.Literal("HasTag"), + value: TSchema.Integer + }, + { tagField: false, flatInUnion: true, index: 0 } + ) + + const AlsoWithTag = TSchema.Struct( + { + _tag: TSchema.Literal("AlsoHasTag"), + value: TSchema.Integer + }, + { tagField: false, flatInUnion: true, index: 1 } + ) + + const NoAutoDetect = TSchema.Union(WithTag, AlsoWithTag) + + // When tagField is disabled, _tag is encoded as a regular field + const value = { _tag: "HasTag" as const, value: 42n } + const encoded = Data.withSchema(NoAutoDetect).toData(value) + + // _tag should be encoded as a field (Constr with index 0) + expect(encoded.fields).toHaveLength(2) + }) + + it("should handle unions with no tag fields at all", () => { + const A = TSchema.Struct( + { x: TSchema.Integer }, + { flatInUnion: true, index: 0 } + ) + + const B = TSchema.Struct( + { y: TSchema.Integer }, + { flatInUnion: true, index: 1 } + ) + + const AB = TSchema.Union(A, B) + + const aValue = { x: 10n } + const encoded = Data.withSchema(AB).toData(aValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([10n]) + + const decoded = Data.withSchema(AB).fromData(encoded) + expect(decoded).toEqual({ x: 10n }) + }) + + it("should handle tag field with numeric literal values", () => { + const Zero = TSchema.Struct( + { + _tag: TSchema.Literal(0), + data: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const One = TSchema.Struct( + { + _tag: TSchema.Literal(1), + data: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const NumericTag = TSchema.Union(Zero, One) + + const zeroValue = { _tag: 0 as const, data: 100n } + const encoded = Data.withSchema(NumericTag).toData(zeroValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([100n]) + + const decoded = Data.withSchema(NumericTag).fromData(encoded) + expect(decoded).toEqual({ _tag: 0, data: 100n }) + }) + + it("should handle tag field with boolean literal values", () => { + const TrueCase = TSchema.Struct( + { + _tag: TSchema.Literal(true), + value: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const FalseCase = TSchema.Struct( + { + _tag: TSchema.Literal(false), + value: TSchema.Integer + }, + { flatInUnion: true, index: 1 } + ) + + const BoolTag = TSchema.Union(TrueCase, FalseCase) + + const trueValue = { _tag: true, value: 42n } + const encoded = Data.withSchema(BoolTag).toData(trueValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([42n]) + + const decoded = Data.withSchema(BoolTag).fromData(encoded) + expect(decoded).toEqual({ _tag: true, value: 42n }) + }) + + it("should handle single member union with tag field", () => { + const Only = TSchema.TaggedStruct("Only", { + value: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Single = TSchema.Union(Only) + + const value = { _tag: "Only" as const, value: 123n } + const encoded = Data.withSchema(Single).toData(value) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([123n]) + + const decoded = Data.withSchema(Single).fromData(encoded) + expect(decoded).toEqual({ _tag: "Only", value: 123n }) + }) + + it("should handle mixed flatInUnion settings gracefully", () => { + // One member is flat, another is not - should still detect tag field + const Flat = TSchema.Struct( + { + _tag: TSchema.Literal("Flat"), + value: TSchema.Integer + }, + { flatInUnion: true, index: 0 } + ) + + const NotFlat = TSchema.Struct( + { + _tag: TSchema.Literal("NotFlat"), + value: TSchema.Integer + }, + { flatInUnion: false, index: 1 } + ) + + const Mixed = TSchema.Union(Flat, NotFlat) + + const flatValue = { _tag: "Flat" as const, value: 10n } + const encoded = Data.withSchema(Mixed).toData(flatValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([10n]) // _tag stripped + + const decoded = Data.withSchema(Mixed).fromData(encoded) + expect(decoded).toEqual({ _tag: "Flat", value: 10n }) + }) + + it("should handle union members with different index values", () => { + // Non-sequential indices + const First = TSchema.TaggedStruct("First", { + value: TSchema.Integer + }, { flatInUnion: true, index: 5 }) + + const Second = TSchema.TaggedStruct("Second", { + value: TSchema.Integer + }, { flatInUnion: true, index: 10 }) + + const NonSeq = TSchema.Union(First, Second) + + const firstValue = { _tag: "First" as const, value: 100n } + const encoded = Data.withSchema(NonSeq).toData(firstValue) + + expect(encoded.index).toBe(5n) + expect(encoded.fields).toEqual([100n]) + + const decoded = Data.withSchema(NonSeq).fromData(encoded) + expect(decoded).toEqual({ _tag: "First", value: 100n }) + }) + + it("should handle very long tag field names", () => { + const longTag = "veryLongDiscriminatorFieldNameThatExceedsNormalLength" + + const A = TSchema.Struct( + { + [longTag]: TSchema.Literal("A"), + data: TSchema.Integer + }, + { tagField: longTag, flatInUnion: true, index: 0 } + ) + + const B = TSchema.Struct( + { + [longTag]: TSchema.Literal("B"), + data: TSchema.Integer + }, + { tagField: longTag, flatInUnion: true, index: 1 } + ) + + const LongTag = TSchema.Union(A, B) + + const value = { [longTag]: "A" as const, data: 42n } + const encoded = Data.withSchema(LongTag).toData(value) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([42n]) + + const decoded = Data.withSchema(LongTag).fromData(encoded) + expect(decoded).toEqual({ [longTag]: "A", data: 42n }) + }) + + it("should handle empty string as tag value", () => { + const Empty = TSchema.TaggedStruct("", { + value: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const NotEmpty = TSchema.TaggedStruct("NotEmpty", { + value: TSchema.Integer + }, { flatInUnion: true, index: 1 }) + + const EmptyTag = TSchema.Union(Empty, NotEmpty) + + const emptyValue = { _tag: "" as const, value: 123n } + const encoded = Data.withSchema(EmptyTag).toData(emptyValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([123n]) + + const decoded = Data.withSchema(EmptyTag).fromData(encoded) + expect(decoded).toEqual({ _tag: "", value: 123n }) + }) + + it("should handle unicode characters in tag values", () => { + const Emoji = TSchema.TaggedStruct("🎉", { + value: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Chinese = TSchema.TaggedStruct("中文", { + value: TSchema.Integer + }, { flatInUnion: true, index: 1 }) + + const Unicode = TSchema.Union(Emoji, Chinese) + + const emojiValue = { _tag: "🎉" as const, value: 999n } + const encoded = Data.withSchema(Unicode).toData(emojiValue) + + expect(encoded.index).toBe(0n) + expect(encoded.fields).toEqual([999n]) + + const decoded = Data.withSchema(Unicode).fromData(encoded) + expect(decoded).toEqual({ _tag: "🎉", value: 999n }) + }) + }) + + describe("Round-trip encoding/decoding", () => { + it("should preserve data through encode/decode cycle", () => { + const variants = [ + { _tag: "Mint" as const, amount: 100n }, + { _tag: "Burn" as const, amount: 50n }, + { _tag: "Transfer" as const, from: new Uint8Array([1]), to: new Uint8Array([2]), amount: 75n } + ] + + const Mint = TSchema.TaggedStruct("Mint", { + amount: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Burn = TSchema.TaggedStruct("Burn", { + amount: TSchema.Integer + }, { flatInUnion: true, index: 1 }) + + const Transfer = TSchema.TaggedStruct("Transfer", { + from: TSchema.ByteArray, + to: TSchema.ByteArray, + amount: TSchema.Integer + }, { flatInUnion: true, index: 2 }) + + const Action = TSchema.Union(Mint, Burn, Transfer) + + for (const variant of variants) { + const encoded = Data.withSchema(Action).toData(variant as any) + const decoded = Data.withSchema(Action).fromData(encoded) + expect(decoded).toEqual(variant) + } + }) + + it("should handle CBOR hex round-trip", () => { + const Success = TSchema.TaggedStruct("Success", { + value: TSchema.Integer + }, { flatInUnion: true, index: 0 }) + + const Failure = TSchema.TaggedStruct("Failure", { + error: TSchema.ByteArray + }, { flatInUnion: true, index: 1 }) + + const Result = TSchema.Union(Success, Failure) + + const successValue = { _tag: "Success" as const, value: 42n } + + const hex = Data.withSchema(Result).toCBORHex(successValue) + expect(typeof hex).toBe("string") + + const decoded = Data.withSchema(Result).fromCBORHex(hex) + expect(decoded).toEqual(successValue) + }) + }) +}) diff --git a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak index 051b1f64..d746f164 100644 --- a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak +++ b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak @@ -98,8 +98,7 @@ test encode_map_single_entry() { test encode_map_multiple_entries() { // Maps in Aiken are represented as lists of pairs - let map_data = [(1, #"ff"), (2, #"aa")] - cbor.serialise(map_data) == cbor.serialise(map_data) + cbor.serialise([(#"01", 1), (#"02", 2), (#"03", 3)]) == #"9f9f410101ff9f410202ff9f410303ffff" } test encode_map_int_keys() { @@ -182,8 +181,9 @@ test encode_constructor_with_one_field() { cbor.serialise(Single(42)) == #"d8799f182aff" } -test encode_constructor_with_two_fields() { - cbor.serialise(Pair(1, #"ff")) == #"9f0141ffff" +test encode_pair_tuple() { + // Pairs are tuples, not constructors + cbor.serialise((1, #"ff")) == #"9f0141ffff" } test encode_constructor_with_three_fields() { @@ -265,7 +265,8 @@ test encode_constructor_index_6() { } test encode_constructor_index_7() { - // Constructor 7 uses tag 1280 (0x0500 = 121 + 7 = 128, encoded as d90500) + // Constructor 7 uses alternative tag encoding: 1280 + (7 - 7) = 1280 (0x0500) + // Encoded as: d90500 (tag 1280) followed by 80 (empty array) cbor.serialise(C7) == #"d9050080" } @@ -371,29 +372,28 @@ type ScriptContext { test encode_complex_datum() { let datum = Datum { - owner: #"abcd", - amount: 1000000, - beneficiaries: [(#"1234", 500000), (#"5678", 500000)], - metadata: Some(#"abcd"), + owner: #"abababababababababababababababababababababababababababab", + amount: 1000, + beneficiaries: [], + metadata: Some(#"dead"), } - // This will show us the exact encoding Aiken uses for a realistic datum - cbor.serialise(datum) == cbor.serialise(datum) + cbor.serialise(datum) == #"d8799f581cabababababababababababababababababababababababababababab1903e880d8799f42deadffff" } test encode_redeemer() { - let redeemer = Redeemer { action: 0, params: [#"abcd", #"ef01"] } - cbor.serialise(redeemer) == cbor.serialise(redeemer) + let redeemer = Redeemer { action: 100, params: [#"abcd"] } + cbor.serialise(redeemer) == #"d8799f18649f42abcdffff" } test encode_script_context() { let ctx = ScriptContext { - inputs: [1, 2, 3], - outputs: [4, 5], + inputs: [1, 2], + outputs: [3], fee: 170000, valid_range: (0, 100), } - cbor.serialise(ctx) == cbor.serialise(ctx) + cbor.serialise(ctx) == #"d8799f9f0102ff9f03ff1a000298109f001864ffff" } test encode_pkh_credential() { @@ -407,40 +407,3 @@ test encode_script_hash() { let script = #"1234567890abcdef1234567890abcdef1234567890abcdef1234" cbor.serialise(script) == #"581a1234567890abcdef1234567890abcdef1234567890abcdef1234" } - -// ============================================================================ -// Diagnostic Output Tests -// ============================================================================ - -test diagnostic_int() { - cbor.diagnostic(42) == @"42" -} - -test diagnostic_bytearray() { - cbor.diagnostic(#"a1b2") == @"h'A1B2'" -} - -test diagnostic_list() { - cbor.diagnostic([1, 2, 3]) == @"[_ 1, 2, 3]" -} - -test diagnostic_empty_list() { - cbor.diagnostic([]) == @"[]" -} - -test diagnostic_pair() { - cbor.diagnostic((1, 2)) == @"[_ 1, 2]" -} - -test diagnostic_map() { - // Maps are arrays of pairs in Aiken - cbor.diagnostic([(1, #"ff")]) == @"[_ [_ 1, h'FF']]" -} - -test diagnostic_some() { - cbor.diagnostic(Some(42)) == @"121([_ 42])" -} - -test diagnostic_none() { - cbor.diagnostic(None) == @"122([])" -} From 37c15e366637333fba41f9db2bd3fed1d1a60003 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Thu, 20 Nov 2025 09:18:39 -0700 Subject: [PATCH 4/5] refactor(TSchema): improve Variant type inference and code quality --- packages/evolution/src/core/TSchema.ts | 181 +++++++-------- packages/evolution/test/CBOR.Aiken.test.ts | 209 ++++++++++++++++-- .../test/spec/lib/cbor_encoding_spec.ak | 143 ++++++++++++ 3 files changed, 414 insertions(+), 119 deletions(-) diff --git a/packages/evolution/src/core/TSchema.ts b/packages/evolution/src/core/TSchema.ts index 207fba85..b7906af3 100644 --- a/packages/evolution/src/core/TSchema.ts +++ b/packages/evolution/src/core/TSchema.ts @@ -17,7 +17,7 @@ const KNOWN_TAG_FIELDS = ["_tag", "type", "kind", "variant"] as const */ const getLiteralFieldValue = (schema: Schema.Schema.Any, fieldName: string): any | undefined => { const ast = schema.ast - + // Check if this is a Struct (TypeLiteral or Transformation to TypeLiteral) let typeLiteral: any if (ast._tag === "TypeLiteral") { @@ -27,20 +27,20 @@ const getLiteralFieldValue = (schema: Schema.Schema.Any, fieldName: string): any } else { return undefined } - + // Find the property signature for this field name const propertySignatures = typeLiteral.propertySignatures || [] const propSig = propertySignatures.find((sig: any) => sig.name === fieldName) if (!propSig) return undefined - + // Check if the property type is a Literal or a Transformation to Literal const propType = propSig.type - + // Direct Literal (Schema.Literal) if (propType._tag === "Literal") { return (propType as any).literal } - + // TSchema.Literal (Transformation from Constr to Literal) if (propType._tag === "Transformation") { const transformTo = (propType as any).to @@ -48,7 +48,7 @@ const getLiteralFieldValue = (schema: Schema.Schema.Any, fieldName: string): any return (transformTo as any).literal } } - + return undefined } @@ -60,7 +60,7 @@ export interface ByteArray extends Schema.Schema * This module provides bidirectional transformations: * 1. TypeScript types => Plutus Data type => CBOR hex * 2. CBOR hex => Plutus Data type => TypeScript types - * + * * It also exports utility functions for working with schemas: * - `equivalence`: Creates optimized equality comparison functions * - `is`: Type guard for schema validation @@ -255,20 +255,20 @@ export interface StructOptions { * - false: Inner Struct is kept as a nested Constr * * Default: false - * + * * Note: This only applies when the Struct is a field value, not when used in Union. */ flatFields?: boolean /** * Name of a field to treat as a discriminant tag (e.g., "_tag", "type"). - * + * * Auto-detection: Fields named "_tag", "type", "kind", or "variant" containing * Literal values are automatically stripped from CBOR encoding and injected during decoding. - * + * * This option allows you to: * - Explicitly specify a custom tag field name * - Disable auto-detection with `tagField: false` - * + * * Default: auto-detect from KNOWN_TAG_FIELDS */ tagField?: string | false @@ -280,10 +280,10 @@ export interface StructOptions { * * @since 2.0.0 */ -export const Struct = ( +export function Struct( fields: Fields, options: StructOptions = {} -): Struct => { +): Struct { const { flatFields, flatInUnion, index = 0, tagField } = options // flatInUnion defaults to true when index is specified @@ -293,7 +293,7 @@ export const Struct = ( // Auto-detect tag field: find a field with a known tag name that contains a Literal let detectedTagField: string | undefined if (tagField !== false) { - const explicitTag = typeof tagField === 'string' ? tagField : undefined + const explicitTag = typeof tagField === "string" ? tagField : undefined if (explicitTag) { detectedTagField = explicitTag } else { @@ -303,14 +303,14 @@ export const Struct = ( if (fieldSchema) { // Check if this field is a Literal (either TSchema.Literal or Schema.Literal) const ast = fieldSchema.ast - if (ast._tag === 'Literal') { + if (ast._tag === "Literal") { detectedTagField = knownTag break } // Also check for transformed literals (TSchema.Literal) - if (ast._tag === 'Transformation') { + if (ast._tag === "Transformation") { const toAST = (ast as any).to - if (toAST._tag === 'Literal') { + if (toAST._tag === "Literal") { detectedTagField = knownTag break } @@ -324,77 +324,76 @@ export const Struct = ( strict: false, encode: (encodedStruct) => { // encodedStruct is the result of Schema.Struct(fields), which has already transformed all fields - + // Filter out the tag field if detected (it's metadata, not data) - const fieldEntries = Object.entries(encodedStruct).filter( - ([key]) => key !== detectedTagField - ) + const fieldEntries = Object.entries(encodedStruct).filter(([key]) => key !== detectedTagField) const fieldValues = fieldEntries.map(([_, value]) => value) as ReadonlyArray - + // Check if any field values are Constrs with flatFields:true // If so, spread their fields into this Struct's field array const finalFields = new globalThis.Array() - + for (const fieldValue of fieldValues) { // Check if this field is a Constr from a flatFields Struct - if (fieldValue instanceof Data.Constr && - (fieldValue as any)["__flatFields__"] === true) { + if (fieldValue instanceof Data.Constr && (fieldValue as any)["__flatFields__"] === true) { // Spread its fields into the parent finalFields.push(...fieldValue.fields) } else { finalFields.push(fieldValue) } } - + const constr = new Data.Constr({ index: BigInt(index), fields: finalFields }) - + // Mark this Constr if it was created with flatFields so parent can detect it if (isFlatFields) { - (constr as any)["__flatFields__"] = true + ;(constr as any)["__flatFields__"] = true } - + return constr }, decode: (fromA) => { const keys = Object.keys(fields) const fieldSchemas = Object.values(fields) as ReadonlyArray const result = {} as Record - + let fieldIndex = 0 keys.forEach((key, keyIndex) => { // Skip the tag field during decoding - we'll inject it after if (key === detectedTagField) { return } - + const fieldSchema = fieldSchemas[keyIndex] const fieldAnnotations = fieldSchema.ast.annotations - + // Check if this field is a flatFields Struct const isFieldFlat = fieldAnnotations?.["TSchema.flatFields"] === true - + if (isFieldFlat && fieldSchema.ast._tag === "Transformation") { // This is a flat Struct - we need to reconstruct it from multiple fields // Get the inner Struct fields count const transformAST = fieldSchema.ast as any const toAST = transformAST.to - + // For a Struct, the number of fields is the number of property signatures - const propertySignatures = (toAST._tag === "TypeLiteral" ? toAST.propertySignatures : []) as ReadonlyArray + const propertySignatures = ( + toAST._tag === "TypeLiteral" ? toAST.propertySignatures : [] + ) as ReadonlyArray const numInnerFields = propertySignatures.length - + // Extract the fields for this nested Struct const nestedFields = fromA.fields.slice(fieldIndex, fieldIndex + numInnerFields) - + // Reconstruct as a Constr for the nested Struct to decode const nestedConstr = new Data.Constr({ index: 0n, // flatFields Structs don't preserve their index fields: nestedFields }) - + result[key] = nestedConstr fieldIndex += numInnerFields } else { @@ -403,14 +402,14 @@ export const Struct = ( fieldIndex++ } }) - + // Inject the tag field if detected // We need to inject it as the ENCODED form (Constr), not the decoded form (literal string), // because Effect Schema will decode it using the field schema if (detectedTagField && fields[detectedTagField]) { const tagSchema = fields[detectedTagField] const ast = tagSchema.ast - + // Extract the Literal value and convert it to its encoded Constr form let literalValue: any if (ast._tag === "Literal") { @@ -423,14 +422,14 @@ export const Struct = ( literalValue = (toAST as any).literal } } - + // Encode the literal value as a Constr - TSchema.Literal encodes to Constr(index: 0, fields: []) // Schema.Literal would also encode the same way (for a single literal value) if (literalValue !== undefined) { result[detectedTagField] = new Data.Constr({ index: 0n, fields: [] }) } } - + return result as { [K in keyof Schema.Struct.Encoded]: Schema.Struct.Encoded[K] } } }).annotations({ @@ -629,7 +628,7 @@ export const Union = >(...membe } const memberInfo = memberInfos[matchedIndex] - + // Encode the full value - if members are Structs with tag fields, // they will handle filtering out the tag field themselves const encodedValue = yield* ParseResult.encode(memberInfo.schema as Schema.Schema)(value) @@ -639,23 +638,23 @@ export const Union = >(...membe // Recursively unwrap nested flat Constrs (for nested flatFields support) const unwrapNestedFlat = (constr: Data.Constr): Data.Constr => { // If this Constr has exactly one field and that field is also a flat Constr, unwrap it - if (constr.fields.length === 1 && - constr.fields[0] instanceof Data.Constr && - (constr.fields[0] as any)["__flatFields__"] === true) { + if ( + constr.fields.length === 1 && + constr.fields[0] instanceof Data.Constr && + (constr.fields[0] as any)["__flatFields__"] === true + ) { // Recursively unwrap return unwrapNestedFlat(constr.fields[0] as Data.Constr) } return constr } - + const unwrapped = unwrapNestedFlat(encodedValue) - + // If the member has a custom index, use it; otherwise use position const customIdx = memberInfo.customIndex - const finalIndex = customIdx !== undefined - ? BigInt(customIdx) - : BigInt(memberInfo.position) - + const finalIndex = customIdx !== undefined ? BigInt(customIdx) : BigInt(memberInfo.position) + return new Data.Constr({ index: finalIndex, fields: unwrapped.fields @@ -758,10 +757,13 @@ export const Union = >(...membe : typeof actual === "object" && actual !== null && globalThis.Array.isArray(actual) ? "array" : typeof actual - - const actualStr = typeof actual === "bigint" ? String(actual) : - typeof actual === "object" ? String(actual) : - JSON.stringify(actual) + + const actualStr = + typeof actual === "bigint" + ? String(actual) + : typeof actual === "object" + ? String(actual) + : JSON.stringify(actual) return `Invalid value for Union: received ${actualType} (${actualStr}), expected ${memberNames.join(" or ")}` } @@ -779,61 +781,50 @@ export const Tuple = (element: [...E identifier: "Tuple" }) as Tuple -/** - * Helper type to extract the TypeScript type from a Struct fields definition - * Creates a discriminated union type like: {Mint: {amount: bigint}} | {Burn: {amount: bigint}} - */ -type VariantType> = { - [K in keyof Variants]: { - readonly [P in K]: Schema.Struct.Type - } -}[keyof Variants] - /** * Creates a variant (tagged union) schema for Aiken-style enum types. - * + * * This is a convenience helper that creates properly discriminated TypeScript types * while maintaining single-level CBOR encoding compatible with Aiken. - * + * * @param variants - Object mapping variant names to their field schemas * @returns Union schema with discriminated types - * + * * @since 2.0.0 * @category constructors */ -export const Variant = < - Variants extends Record ->(variants: Variants): Schema.Schema< - VariantType, - Data.Data, - never -> => { - const variantNames = Object.keys(variants) - - // Create Union members: each variant becomes a Struct with nested flat Struct - const members = variantNames.map((name, index) => - Struct( - { - [name]: Struct(variants[name], { flatFields: true }) - }, - { flatInUnion: true, index } - ) +export function Variant>( + variants: Variants +): Union< + ReadonlyArray< + { + [K in keyof Variants]: Struct<{ readonly [P in K]: Struct }> + }[keyof Variants] + > +> { + return Union( + ...Object.entries(variants).map(([name, fields], index) => + Struct( + { + [name]: Struct(fields, { flatFields: true }) + } as any, + { flatInUnion: true, index } + ) + ) as any ) - - return Union(...members) as any } /** * Creates a tagged struct - a shortcut for creating a Struct with a Literal tag field. - * + * * This is a convenience helper that makes it easy to create structs with discriminator fields, * commonly used in discriminated unions. - * + * * @param tagValue - The literal value for the tag (e.g., "Circle", "User") * @param fields - The struct fields (excluding the tag field) * @param options - Struct options (tagField defaults to "_tag", plus flatInUnion, index, etc.) * @returns Struct schema with the tag field - * + * * @since 2.0.0 * @category constructors */ @@ -846,8 +837,8 @@ export const TaggedStruct = < fields: Fields, options?: StructOptions & { tagField?: TagField } ): Struct<{ [K in TagField]: OneLiteral } & Fields> => { - const tagField = (options?.tagField ?? '_tag') as TagField - + const tagField = (options?.tagField ?? "_tag") as TagField + return Struct( { [tagField]: Literal(tagValue), @@ -865,10 +856,10 @@ export const is = Schema.is /** * Creates an equivalence function for a schema that can compare two values for equality. - * + * * This leverages Effect Schema's built-in equivalence generation, which creates * optimized equality checks based on the schema structure. - * + * * @since 2.0.0 * @category combinators */ diff --git a/packages/evolution/test/CBOR.Aiken.test.ts b/packages/evolution/test/CBOR.Aiken.test.ts index 6d948c6e..e4e2931c 100644 --- a/packages/evolution/test/CBOR.Aiken.test.ts +++ b/packages/evolution/test/CBOR.Aiken.test.ts @@ -1,4 +1,3 @@ - import { describe, expect, it } from "vitest" import * as Bytes from "../src/core/Bytes.js" @@ -224,22 +223,26 @@ describe("Aiken CBOR Encoding Compatibility", () => { it("encode_constructor_with_three_fields: should encode Triple(1, 2, 3)", () => { // Approach 1: Using Union with explicit indices const WithFieldsUnion = TSchema.Union( - TSchema.Struct({ value: TSchema.Integer }, { flatInUnion: true, index: 0 }), // Single - index 0 - TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatInUnion: true, index: 1 }) // Triple - index 1 + TSchema.Struct({ value: TSchema.Integer }, { flatInUnion: true, index: 0 }), // Single - index 0 + TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatInUnion: true, index: 1 }) // Triple - index 1 ) const valueUnion = Data.withSchema(WithFieldsUnion).toData({ a: 1n, b: 2n, c: 3n }) const encodedUnion = Data.toCBORHex(valueUnion, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encodedUnion).toBe("d87a9f010203ff") - + // Approach 2: Using discriminated union with TaggedStruct const Single = TSchema.TaggedStruct("Single", { value: TSchema.Integer }, { flatInUnion: true, index: 0 }) - const Triple = TSchema.TaggedStruct("Triple", { a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatInUnion: true, index: 1 }) + const Triple = TSchema.TaggedStruct( + "Triple", + { a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, + { flatInUnion: true, index: 1 } + ) const WithFieldsTagged = TSchema.Union(Single, Triple) - + const valueTagged = Data.withSchema(WithFieldsTagged).toData({ _tag: "Triple" as const, a: 1n, b: 2n, c: 3n }) const encodedTagged = Data.toCBORHex(valueTagged, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encodedTagged).toBe("d87a9f010203ff") - + // Approach 3: Using Variant helper const WithFieldsVariant = TSchema.Variant({ Single: { value: TSchema.Integer }, @@ -248,7 +251,7 @@ describe("Aiken CBOR Encoding Compatibility", () => { const valueVariant = Data.withSchema(WithFieldsVariant).toData({ Triple: { a: 1n, b: 2n, c: 3n } }) const encodedVariant = Data.toCBORHex(valueVariant, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encodedVariant).toBe("d87a9f010203ff") - + // Approach 4: Using Struct with flatInUnion option directly (equivalent to Variant) // This shows that Variant is just syntactic sugar over Struct with flatInUnion and flatFields const WithFieldsStructOnly = TSchema.Union( @@ -257,20 +260,22 @@ describe("Aiken CBOR Encoding Compatibility", () => { { flatInUnion: true, index: 0 } ), TSchema.Struct( - { Triple: TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatFields: true }) }, + { + Triple: TSchema.Struct({ a: TSchema.Integer, b: TSchema.Integer, c: TSchema.Integer }, { flatFields: true }) + }, { flatInUnion: true, index: 1 } ) ) - + // Test both Single and Triple variants const valueSingleStructOnly = Data.withSchema(WithFieldsStructOnly).toData({ Single: { value: 999n } }) const encodedSingleStructOnly = Data.toCBORHex(valueSingleStructOnly, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encodedSingleStructOnly).toBe("d8799f1903e7ff") // Single(999) - + const valueTripleStructOnly = Data.withSchema(WithFieldsStructOnly).toData({ Triple: { a: 1n, b: 2n, c: 3n } }) const encodedTripleStructOnly = Data.toCBORHex(valueTripleStructOnly, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encodedTripleStructOnly).toBe("d87a9f010203ff") - + // Verify all approaches produce identical CBOR for Triple expect(encodedUnion).toBe(encodedTagged) expect(encodedTagged).toBe(encodedVariant) @@ -286,11 +291,7 @@ describe("Aiken CBOR Encoding Compatibility", () => { // Test #31: encode_list_of_bytearrays it("encode_list_of_bytearrays: should encode list of bytearrays", () => { - const value = Data.list([ - Bytes.fromHexUnsafe("aa"), - Bytes.fromHexUnsafe("bb"), - Bytes.fromHexUnsafe("cc") - ]) + const value = Data.list([Bytes.fromHexUnsafe("aa"), Bytes.fromHexUnsafe("bb"), Bytes.fromHexUnsafe("cc")]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f41aa41bb41ccff") }) @@ -330,7 +331,10 @@ describe("Aiken CBOR Encoding Compatibility", () => { 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.map([ + [1n, some100], + [2n, none] + ]) const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("9f9f01d8799f1864ffff9f02d87a80ffff") }) @@ -521,14 +525,14 @@ describe("Aiken CBOR Encoding Compatibility", () => { const amount = 1000n const beneficiaries: Array<[Uint8Array, bigint]> = [] const metadata = Bytes.fromHexUnsafe("dead") - + const Datum = TSchema.Struct({ owner: TSchema.ByteArray, amount: TSchema.Integer, beneficiaries: TSchema.Array(TSchema.Tuple([TSchema.ByteArray, TSchema.Integer])), metadata: TSchema.UndefinedOr(TSchema.ByteArray) }) - + const datum = Data.withSchema(Datum).toData({ owner, amount, @@ -543,12 +547,12 @@ describe("Aiken CBOR Encoding Compatibility", () => { it("encode_redeemer: should encode Redeemer{action: 100, params: [#abcd]}", () => { const action = 100n const params = [Bytes.fromHexUnsafe("abcd")] - + const Redeemer = TSchema.Struct({ action: TSchema.Integer, params: TSchema.Array(TSchema.ByteArray) }) - + const redeemer = Data.withSchema(Redeemer).toData({ action, params }) const encoded = Data.toCBORHex(redeemer, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("d8799f18649f42abcdffff") @@ -560,14 +564,14 @@ describe("Aiken CBOR Encoding Compatibility", () => { const outputs = [3n] const fee = 170000n const valid_range: [bigint, bigint] = [0n, 100n] - + const ScriptContext = TSchema.Struct({ inputs: TSchema.Array(TSchema.Integer), outputs: TSchema.Array(TSchema.Integer), fee: TSchema.Integer, valid_range: TSchema.Tuple([TSchema.Integer, TSchema.Integer]) }) - + const ctx = Data.withSchema(ScriptContext).toData({ inputs, outputs, fee, valid_range }) const encoded = Data.toCBORHex(ctx, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("d8799f9f0102ff9f03ff1a000298109f001864ffff") @@ -586,4 +590,161 @@ describe("Aiken CBOR Encoding Compatibility", () => { const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS) expect(encoded).toBe("581a1234567890abcdef1234567890abcdef1234567890abcdef1234") }) + + // Test #65: encode_credential_verification_key + it("encode_credential_verification_key: should encode Credential with VerificationKey", () => { + const hash = Bytes.fromHexUnsafe("abcdef1234567890abcdef1234567890abcdef1234567890abcdef12") + + const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + + const credential = Data.withSchema(Credential).toData({ + VerificationKey: { hash } + }) + const encoded = Data.toCBORHex(credential, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ff") + }) + + // Test #66: encode_credential_script + it("encode_credential_script: should encode Credential with Script", () => { + const hash = Bytes.fromHexUnsafe("1234567890abcdef1234567890abcdef1234567890abcdef1234ab") + + const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + + const credential = Data.withSchema(Credential).toData({ + Script: { hash } + }) + const encoded = Data.toCBORHex(credential, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abff") + }) + + // Test #67: encode_referenced_inline + it("encode_referenced_inline: should encode Referenced with Inline credential", () => { + const hash = Bytes.fromHexUnsafe("abcdef1234567890abcdef1234567890abcdef1234567890abcdef12") + + const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + + const Referenced = TSchema.Variant({ + Inline: { credential: Credential }, + Pointer: { + slot_number: TSchema.Integer, + transaction_index: TSchema.Integer, + certificate_index: TSchema.Integer + } + }) + + const referenced = Data.withSchema(Referenced).toData({ + Inline: { + credential: { VerificationKey: { hash } } + } + }) + const encoded = Data.toCBORHex(referenced, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d8799fd8799f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ffff") + }) + + // Test #68: encode_referenced_pointer + it("encode_referenced_pointer: should encode Referenced with Pointer", () => { + const Referenced = TSchema.Variant({ + Inline: { + credential: TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + }, + Pointer: { + slot_number: TSchema.Integer, + transaction_index: TSchema.Integer, + certificate_index: TSchema.Integer + } + }) + + const referenced = Data.withSchema(Referenced).toData({ + Pointer: { + slot_number: 100n, + transaction_index: 2n, + certificate_index: 0n + } + }) + const encoded = Data.toCBORHex(referenced, CBOR.AIKEN_DEFAULT_OPTIONS) + expect(encoded).toBe("d87a9f18640200ff") + }) + + // Test #69: encode_address_payment_only + it("encode_address_payment_only: should encode Address with payment credential only", () => { + const hash = Bytes.fromHexUnsafe("ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6") + + const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + + const Address = TSchema.Struct({ + payment_credential: Credential, + stake_credential: TSchema.UndefinedOr( + TSchema.Variant({ + Inline: { credential: Credential }, + Pointer: { + slot_number: TSchema.Integer, + transaction_index: TSchema.Integer, + certificate_index: TSchema.Integer + } + }) + ) + }) + + const address = Data.withSchema(Address).toData({ + payment_credential: { VerificationKey: { hash } }, + stake_credential: undefined + }) + const encoded = Data.toCBORHex(address, CBOR.AIKEN_DEFAULT_OPTIONS) + // d87a80 is Some(None) - the Option wrapper for stake_credential + expect(encoded).toBe("d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd87a80ff") + }) + + // Test #70: encode_address_with_inline_stake_key + it("encode_address_with_inline_stake_key: should encode Address with payment and inline stake (key)", () => { + const paymentHash = Bytes.fromHexUnsafe("ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6") + const stakeHash = Bytes.fromHexUnsafe("64ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507") + + const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } + }) + + const Address = TSchema.Struct({ + payment_credential: Credential, + stake_credential: TSchema.UndefinedOr( + TSchema.Variant({ + Inline: { credential: Credential }, + Pointer: { + slot_number: TSchema.Integer, + transaction_index: TSchema.Integer, + certificate_index: TSchema.Integer + } + }) + ) + }) + + const address = Data.withSchema(Address).toData({ + payment_credential: { VerificationKey: { hash: paymentHash } }, + stake_credential: { + Inline: { + credential: { VerificationKey: { hash: stakeHash } } + } + } + }) + const encoded = Data.toCBORHex(address, CBOR.AIKEN_DEFAULT_OPTIONS) + // UndefinedOr wraps the value with Some (d8799f), then Inline variant (d8799f), then credential + expect(encoded).toBe( + "d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd8799fd8799fd8799f581c64ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507ffffffff" + ) + }) }) diff --git a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak index d746f164..f299a571 100644 --- a/packages/evolution/test/spec/lib/cbor_encoding_spec.ak +++ b/packages/evolution/test/spec/lib/cbor_encoding_spec.ak @@ -1,4 +1,5 @@ use aiken/cbor +use cardano/address.{Address, Inline, Pointer, Script, VerificationKey} // ============================================================================ // Primitive Types @@ -407,3 +408,145 @@ test encode_script_hash() { let script = #"1234567890abcdef1234567890abcdef1234567890abcdef1234" cbor.serialise(script) == #"581a1234567890abcdef1234567890abcdef1234567890abcdef1234" } + +// ============================================================================ +// Address Types (Cardano Address Structure) +// ============================================================================ + +test encode_credential_verification_key() { + // Credential.VerificationKey with 28-byte hash + let vk_hash = #"abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + let cred = VerificationKey(vk_hash) + cbor.serialise(cred) == #"d8799f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ff" +} + +test encode_credential_script() { + // Credential.Script with 27-byte script hash + let script_hash = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let cred = Script(script_hash) + cbor.serialise(cred) == #"d87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abff" +} + +test encode_referenced_inline() { + // Referenced::Inline wrapping a credential + let vk_hash = #"abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + let cred = VerificationKey(vk_hash) + let inline_ref = Inline(cred) + cbor.serialise(inline_ref) == #"d8799fd8799f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ffff" +} + +test encode_referenced_pointer() { + // Referenced::Pointer with slot_number=100, tx_index=2, cert_index=0 + let pointer = + Pointer { slot_number: 100, transaction_index: 2, certificate_index: 0 } + cbor.serialise(pointer) == #"d87a9f18640200ff" +} + +// Address with payment key, no stake credential +test encode_address_payment_key_no_stake() { + // Address { payment_credential: VerificationKey(hash), stake_credential: None } + let pk_hash = #"ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6" + let address = + Address { + payment_credential: VerificationKey(pk_hash), + stake_credential: None, + } + cbor.serialise(address) == #"d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd87a80ff" +} + +// Address with payment key + inline stake key +test encode_address_payment_key_stake_key_inline() { + // Address with both payment and stake credentials (base address) + let payment_hash = #"ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6" + let stake_hash = #"64ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507" + let address = + Address { + payment_credential: VerificationKey(payment_hash), + stake_credential: Some(Inline(VerificationKey(stake_hash))), + } + cbor.serialise(address) == #"d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd8799fd8799fd8799f581c64ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507ffffffff" +} + +// Address with payment script, no stake credential +test encode_address_payment_script_no_stake() { + // Script address without delegation (enterprise script address) + let script_hash = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let address = + Address { payment_credential: Script(script_hash), stake_credential: None } + cbor.serialise(address) == #"d8799fd87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abffd87a80ff" +} + +// Address with payment script + inline stake key +test encode_address_payment_script_stake_key_inline() { + // Script address with stake delegation to a key + let script_hash = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let stake_hash = #"abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + let address = + Address { + payment_credential: Script(script_hash), + stake_credential: Some(Inline(VerificationKey(stake_hash))), + } + cbor.serialise(address) == #"d8799fd87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abffd8799fd8799fd8799f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ffffffff" +} + +// Address with payment key + inline stake script +test encode_address_payment_key_stake_script_inline() { + // Payment key with stake script delegation + let payment_hash = #"ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6" + let stake_script = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let address = + Address { + payment_credential: VerificationKey(payment_hash), + stake_credential: Some(Inline(Script(stake_script))), + } + cbor.serialise(address) == #"d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd8799fd8799fd87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abffffffff" +} + +// Address with payment script + inline stake script +test encode_address_payment_script_stake_script_inline() { + // Both payment and stake are scripts + let payment_script = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let stake_script = #"abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + let address = + Address { + payment_credential: Script(payment_script), + stake_credential: Some(Inline(Script(stake_script))), + } + cbor.serialise(address) == #"d8799fd87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abffd8799fd8799fd87a9f581cabcdef1234567890abcdef1234567890abcdef1234567890abcdef12ffffffff" +} + +// Address with payment key + pointer stake credential +test encode_address_payment_key_stake_pointer() { + // Payment key with pointer to stake credential registration + let payment_hash = #"ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6" + let address = + Address { + payment_credential: VerificationKey(payment_hash), + stake_credential: Some( + Pointer { + slot_number: 2498243, + transaction_index: 7, + certificate_index: 0, + }, + ), + } + cbor.serialise(address) == #"d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd8799fd87a9f1a00261ec30700ffffff" +} + +// Address with payment script + pointer stake credential +test encode_address_payment_script_stake_pointer() { + // Script payment with pointer to stake credential + let script_hash = #"1234567890abcdef1234567890abcdef1234567890abcdef1234ab" + let address = + Address { + payment_credential: Script(script_hash), + stake_credential: Some( + Pointer { + slot_number: 1000, + transaction_index: 5, + certificate_index: 2, + }, + ), + } + cbor.serialise(address) == #"d8799fd87a9f581b1234567890abcdef1234567890abcdef1234567890abcdef1234abffd8799fd87a9f1903e80502ffffff" +} From 7bb1da32488c5a1a92a9c8b90e5aa4514e004232 Mon Sep 17 00:00:00 2001 From: solidsnakedev Date: Thu, 20 Nov 2025 09:27:17 -0700 Subject: [PATCH 5/5] docs: add changeset --- .changeset/brave-keys-dance.md | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .changeset/brave-keys-dance.md diff --git a/.changeset/brave-keys-dance.md b/.changeset/brave-keys-dance.md new file mode 100644 index 00000000..74395a2f --- /dev/null +++ b/.changeset/brave-keys-dance.md @@ -0,0 +1,61 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Improve `Variant` type inference with `PropertyKey` constraint + +The `Variant` helper now accepts `PropertyKey` (string | number | symbol) as variant keys instead of just strings, enabling more flexible discriminated union patterns. + +**Before:** +```typescript +// Only string keys were properly typed +const MyVariant = TSchema.Variant({ + "Success": { value: TSchema.Integer }, + "Error": { message: TSchema.ByteArray } +}) +``` + +**After:** +```typescript +// Now supports symbols and numbers as variant keys +const MyVariant = TSchema.Variant({ + Success: { value: TSchema.Integer }, + Error: { message: TSchema.ByteArray } +}) +// Type inference is improved, especially with const assertions +``` + +Replace `@ts-expect-error` with `as any` following Effect patterns + +Improved code quality by replacing forbidden `@ts-expect-error` directives with explicit `as any` type assertions, consistent with Effect Schema's approach for dynamic object construction. + +Add comprehensive Cardano Address type support + +Added full CBOR encoding support for Cardano address structures with Aiken compatibility: + +```typescript +const Credential = TSchema.Variant({ + VerificationKey: { hash: TSchema.ByteArray }, + Script: { hash: TSchema.ByteArray } +}) + +const Address = TSchema.Struct({ + payment_credential: Credential, + stake_credential: TSchema.UndefinedOr( + TSchema.Variant({ + Inline: { credential: Credential }, + Pointer: { + slot_number: TSchema.Integer, + transaction_index: TSchema.Integer, + certificate_index: TSchema.Integer + } + }) + ) +}) + +// Creates proper CBOR encoding matching Aiken's output +const address = Data.withSchema(Address).toData({ + payment_credential: { VerificationKey: { hash } }, + stake_credential: { Inline: { credential: { VerificationKey: { stakeHash } } } } +}) +```