diff --git a/.changeset/cbor-encoding-preservation.md b/.changeset/cbor-encoding-preservation.md new file mode 100644 index 00000000..c5fa8602 --- /dev/null +++ b/.changeset/cbor-encoding-preservation.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Add CBOR encoding preservation for bit-perfect round-trip fidelity and redesign Redeemers as a discriminated union (RedeemerMap + RedeemerArray) diff --git a/.github/scripts/generate-release-tweet.mjs b/.github/scripts/generate-release-tweet.mjs index c08e82e5..07eb647f 100644 --- a/.github/scripts/generate-release-tweet.mjs +++ b/.github/scripts/generate-release-tweet.mjs @@ -31,6 +31,7 @@ Rules: - The LAST tweet MUST end with the release URL (provided separately, do NOT invent URLs). - NEVER use emojis. Zero emojis in any tweet. - NEVER use hashtags. Zero hashtags in any tweet. +- NEVER use the @ symbol. Do not mention, tag, or reference any user, account, or handle. - Never fabricate features or changes not in the release notes. - If the release is only dependency bumps with no real changes, respond with an empty tweets array. - Do not include a numbering prefix like "1/" or "2/" in thread tweets. @@ -94,14 +95,16 @@ const generateTweets = async (releaseNotes, releaseUrl, token, model) => { parsed.tweets = parsed.tweets.slice(0, MAX_THREAD_LENGTH); } - // Strip emojis and hashtags the model may have included despite instructions + // Strip emojis, hashtags, and @mentions the model may have included despite instructions const EMOJI_RE = /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu; const HASHTAG_RE = /#\w+/g; + const MENTION_RE = /@\S*/g; for (const [i, tweet] of parsed.tweets.entries()) { parsed.tweets[i] = tweet .replace(EMOJI_RE, "") .replace(HASHTAG_RE, "") + .replace(MENTION_RE, "") .replace(/ +/g, " ") .trim(); } diff --git a/.specs/cbor-encoding-preservation.md b/.specs/cbor-encoding-preservation.md new file mode 100644 index 00000000..14559167 --- /dev/null +++ b/.specs/cbor-encoding-preservation.md @@ -0,0 +1,328 @@ +# CBOR Encoding Preservation + +**Status**: DRAFT +**Version**: 1.0.0 +**Owners**: @jonathan + +## Abstract + +Defines how the Evolution SDK preserves original CBOR encoding choices (integer widths, definite/indefinite containers, map key ordering) across decode → domain object → re-encode cycles. Preserving byte-level fidelity prevents transaction ID drift and signature invalidation. + +## Purpose and Scope + +**Covers**: The `CBOR.ts` decoder/encoder, `FromBytes` schema, and every `FromCDDL` transform that bridges CBOR AST ↔ domain types. + +**Does not cover**: The existing `addVKeyWitnessesBytes` byte-splice path (that already preserves bytes by never decoding the body). Does not change the public API surface of any module. + +**Target**: All CBOR-encoded types in `packages/evolution/src/` that use the `Schema.compose(CBOR.FromBytes, FromCDDL)` pipeline. + +## Introduction + +A Cardano transaction ID is `blake2b-256(body_bytes)`. If `decode → re-encode` changes even one byte, the txId changes and every existing signature breaks. + +Today, Evolution's CBOR codec re-encodes using configurable options (`CodecOptions`) rather than replaying the original encoding. This loses non-canonical choices made by the original serializer (e.g., CML uses non-minimal integer widths, other wallets may use indefinite-length containers). + +CML solves this with auto-generated `*Encoding` structs (~35 fields per type, ~200+ encoding fields per era) and `orig_deser_order` arrays. This works but requires massive codegen and per-type boilerplate. + +The Evolution SDK solves this with a single mechanism: a per-node encoding metadata tree attached via Symbol property during decode, replayed during encode, falling back to `CodecOptions`-driven encoding when metadata is absent. + +### Key Insight + +`CBORValueSchema` (the intermediate schema in every `FromBytes` ↔ `FromCDDL` compose boundary) is `Schema.declare(...)` — a passthrough validator that preserves object identity. Symbols on Maps and Arrays survive through `Schema.compose` without cloning. + +## Functional Specification + +### 1. CodecOptions: Preserve Mode + +`CodecOptions` gains a third discriminant: + +```ts +export type CodecOptions = + | { readonly mode: "preserve" } + | { readonly mode: "canonical"; readonly mapsAsObjects?: boolean; readonly encodeMapAsPairs?: boolean } + | { + readonly mode: "custom" + readonly useIndefiniteArrays: boolean + readonly useIndefiniteMaps: boolean + readonly useDefiniteForEmpty: boolean + readonly sortMapKeys: boolean + readonly useMinimalEncoding: boolean + readonly mapsAsObjects?: boolean + readonly encodeMapAsPairs?: boolean + } + +export const PRESERVE_OPTIONS: CodecOptions = { mode: "preserve" } as const +``` + +**Precedence rules:** + +| Mode | Encoder behavior | +|------|------------------| +| `"preserve"` | Use encoding metadata when present. Missing metadata falls back to CML defaults (minimal encoding, definite containers, no key sorting). | +| `"canonical"` | Ignore metadata unconditionally. Produce RFC 8949 §4.2.1 canonical bytes. | +| `"custom"` | Ignore metadata unconditionally. Use the explicit custom settings. | + +The decoder always captures encoding metadata regardless of mode — it is zero-cost (reading what is already in the byte stream). + +**Default parameter change:** All `fromCBORBytes`, `toCBORBytes`, `FromCBORBytes`, `FromCBORHex`, and `CBOR.FromBytes` default parameters change from `CML_DEFAULT_OPTIONS` to `PRESERVE_OPTIONS`. + +This is backward-compatible: when no metadata is present (fresh objects, or first run before this feature lands), `"preserve"` falls back to CML defaults — identical to current behavior. + +### 2. Encoding Metadata Types + +```ts +/** Width of an integer argument: inline (0), 1-byte, 2-byte, 4-byte, or 8-byte. */ +type Sz = 0 | 1 | 2 | 4 | 8 + +/** Container length encoding. */ +type LenEncoding = + | { readonly tag: "indefinite" } + | { readonly tag: "definite"; readonly sz: Sz } + +/** Chunked byte/text string encoding. */ +type StringEncoding = + | { readonly tag: "definite"; readonly sz: Sz } + | { readonly tag: "indefinite"; readonly chunks: ReadonlyArray<{ readonly length: number; readonly sz: Sz }> } + +/** + * Per-node tree capturing how each CBOR value was originally serialized. + * Every field is optional — absent means "use CodecOptions default". + */ +type CBOREncoding = { + readonly lenEncoding?: LenEncoding // arrays, maps + readonly valueEncoding?: Sz // unsigned/negative integers, tags + readonly stringEncoding?: StringEncoding // byte strings, text strings + readonly keyOrder?: ReadonlyArray // maps: original key insertion order + readonly tagEncoding?: Sz // CBOR tag number width + readonly children?: ReadonlyArray // arrays and tag values + readonly entries?: ReadonlyArray< // maps + readonly [CBOREncoding | undefined, CBOREncoding | undefined] + > +} +``` + +### 3. Symbol Key + +```ts +export const kEncoding: unique symbol = Symbol.for("evolution.cbor.encoding") +``` + +`Symbol.for` is used rather than a local `Symbol()` so that encoding metadata survives across module boundaries (e.g., monorepo or bundled duplicate modules). + +### 4. Decoder Changes (`internalDecodeSync`) + +Each `decode*At` function returns an additional `encoding` field alongside `item` and `newOffset`: + +```ts +type DecodeAtResult = { + item: T + newOffset: number + encoding?: CBOREncoding +} +``` + +**Capturing rules**: + +| CBOR type | What to capture | +|-----------|----------------| +| Unsigned/negative integer | `valueEncoding`: the `Sz` implied by `additionalInfo` ≥ 24 (24→1, 25→2, 26→4, 27→8). Values < 24 always encode as inline, so `valueEncoding` is `0` (or omitted). | +| Byte string (definite) | `stringEncoding.sz`: header width | +| Byte string (indefinite) | `stringEncoding.chunks`: length and sz per chunk | +| Text string | Same as byte string | +| Array (definite) | `lenEncoding: { tag: "definite", sz }`, `children` recursively | +| Array (indefinite) | `lenEncoding: { tag: "indefinite" }`, `children` recursively | +| Map (definite) | `lenEncoding: { tag: "definite", sz }`, `keyOrder` = insertion order, `entries` = `[keyEnc, valEnc]` per pair | +| Map (indefinite) | `lenEncoding: { tag: "indefinite" }`, same fields | +| Tag | `tagEncoding`: tag number width. `children[0]` = inner value encoding | + +**Attachment**: After each top-level `decodeItemAt` call returns, if the decoded value is an object (Map, Array, Tag, or BoundedBytes), the encoding tree is attached: + +```ts +if (encoding !== undefined && typeof item === "object" && item !== null) { + (item as any)[kEncoding] = encoding +} +``` + +Primitives (bigint, string, boolean, null, undefined, number) cannot carry Symbol properties. Their encoding lives on the parent's `children`/`entries` tree. + +### 5. Encoder Changes (`internalEncodeSync`) + +```ts +export const internalEncodeSync = (value: CBOR, options: CodecOptions): Uint8Array => { + // Only read metadata in preserve mode + const encoding: CBOREncoding | undefined = + options.mode === "preserve" && typeof value === "object" && value !== null + ? (value as any)[kEncoding] + : undefined + return internalEncodeWithMetadata(value, options, encoding) +} +``` + +The `encoding` parameter is only read when `mode === "preserve"`. In `"canonical"` and `"custom"` modes, `encoding` is always `undefined` — metadata is unconditionally ignored. + +The new `internalEncodeWithMetadata` function mirrors existing `encode*Sync` functions but checks `encoding` fields first (only reachable in preserve mode): + +- **Integers**: If `encoding.valueEncoding` is set, use that specific `Sz`. +- **Byte/text strings**: If `encoding.stringEncoding` is set, replay chunk structure or definite-length `Sz`. +- **Arrays**: If `encoding.lenEncoding` is `{ tag: "indefinite" }`, emit `0x9f...0xff`. Otherwise use definite header with `sz`. Recursively pass `encoding.children[i]` to each element. +- **Maps**: If `encoding.lenEncoding` is indefinite, emit `0xbf...0xff`. Emit keys in `encoding.keyOrder` order. Recursively pass `encoding.entries[i][0]`/`encoding.entries[i][1]` to keys/values. +- **Tags**: If `encoding.tagEncoding` is set, use that `Sz` for the tag number. +- **Fallback**: If any encoding field is `undefined`, fall back to CML defaults (minimal encoding, definite containers, no key sorting). + +### 6. Schema Layer: `FromBytes` + +The `FromBytes` transform changes to thread encoding on both sides: + +```ts +export const FromBytes = (options: CodecOptions) => + Schema.transformOrFail(Schema.Uint8ArrayFromSelf, CBORValueSchema, { + strict: true, + decode: (fromA, _, ast) => + E.try({ + try: () => internalDecodeSync(fromA, options), + // kEncoding is already on the returned CBOR value + catch: (error) => new ParseResult.Type(ast, fromA, `...`) + }), + encode: (toI, _, ast, toA) => + E.try({ + try: () => { + // If the CBOR AST value has encoding metadata, use it. + // Also check toA (the original value before FromCDDL encoding) + // for cases where FromCDDL threads encoding to its output. + const enc = (toI as any)?.[kEncoding] ?? (toA as any)?.[kEncoding] + if (enc && typeof toI === "object" && toI !== null && !(toI as any)[kEncoding]) { + (toI as any)[kEncoding] = enc + } + return internalEncodeSync(toI, options) + }, + catch: (error) => new ParseResult.Type(ast, toI, `...`) + }) + }) +``` + +### 7. FromCDDL Threading Pattern + +Every `FromCDDL` transform follows this pattern: + +**Decode** (CBOR AST → domain object): +```ts +decode: (fromA) => + Eff.gen(function* () { + const map = fromA as Map + // ... existing field extraction ... + const result = new DomainType(fields, { disableValidation: true }) + // Thread encoding from CBOR AST to domain object + const enc = (map as any)[kEncoding] + if (enc !== undefined) (result as any)[kEncoding] = enc + return result + }) +``` + +**Encode** (domain object → CBOR AST): +```ts +encode: (toI, _, _ast, toA) => + Eff.gen(function* () { + const record = new Map() + // ... existing field construction ... + // Thread encoding from domain object (toA) to CBOR AST + const enc = (toA as any)[kEncoding] + if (enc !== undefined) (record as any)[kEncoding] = enc + return record + }) +``` + +The `toA` parameter in `encode` is the **original domain object before transformation** — it carries the encoding that was attached during decode. + +### 8. Map Key Order Invalidation + +When a `FromCDDL.encode` adds or removes map keys compared to the original: + +```ts +// Guard: only replay keyOrder if key set hasn't changed +const enc = (toA as any)[kEncoding] as CBOREncoding | undefined +if (enc?.keyOrder) { + const originalKeyCount = enc.keyOrder.length + const currentKeyCount = record.size + if (originalKeyCount !== currentKeyCount) { + // Key set changed — drop keyOrder, fall back to CodecOptions + const { keyOrder: _, ...restEnc } = enc + if (Object.keys(restEnc).length > 0) { + (record as any)[kEncoding] = restEnc + } + // else: no encoding metadata left, full fallback + } else { + (record as any)[kEncoding] = enc + } +} +``` + +This matches CML's behavior: when `orig_deser_order` count differs from field count, fall back to ascending order. + +### 9. Co-Signing (Adding New Witnesses) + +When adding vkey witnesses to an existing `TransactionWitnessSet`: + +1. The witness set's map encoding (definite/indefinite, key order) is preserved from the original decode. +2. Existing witnesses keep their per-element encoding in `children`. +3. New witnesses get `undefined` encoding → canonical fallback. +4. The inner array's encoding `children` is extended with `undefined` entries for new elements. + +This produces byte-identical output for all existing data while new data uses canonical encoding. + +### 10. Implementation Order + +1. **CBOR.ts — types**: Add `CBOREncoding`, `LenEncoding`, `StringEncoding`, `Sz`, `kEncoding` exports. Add `mode: "preserve"` to `CodecOptions` union. Add `PRESERVE_OPTIONS` constant. +2. **CBOR.ts — decoder**: Modify `decodeItemAt` and each `decode*At` to return `encoding` fields. Attach `kEncoding` Symbol on decoded objects. Capture is unconditional (all modes). +3. **CBOR.ts — encoder**: Add `internalEncodeWithMetadata`. Modify `internalEncodeSync` to read `kEncoding` only when `mode === "preserve"`, ignore otherwise. +4. **CBOR.ts — `FromBytes`**: Use `toA` 4th parameter in encode to thread encoding. Change default options to `PRESERVE_OPTIONS`. +5. **All modules — default parameter change**: Replace `CML_DEFAULT_OPTIONS` with `PRESERVE_OPTIONS` in all `FromCBORBytes`, `FromCBORHex`, `fromCBORBytes`, `toCBORBytes`, etc. default parameters. +6. **TransactionWitnessSet.ts — `FromCDDL`**: Thread `kEncoding` in both decode and encode. +7. **TransactionBody.ts — `FromCDDL`**: Thread `kEncoding` in both decode and encode, with key order invalidation guard. +8. **Transaction.ts — `FromCDDL`**: Thread `kEncoding` for the outer transaction tuple. +9. **Remaining modules**: AuxiliaryData, NativeScripts, Redeemers, BootstrapWitness, etc. +10. **Property test**: Flip `_proof-property.test.ts` from `not.toBe` to `toBe`. + +### Examples + +**Non-canonical indefinite witness set → decode → add witness → re-encode**: +``` +Original (hex): bf1a000000009f9f440102030444aabbccddff9f440506070844eeff1122ffffff + ^^ ^^ indefinite map + ^^^^^^^^^^ 4-byte key 0 (non-minimal) + ^^ ^^ ^^ ^^ indefinite arrays + ^^ ^^ ^^ indefinite pairs + +After adding witness [090a0b0c, 33445566]: +bf1a000000009f9f440102030444aabbccddff9f440506070844eeff1122ff8244090a0b0c4433445566ffff + ^^ new pair: definite (canonical) +``` + +Existing encoding is preserved byte-for-byte. New data uses canonical encoding. + +## Appendix + +### Appendix A: Why Symbol, Not WeakMap + +WeakMap keys must be objects. CBOR AST values include primitives (bigint, string). A WeakMap for the top-level container works, but the child references in array/map entries require per-item metadata anyway. Symbol properties on objects give O(1) direct access with no external state, and are invisible to `JSON.stringify`, `Object.keys`, `for...in`, and `Equal.symbol` comparisons. + +### Appendix B: Why Schema.declare Matters + +`Schema.declare` creates a validation-only schema that does NOT clone the input object. The existing `CBORValueSchema` at CBOR.ts:460 is already `Schema.declare(...)`. This means: + +1. `FromBytes.decode` produces a `Map` with `kEncoding` attached +2. `Schema.compose` passes this Map through `CBORValueSchema` (no-clone) +3. `FromCDDL.decode` receives the **same Map object** with the Symbol intact + +If `CBORValueSchema` were `Schema.MapFromSelf(...)` or `Schema.Struct(...)`, the validation step would create a new Map/object and the Symbol would be lost. + +### Appendix C: Comparison with CML + +| Aspect | CML | Evolution (this spec) | +|--------|-----|----------------------| +| Metadata storage | Auto-generated `*Encoding` structs | Single `CBOREncoding` tree via Symbol | +| Codegen required | Yes (~200+ fields per era) | No | +| Key order | `orig_deser_order: Vec`, invalidated when field count changes | `keyOrder: ReadonlyArray`, same invalidation guard | +| Per-field encoding | Dedicated field per encoding choice | Tree structure with `children`/`entries` | +| Force canonical | `force_canonical: bool` flag | `mode: "canonical"` or `mode: "custom"` — metadata ignored unconditionally | +| Preserve toggle | Implicit (always preserves unless `force_canonical`) | Explicit `mode: "preserve"` — only mode that reads metadata | +| Body mutation | Preserves field encoding, drops key order on field count change | Same behavior | diff --git a/docs/content/docs/encoding/cbor.mdx b/docs/content/docs/encoding/cbor.mdx index fc10f7ce..14cf48e5 100644 --- a/docs/content/docs/encoding/cbor.mdx +++ b/docs/content/docs/encoding/cbor.mdx @@ -1,8 +1,271 @@ --- title: CBOR -description: CBOR encoding and decoding +description: Low-level CBOR encoding, decoding, and byte-identical re-encoding --- +import { Card, Cards } from 'fumadocs-ui/components/card' + # CBOR -Content coming soon. +Low-level CBOR encode/decode with optional byte-identical re-encoding via the `WithFormat` API. + +## Overview + +`CBOR` is the lowest-level encoding layer in the SDK. It decodes raw CBOR bytes into a typed `CBOR` union value, and encodes that value back to bytes using configurable options. + +Most application code should use the module-specific APIs (`Transaction.fromCBORHex`, `TransactionBody.fromCBORHex`, etc.) rather than this module directly. Use `CBOR` when you need to inspect or manipulate raw CBOR structures, implement a custom codec, or re-encode bytes with exact encoding preservation. + +**When NOT to use this module directly:** +- Parsing a full transaction → use `Transaction.fromCBORHex` +- Encoding a domain type → use the module's own `toCBORHex`/`toCBORBytes` +- Working with Plutus data → use the `Data` module + +## Quick Start + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +// Decode hex to a typed CBOR value +const value = CBOR.fromCBORHex("83010203") +// ^? CBOR — an array in this case + +// Inspect the type before narrowing +if (CBOR.isArray(value)) { + const len = value.length // 3 +} + +// Re-encode to hex (canonical by default) +const hex = CBOR.toCBORHex(value) // "83010203" + +// Or to bytes +const bytes = CBOR.toCBORBytes(value) +``` + +## Core Concepts + +### The `CBOR` Union Type + +`CBOR.fromCBORHex` returns a `CBOR` value — a discriminated union over every CBOR major type: + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +// bigint ← CBOR major 0 (uint) and major 1 (nint) +const uint: CBOR.CBOR = 42n +const nint: CBOR.CBOR = -1n + +// Uint8Array ← CBOR major 2 (bytes) +const bytes: CBOR.CBOR = new Uint8Array([0xde, 0xad]) + +// string ← CBOR major 3 (text) +const text: CBOR.CBOR = "hello" + +// ReadonlyArray ← CBOR major 4 (array) +// ReadonlyMap ← CBOR major 5 (map) +// { _tag: "Tag"; tag: number; value: CBOR } ← CBOR major 6 (tag) +// boolean | null | undefined ← CBOR major 7 (simple) +``` + +Use the type guards to narrow before accessing: + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +const raw = CBOR.fromCBORHex("a2016161026162") // { 1 → "a", 2 → "b" } + +if (!CBOR.isMap(raw)) throw new CBOR.CBORError({ message: "Expected map" }) +// raw is now ReadonlyMap + +const entry = raw.get(1n) // "a" +``` + +Available guards: `isInteger`, `isByteArray`, `isArray`, `isMap`, `isRecord`, `isTag`. + +### `CBORFormat` — The Encoding Tree + +Standard CBOR decode throws away encoding choices: whether an integer used 1 or 4 bytes, whether a map was definite or indefinite, what order keys appeared in. This is fine for domain types that own their encoding. It is a problem for relay services that must hand back byte-identical transactions. + +`CBORFormat` is a discriminated union (8 variants) that captures the complete encoding tree for every CBOR node. `fromCBORHexWithFormat` / `fromCBORBytesWithFormat` decode and capture it simultaneously. `toCBORHexWithFormat` / `toCBORBytesWithFormat` replay the captured tree exactly. + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +// "1800" = integer 0 encoded as a 1-byte uint (non-canonical; minimal is "00") +const hex = "1800" + +// Plain path: value decoded, encoding choices discarded +const value = CBOR.fromCBORHex(hex) // 0n +const reEncoded = CBOR.toCBORHex(value) // "00" — minimal, NOT "1800" + +// WithFormat path: encoding tree captured alongside value +const { format, value: v2 } = CBOR.fromCBORHexWithFormat(hex) +const preserved = CBOR.toCBORHexWithFormat(v2, format) // "1800" — byte-identical +``` + +The `CBORFormat` variants are: + +| Variant | CBOR major type | Encoding detail captured | +|---------|----------------|--------------------------| +| `uint` | 0 (uint) | `byteSize` of the argument | +| `nint` | 1 (nint) | `byteSize` of the argument | +| `bytes` | 2 (bytes) | definite vs indefinite, chunk sizes | +| `text` | 3 (text) | definite vs indefinite, chunk sizes | +| `array` | 4 (array) | definite vs indefinite, per-child formats | +| `map` | 5 (map) | definite vs indefinite, key insertion order | +| `tag` | 6 (tag) | tag header `width` | +| `simple`| 7 (simple) | (no choices to capture) | + +### `CodecOptions` Presets + +The plain `toCBORHex`/`toCBORBytes` APIs accept a `CodecOptions` argument that controls how values are encoded. Pre-built presets cover the most common Cardano tool conventions: + +| Preset | Use when | +|--------|----------| +| `CML_DEFAULT_OPTIONS` *(default)* | General Cardano use — definite lengths, minimal integer encoding | +| `CANONICAL_OPTIONS` | RFC 8949 canonical: sorted keys, minimal encoding | +| `CML_DATA_DEFAULT_OPTIONS` | Plutus data with indefinite arrays/maps | +| `AIKEN_DEFAULT_OPTIONS` | Aiken `cbor.serialise()` — indefinite arrays, maps as pairs | +| `CARDANO_NODE_DATA_OPTIONS` | Definite Plutus data (tooling compatibility) | + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +const value = CBOR.fromCBORHex("a2026161016162") // map with non-sorted keys + +// Default (CML): preserve JS Map insertion order +const defaultHex = CBOR.toCBORHex(value) + +// Canonical: sort keys, minimal integer sizes +const canonicalHex = CBOR.toCBORHex(value, CBOR.CANONICAL_OPTIONS) +``` + +## Reference + +### Decode + +| Function | Input | Returns | +|----------|-------|---------| +| `fromCBORHex(hex, options?)` | hex string | `CBOR` | +| `fromCBORBytes(bytes, options?)` | `Uint8Array` | `CBOR` | +| `fromCBORHexWithFormat(hex)` | hex string | `DecodedWithFormat` | +| `fromCBORBytesWithFormat(bytes)` | `Uint8Array` | `DecodedWithFormat` | + +### Encode + +| Function | Input | Returns | +|----------|-------|---------| +| `toCBORHex(value, options?)` | `CBOR` | hex string | +| `toCBORBytes(value, options?)` | `CBOR` | `Uint8Array` | +| `toCBORHexWithFormat(value, format)` | `CBOR` + `CBORFormat` | hex string | +| `toCBORBytesWithFormat(value, format)` | `CBOR` + `CBORFormat` | `Uint8Array` | + +### Type Guards + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +const v: CBOR.CBOR = CBOR.fromCBORHex("01") + +CBOR.isInteger(v) // v is bigint +CBOR.isByteArray(v) // v is Uint8Array +CBOR.isArray(v) // v is ReadonlyArray +CBOR.isMap(v) // v is ReadonlyMap +CBOR.isRecord(v) // v is Record +CBOR.isTag(v) // v is { _tag: "Tag"; tag: number; value: CBOR.CBOR } +``` + +### Structural Matching + +`match` provides exhaustive pattern matching over a `CBOR` value, analogous to a switch on the full union: + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +const value = CBOR.fromCBORHex("43010203") // bytes [01, 02, 03] + +const result = CBOR.match(value, { + integer: (n) => `int: ${n}`, + bytes: (b) => `bytes(${b.length})`, + text: (s) => `text: ${s}`, + array: (a) => `array[${a.length}]`, + map: (m) => `map{${m.size}}`, + record: (_r) => `record`, + tag: (tag, _v) => `tag(${tag})`, + boolean: (b) => `bool: ${b}`, + null: () => `null`, + undefined: () => `undefined`, + float: (f) => `float: ${f}`, + boundedBytes: (b) => `bounded(${b.length})`, +}) +// result = "bytes(3)" +``` + +## Best Practices + +### Use `WithFormat` in any relay or signing service + +If your code receives a `Transaction` CBOR hex from a client, adds witnesses, and returns the result, use `WithFormat` at the transaction level to guarantee the body bytes — and thus the `txId` — are never altered: + +```typescript twoslash +import * as Transaction from "@evolution-sdk/evolution/Transaction" + +function addWalletWitnesses(txHex: string, walletWitnessHex: string): string { + // Byte-level splice: body bytes untouched, txId stable + return Transaction.addVKeyWitnessesHex(txHex, walletWitnessHex) +} +``` + +For cases where you need to inspect the transaction before re-encoding, use the WithFormat round-trip: + +```typescript twoslash +import * as Transaction from "@evolution-sdk/evolution/Transaction" + +function inspectAndReserialize(txHex: string): string { + const { format, value: tx } = Transaction.fromCBORHexWithFormat(txHex) + + // Inspect tx.body, tx.witnessSet, etc. — no mutation + const fee = tx.body.fee + + // Re-encode with the captured format: body bytes byte-identical + return Transaction.toCBORHexWithFormat(tx, format) +} +``` + +### Inject a hand-crafted `CBORFormat` for controlled encoding + +When you need a specific encoding shape that differs from the defaults — e.g. forcing an indefinite-length array — build the `CBORFormat` explicitly: + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +const fmt: CBOR.CBORFormat = { + _tag: "array", + length: { tag: "indefinite" }, + children: [{ _tag: "uint" }, { _tag: "uint" }], +} + +const hex = CBOR.toCBORHexWithFormat([1n, 2n], fmt) +// hex = "9f0102ff" — indefinite-length array +``` + +### Narrow before accessing map entries + +`fromCBORHex` returns `CBOR`, not a narrowed type. Always check before indexing: + +```typescript twoslash +import * as CBOR from "@evolution-sdk/evolution/CBOR" + +function getMapEntry(hex: string, key: bigint): CBOR.CBOR | undefined { + const v = CBOR.fromCBORHex(hex) + if (!CBOR.isMap(v)) return undefined + return v.get(key) +} +``` + +## Related + + + + + + diff --git a/packages/evolution-devnet/test/TxBuilder.PlutusMint.test.ts b/packages/evolution-devnet/test/TxBuilder.PlutusMint.test.ts index 4476a764..bc328d91 100644 --- a/packages/evolution-devnet/test/TxBuilder.PlutusMint.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.PlutusMint.test.ts @@ -171,9 +171,9 @@ describe("TxBuilder Plutus Minting (Devnet Submit)", () => { // Verify redeemers with evaluated exUnits expect(tx.witnessSet.redeemers).toBeDefined() - expect(tx.witnessSet.redeemers!.length).toBe(1) + expect(tx.witnessSet.redeemers!.size).toBe(1) - const redeemer = tx.witnessSet.redeemers![0] + const redeemer = tx.witnessSet.redeemers!.toArray()[0] expect(redeemer.tag).toBe("mint") expect(redeemer.exUnits.mem).toBeGreaterThan(0n) expect(redeemer.exUnits.steps).toBeGreaterThan(0n) diff --git a/packages/evolution-devnet/test/TxBuilder.RedeemerBuilder.test.ts b/packages/evolution-devnet/test/TxBuilder.RedeemerBuilder.test.ts index e974f5f0..62534689 100644 --- a/packages/evolution-devnet/test/TxBuilder.RedeemerBuilder.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.RedeemerBuilder.test.ts @@ -258,10 +258,11 @@ describe("TxBuilder RedeemerBuilder", () => { // Verify we have 4 redeemers: 3 spends + 1 mint expect(spendTx.witnessSet.redeemers).toBeDefined() - expect(spendTx.witnessSet.redeemers!.length).toBe(4) + expect(spendTx.witnessSet.redeemers!.size).toBe(4) - const spendRedeemers = spendTx.witnessSet.redeemers!.filter((r) => r.tag === "spend") - const mintRedeemers = spendTx.witnessSet.redeemers!.filter((r) => r.tag === "mint") + const allRedeemers = spendTx.witnessSet.redeemers!.toArray() + const spendRedeemers = allRedeemers.filter((r) => r.tag === "spend") + const mintRedeemers = allRedeemers.filter((r) => r.tag === "mint") expect(spendRedeemers.length).toBe(3) expect(mintRedeemers.length).toBe(1) diff --git a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts index 7254964b..32f4d546 100644 --- a/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.Scripts.test.ts @@ -198,9 +198,9 @@ describe("TxBuilder Script Handling", () => { // Verify redeemers with evaluated exUnits expect(tx.witnessSet.redeemers).toBeDefined() - expect(tx.witnessSet.redeemers!.length).toBe(1) + expect(tx.witnessSet.redeemers!.size).toBe(1) - const redeemer = tx.witnessSet.redeemers![0] + const redeemer = tx.witnessSet.redeemers!.toArray()[0] expect(redeemer.tag).toBe("spend") expect(redeemer.exUnits.mem).toBeGreaterThan(0n) // mem > 0 expect(redeemer.exUnits.steps).toBeGreaterThan(0n) // steps > 0 @@ -264,9 +264,9 @@ describe("TxBuilder Script Handling", () => { // Verify redeemers with evaluated exUnits expect(tx.witnessSet.redeemers).toBeDefined() - expect(tx.witnessSet.redeemers!.length).toBe(1) + expect(tx.witnessSet.redeemers!.size).toBe(1) - const redeemer = tx.witnessSet.redeemers![0] + const redeemer = tx.witnessSet.redeemers!.toArray()[0] expect(redeemer.tag).toBe("spend") expect(redeemer.exUnits.mem).toBe(1100n) expect(redeemer.exUnits.steps).toBe(160100n) @@ -331,9 +331,9 @@ describe("TxBuilder Script Handling", () => { // Verify redeemers with evaluated exUnits expect(tx.witnessSet.redeemers).toBeDefined() - expect(tx.witnessSet.redeemers!.length).toBe(1) + expect(tx.witnessSet.redeemers!.size).toBe(1) - const redeemer = tx.witnessSet.redeemers![0] + const redeemer = tx.witnessSet.redeemers!.toArray()[0] expect(redeemer.tag).toBe("spend") expect(redeemer.exUnits.mem).toBe(1100n) expect(redeemer.exUnits.steps).toBe(160100n) @@ -1312,8 +1312,8 @@ describe("TxBuilder Script Handling", () => { const tx = await signBuilder.toTransaction() expect(tx.witnessSet.redeemers).toBeDefined() - expect(tx.witnessSet.redeemers!.length).toBe(1) - const redeemer = tx.witnessSet.redeemers![0] + expect(tx.witnessSet.redeemers!.size).toBe(1) + const redeemer = tx.witnessSet.redeemers!.toArray()[0] expect(redeemer.tag).toBe("spend") expect(redeemer.exUnits.mem).toBeGreaterThan(0n) expect(redeemer.exUnits.steps).toBeGreaterThan(0n) diff --git a/packages/evolution-devnet/test/TxBuilder.SpendScriptRef.test.ts b/packages/evolution-devnet/test/TxBuilder.SpendScriptRef.test.ts index 15222445..8c40cff4 100644 --- a/packages/evolution-devnet/test/TxBuilder.SpendScriptRef.test.ts +++ b/packages/evolution-devnet/test/TxBuilder.SpendScriptRef.test.ts @@ -135,7 +135,7 @@ describe("TxBuilder Spend ScriptRef (Devnet Submit)", () => { const spendTx = await spendSignBuilder.toTransaction() expect(spendTx.body.scriptDataHash).toBeDefined() - expect(spendTx.witnessSet.redeemers?.length).toBe(1) + expect(spendTx.witnessSet.redeemers?.size).toBe(1) const spendTxHash = await (await spendSignBuilder.sign()).submit() diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index a218c61e..49c209d7 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -57,6 +57,107 @@ export const CBOR_SIMPLE = { UNDEFINED: 23 } as const +// ============================================================================ +// Encoding Metadata Types +// ============================================================================ + +/** + * Width of a CBOR integer argument: inline (0), 1-byte, 2-byte, 4-byte, or 8-byte. + * + * @since 2.0.0 + * @category model + */ +export type ByteSize = 0 | 1 | 2 | 4 | 8 + +/** + * Container length encoding style captured during decode. + * + * @since 2.0.0 + * @category model + */ +export type LengthEncoding = + | { readonly tag: "indefinite" } + | { readonly tag: "definite"; readonly byteSize: ByteSize } + +/** + * Byte/text string encoding style captured during decode. + * + * @since 2.0.0 + * @category model + */ +export type StringEncoding = + | { readonly tag: "definite"; readonly byteSize: ByteSize } + | { readonly tag: "indefinite"; readonly chunks: ReadonlyArray<{ readonly length: number; readonly byteSize: ByteSize }> } + +// ============================================================================ +// CBORFormat — tagged discriminated union for per-node encoding metadata +// ============================================================================ + +/** + * Tagged discriminated union capturing how each CBOR node was originally + * serialized. Every variant carries a `_tag` discriminant. Encoding-detail + * fields are optional — absent means "use canonical / minimal default". + * + * @since 2.0.0 + * @category model + */ +export type CBORFormat = + | CBORFormat.UInt + | CBORFormat.NInt + | CBORFormat.Bytes + | CBORFormat.Text + | CBORFormat.Array + | CBORFormat.Map + | CBORFormat.Tag + | CBORFormat.Simple + +/** + * @since 2.0.0 + * @category model + */ +export namespace CBORFormat { + /** Unsigned integer (major 0). `byteSize` absent → minimal encoding. */ + export type UInt = { readonly _tag: "uint"; readonly byteSize?: ByteSize } + /** Negative integer (major 1). `byteSize` absent → minimal encoding. */ + export type NInt = { readonly _tag: "nint"; readonly byteSize?: ByteSize } + /** Byte string (major 2). `encoding` absent → definite, minimal length. */ + export type Bytes = { readonly _tag: "bytes"; readonly encoding?: StringEncoding } + /** Text string (major 3). `encoding` absent → definite, minimal length. */ + export type Text = { readonly _tag: "text"; readonly encoding?: StringEncoding } + /** Array (major 4). `length` absent → definite, minimal length header. */ + export type Array = { + readonly _tag: "array" + readonly length?: LengthEncoding + readonly children: ReadonlyArray + } + /** Map (major 5). `keyOrder` stores CBOR-encoded key bytes for serializable ordering. */ + export type Map = { + readonly _tag: "map" + readonly length?: LengthEncoding + readonly keyOrder?: ReadonlyArray + readonly entries: ReadonlyArray + } + /** Tag (major 6). `width` absent → minimal tag header. */ + export type Tag = { + readonly _tag: "tag" + readonly width?: ByteSize + readonly child: CBORFormat + } + /** Simple value or float (major 7). No encoding choices to preserve. */ + export type Simple = { readonly _tag: "simple" } +} + +/** + * Decoded value paired with its captured root format tree. + * + * @since 2.0.0 + * @category model + */ +export type DecodedWithFormat = { + value: A + format: CBORFormat +} + /** * CBOR codec configuration options * @@ -628,13 +729,13 @@ export const FromBytes = (options: CodecOptions) => `Failed to decode CBOR value: ${error instanceof CBORError ? error.message : String(error)}` ) }), - encode: (toA, _, ast) => + encode: (toI, _, ast) => E.try({ - try: () => internalEncodeSync(toA, options), + try: () => internalEncodeSync(toI, options), catch: (error) => new ParseResult.Type( ast, - toA, + toI, `Failed to encode CBOR value: ${error instanceof CBORError ? error.message : String(error)}` ) }) @@ -699,6 +800,16 @@ export namespace Either { export const fromCBORBytes = (bytes: Uint8Array, options: CodecOptions = CML_DEFAULT_OPTIONS): CBOR => internalDecodeSync(bytes, options) +/** + * Parse a CBOR value from CBOR bytes and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytesWithFormat = ( + bytes: Uint8Array +): DecodedWithFormat => internalDecodeWithFormatSync(bytes) + /** * Parse a CBOR value from CBOR hex string. * @@ -710,6 +821,19 @@ export const fromCBORHex = (hex: string, options: CodecOptions = CML_DEFAULT_OPT return internalDecodeSync(bytes, options) } +/** + * Parse a CBOR value from CBOR hex string and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHexWithFormat = ( + hex: string +): DecodedWithFormat => { + const bytes = Bytes.fromHex(hex) + return internalDecodeWithFormatSync(bytes) +} + // ============================================================================ // Encoding Functions // ============================================================================ @@ -723,6 +847,17 @@ export const fromCBORHex = (hex: string, options: CodecOptions = CML_DEFAULT_OPT export const toCBORBytes = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS): Uint8Array => internalEncodeSync(value, options) +/** + * Convert a CBOR value to CBOR bytes using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytesWithFormat = ( + value: CBOR, + format: CBORFormat +): Uint8Array => internalEncodeSync(value, CML_DEFAULT_OPTIONS, format) + /** * Convert a CBOR value to CBOR hex string. * @@ -732,22 +867,97 @@ export const toCBORBytes = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPT export const toCBORHex = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS): string => Bytes.toHex(internalEncodeSync(value, options)) +/** + * Convert a CBOR value to CBOR hex string using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHexWithFormat = ( + value: CBOR, + format: CBORFormat +): string => Bytes.toHex(internalEncodeSync(value, CML_DEFAULT_OPTIONS, format)) + // ============================================================================ // Sync core (Step 2): fast, exception-based encode/decode with no Effect // These functions throw CBORError on failure and are used by Either and direct APIs. // ============================================================================ // Encode (sync) -export const internalEncodeSync = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS): Uint8Array => { + +/** Encode an integer with minimal CBOR width (fallback when metadata width is too small). */ +const encodeIntMinimal = (majorType: number, value: bigint): Uint8Array => { + const mt = majorType << 5 + if (value < 24n) return new Uint8Array([mt | Number(value)]) + if (value < 256n) return new Uint8Array([mt | 24, Number(value)]) + if (value < 65536n) { + const n = Number(value) + return new Uint8Array([mt | 25, (n >> 8) & 0xff, n & 0xff]) + } + if (value < 4294967296n) { + const n = Number(value) + return new Uint8Array([mt | 26, (n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]) + } + const low = Number(value & 0xffffffffn) + const high = Number(value >> 32n) + return new Uint8Array([ + mt | 27, + (high >> 24) & 0xff, (high >> 16) & 0xff, (high >> 8) & 0xff, high & 0xff, + (low >> 24) & 0xff, (low >> 16) & 0xff, (low >> 8) & 0xff, low & 0xff + ]) +} + +/** Encode a CBOR header: major type (0-7) + value with specific ByteSize width. + * Falls back to minimal encoding if the value no longer fits the recorded width. */ +const encodeIntHeader = (majorType: number, value: bigint, byteSize: ByteSize): Uint8Array => { + const mt = majorType << 5 + if (byteSize === 0) { + if (value >= 24n) return encodeIntMinimal(majorType, value) + return new Uint8Array([mt | Number(value)]) + } + if (byteSize === 1) { + if (value >= 256n) return encodeIntMinimal(majorType, value) + return new Uint8Array([mt | 24, Number(value)]) + } + if (byteSize === 2) { + if (value >= 65536n) return encodeIntMinimal(majorType, value) + const n = Number(value) + return new Uint8Array([mt | 25, (n >> 8) & 0xff, n & 0xff]) + } + if (byteSize === 4) { + if (value >= 4294967296n) return encodeIntMinimal(majorType, value) + const n = Number(value) + return new Uint8Array([mt | 26, (n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]) + } + // byteSize === 8 + const low = Number(value & 0xffffffffn) + const high = Number(value >> 32n) + return new Uint8Array([ + mt | 27, + (high >> 24) & 0xff, + (high >> 16) & 0xff, + (high >> 8) & 0xff, + high & 0xff, + (low >> 24) & 0xff, + (low >> 16) & 0xff, + (low >> 8) & 0xff, + low & 0xff + ]) +} + +export const internalEncodeSync = (value: CBOR, options: CodecOptions = CML_DEFAULT_OPTIONS, fmt?: CBORFormat): Uint8Array => { + // Explicit fmt is the only source of format metadata. + const resolvedFmt: CBORFormat | undefined = fmt + if (typeof value === "bigint") { - if (value >= 0n) return encodeUintSync(value, options) - return encodeNintSync(value, options) - } - if (value instanceof Uint8Array) return encodeBytesSync(value, options) - if (typeof value === "string") return encodeTextSync(value, options) - if (Array.isArray(value)) return encodeArraySync(value, options) - if (value instanceof Map) return encodeMapSync(value, options) - if (isTag(value)) return encodeTagSync(value.tag, value.value, options) + if (value >= 0n) return encodeUintSync(value, options, resolvedFmt) + return encodeNintSync(value, options, resolvedFmt) + } + if (value instanceof Uint8Array) return encodeBytesSync(value, options, resolvedFmt) + if (typeof value === "string") return encodeTextSync(value, options, resolvedFmt) + if (Array.isArray(value)) return encodeArraySync(value, options, resolvedFmt) + if (value instanceof Map) return encodeMapSync(value, options, resolvedFmt) + if (isTag(value)) return encodeTagSync(value.tag, value.value, options, resolvedFmt) // BoundedBytes: PlutusData byte strings, encoded per Conway CDDL bounded_bytes = bytes .size (0..64) if ( typeof value === "object" && @@ -765,21 +975,26 @@ export const internalEncodeSync = (value: CBOR, options: CodecOptions = CML_DEFA !(value instanceof Uint8Array) && !(value instanceof Tag) ) { - return encodeRecordSync(value as { readonly [key: string | number]: CBOR }, options) + return encodeRecordSync(value as { readonly [key: string | number]: CBOR }, options, resolvedFmt) } if (typeof value === "boolean" || value === null || value === undefined) return encodeSimpleSync(value) if (typeof value === "number") return encodeFloatSync(value, options) throw new CBORError({ message: `Unsupported CBOR value type: ${typeof value}` }) } -const encodeUintSync = (value: bigint, options: CodecOptions): Uint8Array => { +const encodeUintSync = (value: bigint, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { if (value < 0n) throw new CBORError({ message: `Cannot encode negative value ${value} as unsigned integer` }) const maxUint64 = 18446744073709551615n if (value > maxUint64) { const bytes = bigintToBytes(value) - return encodeTagSync(2, bytes, options) + return encodeTagSync(2, bytes, options, fmt) + } + // Use specific ByteSize from format metadata + if (fmt?._tag === "uint" && fmt.byteSize !== undefined) { + return encodeIntHeader(0, value, fmt.byteSize) } - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) + // Preserve mode without metadata uses minimal encoding (CML default) + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding // Fast path for very small integers using pre-allocated arrays if (value < 24n) { @@ -810,16 +1025,20 @@ const encodeUintSync = (value: bigint, options: CodecOptions): Uint8Array => { } } -const encodeNintSync = (value: bigint, options: CodecOptions): Uint8Array => { +const encodeNintSync = (value: bigint, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { if (value >= 0n) throw new CBORError({ message: `Cannot encode non-negative value ${value} as negative integer` }) const minInt64 = -18446744073709551615n if (value < minInt64) { const positiveValue = -(value + 1n) const bytes = bigintToBytes(positiveValue) - return encodeTagSync(3, bytes, options) + return encodeTagSync(3, bytes, options, fmt) } const positiveValue = -value - 1n - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) + // Use specific ByteSize from format metadata + if (fmt?._tag === "nint" && fmt.byteSize !== undefined) { + return encodeIntHeader(1, positiveValue, fmt.byteSize) + } + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding if (positiveValue < 24n) { return new Uint8Array([0x20 + Number(positiveValue)]) } else if (positiveValue < 256n && useMinimal) { @@ -928,9 +1147,42 @@ const encodeBoundedBytesSync = (value: Uint8Array): Uint8Array => { return result } -const encodeBytesSync = (value: Uint8Array, options: CodecOptions): Uint8Array => { +const encodeBytesSync = (value: Uint8Array, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { const length = value.length - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) + const stringEncoding = fmt?._tag === "bytes" ? fmt.encoding : undefined + + // Use string encoding metadata if available + if (stringEncoding !== undefined) { + if (stringEncoding.tag === "indefinite") { + const chunks = stringEncoding.chunks + let totalSize = 2 // 0x5f + 0xff + for (const chunk of chunks) { + totalSize += encodeIntHeader(2, BigInt(chunk.length), chunk.byteSize).length + chunk.length + } + const result = new Uint8Array(totalSize) + let pos = 0 + result[pos++] = 0x5f + let srcOffset = 0 + for (const chunk of chunks) { + const header = encodeIntHeader(2, BigInt(chunk.length), chunk.byteSize) + result.set(header, pos) + pos += header.length + result.set(value.subarray(srcOffset, srcOffset + chunk.length), pos) + pos += chunk.length + srcOffset += chunk.length + } + result[pos] = 0xff + return result + } + // Definite with specific byteSize + const header = encodeIntHeader(2, BigInt(length), stringEncoding.byteSize) + const result = new Uint8Array(header.length + length) + result.set(header, 0) + result.set(value, header.length) + return result + } + + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding // Fast path for empty bytes if (length === 0) { @@ -987,7 +1239,40 @@ export const BoundedBytes = { (value as { _tag: unknown })._tag === "BoundedBytes" } as const -const encodeTextSync = (value: string, options: CodecOptions): Uint8Array => { +const encodeTextSync = (value: string, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { + const stringEncoding = fmt?._tag === "text" ? fmt.encoding : undefined + // Use string encoding metadata if available + if (stringEncoding !== undefined) { + const utf8 = TEXT_ENCODER.encode(value) + if (stringEncoding.tag === "indefinite") { + const chunks = stringEncoding.chunks + let totalSize = 2 // 0x7f + 0xff + for (const chunk of chunks) { + totalSize += encodeIntHeader(3, BigInt(chunk.length), chunk.byteSize).length + chunk.length + } + const result = new Uint8Array(totalSize) + let pos = 0 + result[pos++] = 0x7f + let srcOffset = 0 + for (const chunk of chunks) { + const header = encodeIntHeader(3, BigInt(chunk.length), chunk.byteSize) + result.set(header, pos) + pos += header.length + result.set(utf8.subarray(srcOffset, srcOffset + chunk.length), pos) + pos += chunk.length + srcOffset += chunk.length + } + result[pos] = 0xff + return result + } + // Definite with specific byteSize + const header = encodeIntHeader(3, BigInt(utf8.length), stringEncoding.byteSize) + const result = new Uint8Array(header.length + utf8.length) + result.set(header, 0) + result.set(utf8, header.length) + return result + } + // Fast path for empty strings if (value.length === 0) { return new Uint8Array([0x60]) @@ -995,7 +1280,7 @@ const encodeTextSync = (value: string, options: CodecOptions): Uint8Array => { const utf8Bytes = TEXT_ENCODER.encode(value) const length = utf8Bytes.length - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding // Optimize header encoding let headerBytes: Uint8Array @@ -1023,31 +1308,59 @@ const encodeTextSync = (value: string, options: CodecOptions): Uint8Array => { return result } -const encodeArraySync = (value: ReadonlyArray, options: CodecOptions): Uint8Array => { +const encodeArraySync = (value: ReadonlyArray, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { const length = value.length - const useIndefinite = options.mode === "custom" && options.useIndefiniteArrays && length > 0 // Fast path for empty arrays if (length === 0) { return new Uint8Array([0x80]) } - // Pre-encode items + const arrayFmt = fmt?._tag === "array" ? fmt : undefined + + // Use format metadata if available + if (arrayFmt?.length !== undefined) { + const items = new Array(length) + for (let i = 0; i < length; i++) { + items[i] = internalEncodeSync(value[i], options, arrayFmt.children[i]) + } + if (arrayFmt.length.tag === "indefinite") { + return encodeArrayAsIndefinite(items) + } + // Definite with specific byteSize + const header = encodeIntHeader(4, BigInt(length), arrayFmt.length.byteSize) + const totalItemsLen = items.reduce((acc, b) => acc + b.length, 0) + const out = new Uint8Array(header.length + totalItemsLen) + out.set(header, 0) + let offset = header.length + for (const b of items) { + out.set(b, offset) + offset += b.length + } + return out + } + + const useIndefinite = options.mode === "custom" && options.useIndefiniteArrays && length > 0 + + // Pre-encode items (pass child formats if available even without length override) const items = new Array(length) for (let i = 0; i < length; i++) { - items[i] = internalEncodeSync(value[i], options) + items[i] = internalEncodeSync(value[i], options, arrayFmt?.children[i]) } // Use low-level helpers return useIndefinite ? encodeArrayAsIndefinite(items) : encodeArrayAsDefinite(items) } -const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions): Uint8Array => { +const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { const length = pairs.length - 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 + const mapFmt = fmt?._tag === "map" ? fmt : undefined + + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding + const sortKeys = !mapFmt && (options.mode === "canonical" || (options.mode === "custom" && options.sortMapKeys)) + const useIndefinite = !mapFmt && options.mode === "custom" && options.useIndefiniteMaps && length > 0 + const encodeAsPairs = + !mapFmt && (options.mode === "canonical" || options.mode === "custom") && options.encodeMapAsPairs === true // If encoding as array of pairs (Aiken/Plutus style), delegate to array encoding if (encodeAsPairs) { @@ -1056,13 +1369,21 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions) } // Fast path for empty maps - if (length === 0) { - return new Uint8Array([0xa0]) - } + if (length === 0) return new Uint8Array([0xa0]) - // Pre-encode pairs + // Encode each pair (with per-entry formats if format is available, otherwise canonical) let encodedPairs: Array<{ encodedKey: Uint8Array; encodedValue: Uint8Array }> - if (sortKeys) { + if (mapFmt) { + encodedPairs = new Array(length) + for (let i = 0; i < length; i++) { + const [key, val] = pairs[i] + const [keyFmt, valFmt] = mapFmt.entries[i] ?? [undefined, undefined] + encodedPairs[i] = { + encodedKey: internalEncodeSync(key, options, keyFmt), + encodedValue: internalEncodeSync(val, options, valFmt) + } + } + } else if (sortKeys) { encodedPairs = pairs.map(([key, val]) => ({ encodedKey: internalEncodeSync(key, options), encodedValue: internalEncodeSync(val, options) @@ -1072,27 +1393,23 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions) encodedPairs = new Array(length) for (let i = 0; i < length; i++) { const [key, val] = pairs[i] - const ek = internalEncodeSync(key, options) - const ev = internalEncodeSync(val, options) - encodedPairs[i] = { encodedKey: ek, encodedValue: ev } + encodedPairs[i] = { + encodedKey: internalEncodeSync(key, options), + encodedValue: internalEncodeSync(val, options) + } } } // Compute payload size let payloadSize = 0 - for (let i = 0; i < encodedPairs.length; i++) { - const p = encodedPairs[i] - payloadSize += p.encodedKey.length + p.encodedValue.length - } + for (const p of encodedPairs) payloadSize += p.encodedKey.length + p.encodedValue.length - // Compute header - if (useIndefinite) { - const totalSize = 1 + payloadSize + 1 - const out = new Uint8Array(totalSize) + // Build output: indefinite or definite header + if (mapFmt?.length?.tag === "indefinite" || useIndefinite) { + const out = new Uint8Array(1 + payloadSize + 1) let off = 0 out[off++] = 0xbf - for (let i = 0; i < encodedPairs.length; i++) { - const p = encodedPairs[i] + for (const p of encodedPairs) { out.set(p.encodedKey, off) off += p.encodedKey.length out.set(p.encodedValue, off) @@ -1100,56 +1417,106 @@ const encodeMapEntriesSync = (pairs: Array<[CBOR, CBOR]>, options: CodecOptions) } out[off] = 0xff return out + } + + // Definite header: use format byteSize if specified, else minimal + let headerBytes: Uint8Array + if (mapFmt?.length !== undefined) { + headerBytes = encodeIntHeader(5, BigInt(length), mapFmt.length.byteSize) + } else if (length < 24) { + headerBytes = new Uint8Array([0xa0 + length]) + } else if (length < 256 && useMinimal) { + headerBytes = new Uint8Array([0xb8, length]) + } else if (length < 65536 && useMinimal) { + headerBytes = new Uint8Array([0xb9, length >> 8, length & 0xff]) + } else if (length < 4294967296 && useMinimal) { + headerBytes = new Uint8Array([ + 0xba, + (length >> 24) & 0xff, + (length >> 16) & 0xff, + (length >> 8) & 0xff, + length & 0xff + ]) } else { - // Optimize header encoding - let headerSize: number - let headerBytes: Uint8Array - if (length < 24) { - headerSize = 1 - headerBytes = new Uint8Array([0xa0 + length]) - } else if (length < 256 && useMinimal) { - headerSize = 2 - headerBytes = new Uint8Array([0xb8, length]) - } else if (length < 65536 && useMinimal) { - headerSize = 3 - headerBytes = new Uint8Array([0xb9, length >> 8, length & 0xff]) - } else if (length < 4294967296 && useMinimal) { - headerSize = 5 - headerBytes = new Uint8Array([ - 0xba, - (length >> 24) & 0xff, - (length >> 16) & 0xff, - (length >> 8) & 0xff, - length & 0xff - ]) - } else { - throw new CBORError({ message: `Map too long: ${length} entries` }) - } + throw new CBORError({ message: `Map too long: ${length} entries` }) + } - const totalSize = headerSize + payloadSize - const out = new Uint8Array(totalSize) + const out = new Uint8Array(headerBytes.length + payloadSize) + out.set(headerBytes, 0) + let off = headerBytes.length + for (const p of encodedPairs) { + out.set(p.encodedKey, off) + off += p.encodedKey.length + out.set(p.encodedValue, off) + off += p.encodedValue.length + } + return out +} - // Copy header - out.set(headerBytes, 0) +/** + * Schema-derived structural equivalence for CBOR values. + * Handles Uint8Array, Array, Map, Tag and all primitives via the + * recursive CBORSchema definition — no hand-rolled comparison needed. + * + * Derived once at module init; at call time it's a plain function. + * + * @since 2.0.0 + * @category equality + */ +export const equals: (a: CBOR, b: CBOR) => boolean = Schema.equivalence(CBORSchema) - // Copy payload pairs - let off = headerSize - for (let i = 0; i < encodedPairs.length; i++) { - const p = encodedPairs[i] - out.set(p.encodedKey, off) - off += p.encodedKey.length - out.set(p.encodedValue, off) - off += p.encodedValue.length - } - return out - } +/** + * Look up a CBOR key in a Map, falling back to content-based comparison + * for complex keys (Uint8Array, Array, Tag) where Map.get uses reference + * equality which fails when the map was rebuilt with new objects. + */ +const mapGetCBOR = (map: ReadonlyMap, key: CBOR): CBOR | undefined => { + const direct = map.get(key) + if (direct !== undefined) return direct + // Primitives (bigint, string, boolean, null, number) match by value equality + // via Map.get; if that failed, the key genuinely doesn't exist + if (typeof key !== "object" || key === null) return undefined + for (const [k, v] of map) { + if (equals(key, k)) return v + } + return undefined } -const encodeMapSync = (value: ReadonlyMap, options: CodecOptions): Uint8Array => { - return encodeMapEntriesSync(Array.from(value.entries()), options) +const encodeMapSync = (value: ReadonlyMap, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { + const mapFmt = fmt?._tag === "map" ? fmt : undefined + // Use keyOrder from format to replay original insertion order, then append any new keys + if (mapFmt?.keyOrder && mapFmt.keyOrder.length > 0) { + const pairs: Array<[CBOR, CBOR]> = [] + const reorderedEntries: Array = [] + const decodedKeyOrderKeys: Array = [] + + // First pass: replay surviving keyOrder keys + for (let j = 0; j < mapFmt.keyOrder.length; j++) { + const key = internalDecodeSync(mapFmt.keyOrder[j]) + decodedKeyOrderKeys.push(key) + const mapped = mapGetCBOR(value, key) + if (mapped !== undefined) { + pairs.push([key, mapped]) + reorderedEntries.push(mapFmt.entries[j] ?? [{ _tag: "simple" }, { _tag: "simple" }]) + } + // Key missing from map: simply skip (key was removed) + } + + // Second pass: append new keys not covered by keyOrder + for (const [key, val] of value) { + if (!decodedKeyOrderKeys.some((k) => equals(key, k))) { + pairs.push([key, val]) + reorderedEntries.push([{ _tag: "simple" }, { _tag: "simple" }]) + } + } + + const orderedFmt: CBORFormat.Map = { ...mapFmt, entries: reorderedEntries } + return encodeMapEntriesSync(pairs, options, orderedFmt) + } + return encodeMapEntriesSync(Array.from(value.entries()), options, fmt) } -const encodeRecordSync = (value: { readonly [key: string | number]: CBOR }, options: CodecOptions): Uint8Array => { +const encodeRecordSync = (value: { readonly [key: string | number]: CBOR }, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { // Optimize by avoiding Object.entries() and map() allocation const mapEntries: Array<[CBOR, CBOR]> = [] for (const key in value) { @@ -1163,11 +1530,29 @@ const encodeRecordSync = (value: { readonly [key: string | number]: CBOR }, opti } } } - return encodeMapEntriesSync(mapEntries, options) + return encodeMapEntriesSync(mapEntries, options, fmt) } -const encodeTagSync = (tag: number, value: CBOR, options: CodecOptions): Uint8Array => { - const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding) +const encodeTagSync = (tag: number, value: CBOR, options: CodecOptions, fmt?: CBORFormat): Uint8Array => { + const tagFmt = fmt?._tag === "tag" ? fmt : undefined + // Use specific ByteSize from format metadata (pass child format even when width is canonical) + if (tagFmt !== undefined) { + const header = tagFmt.width !== undefined + ? encodeIntHeader(6, BigInt(tag), tagFmt.width) + : (() => { + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding + if (tag < 24) return new Uint8Array([0xc0 + tag]) + if (tag < 256 && useMinimal) return new Uint8Array([0xd8, tag & 0xff]) + if (tag < 65536 && useMinimal) return new Uint8Array([0xd9, (tag >> 8) & 0xff, tag & 0xff]) + throw new CBORError({ message: `Tag ${tag} too large` }) + })() + const body = internalEncodeSync(value, options, tagFmt.child) + const out = new Uint8Array(header.length + body.length) + out.set(header, 0) + out.set(body, header.length) + return out + } + const useMinimal = options.mode !== "custom" || options.useMinimalEncoding let headerSize = 0 let h0 = 0, h1 = 0, @@ -1252,12 +1637,12 @@ export const decodeItemWithOffset = ( data: Uint8Array, offset: number, options: CodecOptions = CML_DEFAULT_OPTIONS -): { item: CBOR; newOffset: number } => decodeItemAt(data, offset, options) +): { item: CBOR; newOffset: number } => decodeItemAt(data, offset, options, "none") // Decode (sync) export const internalDecodeSync = (data: Uint8Array, options: CodecOptions = DEFAULT_OPTIONS): CBOR => { if (data.length === 0) throw new CBORError({ message: "Empty CBOR data" }) - const { item, newOffset } = decodeItemAt(data, 0, options) + const { item, newOffset } = decodeItemAt(data, 0, options, "none") if (newOffset !== data.length) { throw new CBORError({ message: `Invalid CBOR: expected to consume ${data.length} bytes, but consumed ${newOffset}` @@ -1266,97 +1651,155 @@ export const internalDecodeSync = (data: Uint8Array, options: CodecOptions = DEF return item } +/** + * Decode CBOR bytes and return both the decoded value and the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const internalDecodeWithFormatSync = ( + data: Uint8Array +): DecodedWithFormat => { + if (data.length === 0) throw new CBORError({ message: "Empty CBOR data" }) + const result = decodeItemAt(data, 0, DEFAULT_OPTIONS, "format") + if (result.newOffset !== data.length) { + throw new CBORError({ + message: `Invalid CBOR: expected to consume ${data.length} bytes, but consumed ${result.newOffset}` + }) + } + return { + value: result.item, + format: result.format! + } +} + // Fast, offset-based decode helpers (no slicing or copying of input buffer) -type DecodeAtResult = { item: T; newOffset: number } -const decodeItemAt = (data: Uint8Array, offset: number, options: CodecOptions): DecodeAtResult => { +/** + * Controls what metadata `decodeItemAt` and its helpers track: + * - "none" — no metadata (canonical / cml / aiken paths, fastest) + * - "format" — `CBORFormat` tagged union built directly (WithFormat path) + */ +type DecodeTrack = "none" | "format" + +type DecodeAtResult = { + item: T + newOffset: number + /** Populated only when track === "format" */ + format?: CBORFormat +} + +/** Map CBOR additional info to ByteSize width: <24 → 0 (inline), 24 → 1, 25 → 2, 26 → 4, 27 → 8. */ +const additionalInfoToByteSize = (ai: number): ByteSize => { + if (ai < 24) return 0 + if (ai === 24) return 1 + if (ai === 25) return 2 + if (ai === 26) return 4 + return 8 +} + +/** Map decodeLengthAt bytesRead to ByteSize width: 1 → 0, 2 → 1, 3 → 2, 5 → 4. */ +const bytesReadToByteSize = (bytesRead: number): ByteSize => + bytesRead <= 1 ? 0 : bytesRead === 2 ? 1 : bytesRead === 3 ? 2 : 4 + +const decodeItemAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const majorType = (firstByte >> 5) & 0x07 + let result: DecodeAtResult switch (majorType) { - case CBOR_MAJOR_TYPE.UNSIGNED_INTEGER: { - return decodeUintAt(data, offset) - } - case CBOR_MAJOR_TYPE.NEGATIVE_INTEGER: { - return decodeNintAt(data, offset) - } - case CBOR_MAJOR_TYPE.BYTE_STRING: { - return decodeBytesAt(data, offset) - } - case CBOR_MAJOR_TYPE.TEXT_STRING: { - return decodeTextAt(data, offset) - } - case CBOR_MAJOR_TYPE.ARRAY: { - return decodeArrayAt(data, offset, options) - } - case CBOR_MAJOR_TYPE.MAP: { - return decodeMapAt(data, offset, options) - } - case CBOR_MAJOR_TYPE.TAG: { - return decodeTagAt(data, offset, options) - } - case CBOR_MAJOR_TYPE.SIMPLE_FLOAT: { - return decodeSimpleOrFloatAt(data, offset) - } + case CBOR_MAJOR_TYPE.UNSIGNED_INTEGER: + result = decodeUintAt(data, offset, track) + break + case CBOR_MAJOR_TYPE.NEGATIVE_INTEGER: + result = decodeNintAt(data, offset, track) + break + case CBOR_MAJOR_TYPE.BYTE_STRING: + result = decodeBytesAt(data, offset, track) + break + case CBOR_MAJOR_TYPE.TEXT_STRING: + result = decodeTextAt(data, offset, track) + break + case CBOR_MAJOR_TYPE.ARRAY: + result = decodeArrayAt(data, offset, options, track) + break + case CBOR_MAJOR_TYPE.MAP: + result = decodeMapAt(data, offset, options, track) + break + case CBOR_MAJOR_TYPE.TAG: + result = decodeTagAt(data, offset, options, track) + break + case CBOR_MAJOR_TYPE.SIMPLE_FLOAT: + result = decodeSimpleOrFloatAt(data, offset, track) + break default: throw new CBORError({ message: `Unsupported major type: ${majorType}` }) } + // In format mode, simple/float values don't set result.format — fill the sentinel here. + if (track === "format" && result.format === undefined) { + result.format = { _tag: "simple" } + } + return result } -const decodeUintAt = (data: Uint8Array, offset: number): DecodeAtResult => { +const decodeUintAt = (data: Uint8Array, offset: number, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f + const bs = additionalInfoToByteSize(additionalInfo) + let item: bigint + let newOffset: number if (additionalInfo < 24) { - return { item: BigInt(additionalInfo), newOffset: offset + 1 } + item = BigInt(additionalInfo); newOffset = offset + 1 } else if (additionalInfo === 24) { if (data.length < offset + 2) throw new CBORError({ message: "Insufficient data for 1-byte unsigned integer" }) - return { item: BigInt(data[offset + 1]), newOffset: offset + 2 } + item = BigInt(data[offset + 1]); newOffset = offset + 2 } else if (additionalInfo === 25) { if (data.length < offset + 3) throw new CBORError({ message: "Insufficient data for 2-byte unsigned integer" }) - return { item: BigInt(data[offset + 1]) * 256n + BigInt(data[offset + 2]), newOffset: offset + 3 } + item = BigInt(data[offset + 1]) * 256n + BigInt(data[offset + 2]); newOffset = offset + 3 } else if (additionalInfo === 26) { if (data.length < offset + 5) throw new CBORError({ message: "Insufficient data for 4-byte unsigned integer" }) - const v = - BigInt(data[offset + 1]) * 16777216n + - BigInt(data[offset + 2]) * 65536n + - BigInt(data[offset + 3]) * 256n + - BigInt(data[offset + 4]) - return { item: v, newOffset: offset + 5 } + item = BigInt(data[offset + 1]) * 16777216n + BigInt(data[offset + 2]) * 65536n + + BigInt(data[offset + 3]) * 256n + BigInt(data[offset + 4]); newOffset = offset + 5 } else if (additionalInfo === 27) { if (data.length < offset + 9) throw new CBORError({ message: "Insufficient data for 8-byte unsigned integer" }) - let result = 0n - for (let i = 1; i <= 8; i++) result = result * 256n + BigInt(data[offset + i]) - return { item: result, newOffset: offset + 9 } + item = 0n + for (let i = 1; i <= 8; i++) item = item * 256n + BigInt(data[offset + i]) + newOffset = offset + 9 + } else { + throw new CBORError({ message: `Unsupported additional info for unsigned integer: ${additionalInfo}` }) } - throw new CBORError({ message: `Unsupported additional info for unsigned integer: ${additionalInfo}` }) + if (track === "format") return { item, newOffset, format: bs === 0 ? { _tag: "uint" } : { _tag: "uint", byteSize: bs } } + return { item, newOffset } } -const decodeNintAt = (data: Uint8Array, offset: number): DecodeAtResult => { +const decodeNintAt = (data: Uint8Array, offset: number, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f + const bs = additionalInfoToByteSize(additionalInfo) + let item: bigint + let newOffset: number if (additionalInfo < 24) { - return { item: -1n - BigInt(additionalInfo), newOffset: offset + 1 } + item = -1n - BigInt(additionalInfo); newOffset = offset + 1 } else if (additionalInfo === 24) { if (data.length < offset + 2) throw new CBORError({ message: "Insufficient data for 1-byte negative integer" }) - return { item: -1n - BigInt(data[offset + 1]), newOffset: offset + 2 } + item = -1n - BigInt(data[offset + 1]); newOffset = offset + 2 } else if (additionalInfo === 25) { if (data.length < offset + 3) throw new CBORError({ message: "Insufficient data for 2-byte negative integer" }) - const v = BigInt(data[offset + 1]) * 256n + BigInt(data[offset + 2]) - return { item: -1n - v, newOffset: offset + 3 } + item = -1n - (BigInt(data[offset + 1]) * 256n + BigInt(data[offset + 2])); newOffset = offset + 3 } else if (additionalInfo === 26) { if (data.length < offset + 5) throw new CBORError({ message: "Insufficient data for 4-byte negative integer" }) - const v = - BigInt(data[offset + 1]) * 16777216n + - BigInt(data[offset + 2]) * 65536n + - BigInt(data[offset + 3]) * 256n + - BigInt(data[offset + 4]) - return { item: -1n - v, newOffset: offset + 5 } + const v = BigInt(data[offset + 1]) * 16777216n + BigInt(data[offset + 2]) * 65536n + + BigInt(data[offset + 3]) * 256n + BigInt(data[offset + 4]) + item = -1n - v; newOffset = offset + 5 } else if (additionalInfo === 27) { if (data.length < offset + 9) throw new CBORError({ message: "Insufficient data for 8-byte negative integer" }) - let result = 0n - for (let i = 1; i <= 8; i++) result = result * 256n + BigInt(data[offset + i]) - return { item: -1n - result, newOffset: offset + 9 } + let v = 0n + for (let i = 1; i <= 8; i++) v = v * 256n + BigInt(data[offset + i]) + item = -1n - v; newOffset = offset + 9 + } else { + throw new CBORError({ message: `Unsupported additional info for negative integer: ${additionalInfo}` }) } - throw new CBORError({ message: `Unsupported additional info for negative integer: ${additionalInfo}` }) + if (track === "format") return { item, newOffset, format: bs === 0 ? { _tag: "nint" } : { _tag: "nint", byteSize: bs } } + return { item, newOffset } } const decodeLengthAt = (data: Uint8Array, offset: number): { length: number; bytesRead: number } => { @@ -1380,12 +1823,13 @@ const decodeLengthAt = (data: Uint8Array, offset: number): { length: number; byt throw new CBORError({ message: `Unsupported length encoding: ${additionalInfo}` }) } -const decodeBytesAt = (data: Uint8Array, offset: number): DecodeAtResult => { +const decodeBytesAt = (data: Uint8Array, offset: number, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { let cur = offset + 1 const chunks: Array = [] + const chunkInfos: Array<{ readonly length: number; readonly byteSize: ByteSize }> = [] let foundBreak = false while (cur < data.length) { const b = data[cur] @@ -1401,6 +1845,7 @@ const decodeBytesAt = (data: Uint8Array, offset: number): DecodeAtResult => { const end = start + length if (end > data.length) throw new CBORError({ message: "Insufficient data for byte string chunk" }) chunks.push(data.subarray(start, end)) + if (track !== "none") chunkInfos.push({ length, byteSize: bytesReadToByteSize(bytesRead) }) cur = end } if (!foundBreak) { @@ -1410,26 +1855,29 @@ const decodeBytesAt = (data: Uint8Array, offset: number): DecodeAtResult => { for (let i = 0; i < chunks.length; i++) total += chunks[i].length const out = new Uint8Array(total) let pos = 0 - for (const ch of chunks) { - out.set(ch, pos) - pos += ch.length - } - return { item: out, newOffset: cur } + for (const ch of chunks) { out.set(ch, pos); pos += ch.length } + if (track === "none") return { item: out, newOffset: cur } + const se: StringEncoding = { tag: "indefinite", chunks: chunkInfos } + return { item: out, newOffset: cur, format: { _tag: "bytes", encoding: se } } } else { const { bytesRead, length } = decodeLengthAt(data, offset) const start = offset + bytesRead const end = start + length if (end > data.length) throw new CBORError({ message: "Insufficient data for byte string" }) - return { item: data.subarray(start, end), newOffset: end } + const item = data.subarray(start, end) + if (track === "none") return { item, newOffset: end } + const bs = bytesReadToByteSize(bytesRead) + return { item, newOffset: end, format: bs === 0 ? { _tag: "bytes" } : { _tag: "bytes", encoding: { tag: "definite", byteSize: bs } } } } } -const decodeTextAt = (data: Uint8Array, offset: number): DecodeAtResult => { +const decodeTextAt = (data: Uint8Array, offset: number, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { let cur = offset + 1 const parts: Array = [] + const chunkInfos: Array<{ readonly length: number; readonly byteSize: ByteSize }> = [] let foundBreak = false while (cur < data.length) { const b = data[cur] @@ -1444,132 +1892,175 @@ const decodeTextAt = (data: Uint8Array, offset: number): DecodeAtResult => { const start = cur + bytesRead const end = start + length if (end > data.length) throw new CBORError({ message: "Insufficient data for text string chunk" }) - const str = TEXT_DECODER.decode(data.subarray(start, end)) - parts.push(str) + parts.push(TEXT_DECODER.decode(data.subarray(start, end))) + if (track !== "none") chunkInfos.push({ length, byteSize: bytesReadToByteSize(bytesRead) }) cur = end } if (!foundBreak) { throw new CBORError({ message: "Indefinite text string missing break byte (0xff)" }) } - return { item: parts.join(""), newOffset: cur } + const item = parts.join("") + if (track === "none") return { item, newOffset: cur } + const se: StringEncoding = { tag: "indefinite", chunks: chunkInfos } + return { item, newOffset: cur, format: { _tag: "text", encoding: se } } } else { const { bytesRead, length } = decodeLengthAt(data, offset) const start = offset + bytesRead const end = start + length if (end > data.length) throw new CBORError({ message: "Insufficient data for text string" }) - const str = TEXT_DECODER.decode(data.subarray(start, end)) - return { item: str, newOffset: end } + const item = TEXT_DECODER.decode(data.subarray(start, end)) + if (track === "none") return { item, newOffset: end } + const bs = bytesReadToByteSize(bytesRead) + return { item, newOffset: end, format: bs === 0 ? { _tag: "text" } : { _tag: "text", encoding: { tag: "definite", byteSize: bs } } } } } -const decodeArrayAt = (data: Uint8Array, offset: number, options: CodecOptions): DecodeAtResult => { +const decodeArrayAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { const arr: Array = [] let cur = offset + 1 let foundBreak = false - while (cur < data.length) { - if (data[cur] === 0xff) { - cur += 1 - foundBreak = true - break + if (track === "none") { + while (cur < data.length) { + if (data[cur] === 0xff) { cur += 1; foundBreak = true; break } + const child = decodeItemAt(data, cur, options, "none") + arr.push(child.item); cur = child.newOffset } - const { item, newOffset } = decodeItemAt(data, cur, options) - arr.push(item) - cur = newOffset + if (!foundBreak) throw new CBORError({ message: "Indefinite array missing break byte (0xff)" }) + return { item: arr, newOffset: cur } } - if (!foundBreak) { - throw new CBORError({ message: "Indefinite array missing break byte (0xff)" }) + const childFormats: Array = [] + while (cur < data.length) { + if (data[cur] === 0xff) { cur += 1; foundBreak = true; break } + const child = decodeItemAt(data, cur, options, "format") + arr.push(child.item); childFormats.push(child.format!); cur = child.newOffset } - return { item: arr, newOffset: cur } + if (!foundBreak) throw new CBORError({ message: "Indefinite array missing break byte (0xff)" }) + return { item: arr, newOffset: cur, format: { _tag: "array", length: { tag: "indefinite" }, children: childFormats } } } else { const { bytesRead, length } = decodeLengthAt(data, offset) let cur = offset + bytesRead const arr: Array = new Array(length) + if (track === "none") { + for (let i = 0; i < length; i++) { + const { item, newOffset } = decodeItemAt(data, cur, options, "none") + arr[i] = item; cur = newOffset + } + return { item: arr, newOffset: cur } + } + const bs = bytesReadToByteSize(bytesRead) + const le: LengthEncoding = { tag: "definite", byteSize: bs } + const childFormats: Array = new Array(length) for (let i = 0; i < length; i++) { - const { item, newOffset } = decodeItemAt(data, cur, options) - arr[i] = item - cur = newOffset + const child = decodeItemAt(data, cur, options, "format") + arr[i] = child.item; childFormats[i] = child.format!; cur = child.newOffset } - return { item: arr, newOffset: cur } + return { item: arr, newOffset: cur, format: { _tag: "array", ...(bs !== 0 ? { length: le } : {}), children: childFormats } } } } -const decodeMapAt = (data: Uint8Array, offset: number, options: CodecOptions): DecodeAtResult => { +const decodeMapAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f + const isObj = options.mode === "custom" && options.mapsAsObjects if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { - const isObj = options.mode === "custom" && options.mapsAsObjects const map = isObj ? ({} as Record) : new Map() let cur = offset + 1 let foundBreak = false - while (cur < data.length) { - if (data[cur] === 0xff) { - cur += 1 - foundBreak = true - break + if (track === "none") { + while (cur < data.length) { + if (data[cur] === 0xff) { cur += 1; foundBreak = true; break } + const k = decodeItemAt(data, cur, options, "none"); cur = k.newOffset + const v = decodeItemAt(data, cur, options, "none"); cur = v.newOffset + if (map instanceof Map) map.set(k.item, v.item) + else map[String(k.item as unknown)] = v.item } - const k = decodeItemAt(data, cur, options) - cur = k.newOffset - const v = decodeItemAt(data, cur, options) - cur = v.newOffset - if (map instanceof Map) map.set(k.item, v.item) - else map[String(k.item as any)] = v.item + if (!foundBreak) throw new CBORError({ message: "Indefinite map missing break byte (0xff)" }) + return { item: map, newOffset: cur } } - if (!foundBreak) { - throw new CBORError({ message: "Indefinite map missing break byte (0xff)" }) + const keyOrderBytes: Array = [] + const entryFormats: Array = [] + while (cur < data.length) { + if (data[cur] === 0xff) { cur += 1; foundBreak = true; break } + const keyStart = cur + const k = decodeItemAt(data, cur, options, "format"); cur = k.newOffset + const v = decodeItemAt(data, cur, options, "format"); cur = v.newOffset + keyOrderBytes.push(data.slice(keyStart, k.newOffset)); entryFormats.push([k.format!, v.format!]) + if (map instanceof Map) map.set(k.item, v.item) + else map[String(k.item as unknown)] = v.item } - return { item: map, newOffset: cur } + if (!foundBreak) throw new CBORError({ message: "Indefinite map missing break byte (0xff)" }) + return { item: map, newOffset: cur, format: { _tag: "map", length: { tag: "indefinite" }, keyOrder: keyOrderBytes, entries: entryFormats } } } else { const { bytesRead, length } = decodeLengthAt(data, offset) let cur = offset + bytesRead - const isObj = options.mode === "custom" && options.mapsAsObjects const map = isObj ? ({} as Record) : new Map() + if (track === "none") { + for (let i = 0; i < length; i++) { + const k = decodeItemAt(data, cur, options, "none") + cur = k.newOffset + const v = decodeItemAt(data, cur, options, "none") + cur = v.newOffset + if (map instanceof Map) map.set(k.item, v.item) + else map[String(k.item as unknown)] = v.item + } + return { item: map, newOffset: cur } + } + const bs = bytesReadToByteSize(bytesRead) + const le: LengthEncoding = { tag: "definite", byteSize: bs } + const keyOrderBytes: Array = new Array(length) + const entryFormats: Array = new Array(length) for (let i = 0; i < length; i++) { - const k = decodeItemAt(data, cur, options) + const keyStart = cur + const k = decodeItemAt(data, cur, options, "format") cur = k.newOffset - const v = decodeItemAt(data, cur, options) + const v = decodeItemAt(data, cur, options, "format") cur = v.newOffset + keyOrderBytes[i] = data.slice(keyStart, k.newOffset) + entryFormats[i] = [k.format!, v.format!] if (map instanceof Map) map.set(k.item, v.item) - else map[String(k.item as any)] = v.item + else map[String(k.item as unknown)] = v.item } - return { item: map, newOffset: cur } + return { item: map, newOffset: cur, format: { _tag: "map", ...(bs !== 0 ? { length: le } : {}), keyOrder: keyOrderBytes, entries: entryFormats } } } } -const decodeTagAt = (data: Uint8Array, offset: number, options: CodecOptions): DecodeAtResult => { +const decodeTagAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f let tagValue: number let cur = offset if (additionalInfo < 24) { - tagValue = additionalInfo - cur += 1 + tagValue = additionalInfo; cur += 1 } else if (additionalInfo === 24) { if (data.length < offset + 2) throw new CBORError({ message: "Insufficient data for 1-byte tag" }) - tagValue = data[offset + 1] - cur += 2 + tagValue = data[offset + 1]; cur += 2 } else if (additionalInfo === 25) { if (data.length < offset + 3) throw new CBORError({ message: "Insufficient data for 2-byte tag" }) - tagValue = (data[offset + 1] << 8) | data[offset + 2] - cur += 3 + tagValue = (data[offset + 1] << 8) | data[offset + 2]; cur += 3 } else { throw new CBORError({ message: `Unsupported tag encoding: ${additionalInfo}` }) } - const inner = decodeItemAt(data, cur, options) + const inner = decodeItemAt(data, cur, options, track) cur = inner.newOffset + const bs = additionalInfoToByteSize(additionalInfo) if (tagValue === 2 || tagValue === 3) { if (!(inner.item instanceof Uint8Array)) throw new CBORError({ message: `Expected bytes for bigint tag ${tagValue}` }) - let result = 0n - for (let i = 0; i < inner.item.length; i++) result = (result << 8n) | BigInt(inner.item[i]) - return { item: tagValue === 2 ? result : -1n - result, newOffset: cur } - } - return { item: { _tag: "Tag", tag: tagValue, value: inner.item }, newOffset: cur } + let n = 0n + for (let i = 0; i < inner.item.length; i++) n = (n << 8n) | BigInt(inner.item[i]) + const item = tagValue === 2 ? n : -1n - n + if (track === "none") return { item, newOffset: cur } + return { item, newOffset: cur, format: { _tag: "tag", ...(bs !== 0 ? { width: bs } : {}), child: inner.format! } } + } + const item = { _tag: "Tag" as const, tag: tagValue, value: inner.item } + if (track === "none") return { item, newOffset: cur } + return { item, newOffset: cur, format: { _tag: "tag", ...(bs !== 0 ? { width: bs } : {}), child: inner.format! } } } -const decodeSimpleOrFloatAt = (data: Uint8Array, offset: number): DecodeAtResult => { +const decodeSimpleOrFloatAt = (data: Uint8Array, offset: number, _track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f if (additionalInfo === CBOR_SIMPLE.FALSE) return { item: false, newOffset: offset + 1 } diff --git a/packages/evolution/src/Data.ts b/packages/evolution/src/Data.ts index 78bc495e..16a7969a 100644 --- a/packages/evolution/src/Data.ts +++ b/packages/evolution/src/Data.ts @@ -739,53 +739,14 @@ export const hash = (data: Data): number => { } /** - * Deep structural equality for Plutus Data values. - * Handles maps, lists, ints, bytes, and constrs. + * Schema-derived structural equality for Plutus Data values. + * Handles maps, lists, ints, bytes, and constrs via the + * recursive DataSchema definition — no hand-rolled comparison needed. * * @since 2.0.0 * @category equality */ -export const equals = (a: Data, b: Data): boolean => { - // bigint - if (typeof a === "bigint" && typeof b === "bigint") return a === b - - // Uint8Array (ByteArray) - bytewise comparison - if (a instanceof Uint8Array && b instanceof Uint8Array) { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false - return true - } - - // Arrays (Lists) - if (Array.isArray(a) && Array.isArray(b)) { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (!equals(a[i] as Data, b[i] as Data)) return false - return true - } - - // Constr - if (a instanceof Constr && b instanceof Constr) { - if (a.index !== b.index) return false - if (a.fields.length !== b.fields.length) return false - for (let i = 0; i < a.fields.length; i++) if (!equals(a.fields[i] as Data, b.fields[i] as Data)) return false - return true - } - - // Map - if (a instanceof Map && b instanceof Map) { - if (a.size !== b.size) return false - const aEntries = Array.from(a.entries()) - for (const [ak, av] of aEntries) { - // find equivalent key in b - const match = Array.from(b.entries()).find(([bk]) => equals(ak as Data, bk as Data)) - if (!match) return false - if (!equals(av as Data, match[1] as Data)) return false - } - return true - } - - return false -} +export const equals: (a: Data, b: Data) => boolean = Schema.equivalence(DataSchema) export const CDDLSchema = CBOR.CBORSchema diff --git a/packages/evolution/src/Redeemers.ts b/packages/evolution/src/Redeemers.ts index d7fc7f96..1dfba9a3 100644 --- a/packages/evolution/src/Redeemers.ts +++ b/packages/evolution/src/Redeemers.ts @@ -4,9 +4,10 @@ import * as CBOR from "./CBOR.js" import * as Data from "./Data.js" import * as Redeemer from "./Redeemer.js" -/** - * Helper for array equality using element-by-element comparison. - */ +// ============================================================================ +// Shared helpers +// ============================================================================ + const arrayEquals = (a: ReadonlyArray, b: ReadonlyArray): boolean => { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { @@ -15,9 +16,6 @@ const arrayEquals = (a: ReadonlyArray, b: ReadonlyArray): boolean => { return true } -/** - * Helper for array hashing using element hashes. - */ const arrayHash = (arr: ReadonlyArray): number => { let hash = 0 for (const item of arr) { @@ -26,42 +24,216 @@ const arrayHash = (arr: ReadonlyArray): number => { return hash } +// ============================================================================ +// Map key type +// ============================================================================ + +/** + * A redeemer map key: `[tag, index]`. + * + * Mirrors the CDDL: `[tag : redeemer_tag, index : uint .size 4]` + * + * @since 2.0.0 + * @category model + */ +export type RedeemerKey = readonly [Redeemer.RedeemerTag, bigint] + +/** + * Create a string key from a RedeemerKey for lookup convenience. + * + * @since 2.0.0 + * @category utilities + */ +export const keyToString = ([tag, index]: RedeemerKey): string => `${tag}:${index}` + +// ============================================================================ +// Map entry value type +// ============================================================================ + +/** + * A redeemer map entry value: `[data, ex_units]`. + * + * Mirrors the CDDL: `[data : plutus_data, ex_units : ex_units]` + * + * @since 2.0.0 + * @category model + */ +export class RedeemerValue extends Schema.Class("RedeemerValue")({ + data: Schema.typeSchema(Data.DataSchema), + exUnits: Redeemer.ExUnits +}) { + [Equal.symbol](that: unknown): boolean { + return ( + that instanceof RedeemerValue && Data.equals(this.data, that.data) && Equal.equals(this.exUnits, that.exUnits) + ) + } + + [Hash.symbol](): number { + return Hash.cached(this, Hash.combine(Hash.hash(this.data))(Hash.hash(this.exUnits))) + } +} + +// ============================================================================ +// Domain types — discriminated union (Credential pattern) +// ============================================================================ + /** - * Encoding format for redeemers collection. + * Redeemers in map format (Conway recommended). * - * Conway CDDL supports two formats: + * Mirrors the CDDL exactly: * ``` - * ; Flat Array support is included for backwards compatibility and - * ; will be removed in the next era. It is recommended for tools to - * ; adopt using a Map instead of Array going forward. - * redeemers = - * [ + redeemer ] - * / { + [tag : redeemer_tag, index : uint .size 4] => [ data : plutus_data, ex_units : ex_units ] } + * { + [tag : redeemer_tag, index : uint .size 4] => [ data : plutus_data, ex_units : ex_units ] } * ``` * - * - "array": Legacy flat array format - backwards compatible, will be deprecated - * - "map": New map format - recommended for Conway+ + * The map is keyed by `[tag, index]` tuples. Note: JS Map uses reference + * equality for non-primitive keys, so lookups by tuple won't work — use + * `get()` or `toArray()` helpers instead. * * @since 2.0.0 * @category model */ -export type Format = "array" | "map" +export class RedeemerMap extends Schema.TaggedClass()("RedeemerMap", { + value: Schema.Map({ + key: Schema.Tuple(Redeemer.RedeemerTag, Schema.BigIntFromSelf), + value: Schema.typeSchema(RedeemerValue) + }) +}) { + /** + * Look up a redeemer entry by tag and index. + * + * @since 2.0.0 + * @category accessors + */ + get(tag: Redeemer.RedeemerTag, index: bigint): RedeemerValue | undefined { + for (const [[t, i], v] of this.value) { + if (t === tag && i === index) return v + } + return undefined + } + + /** + * Number of redeemer entries. + * + * @since 2.0.0 + * @category accessors + */ + get size(): number { + return this.value.size + } + + /** + * Convert to an array of `Redeemer` objects (convenience for consumers). + * + * @since 2.0.0 + * @category conversions + */ + toArray(): ReadonlyArray { + const result: Array = [] + for (const [[tag, index], { data, exUnits }] of this.value) { + result.push(new Redeemer.Redeemer({ tag, index, data, exUnits })) + } + return result + } + + toJSON() { + return { + _tag: "RedeemerMap" as const, + entries: Array.from(this.value.entries()).map(([[tag, index], { data, exUnits }]) => ({ + key: { tag, index: index.toString() }, + value: { data, exUnits: exUnits.toJSON() } + })) + } + } + + toString(): string { + return Inspectable.format(this.toJSON()) + } + + [Inspectable.NodeInspectSymbol](): unknown { + return this.toJSON() + } + + [Equal.symbol](that: unknown): boolean { + if (!(that instanceof RedeemerMap)) return false + if (this.value.size !== that.value.size) return false + // Order-insensitive: sort both by [tag, index] then compare Redeemer objects + // (Redeemer is a TaggedClass with proper Equal support, unlike raw Data.Data) + const sortKey = (r: Redeemer.Redeemer) => `${r.tag}:${r.index}` + const sortedThis = [...this.toArray()].sort((a, b) => sortKey(a).localeCompare(sortKey(b))) + const sortedThat = [...that.toArray()].sort((a, b) => sortKey(a).localeCompare(sortKey(b))) + return arrayEquals(sortedThis, sortedThat) + } + + [Hash.symbol](): number { + // Order-insensitive: sort by key then hash the sorted array + const sortKey = (r: Redeemer.Redeemer) => `${r.tag}:${r.index}` + const sorted = [...this.toArray()].sort((a, b) => sortKey(a).localeCompare(sortKey(b))) + return Hash.cached(this, arrayHash(sorted)) + } +} + +/** + * Create a `RedeemerMap` from an array of `Redeemer` objects. + * + * @since 2.0.0 + * @category constructors + */ +export const makeRedeemerMap = (redeemers: ReadonlyArray): RedeemerMap => { + const map = new Map() + for (const r of redeemers) { + const key: RedeemerKey = [r.tag, r.index] + // Detect semantic duplicates (same tag + index) + for (const [existingKey] of map) { + if (existingKey[0] === key[0] && existingKey[1] === key[1]) { + throw new Error(`Duplicate redeemer key: [${key[0]}, ${key[1]}]`) + } + } + map.set(key, new RedeemerValue({ data: r.data, exUnits: r.exUnits })) + } + return new RedeemerMap({ value: map }) +} /** - * Redeemers collection based on Conway CDDL specification. + * Redeemers in legacy array format. * - * Represents a collection of redeemers that can be encoded in either array or map format. + * Mirrors the CDDL: + * ``` + * [ + redeemer ] + * ``` + * + * Backwards compatible — will be deprecated in the next era. + * Prefer `RedeemerMap` for new transactions. * * @since 2.0.0 * @category model */ -export class Redeemers extends Schema.TaggedClass()("Redeemers", { - values: Schema.Array(Redeemer.Redeemer) +export class RedeemerArray extends Schema.TaggedClass()("RedeemerArray", { + value: Schema.Array(Redeemer.Redeemer) }) { + /** + * Number of redeemer entries. + * + * @since 2.0.0 + * @category accessors + */ + get size(): number { + return this.value.length + } + + /** + * Convert to an array of `Redeemer` objects (identity for array format). + * + * @since 2.0.0 + * @category conversions + */ + toArray(): ReadonlyArray { + return this.value + } + toJSON() { return { - _tag: "Redeemers" as const, - values: this.values.map((r) => r.toJSON()) + _tag: "RedeemerArray" as const, + value: this.value.map((r) => r.toJSON()) } } @@ -74,18 +246,37 @@ export class Redeemers extends Schema.TaggedClass()("Redeemers", { } [Equal.symbol](that: unknown): boolean { - return that instanceof Redeemers && arrayEquals(this.values, that.values) + return that instanceof RedeemerArray && arrayEquals(this.value, that.value) } [Hash.symbol](): number { - return Hash.cached(this, arrayHash(this.values)) + return Hash.cached(this, arrayHash(this.value)) } } /** - * CDDL schema for Redeemers in array format. + * Union schema for redeemers — accepts either map or array format. + * Follows the Credential pattern: `Credential = Union(KeyHash, ScriptHash)`. + * + * @since 2.0.0 + * @category schemas + */ +export const Redeemers = Schema.Union(RedeemerMap, RedeemerArray) + +/** + * Union type: `RedeemerMap | RedeemerArray` * - * `redeemers = [ + redeemer ]` + * @since 2.0.0 + * @category model + */ +export type Redeemers = typeof Redeemers.Type + +// ============================================================================ +// CDDL schemas — one per wire format +// ============================================================================ + +/** + * CDDL schema for array format: `[ + redeemer ]` * * @since 2.0.0 * @category schemas @@ -93,18 +284,18 @@ export class Redeemers extends Schema.TaggedClass()("Redeemers", { export const ArrayCDDLSchema = Schema.Array(Redeemer.CDDLSchema) /** - * CDDL transformation schema for Redeemers array format. + * CDDL transformation for array format → `RedeemerArray`. * * @since 2.0.0 * @category schemas */ -export const FromArrayCDDL = Schema.transformOrFail(ArrayCDDLSchema, Schema.typeSchema(Redeemers), { +export const FromArrayCDDL = Schema.transformOrFail(ArrayCDDLSchema, Schema.typeSchema(RedeemerArray), { strict: true, - encode: (toA) => Eff.all(toA.values.map((r) => ParseResult.encode(Redeemer.FromCDDL)(r))), + encode: (toA) => Eff.all(toA.value.map((r) => ParseResult.encode(Redeemer.FromCDDL)(r))), decode: (fromA) => Eff.gen(function* () { - const values = yield* Eff.all(fromA.map((tuple) => ParseResult.decode(Redeemer.FromCDDL)(tuple))) - return new Redeemers({ values }) + const value = yield* Eff.all(fromA.map((tuple) => ParseResult.decode(Redeemer.FromCDDL)(tuple))) + return new RedeemerArray({ value }) }) }) @@ -125,25 +316,27 @@ const MapKeyCDDLSchema = Schema.Tuple(CBOR.Integer, CBOR.Integer) const MapValueCDDLSchema = Schema.Tuple(Data.CDDLSchema, Schema.Tuple(CBOR.Integer, CBOR.Integer)) /** - * CDDL schema for Redeemers in map format. + * CDDL schema for map format: `{ + [tag, index] => [data, ex_units] }` * - * `{ + [tag, index] => [data, ex_units] }` + * Uses `MapFromSelf` (not `Map`) so the Encoded type is a JS Map — matching + * how `CBOR.FromBytes` represents CBOR major-type-5 maps at runtime. + * This is the same pattern used by Withdrawals, Mint, MultiAsset, CostModel. * * @since 2.0.0 * @category schemas */ -export const MapCDDLSchema = Schema.Map({ +export const MapCDDLSchema = Schema.MapFromSelf({ key: MapKeyCDDLSchema, value: MapValueCDDLSchema }) /** - * CDDL transformation schema for Redeemers map format. + * CDDL transformation for map format → `RedeemerMap`. * * @since 2.0.0 * @category schemas */ -export const FromMapCDDL = Schema.transformOrFail(MapCDDLSchema, Schema.typeSchema(Redeemers), { +export const FromMapCDDL = Schema.transformOrFail(MapCDDLSchema, Schema.typeSchema(RedeemerMap), { strict: true, encode: (toA) => Eff.gen(function* () { @@ -153,46 +346,53 @@ export const FromMapCDDL = Schema.transformOrFail(MapCDDLSchema, Schema.typeSche readonly [Schema.Schema.Type, readonly [bigint, bigint]] ] > = [] - for (const r of toA.values) { - const tagInteger = Redeemer.tagToInteger(r.tag) - const dataCBOR = yield* ParseResult.encode(Data.FromCDDL)(r.data) + for (const [[tag, index], { data, exUnits }] of toA.value) { + const tagInteger = Redeemer.tagToInteger(tag) + const dataCBOR = yield* ParseResult.encode(Data.FromCDDL)(data) entries.push([ - [tagInteger, r.index], - [dataCBOR, [r.exUnits.mem, r.exUnits.steps]] + [tagInteger, index], + [dataCBOR, [exUnits.mem, exUnits.steps]] ]) } return new Map(entries) }), decode: (fromA) => Eff.gen(function* () { - const values: Array = [] + const entries: Array = [] for (const [[tagInteger, index], [dataCBOR, [mem, steps]]] of fromA.entries()) { const tag = Redeemer.integerToTag(tagInteger) const data = yield* ParseResult.decode(Data.FromCDDL)(dataCBOR) - values.push(new Redeemer.Redeemer({ data, exUnits: new Redeemer.ExUnits({ mem, steps }), index, tag })) + entries.push([ + [tag, index] as const, + new RedeemerValue({ data, exUnits: new Redeemer.ExUnits({ mem, steps }) }) + ]) } - return new Redeemers({ values }) + return new RedeemerMap({ value: new Map(entries) }) }) }) /** - * Default CDDL schema for Redeemers (array format). + * Default CDDL schema (map format — Conway recommended). * * @since 2.0.0 * @category schemas */ -export const CDDLSchema = ArrayCDDLSchema +export const CDDLSchema = MapCDDLSchema /** - * Default CDDL transformation (array format). + * Default CDDL transformation (map format). * * @since 2.0.0 * @category schemas */ -export const FromCDDL = FromArrayCDDL +export const FromCDDL = FromMapCDDL + +// ============================================================================ +// CBOR bytes / hex schemas +// ============================================================================ /** - * CBOR bytes transformation schema for Redeemers (array format). + * CBOR bytes schema for array format. * * @since 2.0.0 * @category schemas @@ -200,12 +400,12 @@ export const FromCDDL = FromArrayCDDL export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose(CBOR.FromBytes(options), FromArrayCDDL).annotations({ identifier: "Redeemers.FromCBORBytes", - title: "Redeemers from CBOR Bytes", - description: "Transforms CBOR bytes to Redeemers using array format" + title: "Redeemers from CBOR Bytes (Array)", + description: "Transforms CBOR bytes to RedeemerArray" }) /** - * CBOR hex transformation schema for Redeemers (array format). + * CBOR hex schema for array format. * * @since 2.0.0 * @category schemas @@ -213,12 +413,12 @@ export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTI export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.compose(Schema.Uint8ArrayFromHex, FromCBORBytes(options)).annotations({ identifier: "Redeemers.FromCBORHex", - title: "Redeemers from CBOR Hex", - description: "Transforms CBOR hex string to Redeemers using array format" + title: "Redeemers from CBOR Hex (Array)", + description: "Transforms CBOR hex string to RedeemerArray" }) /** - * CBOR bytes transformation schema for Redeemers (map format). + * CBOR bytes schema for map format. * * @since 2.0.0 * @category schemas @@ -227,11 +427,11 @@ export const FromCBORBytesMap = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_O Schema.compose(CBOR.FromBytes(options), FromMapCDDL).annotations({ identifier: "Redeemers.FromCBORBytesMap", title: "Redeemers from CBOR Bytes (Map)", - description: "Transforms CBOR bytes to Redeemers using map format" + description: "Transforms CBOR bytes to RedeemerMap" }) /** - * CBOR hex transformation schema for Redeemers (map format). + * CBOR hex schema for map format. * * @since 2.0.0 * @category schemas @@ -240,21 +440,30 @@ export const FromCBORHexMap = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPT Schema.compose(Schema.Uint8ArrayFromHex, FromCBORBytesMap(options)).annotations({ identifier: "Redeemers.FromCBORHexMap", title: "Redeemers from CBOR Hex (Map)", - description: "Transforms CBOR hex string to Redeemers using map format" + description: "Transforms CBOR hex string to RedeemerMap" }) +// ============================================================================ +// Arbitrary +// ============================================================================ + /** - * FastCheck arbitrary for Redeemers. + * FastCheck arbitrary for Redeemers — generates both map and array variants. * * @since 2.0.0 * @category arbitrary */ -export const arbitrary = FastCheck.array(Redeemer.arbitrary, { maxLength: 5 }).map( - (values) => new Redeemers({ values }) +export const arbitrary: FastCheck.Arbitrary = FastCheck.array(Redeemer.arbitrary, { maxLength: 5 }).chain( + (redeemers) => + FastCheck.constantFrom(makeRedeemerMap(redeemers), new RedeemerArray({ value: redeemers })) ) +// ============================================================================ +// Convenience parse / encode functions +// ============================================================================ + /** - * Parse Redeemers from CBOR bytes (array format). + * Parse from CBOR bytes (array format). * * @since 2.0.0 * @category parsing @@ -263,7 +472,7 @@ export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CB Schema.decodeSync(FromCBORBytes(options))(bytes) /** - * Parse Redeemers from CBOR hex string (array format). + * Parse from CBOR hex string (array format). * * @since 2.0.0 * @category parsing @@ -272,7 +481,7 @@ export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_D Schema.decodeSync(FromCBORHex(options))(hex) /** - * Parse Redeemers from CBOR bytes (map format). + * Parse from CBOR bytes (map format). * * @since 2.0.0 * @category parsing @@ -281,7 +490,7 @@ export const fromCBORBytesMap = (bytes: Uint8Array, options: CBOR.CodecOptions = Schema.decodeSync(FromCBORBytesMap(options))(bytes) /** - * Parse Redeemers from CBOR hex string (map format). + * Parse from CBOR hex string (map format). * * @since 2.0.0 * @category parsing @@ -290,37 +499,37 @@ export const fromCBORHexMap = (hex: string, options: CBOR.CodecOptions = CBOR.CM Schema.decodeSync(FromCBORHexMap(options))(hex) /** - * Encode Redeemers to CBOR bytes (array format). + * Encode to CBOR bytes (array format). * * @since 2.0.0 * @category encoding */ -export const toCBORBytes = (data: Redeemers, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORBytes = (data: RedeemerArray, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORBytes(options))(data) /** - * Encode Redeemers to CBOR hex string (array format). + * Encode to CBOR hex string (array format). * * @since 2.0.0 * @category encoding */ -export const toCBORHex = (data: Redeemers, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORHex = (data: RedeemerArray, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORHex(options))(data) /** - * Encode Redeemers to CBOR bytes (map format). + * Encode to CBOR bytes (map format). * * @since 2.0.0 * @category encoding */ -export const toCBORBytesMap = (data: Redeemers, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORBytesMap = (data: RedeemerMap, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORBytesMap(options))(data) /** - * Encode Redeemers to CBOR hex string (map format). + * Encode to CBOR hex string (map format). * * @since 2.0.0 * @category encoding */ -export const toCBORHexMap = (data: Redeemers, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORHexMap = (data: RedeemerMap, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORHexMap(options))(data) diff --git a/packages/evolution/src/Transaction.ts b/packages/evolution/src/Transaction.ts index 31656169..dfa61021 100644 --- a/packages/evolution/src/Transaction.ts +++ b/packages/evolution/src/Transaction.ts @@ -63,16 +63,10 @@ export class Transaction extends Schema.TaggedClass()("Transaction" * * CDDL: transaction = [transaction_body, transaction_witness_set, bool, auxiliary_data / nil] */ -export const CDDLSchema = Schema.Tuple( - TransactionBody.CDDLSchema.annotations({ identifier: "TransactionBodyCDDL", description: "Transaction body" }), - TransactionWitnessSet.CDDLSchema.annotations({ - identifier: "TransactionWitnessSetCDDL", - description: "Transaction witness set" - }), - Schema.Boolean, - // Auxiliary data is a CBOR value; CBOR schema already includes null in its domain - CBOR.CBORSchema.annotations({ identifier: "AuxiliaryDataCDDL", description: "Auxiliary data as raw CBOR" }) -).annotations({ identifier: "TransactionCDDL", description: "Transaction tuple structure" }) +export const CDDLSchema = Schema.declare( + (input: unknown): input is readonly [Map, Map, boolean, CBOR.CBOR | null] => + Array.isArray(input) +).annotations({ identifier: "Transaction.CDDLSchema", description: "Transaction tuple structure" }) /** * Transform between CDDL tuple and Transaction class. @@ -89,7 +83,8 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra const isValid = tx.isValid const auxiliaryData = tx.auxiliaryData === null ? null : yield* ParseResult.encode(AuxiliaryData.FromCDDL)(tx.auxiliaryData) - return [body, witnessSet, isValid, auxiliaryData] as const + const result = [body, witnessSet, isValid, auxiliaryData] as const + return result }), decode: (tuple) => Eff.gen(function* () { @@ -129,12 +124,72 @@ export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CB export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.decodeSync(FromCBORHex(options))(hex) +/** + * Parse a Transaction from CBOR bytes and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytesWithFormat = ( + bytes: Uint8Array +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORBytesWithFormat(bytes) + const value = Schema.decodeSync(FromCDDL)( + decoded.value as readonly [Map, Map, boolean, CBOR.CBOR | null] + ) + return { value, format: decoded.format } +} + +/** + * Parse a Transaction from CBOR hex string and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHexWithFormat = ( + hex: string +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORHexWithFormat(hex) + const value = Schema.decodeSync(FromCDDL)( + decoded.value as readonly [Map, Map, boolean, CBOR.CBOR | null] + ) + return { value, format: decoded.format } +} + export const toCBORBytes = (data: Transaction, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORBytes(options))(data) export const toCBORHex = (data: Transaction, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORHex(options))(data) +/** + * Convert a Transaction to CBOR bytes using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytesWithFormat = ( + data: Transaction, + format: CBOR.CBORFormat +): Uint8Array => { + const cborTuple = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORBytesWithFormat(cborTuple as unknown as CBOR.CBOR, format) +} + +/** + * Convert a Transaction to CBOR hex string using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHexWithFormat = ( + data: Transaction, + format: CBOR.CBORFormat +): string => { + const cborTuple = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORHexWithFormat(cborTuple as unknown as CBOR.CBOR, format) +} + // ============================================================================ // Byte-level witness merging (CML-like approach) // @@ -299,6 +354,39 @@ export const addVKeyWitnessesHex = ( return Schema.encodeSync(Schema.Uint8ArrayFromHex)(result) } +// ============================================================================ +// Domain-level witness addition +// ============================================================================ + +/** + * Add VKey witnesses to a transaction at the domain level. + * + * This creates a new Transaction with the additional witnesses merged in. + * All encoding metadata (body bytes, redeemers format, witness map structure) + * is preserved so that txId and scriptDataHash remain stable. + * + * @since 2.0.0 + * @category encoding + */ +export const addVKeyWitnesses = ( + tx: Transaction, + witnesses: ReadonlyArray +): Transaction => { + if (witnesses.length === 0) return tx + const oldWs = tx.witnessSet + const newWs = new TransactionWitnessSet.TransactionWitnessSet( + { + ...oldWs, + vkeyWitnesses: [...(oldWs.vkeyWitnesses ?? []), ...witnesses] + }, + { disableValidation: true } + ) + return new Transaction( + { body: tx.body, witnessSet: newWs, isValid: tx.isValid, auxiliaryData: tx.auxiliaryData }, + { disableValidation: true } + ) +} + // ============================================================================ // Arbitrary (FastCheck) // ============================================================================ diff --git a/packages/evolution/src/TransactionBody.ts b/packages/evolution/src/TransactionBody.ts index 3a21beec..def47495 100644 --- a/packages/evolution/src/TransactionBody.ts +++ b/packages/evolution/src/TransactionBody.ts @@ -199,10 +199,9 @@ const decodeInputs = ParseResult.decodeUnknownEither(CBOR.tag(258, Schema.Array( * @since 2.0.0 * @category schemas */ -export const CDDLSchema = Schema.MapFromSelf({ - key: CBOR.Integer, - value: CBOR.CBORSchema -}) +export const CDDLSchema = Schema.declare( + (input: unknown): input is Map => input instanceof Map +).annotations({ identifier: "TransactionBody.CDDLSchema" }) type CDDLSchema = typeof CDDLSchema.Type @@ -449,7 +448,7 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra const currentTreasuryValue = fromA.get(21n) as Coin.Coin | undefined const donation = fromA.get(22n) as Coin.Coin | undefined - return new TransactionBody( + const result = new TransactionBody( { inputs, outputs, @@ -474,6 +473,7 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra }, { disableValidation: true } ) + return result }) }) @@ -543,6 +543,62 @@ export const toCBORBytes = (data: TransactionBody, options: CBOR.CodecOptions = export const toCBORHex = (data: TransactionBody, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORHex(options))(data) +/** + * Parse a TransactionBody from CBOR bytes and return the root format tree. + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORBytesWithFormat = ( + bytes: Uint8Array +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORBytesWithFormat(bytes) + const value = Schema.decodeSync(FromCDDL)(decoded.value as Map) + return { value, format: decoded.format } +} + +/** + * Parse a TransactionBody from CBOR hex string and return the root format tree. + * + * @since 2.0.0 + * @category conversion + */ +export const fromCBORHexWithFormat = ( + hex: string +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORHexWithFormat(hex) + const value = Schema.decodeSync(FromCDDL)(decoded.value as Map) + return { value, format: decoded.format } +} + +/** + * Convert a TransactionBody to CBOR bytes using an explicit root format tree. + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORBytesWithFormat = ( + data: TransactionBody, + format: CBOR.CBORFormat +): Uint8Array => { + const cborMap = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORBytesWithFormat(cborMap, format) +} + +/** + * Convert a TransactionBody to CBOR hex string using an explicit root format tree. + * + * @since 2.0.0 + * @category conversion + */ +export const toCBORHexWithFormat = ( + data: TransactionBody, + format: CBOR.CBORFormat +): string => { + const cborMap = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORHexWithFormat(cborMap, format) +} + // ============================================================================ // FastCheck Arbitrary // ============================================================================ diff --git a/packages/evolution/src/TransactionMetadatum.ts b/packages/evolution/src/TransactionMetadatum.ts index 87b7f1f8..3462c65a 100644 --- a/packages/evolution/src/TransactionMetadatum.ts +++ b/packages/evolution/src/TransactionMetadatum.ts @@ -198,51 +198,16 @@ export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTION // ============================================================================ /** - * Check if two TransactionMetadatum instances are equal. + * Schema-derived structural equality for TransactionMetadatum values. + * Handles maps, lists, ints, bytes, and text via the + * recursive TransactionMetadatumSchema definition — no hand-rolled comparison needed. * * @since 2.0.0 - * @category utilities + * @category equality */ -export const equals = (a: TransactionMetadatum, b: TransactionMetadatum): boolean => { - // String comparison - if (typeof a === "string" && typeof b === "string") { - return a === b - } - - // BigInt comparison - if (typeof a === "bigint" && typeof b === "bigint") { - return a === b - } - - // Uint8Array comparison - if (a instanceof Uint8Array && b instanceof Uint8Array) { - return a.length === b.length && a.every((byte, i) => byte === b[i]) - } - - // Array comparison - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((item, i) => equals(item, b[i])) - } - - // Map comparison - if (a instanceof globalThis.Map && b instanceof globalThis.Map) { - if (a.size !== b.size) return false - for (const [key, value] of a.entries()) { - let found = false - for (const [bKey, bVal] of b.entries()) { - if (equals(key, bKey)) { - if (!equals(value, bVal)) return false - found = true - break - } - } - if (!found) return false - } - return true - } - - return false -} +export const equals: (a: TransactionMetadatum, b: TransactionMetadatum) => boolean = Schema.equivalence( + TransactionMetadatumSchema +) /** * FastCheck arbitrary for generating random TransactionMetadatum instances. diff --git a/packages/evolution/src/TransactionWitnessSet.ts b/packages/evolution/src/TransactionWitnessSet.ts index da3f21ff..201afc25 100644 --- a/packages/evolution/src/TransactionWitnessSet.ts +++ b/packages/evolution/src/TransactionWitnessSet.ts @@ -183,7 +183,7 @@ export class TransactionWitnessSet extends Schema.Class(" bootstrapWitnesses: Schema.optional(Schema.Array(Bootstrap.BootstrapWitness)), plutusV1Scripts: Schema.optional(Schema.Array(PlutusV1.PlutusV1)), plutusData: Schema.optional(Schema.Array(PlutusData.DataSchema)), - redeemers: Schema.optional(Schema.Array(Redeemer.Redeemer)), + redeemers: Schema.optional(Schema.typeSchema(Redeemers.Redeemers)), plutusV2Scripts: Schema.optional(Schema.Array(PlutusV2.PlutusV2)), plutusV3Scripts: Schema.optional(Schema.Array(PlutusV3.PlutusV3)) }) { @@ -199,7 +199,7 @@ export class TransactionWitnessSet extends Schema.Class(" bootstrapWitnesses: this.bootstrapWitnesses?.map((b) => b.toJSON()), plutusV1Scripts: this.plutusV1Scripts, plutusData: this.plutusData, - redeemers: this.redeemers?.map((r) => r.toJSON()), + redeemers: this.redeemers?.toJSON(), plutusV2Scripts: this.plutusV2Scripts, plutusV3Scripts: this.plutusV3Scripts } @@ -233,7 +233,7 @@ export class TransactionWitnessSet extends Schema.Class(" arrayEquals(this.bootstrapWitnesses, that.bootstrapWitnesses) && arrayEquals(this.plutusV1Scripts, that.plutusV1Scripts) && plutusDataArrayEquals(this.plutusData, that.plutusData) && - arrayEquals(this.redeemers, that.redeemers) && + Equal.equals(this.redeemers, that.redeemers) && arrayEquals(this.plutusV2Scripts, that.plutusV2Scripts) && arrayEquals(this.plutusV3Scripts, that.plutusV3Scripts) ) @@ -256,7 +256,7 @@ export class TransactionWitnessSet extends Schema.Class(" ) )(arrayHash(this.plutusV1Scripts)) )(plutusDataArrayHash(this.plutusData)) - )(arrayHash(this.redeemers)) + )(Hash.hash(this.redeemers)) )(arrayHash(this.plutusV2Scripts)) )(arrayHash(this.plutusV3Scripts)) ) @@ -292,10 +292,9 @@ export class TransactionWitnessSet extends Schema.Class(" * @since 2.0.0 * @category schemas */ -export const CDDLSchema = Schema.MapFromSelf({ - key: CBOR.Integer, - value: CBOR.CBORSchema -}) +export const CDDLSchema = Schema.declare( + (input: unknown): input is Map => input instanceof Map +).annotations({ identifier: "TransactionWitnessSet.CDDLSchema" }) /** * CDDL transformation schema for TransactionWitnessSet. @@ -358,19 +357,19 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra record.set(4n, CBOR.Tag.make({ tag: 258, value: plutusDataCBOR })) } - // 5: redeemers — respect the format that was decoded (map or array) - if (toA.redeemers && toA.redeemers.length > 0) { - const redeemersCollection = new Redeemers.Redeemers({ values: [...toA.redeemers] }) - const format = (toA as any)._redeemersFormat as Redeemers.Format | undefined - if (format === "map") { - // FromMapCDDL.encode produces array-of-pairs (Schema.Map encoded form); - // convert to JS Map so the CBOR encoder writes a major-type-5 map. - const redeemersEncoded = yield* ParseResult.encode(Redeemers.FromMapCDDL)(redeemersCollection) - const redeemersMap = new Map(redeemersEncoded as Iterable) - record.set(5n, redeemersMap) - } else { - const redeemersEncoded = yield* ParseResult.encode(Redeemers.FromArrayCDDL)(redeemersCollection) - record.set(5n, redeemersEncoded) + // 5: redeemers — format determined by the discriminated union _tag + if (toA.redeemers && toA.redeemers.size > 0) { + switch (toA.redeemers._tag) { + case "RedeemerMap": { + const encoded = yield* ParseResult.encode(Redeemers.FromMapCDDL)(toA.redeemers) + record.set(5n, new Map(encoded as Iterable)) + break + } + case "RedeemerArray": { + const encoded = yield* ParseResult.encode(Redeemers.FromArrayCDDL)(toA.redeemers) + record.set(5n, encoded) + break + } } } @@ -398,7 +397,7 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra bootstrapWitnesses?: Array plutusV1Scripts?: Array plutusData?: Array - redeemers?: Array + redeemers?: Redeemers.Redeemers plutusV2Scripts?: Array plutusV3Scripts?: Array } = {} @@ -473,18 +472,15 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra // 5: redeemers — Conway CDDL supports both array and map formats: // redeemers = [ + redeemer ] / { + [tag, index] => [data, ex_units] } - let redeemersFormat: Redeemers.Format | undefined const redeemersRaw = fromA.get(5n) if (redeemersRaw !== undefined) { if (redeemersRaw instanceof Map) { // Map format (Conway recommended) - // Schema.Map expects array-of-pairs as encoded input, so convert Map → entries array - const entries = Array.from((redeemersRaw as Map).entries()) + // MapCDDLSchema uses MapFromSelf so it expects a JS Map directly const redeemersCollection = yield* ParseResult.decode(Redeemers.FromMapCDDL)( - entries as unknown as Schema.Schema.Encoded + redeemersRaw as unknown as Schema.Schema.Encoded ) - witnessSet.redeemers = [...redeemersCollection.values] - redeemersFormat = "map" + witnessSet.redeemers = redeemersCollection } else { // Array format (legacy, or tag-258 wrapped) const asRedeemersArray = ( @@ -504,8 +500,7 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra const redeemersArray = asRedeemersArray(redeemersRaw) if (redeemersArray !== undefined) { const redeemersCollection = yield* ParseResult.decode(Redeemers.FromArrayCDDL)(redeemersArray) - witnessSet.redeemers = [...redeemersCollection.values] - redeemersFormat = "array" + witnessSet.redeemers = redeemersCollection } } } @@ -525,11 +520,7 @@ export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(Tra } // Build the class instance directly to allow fully empty witness sets - const result = new TransactionWitnessSet(witnessSet, { disableValidation: true }) - if (redeemersFormat !== undefined) { - ;(result as any)._redeemersFormat = redeemersFormat - } - return result + return new TransactionWitnessSet(witnessSet, { disableValidation: true }) }) }) @@ -570,7 +561,7 @@ export const arbitrary: FastCheck.Arbitrary = FastCheck.r ), plutusData: FastCheck.option(FastCheck.array(PlutusData.arbitrary)), redeemers: FastCheck.option( - FastCheck.array( + FastCheck.uniqueArray( FastCheck.record({ data: PlutusData.arbitrary, exUnits: FastCheck.tuple( @@ -579,7 +570,17 @@ export const arbitrary: FastCheck.Arbitrary = FastCheck.r ).map(([mem, steps]) => new Redeemer.ExUnits({ mem, steps })), index: FastCheck.bigInt({ min: 0n, max: 1000n }), tag: FastCheck.constantFrom("spend" as const, "mint" as const, "cert" as const, "reward" as const) - }).map(({ data, exUnits, index, tag }) => new Redeemer.Redeemer({ tag, index, data, exUnits })) + }).map(({ data, exUnits, index, tag }) => new Redeemer.Redeemer({ tag, index, data, exUnits })), + { + minLength: 1, + maxLength: 5, + selector: (r) => `${r.tag}:${r.index}` + } + ).chain((redeemers) => + FastCheck.constantFrom( + Redeemers.makeRedeemerMap(redeemers), + new Redeemers.RedeemerArray({ value: redeemers }) + ) ) ), plutusV2Scripts: FastCheck.option( @@ -611,6 +612,24 @@ export const arbitrary: FastCheck.Arbitrary = FastCheck.r export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.decodeSync(FromCBORBytes(options))(bytes) +/** + * Parse a TransactionWitnessSet from CBOR bytes and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORBytesWithFormat = ( + bytes: Uint8Array +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORBytesWithFormat(bytes) + const value = Schema.decodeSync(FromCDDL)(decoded.value as Map) + + return { + value, + format: decoded.format + } +} + /** * Parse a TransactionWitnessSet from CBOR hex string. * @@ -620,6 +639,24 @@ export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = CB export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.decodeSync(FromCBORHex(options))(hex) +/** + * Parse a TransactionWitnessSet from CBOR hex string and return the root format tree. + * + * @since 2.0.0 + * @category parsing + */ +export const fromCBORHexWithFormat = ( + hex: string +): CBOR.DecodedWithFormat => { + const decoded = CBOR.fromCBORHexWithFormat(hex) + const value = Schema.decodeSync(FromCDDL)(decoded.value as Map) + + return { + value, + format: decoded.format + } +} + // ============================================================================ // Encoding Functions // ============================================================================ @@ -633,6 +670,20 @@ export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_D export const toCBORBytes = (data: TransactionWitnessSet, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORBytes(options))(data) +/** + * Convert a TransactionWitnessSet to CBOR bytes using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORBytesWithFormat = ( + data: TransactionWitnessSet, + format: CBOR.CBORFormat +): Uint8Array => { + const cborMap = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORBytesWithFormat(cborMap, format) +} + /** * Convert a TransactionWitnessSet to CBOR hex string. * @@ -642,6 +693,20 @@ export const toCBORBytes = (data: TransactionWitnessSet, options: CBOR.CodecOpti export const toCBORHex = (data: TransactionWitnessSet, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => Schema.encodeSync(FromCBORHex(options))(data) +/** + * Convert a TransactionWitnessSet to CBOR hex string using an explicit root format tree. + * + * @since 2.0.0 + * @category encoding + */ +export const toCBORHexWithFormat = ( + data: TransactionWitnessSet, + format: CBOR.CBORFormat +): string => { + const cborMap = Schema.encodeSync(FromCDDL)(data) + return CBOR.toCBORHexWithFormat(cborMap, format) +} + // ============================================================================ // Factory Functions // ============================================================================ diff --git a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts index 03e3b4a6..79ad0587 100644 --- a/packages/evolution/src/sdk/builders/SignBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/SignBuilderImpl.ts @@ -230,7 +230,7 @@ export const makeSignBuilder = (params: { plutusV2Scripts: [...(acc.plutusV2Scripts ?? []), ...(ws.plutusV2Scripts ?? [])], plutusV3Scripts: [...(acc.plutusV3Scripts ?? []), ...(ws.plutusV3Scripts ?? [])], plutusData: [...(acc.plutusData ?? []), ...(ws.plutusData ?? [])], - redeemers: [...(acc.redeemers ?? [])] + redeemers: acc.redeemers }), // Start from transaction's witness set (NOT empty) to preserve attached scripts new TransactionWitnessSet.TransactionWitnessSet({ @@ -241,7 +241,7 @@ export const makeSignBuilder = (params: { plutusV2Scripts: transaction.witnessSet.plutusV2Scripts ?? [], plutusV3Scripts: transaction.witnessSet.plutusV3Scripts ?? [], plutusData: transaction.witnessSet.plutusData ?? [], - redeemers: transaction.witnessSet.redeemers ?? [] + redeemers: transaction.witnessSet.redeemers }) ) diff --git a/packages/evolution/src/sdk/builders/TransactionBuilder.ts b/packages/evolution/src/sdk/builders/TransactionBuilder.ts index 92f51627..4b20f9cc 100644 --- a/packages/evolution/src/sdk/builders/TransactionBuilder.ts +++ b/packages/evolution/src/sdk/builders/TransactionBuilder.ts @@ -1059,12 +1059,9 @@ export interface BuildOptions { /** * Format for encoding redeemers in the script data hash. * - * - `"array"` (DEFAULT): Conway-era format, redeemers encoded as array - * - `"map"`: Babbage-era format, redeemers encoded as map + * @deprecated Redeemer format is now determined by the concrete `Redeemers` type + * (`RedeemerMap` or `RedeemerArray`). This option is ignored. * - * Use `"map"` for Babbage compatibility or debugging. - * - * @default "array" * @since 2.0.0 */ readonly scriptDataFormat?: "array" | "map" diff --git a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts index 29270220..a47af4c8 100644 --- a/packages/evolution/src/sdk/builders/TxBuilderImpl.ts +++ b/packages/evolution/src/sdk/builders/TxBuilderImpl.ts @@ -18,6 +18,7 @@ import type * as PlutusV2 from "../../PlutusV2.js" import type * as PlutusV3 from "../../PlutusV3.js" import * as PolicyId from "../../PolicyId.js" import * as Redeemer from "../../Redeemer.js" +import * as Redeemers from "../../Redeemers.js" import type * as RewardAccount from "../../RewardAccount.js" import * as CoreScript from "../../Script.js" import * as ScriptDataHash from "../../ScriptDataHash.js" @@ -665,6 +666,7 @@ export const assembleTransaction = ( // Compute scriptDataHash if there are Plutus scripts (redeemers present) let scriptDataHash: ReturnType | undefined + let redeemersConcrete: Redeemers.RedeemerMap | undefined if (redeemers.length > 0) { // Get config to access provider for full protocol parameters const config = yield* TxBuilderConfigTag @@ -751,16 +753,15 @@ export const assembleTransaction = ( }) // Compute the hash of script data (redeemers + optional datums + cost models) - const buildOpts = yield* BuildOptionsTag - const scriptDataFmt = buildOpts.scriptDataFormat ?? "array" + // Use the same concrete Redeemers type that goes into the witness set + redeemersConcrete = Redeemers.makeRedeemerMap(redeemers) scriptDataHash = hashScriptData( - redeemers, + redeemersConcrete, costModels, - plutusDataArray.length > 0 ? plutusDataArray : undefined, - scriptDataFmt + plutusDataArray.length > 0 ? plutusDataArray : undefined ) yield* Effect.logDebug( - `[Assembly] Computed scriptDataHash (format=${scriptDataFmt}): ${scriptDataHash.hash.toString()}` + `[Assembly] Computed scriptDataHash: ${scriptDataHash.hash.toString()}` ) } @@ -845,7 +846,7 @@ export const assembleTransaction = ( bootstrapWitnesses: [], plutusV1Scripts, plutusData: plutusDataArray, - redeemers, + redeemers: redeemers.length > 0 ? redeemersConcrete : undefined, plutusV2Scripts, plutusV3Scripts }) @@ -1152,16 +1153,17 @@ export const buildFakeWitnessSet = ( // Build fake redeemers from state.redeemers for accurate size estimation // Redeemers contribute to transaction size and must be included in fee calculation const fakeRedeemers: Array = [] + let fakeIndex = 0n for (const [_key, redeemerData] of state.redeemers) { // Use placeholder exUnits if not yet evaluated (will be updated after UPLC evaluation) const exUnits = redeemerData.exUnits ?? { mem: 0n, steps: 0n } - // Create a redeemer with index 0 - the actual index will be computed in assembly - // For fee calculation, we just need accurate CBOR size estimation + // Use unique placeholder indices — actual indices will be computed in assembly. + // For fee calculation, we just need accurate CBOR size estimation. fakeRedeemers.push( new Redeemer.Redeemer({ tag: redeemerData.tag, - index: 0n, // Placeholder, will be set correctly in assembly + index: fakeIndex++, // Unique placeholder, will be set correctly in assembly data: redeemerData.data, exUnits: new Redeemer.ExUnits({ mem: exUnits.mem, steps: exUnits.steps }) }) @@ -1174,7 +1176,7 @@ export const buildFakeWitnessSet = ( bootstrapWitnesses: [], plutusV1Scripts, plutusData: [], - redeemers: fakeRedeemers, + redeemers: fakeRedeemers.length > 0 ? Redeemers.makeRedeemerMap(fakeRedeemers) : undefined, plutusV2Scripts, plutusV3Scripts }) diff --git a/packages/evolution/src/utils/Hash.ts b/packages/evolution/src/utils/Hash.ts index f535ad59..fd188bea 100644 --- a/packages/evolution/src/utils/Hash.ts +++ b/packages/evolution/src/utils/Hash.ts @@ -122,27 +122,21 @@ const concatBytes = (...arrays: ReadonlyArray): Uint8Array => { return result } -/** - * Format for encoding redeemers in the script data hash. - * - * - "array": Legacy format `[ + redeemer ]` (Shelley-Babbage) - * - "map": Conway format `{ + [tag, index] => [data, ex_units] }` - */ -export type RedeemersFormat = "array" | "map" - /** * Compute script_data_hash using standard module encoders. * + * Accepts the concrete `Redeemers` union type — encoding format is determined + * by `_tag` (`RedeemerMap` → map CBOR, `RedeemerArray` → array CBOR). + * * The payload format per CDDL spec is raw concatenation (not a CBOR structure): * ``` * redeemers_bytes || datums_bytes || language_views_bytes * ``` */ export const hashScriptData = ( - redeemers: ReadonlyArray, + redeemers: Redeemers.Redeemers, costModels: CostModel.CostModels, datums?: ReadonlyArray, - format: RedeemersFormat = "array", options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS ): ScriptDataHash.ScriptDataHash => { const hasDatums = Array.isArray(datums) && datums.length > 0 @@ -152,7 +146,7 @@ export const hashScriptData = ( let payload: Uint8Array - if (hasDatums && redeemers.length === 0) { + if (hasDatums && redeemers.size === 0) { // Special case (CDDL): [ A0 | tag(258) datums | A0 ] const datumsBytes = encodeDatumsTaggedSet(datums) payload = concatBytes( @@ -161,12 +155,11 @@ export const hashScriptData = ( new Uint8Array([0xa0]) // Empty map ) } else { - // Normal case: [ redeemers | datums | language_views ] - const redeemersCollection = new Redeemers.Redeemers({ values: [...redeemers] }) + // Encode redeemers based on concrete type const redeemersBytes = - format === "map" - ? Redeemers.toCBORBytesMap(redeemersCollection, options) - : Redeemers.toCBORBytes(redeemersCollection, options) + redeemers._tag === "RedeemerMap" + ? Redeemers.toCBORBytesMap(redeemers, options) + : Redeemers.toCBORBytes(redeemers, options) const datumsBytes = hasDatums ? encodeDatumsTaggedSet(datums) : undefined payload = datumsBytes diff --git a/packages/evolution/test/CBOR-with-format.test.ts b/packages/evolution/test/CBOR-with-format.test.ts new file mode 100644 index 00000000..a37fe75a --- /dev/null +++ b/packages/evolution/test/CBOR-with-format.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest" + +import * as CBOR from "../src/CBOR.js" + +// --------------------------------------------------------------------------- +// Encoding vectors — every non-canonical encoding the CBOR layer can capture +// --------------------------------------------------------------------------- + +const ENCODING_VECTORS = [ + { name: "uint non-minimal width (0 encoded as uint8)", hex: "1800" }, + { name: "nint non-minimal width (-1 encoded as uint8)", hex: "3800" }, + { name: "bytes indefinite chunks", hex: "5f4201024103ff" }, + { name: "text indefinite chunks", hex: "7f6161626263ff" }, + { name: "array indefinite length", hex: "9f0102ff" }, + { name: "map indefinite length", hex: "bf01020304ff" }, + { name: "tag non-minimal width (tag 24 as uint8)", hex: "d81800" }, + { name: "map non-canonical key order", hex: "a2036163016161" }, + { name: "nested mixed encodings", hex: "a205a182000082d8798082186418c803d901028143010203" } +] as const + +describe("CBOR WithFormat — round-trips (hex)", () => { + it.each(ENCODING_VECTORS)("hex: $name", ({ hex }) => { + const { format, value } = CBOR.fromCBORHexWithFormat(hex) + expect(CBOR.toCBORHexWithFormat(value, format)).toBe(hex) + }) +}) + +describe("CBOR WithFormat — round-trips (bytes)", () => { + it.each(ENCODING_VECTORS)("bytes: $name", ({ hex }) => { + const bytes = Buffer.from(hex, "hex") + const { format, value } = CBOR.fromCBORBytesWithFormat(new Uint8Array(bytes)) + const reencoded = CBOR.toCBORBytesWithFormat(value, format) + expect(Buffer.from(reencoded).toString("hex")).toBe(hex) + }) +}) + +describe("CBOR WithFormat — format tree is the complete specification", () => { + it("WithFormat always applies regardless of what plain encode would produce", () => { + const hex = "1800" // non-canonical: 0 encoded as uint8 + + const { format, value } = CBOR.fromCBORHexWithFormat(hex) + + // WithFormat always preserves — no options can override it + expect(CBOR.toCBORHexWithFormat(value, format)).toBe(hex) + + // Plain encode normalises (format tree is ignored, as expected) + expect(CBOR.toCBORHex(value, CBOR.CANONICAL_OPTIONS)).not.toBe(hex) + expect(CBOR.toCBORHex(value, CBOR.CML_DEFAULT_OPTIONS)).not.toBe(hex) + }) + + it("WithFormat is independent of plain decode (plain decode never captures format)", () => { + const hex = "a218186162016161" // map with non-canonical key order + const { format, value: withFmtValue } = CBOR.fromCBORHexWithFormat(hex) + const plainValue = CBOR.fromCBORHex(hex) + + // WithFormat round-trip preserves byte-exact hex + expect(CBOR.toCBORHexWithFormat(withFmtValue, format)).toBe(hex) + + // Plain round-trip canonicalises + expect(CBOR.toCBORHex(plainValue, CBOR.CANONICAL_OPTIONS)).not.toBe(hex) + }) +}) + +describe("CBOR WithFormat — hand-crafted format injection", () => { + it("explicit uint format with byteSize:1 forces non-minimal encoding", () => { + const fmt: CBOR.CBORFormat = { _tag: "uint", byteSize: 1 } + // 0n would normally encode as 0x00; byteSize:1 forces 0x1800 + expect(CBOR.toCBORHexWithFormat(0n, fmt)).toBe("1800") + }) + + it("explicit nint format with byteSize:1 forces non-minimal encoding", () => { + const fmt: CBOR.CBORFormat = { _tag: "nint", byteSize: 1 } + // -1n would normally encode as 0x20; byteSize:1 forces 0x3800 + expect(CBOR.toCBORHexWithFormat(-1n, fmt)).toBe("3800") + }) + + it("explicit array format with indefinite length produces indefinite encoding", () => { + const fmt: CBOR.CBORFormat = { + _tag: "array", + length: { tag: "indefinite" }, + children: [{ _tag: "uint" }, { _tag: "uint" }] + } + // Definite [1, 2] = 0x820102; indefinite = 0x9f0102ff + expect(CBOR.toCBORHexWithFormat([1n, 2n], fmt)).toBe("9f0102ff") + }) + + it("captured format round-trips correctly for map with non-canonical key order", () => { + // key 3 (0x03) before key 1 (0x01) — non-canonical order + const hex = "a2036163016161" + const { format, value } = CBOR.fromCBORHexWithFormat(hex) + + // Captured format preserves the key order + expect(CBOR.toCBORHexWithFormat(value, format)).toBe(hex) + + // Plain encode (no format) preserves JS Map insertion order — key 3 first + expect(CBOR.toCBORHex(value)).toBe(hex) + }) +}) diff --git a/packages/evolution/test/Transaction-byte-splice.test.ts b/packages/evolution/test/Transaction-byte-splice.test.ts new file mode 100644 index 00000000..c65b3e22 --- /dev/null +++ b/packages/evolution/test/Transaction-byte-splice.test.ts @@ -0,0 +1,183 @@ +import { FastCheck } from "effect" +import { describe, expect, it } from "vitest" + +import * as CBOR from "../src/CBOR.js" +import * as PlutusData from "../src/Data.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" + +// --------------------------------------------------------------------------- +// addVKeyWitnessesBytes — byte-level witness merging +// +// Operates directly on raw CBOR bytes. Only the vkey witnesses value (key 0) +// in the witness set map is modified. Everything else — body, redeemers, +// datums, scripts, isValid, auxData, map entry ordering — is preserved +// byte-for-byte. +// --------------------------------------------------------------------------- + +const buildWalletWitnessBytes = (): Uint8Array => { + const wsMap = new Map() + wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[new Uint8Array(32).fill(0xaa), new Uint8Array(64).fill(0xbb)]] })) + return CBOR.toCBORBytes(wsMap) +} + +describe("addVKeyWitnessesBytes", () => { + it("preserves body and tail bytes — only vkey witnesses value changes", () => { + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const txBytes = Transaction.toCBORBytes(sampleTx) + const txHex = Buffer.from(txBytes).toString("hex") + + const walletWsBytes = buildWalletWitnessBytes() + const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, walletWsBytes) + const signedHex = Buffer.from(signedBytes).toString("hex") + + const hdr = (txBytes[0] & 0x1f) < 24 ? 1 : 2 + const { newOffset: bodyEnd } = CBOR.decodeItemWithOffset(txBytes, hdr) + expect(signedHex.slice(hdr * 2, bodyEnd * 2)).toBe(txHex.slice(hdr * 2, bodyEnd * 2)) + + const { newOffset: wsEnd } = CBOR.decodeItemWithOffset(txBytes, bodyEnd) + const hdr2 = (signedBytes[0] & 0x1f) < 24 ? 1 : 2 + const { newOffset: bodyEnd2 } = CBOR.decodeItemWithOffset(signedBytes, hdr2) + const { newOffset: wsEnd2 } = CBOR.decodeItemWithOffset(signedBytes, bodyEnd2) + // tail bytes (isValid + auxData) must be identical in both + expect(signedHex.slice(wsEnd2 * 2)).toBe(txHex.slice(wsEnd * 2)) + }) + + it("preserves non-canonical body encoding (txId stable)", () => { + // fee=0 encoded as 0x1800 (non-canonical) + const nonCanonicalHex = "84a300d90102800180021800a0f5f6" + const txBytes = Buffer.from(nonCanonicalHex, "hex") + const signedBytes = Transaction.addVKeyWitnessesBytes(new Uint8Array(txBytes), buildWalletWitnessBytes()) + expect(Buffer.from(signedBytes).toString("hex")).toContain("a300d90102800180021800") + }) + + it("preserves redeemers bytes verbatim (scriptDataHash stable)", () => { + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) + + const constrData = PlutusData.constr(0n, []) + const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) + const redeemersMap = new Map() + redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) + const witnessMap = new Map() + witnessMap.set(5n, redeemersMap) + const witnessBytes = CBOR.toCBORBytes(witnessMap) + + const wsParsed = CBOR.fromCBORBytes(witnessBytes) as Map + const originalRedeemersHex = Buffer.from(CBOR.toCBORBytes(wsParsed.get(5n)!)).toString("hex") + + const txBytes = CBOR.encodeArrayAsDefinite([ + bodyBytes, witnessBytes, CBOR.internalEncodeSync(true), CBOR.internalEncodeSync(null) + ]) + + const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, buildWalletWitnessBytes()) + const signedWsMap = (CBOR.fromCBORBytes(signedBytes) as Array)[1] as Map + + expect(signedWsMap.get(5n)).toBeInstanceOf(Map) + expect(Buffer.from(CBOR.toCBORBytes(signedWsMap.get(5n)!)).toString("hex")).toBe(originalRedeemersHex) + expect(signedWsMap.get(0n)).toBeDefined() + }) + + it("preserves map entry ordering — new key 0 appended at end", () => { + const wsMap = new Map() + wsMap.set(3n, CBOR.Tag.make({ tag: 258, value: [new Uint8Array([1, 2, 3])] })) + const constrData = PlutusData.constr(0n, []) + const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) + const redeemersMap = new Map() + redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) + wsMap.set(5n, redeemersMap) + const wsBytes = CBOR.toCBORBytes(wsMap) + + const { count, hdrSize } = readMapHeader(wsBytes) + let off = hdrSize + const entryHexes: Array = [] + for (let i = 0; i < count; i++) { + const start = off + const { newOffset: kEnd } = CBOR.decodeItemWithOffset(wsBytes, off) + const { newOffset: vEnd } = CBOR.decodeItemWithOffset(wsBytes, kEnd) + entryHexes.push(Buffer.from(wsBytes.slice(start, vEnd)).toString("hex")) + off = vEnd + } + + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const txBytes = CBOR.encodeArrayAsDefinite([ + TransactionBody.toCBORBytes(sampleTx.body), + wsBytes, + CBOR.internalEncodeSync(true), + CBOR.internalEncodeSync(null) + ]) + + const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, buildWalletWitnessBytes()) + const signedHex = Buffer.from(signedBytes).toString("hex") + + for (const entry of entryHexes) { + expect(signedHex).toContain(entry) + } + + const signedWsMap = (CBOR.fromCBORBytes(signedBytes) as Array)[1] as Map + expect([...signedWsMap.keys()]).toContain(0n) + expect([...signedWsMap.keys()]).toContain(3n) + expect([...signedWsMap.keys()]).toContain(5n) + }) + + it("splices in-place when key 0 already exists — merges witness arrays", () => { + const wsMap = new Map() + wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[new Uint8Array(32).fill(0x11), new Uint8Array(64).fill(0x22)]] })) + const constrData = PlutusData.constr(0n, []) + const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) + const redeemersMap = new Map() + redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [50n, 100n]] as unknown as CBOR.CBOR) + wsMap.set(5n, redeemersMap) + const wsBytes = CBOR.toCBORBytes(wsMap) + + const { count, hdrSize } = readMapHeader(wsBytes) + let off = hdrSize + let redeemersEntryHex = "" + for (let i = 0; i < count; i++) { + const start = off + const { item: k, newOffset: kEnd } = CBOR.decodeItemWithOffset(wsBytes, off) + const { newOffset: vEnd } = CBOR.decodeItemWithOffset(wsBytes, kEnd) + if (k === 5n) redeemersEntryHex = Buffer.from(wsBytes.slice(start, vEnd)).toString("hex") + off = vEnd + } + + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const txBytes = CBOR.encodeArrayAsDefinite([ + TransactionBody.toCBORBytes(sampleTx.body), + wsBytes, + CBOR.internalEncodeSync(true), + CBOR.internalEncodeSync(null) + ]) + + const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, buildWalletWitnessBytes()) + const signedHex = Buffer.from(signedBytes).toString("hex") + + expect(signedHex).toContain(redeemersEntryHex) + + const signedWsMap = (CBOR.fromCBORBytes(signedBytes) as Array)[1] as Map + const vkeys = unwrapVkeyArray(signedWsMap.get(0n)) + expect(vkeys.length).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readMapHeader(data: Uint8Array): { count: number; hdrSize: number } { + const ai = data[0] & 0x1f + if (ai < 24) return { count: ai, hdrSize: 1 } + if (ai === 24) return { count: data[1], hdrSize: 2 } + if (ai === 25) return { count: (data[1] << 8) | data[2], hdrSize: 3 } + throw new Error(`Unsupported map header additionalInfo: ${ai}`) +} + +function unwrapVkeyArray(val: CBOR.CBOR | undefined): Array { + if (val === undefined) return [] + if (CBOR.isTag(val)) { + const tag = val as { _tag: "Tag"; tag: number; value: unknown } + if (tag.tag === 258 && Array.isArray(tag.value)) return tag.value as Array + } + if (Array.isArray(val)) return val as Array + return [] +} diff --git a/packages/evolution/test/Transaction-with-format.test.ts b/packages/evolution/test/Transaction-with-format.test.ts new file mode 100644 index 00000000..87d64b2d --- /dev/null +++ b/packages/evolution/test/Transaction-with-format.test.ts @@ -0,0 +1,119 @@ +import * as CML from "@dcspark/cardano-multiplatform-lib-nodejs" +import { FastCheck } from "effect" +import { describe, expect, it } from "vitest" + +import * as CBOR from "../src/CBOR.js" +import * as Ed25519Signature from "../src/Ed25519Signature.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as VKey from "../src/VKey.js" + +// fee=0 encoded as 0x1800 (non-canonical uint8) instead of canonical 0x00 +// full tx = 84 a0 f5 f6 +const NON_CANONICAL_TX_HEX = "84a300d90102800180021800a0f5f6" +const NON_CANONICAL_BODY_HEX = "a300d90102800180021800" + +const buildDummyVKeyWitness = (): TransactionWitnessSet.VKeyWitness => + new TransactionWitnessSet.VKeyWitness({ + vkey: VKey.fromBytes(new Uint8Array(32).fill(0xaa)), + signature: Ed25519Signature.fromBytes(new Uint8Array(64).fill(0xbb)) + }) + +// --------------------------------------------------------------------------- +// Non-canonical encoding preservation +// --------------------------------------------------------------------------- + +describe("Transaction WithFormat — non-canonical encoding", () => { + it("round-trips non-canonical transaction bytes exactly (hex)", () => { + const { format, value: tx } = Transaction.fromCBORHexWithFormat(NON_CANONICAL_TX_HEX) + expect(Transaction.toCBORHexWithFormat(tx, format)).toBe(NON_CANONICAL_TX_HEX) + }) + + it("round-trips non-canonical transaction bytes exactly (bytes)", () => { + const bytes = Buffer.from(NON_CANONICAL_TX_HEX, "hex") + const { format, value: tx } = Transaction.fromCBORBytesWithFormat(new Uint8Array(bytes)) + const reencoded = Transaction.toCBORBytesWithFormat(tx, format) + expect(Buffer.from(reencoded).toString("hex")).toBe(NON_CANONICAL_TX_HEX) + }) + + it("preserves non-canonical body bytes (txId stable) — verified via CML", () => { + const { format, value: tx } = Transaction.fromCBORHexWithFormat(NON_CANONICAL_TX_HEX) + const reEncoded = Transaction.toCBORHexWithFormat(tx, format) + + expect(reEncoded).toContain(NON_CANONICAL_BODY_HEX) + + const hashBefore = CML.hash_transaction(CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX).body()).to_hex() + const hashAfter = CML.hash_transaction(CML.Transaction.from_cbor_hex(reEncoded).body()).to_hex() + expect(hashAfter).toBe(hashBefore) + }) + + it("addVKeyWitnessesHex preserves non-canonical body (txId stable)", () => { + const walletWsHex = buildWalletWitnessHex() + const signedHex = Transaction.addVKeyWitnessesHex(NON_CANONICAL_TX_HEX, walletWsHex) + + expect(signedHex).toContain(NON_CANONICAL_BODY_HEX) + + const hashBefore = CML.hash_transaction(CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX).body()).to_hex() + const hashAfter = CML.hash_transaction(CML.Transaction.from_cbor_hex(signedHex).body()).to_hex() + expect(hashAfter).toBe(hashBefore) + }) +}) + +// --------------------------------------------------------------------------- +// Domain-level modification with WithFormat +// --------------------------------------------------------------------------- + +describe("Transaction WithFormat — add witnesses and re-encode", () => { + it("addVKeyWitnesses + toCBORHexWithFormat preserves non-canonical body encoding", () => { + const { format, value: decoded } = Transaction.fromCBORHexWithFormat(NON_CANONICAL_TX_HEX) + + const modifiedTx = Transaction.addVKeyWitnesses(decoded, [buildDummyVKeyWitness()]) + const modifiedHex = Transaction.toCBORHexWithFormat(modifiedTx, format) + + expect(modifiedHex).toContain(NON_CANONICAL_BODY_HEX) + }) +}) + +// --------------------------------------------------------------------------- +// Property: round-trip preserves body bytes regardless of encoding +// --------------------------------------------------------------------------- + +describe("Transaction WithFormat — property", () => { + it("round-trip preserves body bytes for any encoded transaction", () => { + FastCheck.assert( + FastCheck.property(Transaction.arbitrary, (tx) => { + const encoded = Transaction.toCBORBytes(tx, { + mode: "custom", + useIndefiniteArrays: true, + useIndefiniteMaps: true, + useDefiniteForEmpty: true, + sortMapKeys: false, + useMinimalEncoding: true, + mapsAsObjects: false + }) + + const { format, value: decoded } = Transaction.fromCBORBytesWithFormat(encoded) + const reEncoded = Transaction.toCBORBytesWithFormat(decoded, format) + + expect(bodyHex(reEncoded)).toBe(bodyHex(encoded)) + }), + { numRuns: 50 } + ) + }) +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bodyHex(tx: Uint8Array): string { + const hdr = (tx[0] & 0x1f) < 24 ? 1 : 2 + const { newOffset } = CBOR.decodeItemWithOffset(tx, hdr) + return Buffer.from(tx.slice(hdr, newOffset)).toString("hex") +} + +function buildWalletWitnessHex(): string { + const wsMap = new Map() + wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[new Uint8Array(32).fill(0xaa), new Uint8Array(64).fill(0xbb)]] })) + return Buffer.from(CBOR.toCBORBytes(wsMap)).toString("hex") +} diff --git a/packages/evolution/test/Transaction-witness-add.test.ts b/packages/evolution/test/Transaction-witness-add.test.ts new file mode 100644 index 00000000..3bc19bd1 --- /dev/null +++ b/packages/evolution/test/Transaction-witness-add.test.ts @@ -0,0 +1,85 @@ +import { FastCheck } from "effect" +import { describe, expect, it } from "vitest" + +import * as CBOR from "../src/CBOR.js" +import * as PlutusData from "../src/Data.js" +import * as Ed25519Signature from "../src/Ed25519Signature.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as VKey from "../src/VKey.js" + +// --------------------------------------------------------------------------- +// Domain-level witness addition +// +// Tests that adding vkey witnesses at the domain level (via Transaction.addVKeyWitnesses) +// preserves body bytes so txId remains stable. +// --------------------------------------------------------------------------- + +const buildDummyVKeyWitness = (): TransactionWitnessSet.VKeyWitness => + new TransactionWitnessSet.VKeyWitness({ + vkey: VKey.fromBytes(new Uint8Array(32).fill(0xaa)), + signature: Ed25519Signature.fromBytes(new Uint8Array(64).fill(0xbb)) + }) + +describe("Transaction.addVKeyWitnesses — body byte stability", () => { + it("preserves body bytes when adding vkey witnesses at domain level", () => { + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const originalBytes = Transaction.toCBORBytes(sampleTx) + const originalBodyHex = bodyHex(originalBytes) + + const modifiedTx = Transaction.addVKeyWitnesses(Transaction.fromCBORBytes(originalBytes), [buildDummyVKeyWitness()]) + expect(bodyHex(Transaction.toCBORBytes(modifiedTx))).toBe(originalBodyHex) + }) + + it("preserves body bytes — property test (50 random txs)", () => { + FastCheck.assert( + FastCheck.property(Transaction.arbitrary, (tx) => { + const originalBytes = Transaction.toCBORBytes(tx) + const originalBody = bodyHex(originalBytes) + + const modifiedTx = Transaction.addVKeyWitnesses(Transaction.fromCBORBytes(originalBytes), [buildDummyVKeyWitness()]) + expect(bodyHex(Transaction.toCBORBytes(modifiedTx))).toBe(originalBody) + }), + { numRuns: 50 } + ) + }) + + it("preserves redeemers bytes when adding vkeys at domain level", () => { + const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) + const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) + + const constrData = PlutusData.constr(0n, []) + const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) + const redeemersMap = new Map() + redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) + const witnessMap = new Map() + witnessMap.set(5n, redeemersMap) + const witnessBytes = CBOR.toCBORBytes(witnessMap) + + const wsParsed = CBOR.fromCBORBytes(witnessBytes) as Map + const originalRedeemersHex = Buffer.from(CBOR.toCBORBytes(wsParsed.get(5n)!)).toString("hex") + + const txBytes = CBOR.encodeArrayAsDefinite([ + bodyBytes, witnessBytes, CBOR.internalEncodeSync(true), CBOR.internalEncodeSync(null) + ]) + + const modifiedTx = Transaction.addVKeyWitnesses(Transaction.fromCBORBytes(txBytes), [buildDummyVKeyWitness()]) + const modifiedBytes = Transaction.toCBORBytes(modifiedTx) + + const signedWsMap = (CBOR.fromCBORBytes(modifiedBytes) as Array)[1] as Map + expect(signedWsMap.get(5n)).toBeInstanceOf(Map) + expect(Buffer.from(CBOR.toCBORBytes(signedWsMap.get(5n)!)).toString("hex")).toBe(originalRedeemersHex) + expect(signedWsMap.get(0n)).toBeDefined() + }) +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bodyHex(tx: Uint8Array): string { + const hdr = (tx[0] & 0x1f) < 24 ? 1 : 2 + const { newOffset } = CBOR.decodeItemWithOffset(tx, hdr) + return Buffer.from(tx.slice(hdr, newOffset)).toString("hex") +} diff --git a/packages/evolution/test/Transaction.CML.test.ts b/packages/evolution/test/Transaction.CML.test.ts index 73fee79c..7ef4b638 100644 --- a/packages/evolution/test/Transaction.CML.test.ts +++ b/packages/evolution/test/Transaction.CML.test.ts @@ -2,14 +2,22 @@ import * as CML from "@dcspark/cardano-multiplatform-lib-nodejs" import { Equal, FastCheck } from "effect" import { describe, expect, it } from "vitest" +import * as CBOR from "../src/CBOR.js" import * as Transaction from "../src/Transaction.js" /** - * CML compatibility test for full Transaction CBOR serialization. + * CML compatibility tests for Transaction CBOR serialization. * - * Validates Evolution SDK Transaction CBOR matches CML's encoding and - * roundtrips through both libraries. + * Three concerns: + * 1. Evolution canonical encoding matches CML's canonical encoding. + * 2. CML baselines for non-canonical body byte preservation (Issue 1). + * 3. CML baselines for Conway map-format redeemer round-trips (Issue 2). */ + +// --------------------------------------------------------------------------- +// Canonical round-trip +// --------------------------------------------------------------------------- + describe("Transaction CML Compatibility", () => { it("property: Evolution Transaction CBOR equals CML and roundtrips", () => { FastCheck.assert( @@ -29,3 +37,247 @@ describe("Transaction CML Compatibility", () => { ) }) }) + +// --------------------------------------------------------------------------- +// Issue 1 — Body bytes must be preserved to keep txId stable +// --------------------------------------------------------------------------- + +describe("CML baseline: non-canonical body byte preservation", () => { + // Non-canonical CBOR: fee field (key 2) is 0 encoded as 0x1800 (2 bytes) + // instead of the canonical 0x00 (1 byte). + // body = a3 00 d9010280 01 80 02 1800 + // full tx = 84 a0 f5 f6 + const NON_CANONICAL_TX_HEX = "84a300d90102800180021800a0f5f6" + const BODY_HEX = "a300d90102800180021800" + + it("CML baseline: preserves non-canonical body bytes and txId on round-trip", () => { + const tx = CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX) + const reEncoded = tx.to_cbor_hex() + + // CML round-trip preserves the exact bytes (including non-canonical 1800) + expect(reEncoded).toBe(NON_CANONICAL_TX_HEX) + + // Hash is computed over raw body bytes, so it stays stable + const hashBefore = CML.hash_transaction(tx.body()).to_hex() + const tx2 = CML.Transaction.from_cbor_hex(reEncoded) + const hashAfter = CML.hash_transaction(tx2.body()).to_hex() + expect(hashAfter).toBe(hashBefore) + }) + + it("CML baseline: canonical encoding changes the hash (proving non-canonical matters)", () => { + const tx = CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX) + const canonicalHex = tx.to_canonical_cbor_hex() + + // Canonical normalises 0x1800 → 0x00, so the hex differs + expect(canonicalHex).not.toBe(NON_CANONICAL_TX_HEX) + + // And the txId differs + const hashOriginal = CML.hash_transaction( + CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX).body() + ).to_hex() + const hashCanonical = CML.hash_transaction( + CML.Transaction.from_cbor_hex(canonicalHex).body() + ).to_hex() + expect(hashCanonical).not.toBe(hashOriginal) + }) + + it("addVKeyWitnessesHex preserves non-canonical body — matching CML behavior", () => { + const walletWsHex = buildDummyWalletWitnessHex() + + // Byte-level merge + const signedHex = Transaction.addVKeyWitnessesHex(NON_CANONICAL_TX_HEX, walletWsHex) + + // The non-canonical body bytes are preserved verbatim + expect(signedHex).toContain(BODY_HEX) + + // CML confirms the txId is stable across both + const hashOriginal = CML.hash_transaction( + CML.Transaction.from_cbor_hex(NON_CANONICAL_TX_HEX).body() + ).to_hex() + const hashSigned = CML.hash_transaction( + CML.Transaction.from_cbor_hex(signedHex).body() + ).to_hex() + expect(hashSigned).toBe(hashOriginal) + }) +}) + +// --------------------------------------------------------------------------- +// Issue 2 — Conway map-format redeemers must survive decode→encode +// --------------------------------------------------------------------------- + +describe("CML baseline: Conway map-format redeemer round-trips", () => { + /** Build a full transaction hex with map-format redeemers using CML. */ + const buildCmlTxWithMapRedeemers = () => { + const body = CML.TransactionBody.from_cbor_hex("a300d901028001800200") + const exUnits = CML.ExUnits.new(100n, 200n) + const plutusData = CML.PlutusData.new_integer(CML.BigInteger.from_str("0")) + const redeemerKey = CML.RedeemerKey.new(CML.RedeemerTag.Spend, 0n) + const redeemerVal = CML.RedeemerVal.new(plutusData, exUnits) + const redeemerMap = CML.MapRedeemerKeyToRedeemerVal.new() + redeemerMap.insert(redeemerKey, redeemerVal) + const redeemers = CML.Redeemers.new_map_redeemer_key_to_redeemer_val(redeemerMap) + const ws = CML.TransactionWitnessSet.new() + ws.set_redeemers(redeemers) + return CML.Transaction.new(body, ws, true).to_cbor_hex() + } + + it("CML baseline: map-format redeemers round-trip perfectly", () => { + const txHex = buildCmlTxWithMapRedeemers() + const tx2 = CML.Transaction.from_cbor_hex(txHex) + const reEncoded = tx2.to_cbor_hex() + + // Byte-perfect round-trip + expect(reEncoded).toBe(txHex) + + // Redeemers are still map-format (major type 5 = 0xa_ prefix) + const redeemersHex = tx2.witness_set().redeemers().to_cbor_hex() + const majorType = (parseInt(redeemersHex.substring(0, 2), 16) >> 5) & 0x07 + expect(majorType).toBe(5) // CBOR map + }) + + it("evolution-sdk decodes CML-produced map-format redeemers (not silently dropped)", () => { + const txHex = buildCmlTxWithMapRedeemers() + + // Decode through evolution-sdk + const tx = Transaction.fromCBORHex(txHex) + + // Redeemers must exist and contain 1 entry + expect(tx.witnessSet.redeemers).toBeDefined() + expect(tx.witnessSet.redeemers!.size).toBe(1) + + // The redeemer must be a spend with index 0 + const r = tx.witnessSet.redeemers!.toArray()[0] + expect(r.tag).toBe("spend") + expect(r.index).toBe(0n) + expect(r.exUnits.mem).toBe(100n) + expect(r.exUnits.steps).toBe(200n) + }) + + it("evolution-sdk re-encodes map-format redeemers as a CBOR map (not array)", () => { + const txHex = buildCmlTxWithMapRedeemers() + + // Decode → re-encode via evolution-sdk + const tx = Transaction.fromCBORHex(txHex) + const reEncoded = Transaction.toCBORHex(tx) + + // Parse the re-encoded witness set at CBOR level + const wsBytes = extractWitnessSetBytes(reEncoded) + const wsMap = CBOR.fromCBORBytes(wsBytes) as Map + const redeemersRaw = wsMap.get(5n) + + // Must be a Map (CBOR major type 5), not an Array (major type 4) + expect(redeemersRaw).toBeInstanceOf(Map) + + // CML must also accept the re-encoded transaction and see map-format redeemers + const cmlTx = CML.Transaction.from_cbor_hex(reEncoded) + const cmlRedeemers = cmlTx.witness_set().redeemers() + expect(cmlRedeemers).toBeDefined() + const cmlRedeemersHex = cmlRedeemers.to_cbor_hex() + const majorType = (parseInt(cmlRedeemersHex.substring(0, 2), 16) >> 5) & 0x07 + expect(majorType).toBe(5) // still map-format + }) + + it("evolution-sdk still handles CML-produced array-format redeemers correctly", () => { + // Build a CML transaction with array (legacy) format redeemers + const body = CML.TransactionBody.from_cbor_hex("a300d901028001800200") + const exUnits = CML.ExUnits.new(100n, 200n) + const plutusData = CML.PlutusData.new_integer(CML.BigInteger.from_str("0")) + const legacyRedeemer = CML.LegacyRedeemer.new( + CML.RedeemerTag.Spend, + 0n, + plutusData, + exUnits + ) + const legacyList = CML.LegacyRedeemerList.new() + legacyList.add(legacyRedeemer) + const redeemers = CML.Redeemers.new_arr_legacy_redeemer(legacyList) + const ws = CML.TransactionWitnessSet.new() + ws.set_redeemers(redeemers) + const txHex = CML.Transaction.new(body, ws, true).to_cbor_hex() + + // Verify CML produced array format + const cmlRedeemersHex = redeemers.to_cbor_hex() + const majorType = (parseInt(cmlRedeemersHex.substring(0, 2), 16) >> 5) & 0x07 + expect(majorType).toBe(4) // CBOR array + + // evolution-sdk decodes it + const evoTx = Transaction.fromCBORHex(txHex) + expect(evoTx.witnessSet.redeemers).toBeDefined() + expect(evoTx.witnessSet.redeemers!.size).toBe(1) + expect(evoTx.witnessSet.redeemers!.toArray()[0].tag).toBe("spend") + + // Re-encode stays array format + const reEncoded = Transaction.toCBORHex(evoTx) + const wsBytes = extractWitnessSetBytes(reEncoded) + const wsMap = CBOR.fromCBORBytes(wsBytes) as Map + expect(Array.isArray(wsMap.get(5n))).toBe(true) + }) +}) + +// --------------------------------------------------------------------------- +// Combined: byte-level merge preserves map-format redeemers +// --------------------------------------------------------------------------- + +describe("Combined: addVKeyWitnessesHex preserves map-format redeemers (CML verified)", () => { + it("merges a vkey witness without disturbing map-format redeemers", () => { + // CML-built transaction with map-format redeemers + const body = CML.TransactionBody.from_cbor_hex("a300d901028001800200") + const exUnits = CML.ExUnits.new(50n, 100n) + const plutusData = CML.PlutusData.new_integer(CML.BigInteger.from_str("42")) + const redeemerKey = CML.RedeemerKey.new(CML.RedeemerTag.Spend, 0n) + const redeemerVal = CML.RedeemerVal.new(plutusData, exUnits) + const redeemerMap = CML.MapRedeemerKeyToRedeemerVal.new() + redeemerMap.insert(redeemerKey, redeemerVal) + const redeemers = CML.Redeemers.new_map_redeemer_key_to_redeemer_val(redeemerMap) + const ws = CML.TransactionWitnessSet.new() + ws.set_redeemers(redeemers) + const txHex = CML.Transaction.new(body, ws, true).to_cbor_hex() + + // Capture CML's raw redeemers CBOR hex + const originalRedeemersHex = redeemers.to_cbor_hex() + + // Merge a dummy vkey witness using evolution-sdk byte-level merge + const walletWsHex = buildDummyWalletWitnessHex() + const signedHex = Transaction.addVKeyWitnessesHex(txHex, walletWsHex) + + // The signed transaction must contain the original redeemers bytes verbatim + expect(signedHex).toContain(originalRedeemersHex) + + // CML must accept the signed transaction + const cmlSigned = CML.Transaction.from_cbor_hex(signedHex) + + // Redeemers must still be present and in map format + const cmlSignedRedeemers = cmlSigned.witness_set().redeemers() + expect(cmlSignedRedeemers).toBeDefined() + const signedRedeemersHex = cmlSignedRedeemers.to_cbor_hex() + const majorType = (parseInt(signedRedeemersHex.substring(0, 2), 16) >> 5) & 0x07 + expect(majorType).toBe(5) // still map + + // Vkey witnesses must have been added + const signedVkeys = cmlSigned.witness_set().vkeywitnesses() + expect(signedVkeys).toBeDefined() + expect(signedVkeys.len()).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a dummy wallet witness set CBOR hex with one vkey witness. */ +function buildDummyWalletWitnessHex(): string { + const vkey = new Uint8Array(32).fill(0xaa) + const sig = new Uint8Array(64).fill(0xbb) + const wsMap = new Map() + wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[vkey, sig]] })) + return Buffer.from(CBOR.toCBORBytes(wsMap)).toString("hex") +} + +/** Extract the raw witness set bytes from a full transaction hex. */ +function extractWitnessSetBytes(txHex: string): Uint8Array { + const txBytes = Buffer.from(txHex, "hex") + const arrHdr = (txBytes[0] & 0x1f) < 24 ? 1 : 2 + const { newOffset: bodyEnd } = CBOR.decodeItemWithOffset(txBytes, arrHdr) + const { newOffset: wsEnd } = CBOR.decodeItemWithOffset(txBytes, bodyEnd) + return new Uint8Array(txBytes.slice(bodyEnd, wsEnd)) +} diff --git a/packages/evolution/test/Transaction.preserving.test.ts b/packages/evolution/test/Transaction.preserving.test.ts deleted file mode 100644 index 59487e07..00000000 --- a/packages/evolution/test/Transaction.preserving.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { FastCheck } from "effect" -import { describe, expect, it } from "vitest" - -import * as CBOR from "../src/CBOR.js" -import * as PlutusData from "../src/Data.js" -import * as Redeemer from "../src/Redeemer.js" -import * as Redeemers from "../src/Redeemers.js" -import * as Transaction from "../src/Transaction.js" -import * as TransactionBody from "../src/TransactionBody.js" -import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" - -// --------------------------------------------------------------------------- -// addVKeyWitnessesBytes — CML-like byte-level witness merging -// -// The function operates directly on raw CBOR bytes. Only the vkey witnesses -// value (key 0) in the witness set map is modified. Everything else — body, -// redeemers, datums, scripts, isValid, auxData, map entry ordering — is -// preserved byte-for-byte. -// --------------------------------------------------------------------------- - -/** Helper: build a dummy wallet witness set CBOR containing one vkey witness. */ -const buildWalletWitnessBytes = (): Uint8Array => { - const vkey = new Uint8Array(32).fill(0xaa) - const sig = new Uint8Array(64).fill(0xbb) - const wsMap = new Map() - wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[vkey, sig]] })) - return CBOR.toCBORBytes(wsMap) -} - -describe("addVKeyWitnessesBytes", () => { - it("preserves every byte except the vkey witnesses value", () => { - // Generate a tx, capture its hex - const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) - const txBytes = Transaction.toCBORBytes(sampleTx) - const txHex = Buffer.from(txBytes).toString("hex") - - // Merge a wallet witness - const walletWsBytes = buildWalletWitnessBytes() - const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, walletWsBytes) - const signedHex = Buffer.from(signedBytes).toString("hex") - - // Body bytes must be identical (same position in both) - const hdr = (txBytes[0] & 0x1f) < 24 ? 1 : 2 - const { newOffset: bodyEnd } = CBOR.decodeItemWithOffset(txBytes, hdr) - const originalBody = txHex.slice(hdr * 2, bodyEnd * 2) - - const hdr2 = (signedBytes[0] & 0x1f) < 24 ? 1 : 2 - const { newOffset: bodyEnd2 } = CBOR.decodeItemWithOffset(signedBytes, hdr2) - const signedBody = signedHex.slice(hdr2 * 2, bodyEnd2 * 2) - - expect(signedBody).toBe(originalBody) - - // isValid + auxData bytes must be identical (tail of the transaction) - const { newOffset: wsEnd } = CBOR.decodeItemWithOffset(txBytes, bodyEnd) - const originalTail = txHex.slice(wsEnd * 2) - - const { newOffset: wsEnd2 } = CBOR.decodeItemWithOffset(signedBytes, bodyEnd2) - const signedTail = signedHex.slice(wsEnd2 * 2) - - expect(signedTail).toBe(originalTail) - }) - - it("preserves non-canonical body encoding (txId stable)", () => { - // fee=0 encoded as 0x1800 (non-canonical) instead of 0x00 - const nonCanonicalHex = "84a300d90102800180021800a0f5f6" - const txBytes = Buffer.from(nonCanonicalHex, "hex") - - const walletWsBytes = buildWalletWitnessBytes() - const signedBytes = Transaction.addVKeyWitnessesBytes(new Uint8Array(txBytes), walletWsBytes) - - // The non-canonical body (a300d90102800180021800) must appear verbatim - const signedHex = Buffer.from(signedBytes).toString("hex") - expect(signedHex).toContain("a300d90102800180021800") - }) - - it("preserves redeemers bytes when adding vkeys (scriptDataHash stable)", () => { - // Build a tx with map-format redeemers - const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) - const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) - - // Map-format redeemers with specific CBOR encoding - const constrData = PlutusData.constr(0n, []) - const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) - const redeemersMap = new Map() - redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) - const witnessMap = new Map() - witnessMap.set(5n, redeemersMap) - const witnessBytes = CBOR.toCBORBytes(witnessMap) - - // Capture the raw redeemers bytes from the witness set - const wsParsed = CBOR.fromCBORBytes(witnessBytes) as Map - const originalRedeemersHex = Buffer.from(CBOR.toCBORBytes(wsParsed.get(5n)!)).toString("hex") - - // Assemble full transaction - const txBytes = CBOR.encodeArrayAsDefinite([ - bodyBytes, - witnessBytes, - CBOR.internalEncodeSync(true), - CBOR.internalEncodeSync(null) - ]) - - // Merge a wallet witness - const walletWsBytes = buildWalletWitnessBytes() - const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, walletWsBytes) - - // Extract the witness set from the signed tx and check redeemers - const signedArray = CBOR.fromCBORBytes(signedBytes) as Array - const signedWsMap = signedArray[1] as Map - - // Redeemers still present and in map format - expect(signedWsMap.get(5n)).toBeInstanceOf(Map) - - // Redeemers bytes are IDENTICAL - const signedRedeemersHex = Buffer.from(CBOR.toCBORBytes(signedWsMap.get(5n)!)).toString("hex") - expect(signedRedeemersHex).toBe(originalRedeemersHex) - - // Vkeys were added - expect(signedWsMap.get(0n)).toBeDefined() - }) - - it("preserves map entry ordering", () => { - // Build witness set with keys in order: 3, 5 (no key 0) - const wsMap = new Map() - wsMap.set(3n, CBOR.Tag.make({ tag: 258, value: [new Uint8Array([1, 2, 3])] })) - const constrData = PlutusData.constr(0n, []) - const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) - const redeemersMap = new Map() - redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) - wsMap.set(5n, redeemersMap) - const wsBytes = CBOR.toCBORBytes(wsMap) - - // Capture the raw bytes of the key 3 and key 5 entries - const { count, hdrSize } = readMapCountHelper(wsBytes) - expect(count).toBe(2) - let off = hdrSize - const entries: Array<{ key: bigint; raw: string }> = [] - for (let i = 0; i < count; i++) { - const kvStart = off - const { item: k, newOffset: kEnd } = CBOR.decodeItemWithOffset(wsBytes, off) - const { newOffset: vEnd } = CBOR.decodeItemWithOffset(wsBytes, kEnd) - entries.push({ key: k as bigint, raw: Buffer.from(wsBytes.slice(kvStart, vEnd)).toString("hex") }) - off = vEnd - } - - // Build a tx with this witness set - const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) - const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) - const txBytes = CBOR.encodeArrayAsDefinite([ - bodyBytes, - wsBytes, - CBOR.internalEncodeSync(true), - CBOR.internalEncodeSync(null) - ]) - - // Add vkeys - const walletWsBytes = buildWalletWitnessBytes() - const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, walletWsBytes) - - // Parse signed witness set — key 3 and 5 entries should appear in original order - // with their original raw bytes, and key 0 appended at the end - const signedHex = Buffer.from(signedBytes).toString("hex") - for (const entry of entries) { - expect(signedHex).toContain(entry.raw) - } - - // Key 0 should appear after the original entries - const signedArray = CBOR.fromCBORBytes(signedBytes) as Array - const signedWsMap = signedArray[1] as Map - expect(signedWsMap.has(0n)).toBe(true) - expect(signedWsMap.has(3n)).toBe(true) - expect(signedWsMap.has(5n)).toBe(true) - }) - - it("splices in-place when key 0 already exists", () => { - // Build witness set with existing vkey + redeemers - const existingVkey = new Uint8Array(32).fill(0x11) - const existingSig = new Uint8Array(64).fill(0x22) - const wsMap = new Map() - wsMap.set(0n, CBOR.Tag.make({ tag: 258, value: [[existingVkey, existingSig]] })) - const constrData = PlutusData.constr(0n, []) - const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) - const redeemersMap = new Map() - redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [50n, 100n]] as unknown as CBOR.CBOR) - wsMap.set(5n, redeemersMap) - const wsBytes = CBOR.toCBORBytes(wsMap) - - // Capture raw redeemers entry bytes - const { hdrSize } = readMapCountHelper(wsBytes) - let off = hdrSize - let redeemersEntryHex = "" - for (let i = 0; i < 2; i++) { - const kvStart = off - const { item: k, newOffset: kEnd } = CBOR.decodeItemWithOffset(wsBytes, off) - const { newOffset: vEnd } = CBOR.decodeItemWithOffset(wsBytes, kEnd) - if (k === 5n) { - redeemersEntryHex = Buffer.from(wsBytes.slice(kvStart, vEnd)).toString("hex") - } - off = vEnd - } - - // Build tx - const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) - const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) - const txBytes = CBOR.encodeArrayAsDefinite([ - bodyBytes, - wsBytes, - CBOR.internalEncodeSync(true), - CBOR.internalEncodeSync(null) - ]) - - // Add wallet witness - const walletWsBytes = buildWalletWitnessBytes() - const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, walletWsBytes) - - // Redeemers entry bytes preserved verbatim - const signedHex = Buffer.from(signedBytes).toString("hex") - expect(signedHex).toContain(redeemersEntryHex) - - // Now has 2 vkey witnesses (1 existing + 1 wallet) - const signedArray = CBOR.fromCBORBytes(signedBytes) as Array - const signedWsMap = signedArray[1] as Map - const vkeys = unwrapVkeyArrayHelper(signedWsMap.get(0n)) - expect(vkeys.length).toBe(2) - }) -}) - -// --------------------------------------------------------------------------- -// Issue 1 & 2 proof tests (regression) -// --------------------------------------------------------------------------- - -describe("Issue 1: body bytes not preserved on standard round-trip", () => { - it("standard round-trip changes body bytes for non-canonical CBOR", () => { - const nonCanonicalHex = "84a300d90102800180021800a0f5f6" - const tx = Transaction.fromCBORHex(nonCanonicalHex) - const standardHex = Transaction.toCBORHex(tx) - expect(standardHex).not.toBe(nonCanonicalHex) - }) - - it("addVKeyWitnessesHex preserves non-canonical body", () => { - const nonCanonicalHex = "84a300d90102800180021800a0f5f6" - const walletWsHex = Buffer.from(buildWalletWitnessBytes()).toString("hex") - const signedHex = Transaction.addVKeyWitnessesHex(nonCanonicalHex, walletWsHex) - // Non-canonical body preserved - expect(signedHex).toContain("a300d90102800180021800") - }) -}) - -describe("Issue 2: map-format redeemers dropped on decode", () => { - it("map-format redeemers survive full Transaction decode→encode", () => { - const [sampleTx] = FastCheck.sample(Transaction.arbitrary, 1) - const bodyBytes = TransactionBody.toCBORBytes(sampleTx.body) - const constrData = PlutusData.constr(0n, []) - const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) - const redeemersMap = new Map() - redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) - const witnessMap = new Map() - witnessMap.set(5n, redeemersMap) - const witnessBytes = CBOR.toCBORBytes(witnessMap) - const fullBytes = CBOR.encodeArrayAsDefinite([ - bodyBytes, - witnessBytes, - CBOR.internalEncodeSync(true), - CBOR.internalEncodeSync(null) - ]) - - const decoded = Transaction.fromCBORBytes(fullBytes) - expect(decoded.witnessSet.redeemers).toBeDefined() - expect(decoded.witnessSet.redeemers!.length).toBe(1) - expect(decoded.witnessSet.redeemers![0].tag).toBe("spend") - }) -}) - -describe("Conway map-format redeemers", () => { - it("decodes and re-encodes map-format redeemers in TransactionWitnessSet", () => { - const constrData = PlutusData.constr(0n, []) - const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(constrData)) - const redeemersMap = new Map() - redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) - const witnessMap = new Map() - witnessMap.set(5n, redeemersMap) - const witnessBytes = CBOR.toCBORBytes(witnessMap) - - const ws = TransactionWitnessSet.fromCBORBytes(witnessBytes) - expect(ws.redeemers).toBeDefined() - expect(ws.redeemers!.length).toBe(1) - expect(ws.redeemers![0].tag).toBe("spend") - expect((ws as any)._redeemersFormat).toBe("map") - - const reEncodedBytes = TransactionWitnessSet.toCBORBytes(ws) - const reDecodedCBOR = CBOR.fromCBORBytes(reEncodedBytes) as Map - expect(reDecodedCBOR.get(5n)).toBeInstanceOf(Map) - }) - - it("still decodes array-format redeemers correctly", () => { - const redeemer = Redeemer.spend(0n, PlutusData.constr(0n, []), new Redeemer.ExUnits({ mem: 100n, steps: 200n })) - const redeemersCollection = new Redeemers.Redeemers({ values: [redeemer] }) - const arrayFormatBytes = Redeemers.toCBORBytes(redeemersCollection) - const arrayFormatCBOR = CBOR.fromCBORBytes(arrayFormatBytes) - const witnessMap = new Map() - witnessMap.set(5n, arrayFormatCBOR) - const witnessBytes = CBOR.toCBORBytes(witnessMap) - - const ws = TransactionWitnessSet.fromCBORBytes(witnessBytes) - expect(ws.redeemers).toBeDefined() - expect(ws.redeemers!.length).toBe(1) - expect((ws as any)._redeemersFormat).toBe("array") - - const reEncodedBytes = TransactionWitnessSet.toCBORBytes(ws) - const reDecodedCBOR = CBOR.fromCBORBytes(reEncodedBytes) as Map - expect(Array.isArray(reDecodedCBOR.get(5n))).toBe(true) - }) -}) - -// --- Test helpers --- - -function readMapCountHelper(data: Uint8Array): { count: number; hdrSize: number } { - const additionalInfo = data[0] & 0x1f - if (additionalInfo < 24) return { count: additionalInfo, hdrSize: 1 } - if (additionalInfo === 24) return { count: data[1], hdrSize: 2 } - if (additionalInfo === 25) return { count: (data[1] << 8) | data[2], hdrSize: 3 } - throw new Error(`Unsupported map header: ${additionalInfo}`) -} - -function unwrapVkeyArrayHelper(val: CBOR.CBOR | undefined): Array { - if (val === undefined) return [] - if (CBOR.isTag(val)) { - const tag = val as { _tag: "Tag"; tag: number; value: unknown } - if (tag.tag === 258 && Array.isArray(tag.value)) return tag.value as Array - return [] - } - if (Array.isArray(val)) return val as Array - return [] -} diff --git a/packages/evolution/test/TransactionBody-with-format.test.ts b/packages/evolution/test/TransactionBody-with-format.test.ts new file mode 100644 index 00000000..1753ed4f --- /dev/null +++ b/packages/evolution/test/TransactionBody-with-format.test.ts @@ -0,0 +1,37 @@ +import { FastCheck } from "effect" +import { describe, expect, it } from "vitest" + +import * as TransactionBody from "../src/TransactionBody.js" + +describe("TransactionBody WithFormat", () => { + it("round-trips via hex with an explicit format tree", () => { + FastCheck.assert( + FastCheck.property(TransactionBody.arbitrary, (body) => { + const hex = TransactionBody.toCBORHex(body) + const { format, value } = TransactionBody.fromCBORHexWithFormat(hex) + expect(TransactionBody.toCBORHexWithFormat(value, format)).toBe(hex) + }), + { numRuns: 20 } + ) + }) + + it("round-trips via bytes with an explicit format tree", () => { + FastCheck.assert( + FastCheck.property(TransactionBody.arbitrary, (body) => { + const bytes = TransactionBody.toCBORBytes(body) + const { format, value } = TransactionBody.fromCBORBytesWithFormat(bytes) + const reencoded = TransactionBody.toCBORBytesWithFormat(value, format) + expect(Buffer.from(reencoded).toString("hex")).toBe(Buffer.from(bytes).toString("hex")) + }), + { numRuns: 20 } + ) + }) + + it("preserves non-canonical encoding in body fields", () => { + // fee=0 encoded as 0x1800 (non-canonical), inputs=[] as d9010280 + // body map: {0: d9010280, 1: [], 2: 0x1800} + const nonCanonicalBodyHex = "a300d90102800180021800" + const { format, value } = TransactionBody.fromCBORHexWithFormat(nonCanonicalBodyHex) + expect(TransactionBody.toCBORHexWithFormat(value, format)).toBe(nonCanonicalBodyHex) + }) +}) diff --git a/packages/evolution/test/TransactionWitnessSet-with-format.test.ts b/packages/evolution/test/TransactionWitnessSet-with-format.test.ts new file mode 100644 index 00000000..78b6cb10 --- /dev/null +++ b/packages/evolution/test/TransactionWitnessSet-with-format.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" + +import * as CBOR from "../src/CBOR.js" +import * as PlutusData from "../src/Data.js" +import * as Ed25519Signature from "../src/Ed25519Signature.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as VKey from "../src/VKey.js" + +const buildWitnessSetHex = (): string => { + const data = PlutusData.constr(0n, []) + const dataCBOR = CBOR.fromCBORBytes(PlutusData.toCBORBytes(data)) + const redeemersMap = new Map() + redeemersMap.set([0n, 0n] as unknown as CBOR.CBOR, [dataCBOR, [100n, 200n]] as unknown as CBOR.CBOR) + + const witnessMap = new Map() + witnessMap.set(5n, redeemersMap) + witnessMap.set(3n, CBOR.Tag.make({ tag: 258, value: [new Uint8Array([1, 2, 3])] })) + + return CBOR.toCBORHex(witnessMap) +} + +describe("TransactionWitnessSet WithFormat", () => { + it("round-trips a witness set with an explicit format tree (hex)", () => { + const hex = buildWitnessSetHex() + const { format, value } = TransactionWitnessSet.fromCBORHexWithFormat(hex) + expect(TransactionWitnessSet.toCBORHexWithFormat(value, format)).toBe(hex) + }) + + it("round-trips a witness set with an explicit format tree (bytes)", () => { + const hex = buildWitnessSetHex() + const bytes = Buffer.from(hex, "hex") + const { format, value } = TransactionWitnessSet.fromCBORBytesWithFormat(new Uint8Array(bytes)) + const reencoded = TransactionWitnessSet.toCBORBytesWithFormat(value, format) + expect(Buffer.from(reencoded).toString("hex")).toBe(hex) + }) + + it("reconciles stale key order metadata when new witness-set fields are added", () => { + const { format, value: decoded } = TransactionWitnessSet.fromCBORHexWithFormat(buildWitnessSetHex()) + + const witness = new TransactionWitnessSet.VKeyWitness({ + vkey: VKey.fromBytes(new Uint8Array(32).fill(0xaa)), + signature: Ed25519Signature.fromBytes(new Uint8Array(64).fill(0xbb)) + }) + + const updated = new TransactionWitnessSet.TransactionWitnessSet( + { + vkeyWitnesses: [witness], + redeemers: decoded.redeemers, + plutusV1Scripts: decoded.plutusV1Scripts + }, + { disableValidation: true } + ) + + const reencoded = TransactionWitnessSet.toCBORHexWithFormat(updated, format) + const redecoded = CBOR.fromCBORHex(reencoded) as Map + + // Key order from the original format is respected; new key 0 appended at end + expect(Array.from(redecoded.keys())).toEqual([5n, 3n, 0n]) + }) +}) diff --git a/packages/evolution/test/UtilsHash.CML.test.ts b/packages/evolution/test/UtilsHash.CML.test.ts index a326035b..16e8ab99 100644 --- a/packages/evolution/test/UtilsHash.CML.test.ts +++ b/packages/evolution/test/UtilsHash.CML.test.ts @@ -8,6 +8,7 @@ import * as CBOR from "../src/CBOR.js" import * as CostModel from "../src/CostModel.js" import * as Data from "../src/Data.js" import * as Redeemer from "../src/Redeemer.js" +import * as Redeemers from "../src/Redeemers.js" import * as ScriptDataHash from "../src/ScriptDataHash.js" import * as TransactionBody from "../src/TransactionBody.js" import * as TransactionHash from "../src/TransactionHash.js" @@ -105,8 +106,9 @@ describe("UtilsHash helpers CML parity", () => { FastCheck.assert( FastCheck.property(redeemersArb, datumsOptArb, smallCostModels, (redeemers, datums, costModels) => { - // Evolution - const evolution = UtilsHash.hashScriptData(redeemers, costModels, datums) + // Evolution — use RedeemerArray (CML uses array format) + const redeemerArray = new Redeemers.RedeemerArray({ value: [...redeemers] }) + const evolution = UtilsHash.hashScriptData(redeemerArray, costModels, datums) const evolutionHex = ScriptDataHash.toHex(evolution) // Build CML inputs from Evolution CBOR encodings @@ -186,7 +188,7 @@ describe("UtilsHash helpers CML parity", () => { PlutusV3: new CostModel.CostModel({ costs: [] }) }) - const redeemers: ReadonlyArray = [] + const redeemers = new Redeemers.RedeemerArray({ value: [] }) const evolution = UtilsHash.hashScriptData(redeemers, cms) const evolutionHex = ScriptDataHash.toHex(evolution) @@ -199,7 +201,7 @@ describe("UtilsHash helpers CML parity", () => { }) it("special case parity: redeemers=[], datums non-empty", () => { - const redeemers: ReadonlyArray = [] + const redeemers = new Redeemers.RedeemerArray({ value: [] }) const datums: ReadonlyArray = [Data.fromCBORHex("d87980")] // Constr(0,[]) const costModels = new CostModel.CostModels({ PlutusV1: new CostModel.CostModel({ costs: [] }),