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() + }) +})