From 3bfa10d2aa8f60014a7b90a01305392b3bc79005 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Fri, 16 Oct 2026 22:09:47 +0000 Subject: [PATCH] fix(evolution): decode deeply nested CBOR and Plutus data without recursing per level (#662) The ledger limits the size of each leaf of Plutus data, not its depth, so a datum can nest roughly as deep as the transaction size allows (around 16,000 levels). CBOR.fromCBORHex threw RangeError from about 1,719 levels and Data.fromCBORHex failed from about 1,250, and every provider decodes inline datums when converting UTxOs, so one deeply nested datum made getUtxos() fail for a whole address. - CBOR.ts: the offset decoder (decodeItemAt and its array/map/tag helpers) now runs on an explicit stack of open containers instead of recursing once per level. Leaf decoding, definite/indefinite handling, maps-as-objects and the format tree are byte-for-byte the behaviours of the recursive version. - Data.ts: cborValueToPlutusData converts on an explicit action stack, with structural guards instead of the CBOR.is* Schema guards (those validate the whole subtree recursively). Validation order and error messages are unchanged. - Data.ts: fromCBORBytes/fromCBORHex decode through those two functions directly and wrap failures in ParseError, instead of routing through the recursive schema parser; the FromCBORBytes/FromCBORHex/FromCDDL schemas keep their existing validation behaviour. Tests: 10 new depth regression tests (16,000-level definite/indefinite lists, maps and tags, the format path, Data from hex/bytes, nested constructors, truncated input). Full suite: 1688 passed / 75 skipped / 0 failed (main baseline 1678 + 10 new). tsc --noEmit and eslint clean. --- .changeset/cbor-deep-nesting.md | 5 + packages/evolution/src/CBOR.ts | 502 +++++++++++++++++---------- packages/evolution/src/Data.ts | 243 ++++++++----- packages/evolution/test/CBOR.test.ts | 68 ++++ packages/evolution/test/Data.test.ts | 45 +++ 5 files changed, 588 insertions(+), 275 deletions(-) create mode 100644 .changeset/cbor-deep-nesting.md diff --git a/.changeset/cbor-deep-nesting.md b/.changeset/cbor-deep-nesting.md new file mode 100644 index 00000000..13ae97ae --- /dev/null +++ b/.changeset/cbor-deep-nesting.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +Decoding deeply nested CBOR no longer overflows the call stack. The ledger limits the size of each leaf of Plutus data, not its depth, so a datum can nest roughly as deep as the transaction size allows (around 16,000 levels); `CBOR.fromCBORHex` failed with `RangeError` from about 1,719 levels and `Data.fromCBORHex` with a `ParseError` wrapping one from about 1,250. The offset CBOR decoder now runs on an explicit stack instead of recursing once per level, `cborValueToPlutusData` converts on an explicit action stack with structural type guards (the `Schema.is` guards validated each subtree recursively), and `Data.fromCBORBytes` / `Data.fromCBORHex` decode through those two functions directly, keeping the `ParseError` failure surface, instead of through the recursive schema parser. The `FromCBORBytes` / `FromCBORHex` / `FromCDDL` schemas keep their existing validation behaviour. diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 26a595de..eb306a30 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -1837,45 +1837,332 @@ const additionalInfoToByteSize = (ai: number): ByteSize => { 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: - 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}` }) +/** + * One open container on the explicit decode stack. Decoding runs on this + * stack instead of recursing once per nesting level, so input nested as + * deeply as the ledger accepts (a datum can nest roughly as deep as the + * transaction size allows) decodes without exhausting the call stack. + */ +type DecodeFrame = + | { + readonly kind: "array" + readonly start: number + readonly indefinite: boolean + readonly lengthByteSize: ByteSize + readonly items: Array + readonly childFormats: Array | undefined + remaining: number + } + | { + readonly kind: "map" + readonly start: number + readonly indefinite: boolean + readonly lengthByteSize: ByteSize + readonly map: Map | Record + readonly keyOrderBytes: Array | undefined + readonly entryFormats: Array | undefined + remaining: number + pendingKey: + | { readonly item: CBOR; readonly format: CBORFormat | undefined; readonly start: number; readonly end: number } + | undefined + } + | { + readonly kind: "tag" + readonly start: number + readonly tagValue: number + readonly widthByteSize: ByteSize + } + +type ContainerHeader = { + readonly bodyOffset: number + readonly count: number + readonly indefinite: boolean + readonly lengthByteSize: ByteSize +} + +const readContainerHeader = (data: Uint8Array, offset: number): ContainerHeader => { + const additionalInfo = data[offset] & 0x1f + if (additionalInfo === CBOR_ADDITIONAL_INFO.INDEFINITE) { + return { bodyOffset: offset + 1, count: 0, indefinite: true, lengthByteSize: 0 } } - // 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" } + const { bytesRead, length } = decodeLengthAt(data, offset) + return { + bodyOffset: offset + bytesRead, + count: length, + indefinite: false, + lengthByteSize: bytesReadToByteSize(bytesRead) } - return result } +const readTagHeader = ( + data: Uint8Array, + offset: number +): { tagValue: number; bodyOffset: number; widthByteSize: ByteSize } => { + const additionalInfo = data[offset] & 0x1f + let tagValue: number + let bodyOffset: number + if (additionalInfo < 24) { + tagValue = additionalInfo + bodyOffset = offset + 1 + } else if (additionalInfo === 24) { + if (data.length < offset + 2) throw new CBORError({ message: "Insufficient data for 1-byte tag" }) + tagValue = data[offset + 1] + bodyOffset = offset + 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] + bodyOffset = offset + 3 + } else { + throw new CBORError({ message: `Unsupported tag encoding: ${additionalInfo}` }) + } + return { tagValue, bodyOffset, widthByteSize: additionalInfoToByteSize(additionalInfo) } +} + +const finishArrayFrame = ( + frame: Extract, + endOffset: number, + track: DecodeTrack +): DecodeAtResult => { + if (track === "none") return { item: frame.items, newOffset: endOffset } + if (frame.indefinite) { + return { + item: frame.items, + newOffset: endOffset, + format: { _tag: "array", length: { tag: "indefinite" }, children: frame.childFormats! } + } + } + const bs = frame.lengthByteSize + const le: LengthEncoding = { tag: "definite", byteSize: bs } + return { + item: frame.items, + newOffset: endOffset, + format: { _tag: "array", ...(bs !== 0 ? { length: le } : {}), children: frame.childFormats! } + } +} + +const finishMapFrame = ( + frame: Extract, + endOffset: number, + track: DecodeTrack +): DecodeAtResult => { + if (track === "none") return { item: frame.map, newOffset: endOffset } + if (frame.indefinite) { + return { + item: frame.map, + newOffset: endOffset, + format: { + _tag: "map", + length: { tag: "indefinite" }, + keyOrder: frame.keyOrderBytes!, + entries: frame.entryFormats! + } + } + } + const bs = frame.lengthByteSize + const le: LengthEncoding = { tag: "definite", byteSize: bs } + return { + item: frame.map, + newOffset: endOffset, + format: { + _tag: "map", + ...(bs !== 0 ? { length: le } : {}), + keyOrder: frame.keyOrderBytes!, + entries: frame.entryFormats! + } + } +} + +const finishTagFrame = ( + frame: Extract, + inner: DecodeAtResult, + track: DecodeTrack +): DecodeAtResult => { + const { tagValue, widthByteSize: bs } = frame + if (tagValue === 2 || tagValue === 3) { + if (!(inner.item instanceof Uint8Array)) + throw new CBORError({ message: `Expected bytes for bigint tag ${tagValue}` }) + 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: inner.newOffset } + return { + item, + newOffset: inner.newOffset, + 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: inner.newOffset } + return { + item, + newOffset: inner.newOffset, + format: { _tag: "tag", ...(bs !== 0 ? { width: bs } : {}), child: inner.format! } + } +} + +const decodeItemAt = (data: Uint8Array, offset: number, options: CodecOptions, track: DecodeTrack): DecodeAtResult => { + const stack: Array = [] + let cur = offset + let completed: DecodeAtResult | undefined + let completedStart = offset + for (;;) { + if (completed !== undefined) { + // Feed the finished item into its parent container, if any. + if (stack.length === 0) return completed + const frame = stack[stack.length - 1] + const done = completed + const doneStart = completedStart + completed = undefined + if (frame.kind === "array") { + frame.items.push(done.item) + if (frame.childFormats !== undefined) frame.childFormats.push(done.format!) + if (!frame.indefinite) { + frame.remaining -= 1 + if (frame.remaining === 0) { + stack.pop() + completed = finishArrayFrame(frame, done.newOffset, track) + completedStart = frame.start + } + } + } else if (frame.kind === "map") { + if (frame.pendingKey === undefined) { + frame.pendingKey = { item: done.item, format: done.format, start: doneStart, end: done.newOffset } + } else { + const key = frame.pendingKey + frame.pendingKey = undefined + if (frame.map instanceof Map) frame.map.set(key.item, done.item) + else frame.map[String(key.item as unknown)] = done.item + if (frame.entryFormats !== undefined) { + frame.keyOrderBytes!.push(data.slice(key.start, key.end)) + frame.entryFormats.push([key.format!, done.format!]) + } + if (!frame.indefinite) { + frame.remaining -= 1 + if (frame.remaining === 0) { + stack.pop() + completed = finishMapFrame(frame, done.newOffset, track) + completedStart = frame.start + } + } + } + } else { + stack.pop() + completed = finishTagFrame(frame, done, track) + completedStart = frame.start + } + continue + } + // An indefinite container ends at its break byte, not at a child header. + const top = stack.length > 0 ? stack[stack.length - 1] : undefined + if ( + top !== undefined && + top.kind !== "tag" && + top.indefinite && + (top.kind === "array" || top.pendingKey === undefined) + ) { + if (cur >= data.length) { + throw new CBORError({ + message: + top.kind === "array" + ? "Indefinite array missing break byte (0xff)" + : "Indefinite map missing break byte (0xff)" + }) + } + if (data[cur] === 0xff) { + cur += 1 + stack.pop() + completed = top.kind === "array" ? finishArrayFrame(top, cur, track) : finishMapFrame(top, cur, track) + completedStart = top.start + continue + } + } + if (cur >= data.length) throw new CBORError({ message: "Unexpected end of CBOR data" }) + const start = cur + const majorType = (data[cur] >> 5) & 0x07 + switch (majorType) { + case CBOR_MAJOR_TYPE.UNSIGNED_INTEGER: + completed = decodeUintAt(data, cur, track) + break + case CBOR_MAJOR_TYPE.NEGATIVE_INTEGER: + completed = decodeNintAt(data, cur, track) + break + case CBOR_MAJOR_TYPE.BYTE_STRING: + completed = decodeBytesAt(data, cur, track) + break + case CBOR_MAJOR_TYPE.TEXT_STRING: + completed = decodeTextAt(data, cur, track) + break + case CBOR_MAJOR_TYPE.ARRAY: { + const header = readContainerHeader(data, cur) + if (!header.indefinite && header.count === 0) { + const empty: Extract = { + kind: "array", + start, + indefinite: false, + lengthByteSize: header.lengthByteSize, + items: [], + childFormats: track === "format" ? [] : undefined, + remaining: 0 + } + completed = finishArrayFrame(empty, header.bodyOffset, track) + } else { + stack.push({ + kind: "array", + start, + indefinite: header.indefinite, + lengthByteSize: header.lengthByteSize, + items: [], + childFormats: track === "format" ? [] : undefined, + remaining: header.count + }) + cur = header.bodyOffset + } + break + } + case CBOR_MAJOR_TYPE.MAP: { + const header = readContainerHeader(data, cur) + const isObj = options.mode === "custom" && options.mapsAsObjects + const frame: Extract = { + kind: "map", + start, + indefinite: header.indefinite, + lengthByteSize: header.lengthByteSize, + map: isObj ? ({} as Record) : new Map(), + keyOrderBytes: track === "format" ? [] : undefined, + entryFormats: track === "format" ? [] : undefined, + remaining: header.count, + pendingKey: undefined + } + if (!header.indefinite && header.count === 0) { + completed = finishMapFrame(frame, header.bodyOffset, track) + } else { + stack.push(frame) + cur = header.bodyOffset + } + break + } + case CBOR_MAJOR_TYPE.TAG: { + const header = readTagHeader(data, cur) + stack.push({ kind: "tag", start, tagValue: header.tagValue, widthByteSize: header.widthByteSize }) + cur = header.bodyOffset + break + } + case CBOR_MAJOR_TYPE.SIMPLE_FLOAT: + completed = decodeSimpleOrFloatAt(data, cur, track) + break + default: + throw new CBORError({ message: `Unsupported major type: ${majorType}` }) + } + if (completed !== undefined) { + completedStart = start + // In format mode, simple/float values don't set result.format — fill the sentinel here. + if (track === "format" && completed.format === undefined) { + completed.format = { _tag: "simple" } + } + cur = completed.newOffset + } + } +} const decodeUintAt = (data: Uint8Array, offset: number, track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f @@ -2050,151 +2337,6 @@ const decodeTextAt = (data: Uint8Array, offset: number, track: DecodeTrack): Dec } } -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 - 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 - } - if (!foundBreak) throw new CBORError({ message: "Indefinite array missing break byte (0xff)" }) - return { item: arr, newOffset: cur } - } - 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 - } - 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 child = decodeItemAt(data, cur, options, "format") - arr[i] = child.item; childFormats[i] = child.format!; cur = child.newOffset - } - return { item: arr, newOffset: cur, format: { _tag: "array", ...(bs !== 0 ? { length: le } : {}), children: childFormats } } - } -} - -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 map = isObj ? ({} as Record) : new Map() - let cur = offset + 1 - let foundBreak = false - 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 - } - if (!foundBreak) throw new CBORError({ message: "Indefinite map missing break byte (0xff)" }) - return { item: map, newOffset: cur } - } - 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 - } - 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 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 keyStart = cur - const k = decodeItemAt(data, cur, options, "format") - cur = k.newOffset - 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 unknown)] = v.item - } - return { item: map, newOffset: cur, format: { _tag: "map", ...(bs !== 0 ? { length: le } : {}), keyOrder: keyOrderBytes, entries: entryFormats } } - } -} - -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 - } 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 - } 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 - } else { - throw new CBORError({ message: `Unsupported tag encoding: ${additionalInfo}` }) - } - 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 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, _track: DecodeTrack): DecodeAtResult => { const firstByte = data[offset] const additionalInfo = firstByte & 0x1f diff --git a/packages/evolution/src/Data.ts b/packages/evolution/src/Data.ts index 4d93ef83..fd488b27 100644 --- a/packages/evolution/src/Data.ts +++ b/packages/evolution/src/Data.ts @@ -825,57 +825,95 @@ export const plutusDataToCBORValue = (data: Data): CBOR.CBOR => { * @category transformation */ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { - // Handle bigint (uint/nint) - if (CBOR.isInteger(cborValue)) { - return cborValue - } - - // Handle Uint8Array (bytes) - if (CBOR.isByteArray(cborValue)) { - return cborValue - } - - // Handle tagged values - if (CBOR.isTag(cborValue)) { - const tag = cborValue.tag - const value = cborValue.value - - // Handle constructor tags (121-127 for indices 0-6) - if (tag >= 121 && tag <= 127) { - if (!Array.isArray(value)) { - throw new DataError({ - message: `Expected array for constructor tag ${tag}, got ${typeof value}` - }) - } - const fields = value.map(cborValueToPlutusData) - return new Constr({ index: Numeric.Uint64Make(BigInt(tag - 121)), fields }) + // Conversion runs on an explicit action stack instead of recursing once + // per nesting level, so a datum nested as deeply as the ledger accepts + // converts without exhausting the call stack. Validation order matches + // the natural reading of the value: a tag's shape is checked before its + // fields are converted, and children convert left to right. + type Action = + | { readonly kind: "visit"; readonly value: CBOR.CBOR } + | { readonly kind: "buildList"; readonly count: number } + | { readonly kind: "buildMap"; readonly count: number } + | { readonly kind: "buildConstr"; readonly index: bigint; readonly count: number } + // Structural guards, not CBOR.is*: the Schema guards validate the whole + // subtree recursively, which is the same unbounded recursion this loop + // exists to remove (and quadratic work on top). For values the decoder + // produces the checks accept exactly the same nodes; the per-node error + // messages below are unchanged. + const isTagValue = (value: CBOR.CBOR): value is { _tag: "Tag"; tag: number; value: CBOR.CBOR } => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + !(value instanceof Map) && + (value as { _tag?: unknown })._tag === "Tag" && + typeof (value as { tag?: unknown }).tag === "number" + const actions: Array = [{ kind: "visit", value: cborValue }] + const results: Array = [] + while (actions.length > 0) { + const action = actions.pop()! + if (action.kind === "buildList") { + results.push(results.splice(results.length - action.count, action.count) as Data) + continue } - - // Handle alternative constructor tags (1280-1400 for indices 7-127) - if (tag >= 1280 && tag <= 1400) { - if (!Array.isArray(value)) { - throw new DataError({ - message: `Expected array for constructor tag ${tag}, got ${typeof value}` - }) - } - const fields = value.map(cborValueToPlutusData) - return new Constr({ index: Numeric.Uint64Make(BigInt(tag - 1280 + 7)), fields }) + if (action.kind === "buildMap") { + const flat = results.splice(results.length - action.count * 2, action.count * 2) + const entries = new Array(action.count) + for (let i = 0; i < action.count; i++) entries[i] = [flat[2 * i], flat[2 * i + 1]] as const + results.push(new Map(entries) as Data) + continue } - - // Handle general constructor tag (102) - if (tag === 102) { - if (!Array.isArray(value)) { + if (action.kind === "buildConstr") { + const fields = results.splice(results.length - action.count, action.count) + results.push(new Constr({ index: Numeric.Uint64Make(action.index), fields })) + continue + } + const value = action.value + // Handle bigint (uint/nint) + if (typeof value === "bigint") { + results.push(value) + continue + } + // Handle Uint8Array (bytes) + if (value instanceof Uint8Array) { + results.push(value) + continue + } + // Handle tagged values + if (isTagValue(value)) { + const tag = value.tag + const inner = value.value + // Handle constructor tags (121-127 for indices 0-6) + if (tag >= 121 && tag <= 127) { + if (!Array.isArray(inner)) { + throw new DataError({ + message: `Expected array for constructor tag ${tag}, got ${typeof inner}` + }) + } + actions.push({ kind: "buildConstr", index: BigInt(tag - 121), count: inner.length }) + for (let i = inner.length - 1; i >= 0; i--) actions.push({ kind: "visit", value: inner[i] }) + continue + } + // Handle alternative constructor tags (1280-1400 for indices 7-127) + if (tag >= 1280 && tag <= 1400) { + if (!Array.isArray(inner)) { + throw new DataError({ + message: `Expected array for constructor tag ${tag}, got ${typeof inner}` + }) + } + actions.push({ kind: "buildConstr", index: BigInt(tag - 1280 + 7), count: inner.length }) + for (let i = inner.length - 1; i >= 0; i--) actions.push({ kind: "visit", value: inner[i] }) + continue + } + // Handle general constructor tag (102) + if (tag === 102 && !Array.isArray(inner)) { throw new DataError({ - message: `Expected array for general constructor tag, got ${typeof value}` + message: `Expected array for general constructor tag, got ${typeof inner}` }) } - - const array = value - if (array.length === 2) { - // Two element arrays are general constructors [index, fields] - const indexValue = array[0] - const fieldsValue = array[1] - + if (tag === 102 && Array.isArray(inner) && inner.length === 2) { + const indexValue = inner[0] + const fieldsValue = inner[1] if (typeof indexValue !== "bigint") { throw new DataError({ message: `Expected bigint for constructor index, got ${typeof indexValue}` @@ -886,58 +924,59 @@ export const cborValueToPlutusData = (cborValue: CBOR.CBOR): Data => { message: `Expected array for constructor fields, got ${typeof fieldsValue}` }) } - - const fields = fieldsValue.map(cborValueToPlutusData) - return new Constr({ index: Numeric.Uint64Make(indexValue), fields }) + actions.push({ kind: "buildConstr", index: indexValue, count: fieldsValue.length }) + for (let i = fieldsValue.length - 1; i >= 0; i--) actions.push({ kind: "visit", value: fieldsValue[i] }) + continue } - } - - // Handle big_uint tag (2) for large positive integers - if (tag === 2) { - if (!(value instanceof Uint8Array)) { - throw new DataError({ - message: `Expected bytes for big_uint tag, got ${typeof value}` - }) + // Handle big_uint tag (2) for large positive integers + if (tag === 2) { + if (!(inner instanceof Uint8Array)) { + throw new DataError({ + message: `Expected bytes for big_uint tag, got ${typeof inner}` + }) + } + // Convert bytes to bigint (big-endian) + results.push(bytesToBigint(inner)) + continue } - // Convert bytes to bigint (big-endian) - return bytesToBigint(value) + // Handle big_nint tag (3) for large negative integers + if (tag === 3) { + if (!(inner instanceof Uint8Array)) { + throw new DataError({ + message: `Expected bytes for big_nint tag, got ${typeof inner}` + }) + } + // Convert bytes to bigint and negate (add 1) per RFC 8949 + const positiveValue = bytesToBigint(inner) + results.push(-(positiveValue + 1n)) + continue + } + throw new DataError({ message: `Unsupported CBOR tag: ${tag}` }) } - - // Handle big_nint tag (3) for large negative integers - if (tag === 3) { - if (!(value instanceof Uint8Array)) { - throw new DataError({ - message: `Expected bytes for big_nint tag, got ${typeof value}` - }) + // Handle arrays + if (Array.isArray(value)) { + // Arrays are Lists + actions.push({ kind: "buildList", count: value.length }) + for (let i = value.length - 1; i >= 0; i--) actions.push({ kind: "visit", value: value[i] }) + continue + } + // Handle Maps + if (value instanceof Map) { + // Maps are Maps + const entries = Array.from(value.entries()) + actions.push({ kind: "buildMap", count: entries.length }) + for (let i = entries.length - 1; i >= 0; i--) { + actions.push({ kind: "visit", value: entries[i][1] }) + actions.push({ kind: "visit", value: entries[i][0] }) } - // Convert bytes to bigint and negate (add 1) per RFC 8949 - const positiveValue = bytesToBigint(value) - return -(positiveValue + 1n) + continue } - - throw new DataError({ message: `Unsupported CBOR tag: ${tag}` }) - } - - // Handle arrays - if (CBOR.isArray(cborValue)) { - // Arrays are Lists - const items = cborValue.map(cborValueToPlutusData) - return items - } - - // Handle Maps - if (CBOR.isMap(cborValue)) { - // Maps are Maps - const entries = Array.from(cborValue.entries()).map( - ([k, v]) => [cborValueToPlutusData(k), cborValueToPlutusData(v)] as const - ) - return new Map(entries) + // Handle unsupported types + throw new DataError({ + message: `Unknown CBOR value type: ${JSON.stringify(value)}` + }) } - - // Handle unsupported types - throw new DataError({ - message: `Unknown CBOR value type: ${JSON.stringify(cborValue)}` - }) + return results[0] } /** @@ -1115,8 +1154,22 @@ export const toCBORHex = (data: Data, options: CBOR.CodecOptions = DEFAULT_CBOR_ * @since 2.0.0 * @category transformation */ -export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS): Data => - Schema.decodeSync(FromCBORBytes(options))(bytes) +export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS): Data => { + // Decode directly, not through the FromCBORBytes schema: the schema + // parser recurses once per nesting level when it parses a value against + // the recursive CBOR and Data schemas, so a datum nested as deeply as the + // ledger accepts overflowed the call stack there. The CBOR decoder and + // cborValueToPlutusData both run on explicit stacks, and the conversion + // rejects every value that is not Plutus data. Failures keep the + // ParseError surface the schema path produced. + try { + return cborValueToPlutusData(CBOR.fromCBORBytes(bytes, options)) + } catch (error) { + throw new ParseResult.ParseError({ + issue: new ParseResult.Type(DataSchema.ast, bytes, error instanceof Error ? error.message : String(error)) + }) + } +} /** * Decode PlutusData from CBOR hex string @@ -1125,7 +1178,7 @@ export const fromCBORBytes = (bytes: Uint8Array, options: CBOR.CodecOptions = DE * @category transformation */ export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = DEFAULT_CBOR_OPTIONS): Data => - Schema.decodeSync(FromCBORHex(options))(hex) + fromCBORBytes(Bytes.fromHex(hex), options) /** * Create a schema that transforms from a custom type to Data and provides CBOR encoding diff --git a/packages/evolution/test/CBOR.test.ts b/packages/evolution/test/CBOR.test.ts index dd9f0907..c498c77a 100644 --- a/packages/evolution/test/CBOR.test.ts +++ b/packages/evolution/test/CBOR.test.ts @@ -939,3 +939,71 @@ describe("CBOR Implementation Tests", () => { }) }) }) + +describe("CBOR deep nesting (issue #662)", () => { + // The ledger limits the size of each leaf of Plutus data, not its depth, + // so a value can nest roughly as deep as the transaction size allows + // (around 16,000 levels). Decoding must not recurse once per level. + const DEPTH = 16_000 + const listDepth = (value: CBOR.CBOR): number => { + let depth = 0 + let cur: CBOR.CBOR = value + while (Array.isArray(cur)) { + depth++ + cur = cur[0] + } + return depth + } + + it("decodes a definite list nested 16,000 levels deep", () => { + const decoded = CBOR.fromCBORHex("81".repeat(DEPTH) + "00") + expect(listDepth(decoded)).toBe(DEPTH) + }) + + it("decodes an indefinite list nested 16,000 levels deep", () => { + const decoded = CBOR.fromCBORHex("9f".repeat(DEPTH) + "00" + "ff".repeat(DEPTH)) + expect(listDepth(decoded)).toBe(DEPTH) + }) + + it("decodes a map nested 16,000 levels deep", () => { + const decoded = CBOR.fromCBORHex("a100".repeat(DEPTH) + "00") + let depth = 0 + let cur: CBOR.CBOR = decoded + while (cur instanceof Map) { + depth++ + cur = cur.get(0n) + } + expect(depth).toBe(DEPTH) + expect(cur).toBe(0n) + }) + + it("decodes a tag nested 16,000 levels deep", () => { + const decoded = CBOR.fromCBORHex("d8c8".repeat(DEPTH) + "00") + let depth = 0 + let cur: CBOR.CBOR = decoded + while (typeof cur === "object" && cur !== null && "_tag" in cur && cur._tag === "Tag") { + depth++ + cur = cur.value + } + expect(depth).toBe(DEPTH) + expect(cur).toBe(0n) + }) + + it("decodes a deeply nested value with its format tree", () => { + const depth = 5_000 + const { format, value } = CBOR.fromCBORHexWithFormat("81".repeat(depth) + "00") + expect(listDepth(value)).toBe(depth) + let formatDepth = 0 + let curFormat = format + while (curFormat._tag === "array") { + formatDepth++ + curFormat = curFormat.children[0] + } + expect(formatDepth).toBe(depth) + }) + + it("still rejects input that ends inside a container", () => { + expect(() => CBOR.fromCBORHex("81".repeat(100))).toThrowError(CBOR.CBORError) + expect(() => CBOR.fromCBORHex("9f".repeat(100) + "00")).toThrowError(CBOR.CBORError) + }) +}) diff --git a/packages/evolution/test/Data.test.ts b/packages/evolution/test/Data.test.ts index 2bba9216..34943118 100644 --- a/packages/evolution/test/Data.test.ts +++ b/packages/evolution/test/Data.test.ts @@ -780,3 +780,48 @@ it("should handle unsorted map and return sorted canonical format", () => { "d9050883a4410742b6491b81df71d083fb9aac1b5f7385f841d1edec4a70f3d8f8803183c150334688472079a8ce4b0f7f249c0a9f4f829d2e4f1bc0556aa7b700508606a14365b7f71b4b62f0e2fbb95d6d" expect(encoded).toBe(expectedCBORHex) }) + +describe("Data deep nesting (issue #662)", () => { + // See CBOR.test.ts: the ledger bounds leaf size, not depth, so a datum + // can nest around 16,000 levels. Neither the CBOR decode nor the + // CBOR-to-Data conversion may recurse once per level. + const DEPTH = 16_000 + const listDepth = (value: Data.Data): number => { + let depth = 0 + let cur: Data.Data = value + while (Array.isArray(cur)) { + depth++ + cur = cur[0] + } + return depth + } + + it("decodes a datum nested 16,000 levels deep from hex", () => { + const decoded = Data.fromCBORHex("81".repeat(DEPTH) + "00") + expect(listDepth(decoded)).toBe(DEPTH) + }) + + it("decodes a datum nested 16,000 levels deep from bytes", () => { + const decoded = Data.fromCBORBytes(Bytes.fromHex("81".repeat(DEPTH) + "00")) + expect(listDepth(decoded)).toBe(DEPTH) + }) + + it("converts a deeply nested CBOR value", () => { + // Shallower than DEPTH only because building a Schema.Class Constr per + // level is slow; 5,000 is still well past the old ~1,250 failure point. + const decoded = Data.fromCBORHex("d87981".repeat(5_000) + "00") + let depth = 0 + let cur: Data.Data = decoded + while (cur instanceof Data.Constr) { + depth++ + cur = cur.fields[0] + } + expect(depth).toBe(5_000) + expect(cur).toBe(0n) + }) + + it("keeps the ParseError surface for invalid data", () => { + expect(() => Data.fromCBORHex("d864820105")).toThrowError(/Unsupported CBOR tag: 100/) + expect(() => Data.fromCBORHex("81".repeat(100))).toThrow() + }) +})