Skip to content

Commit 9c16997

Browse files
Merge pull request #160 from IntersectMBO/fix/conway-bounded-bytes
fix(cbor): encode PlutusData bytes as bounded_bytes via first-class CBOR node
2 parents e8fca95 + 06c8098 commit 9c16997

7 files changed

Lines changed: 574 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@evolution-sdk/evolution": patch
3+
---
4+
5+
Introduces `BoundedBytes` as a first-class CBOR node type that enforces the Conway CDDL `bounded_bytes = bytes .size (0..64)` constraint unconditionally and independently of `CodecOptions`. PlutusData byte strings are now emitted via `CBOR.BoundedBytes.make()`, which applies definite-length encoding for ≤ 64 bytes and indefinite-length 64-byte chunked encoding (`0x5f [chunk]* 0xff`) for larger values. Adds `BoundedBytes` branch to `CBOR.match`. Removes the unused `PreEncoded` node type.

packages/evolution-devnet/test/TxBuilder.Scripts.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import * as Bytes from "@evolution-sdk/evolution/Bytes"
66
import * as Data from "@evolution-sdk/evolution/Data"
77
import * as InlineDatum from "@evolution-sdk/evolution/InlineDatum"
88
import * as PlutusV2 from "@evolution-sdk/evolution/PlutusV2"
9+
import * as PlutusV3 from "@evolution-sdk/evolution/PlutusV3"
910
import * as ScriptHash from "@evolution-sdk/evolution/ScriptHash"
1011
import type { TxBuilderConfig } from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder"
1112
import { makeTxBuilder } from "@evolution-sdk/evolution/sdk/builders/TransactionBuilder"
1213
import { KupmiosProvider } from "@evolution-sdk/evolution/sdk/provider/Kupmios"
1314
import { createScalusEvaluator } from "@evolution-sdk/scalus-uplc"
1415
import { Schema } from "effect"
1516

17+
import plutusJson from "../../evolution/test/spec/plutus.json"
1618
import * as Cluster from "../src/Cluster.js"
1719
import { createCoreTestUtxo } from "./utils/utxo-helpers.js"
1820

@@ -1266,4 +1268,56 @@ describe("TxBuilder Script Handling", () => {
12661268
})
12671269
).rejects.toThrow(/Insufficient collateral available.*Need 5000000.*but only found 4500000/i)
12681270
})
1271+
1272+
it("should build a transaction with a >64-byte redeemer field using bounded_bytes encoding", async () => {
1273+
const alwaysSucceedValidator = plutusJson.validators.find(
1274+
(v) => v.title === "always_succeed.always_succeed.spend"
1275+
)!
1276+
const alwaysSucceedV3 = new PlutusV3.PlutusV3({ bytes: Bytes.fromHex(alwaysSucceedValidator.compiledCode) })
1277+
const scriptHash = ScriptHash.fromScript(alwaysSucceedV3)
1278+
const scriptAddress = Schema.encodeSync(CoreAddress.FromBech32)(
1279+
CoreAddress.Address.make({ networkId: 0, paymentCredential: scriptHash })
1280+
)
1281+
1282+
const scriptUtxo = createCoreTestUtxo({
1283+
transactionId: "a".repeat(64),
1284+
index: 0,
1285+
address: scriptAddress,
1286+
lovelace: 5_000_000n,
1287+
datumOption: new InlineDatum.InlineDatum({ data: Data.constr(0n, []) })
1288+
})
1289+
1290+
const fundingUtxo = createCoreTestUtxo({
1291+
transactionId: "b".repeat(64),
1292+
index: 0,
1293+
address: CHANGE_ADDRESS,
1294+
lovelace: 10_000_000n
1295+
})
1296+
1297+
// 100-byte payload — exceeds the 64-byte bounded_bytes chunk size
1298+
const largePayload = new Uint8Array(100).fill(0xaa)
1299+
1300+
const signBuilder = await makeTxBuilder(baseConfig)
1301+
.collectFrom({ inputs: [scriptUtxo], redeemer: Data.constr(0n, [largePayload]) })
1302+
.attachScript({ script: alwaysSucceedV3 })
1303+
.payToAddress({
1304+
address: CoreAddress.fromBech32(RECEIVER_ADDRESS),
1305+
assets: CoreAssets.fromLovelace(4_000_000n)
1306+
})
1307+
.build({
1308+
changeAddress: CoreAddress.fromBech32(CHANGE_ADDRESS),
1309+
availableUtxos: [fundingUtxo],
1310+
protocolParameters: PROTOCOL_PARAMS,
1311+
passAdditionalUtxos: true
1312+
})
1313+
1314+
const tx = await signBuilder.toTransaction()
1315+
1316+
expect(tx.witnessSet.redeemers).toBeDefined()
1317+
expect(tx.witnessSet.redeemers!.length).toBe(1)
1318+
const redeemer = tx.witnessSet.redeemers![0]
1319+
expect(redeemer.tag).toBe("spend")
1320+
expect(redeemer.exUnits.mem).toBeGreaterThan(0n)
1321+
expect(redeemer.exUnits.steps).toBeGreaterThan(0n)
1322+
})
12691323
})

packages/evolution/src/CBOR.ts

Lines changed: 125 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@ export const CML_DEFAULT_OPTIONS: CodecOptions = {
107107
} as const
108108

109109
/**
110-
* Default CBOR encoding option for Data
110+
* Default CBOR encoding options for PlutusData.
111+
*
112+
* Uses indefinite-length arrays and maps. The `bounded_bytes` constraint
113+
* (Conway CDDL: byte strings ≤ 64 bytes) is enforced at the data-type layer
114+
* via the `BoundedBytes` CBOR node, independent of these codec options.
111115
*
112116
* @since 1.0.0
113117
* @category constants
@@ -123,13 +127,16 @@ export const CML_DATA_DEFAULT_OPTIONS: CodecOptions = {
123127
} as const
124128

125129
/**
126-
* Aiken-compatible CBOR encoding options
130+
* Aiken-compatible CBOR encoding options.
127131
*
128-
* Matches the encoding used by Aiken's cbor.serialise():
129-
* - Indefinite-length arrays (9f...ff)
132+
* Matches the encoding produced by `cbor.serialise()` in Aiken:
133+
* - Indefinite-length arrays (`9f...ff`)
130134
* - Maps encoded as arrays of pairs (not CBOR maps)
131-
* - Strings as bytearrays (major type 2, not 3)
132-
* - Constructor tags: 121-127 for indices 0-6, then 1280+ for 7+
135+
* - Strings as byte arrays (major type 2, not 3)
136+
* - Constructor tags: 121–127 for indices 0–6, then 1280+ for 7+
137+
*
138+
* PlutusData byte strings are chunked per the Conway `bounded_bytes` rule
139+
* via the `BoundedBytes` CBOR node, independent of these codec options.
133140
*
134141
* @since 2.0.0
135142
* @category constants
@@ -333,6 +340,7 @@ export type CBOR =
333340
| null // null value
334341
| undefined // undefined value
335342
| number // floating point numbers
343+
| { _tag: "BoundedBytes"; bytes: Uint8Array } // PlutusData bounded byte strings (Conway CDDL bounded_bytes = bytes .size (0..64))
336344

337345
/**
338346
* **Record vs Map Key Ordering**
@@ -484,6 +492,7 @@ export const match = <R>(
484492
null: () => R
485493
undefined: () => R
486494
float: (value: number) => R
495+
boundedBytes: (value: Uint8Array) => R
487496
}
488497
): R => {
489498
if (typeof value === "bigint") {
@@ -504,6 +513,9 @@ export const match = <R>(
504513
if (isTag(value)) {
505514
return patterns.tag(value.tag, value.value)
506515
}
516+
if (BoundedBytes.is(value)) {
517+
return patterns.boundedBytes(value.bytes)
518+
}
507519
if (
508520
typeof value === "object" &&
509521
value !== null &&
@@ -736,6 +748,15 @@ export const internalEncodeSync = (value: CBOR, options: CodecOptions = CML_DEFA
736748
if (Array.isArray(value)) return encodeArraySync(value, options)
737749
if (value instanceof Map) return encodeMapSync(value, options)
738750
if (isTag(value)) return encodeTagSync(value.tag, value.value, options)
751+
// BoundedBytes: PlutusData byte strings, encoded per Conway CDDL bounded_bytes = bytes .size (0..64)
752+
if (
753+
typeof value === "object" &&
754+
value !== null &&
755+
"_tag" in value &&
756+
(value as { _tag: unknown })._tag === "BoundedBytes"
757+
) {
758+
return encodeBoundedBytesSync((value as { _tag: "BoundedBytes"; bytes: Uint8Array }).bytes)
759+
}
739760
if (
740761
typeof value === "object" &&
741762
value !== null &&
@@ -828,6 +849,79 @@ const encodeNintSync = (value: bigint, options: CodecOptions): Uint8Array => {
828849
}
829850
}
830851

852+
/** Byte-string chunk header size for a given chunk length (CBOR major type 2). */
853+
const chunkHeaderSize = (len: number): number => {
854+
if (len < 24) return 1
855+
if (len < 256) return 2
856+
if (len < 65536) return 3
857+
return 5
858+
}
859+
860+
/** Write a definite-length byte-string header for a chunk into `buf` at `pos`. Returns new position. */
861+
const writeChunkHeader = (buf: Uint8Array, pos: number, len: number): number => {
862+
if (len < 24) {
863+
buf[pos++] = 0x40 + len
864+
} else if (len < 256) {
865+
buf[pos++] = 0x58
866+
buf[pos++] = len
867+
} else if (len < 65536) {
868+
buf[pos++] = 0x59
869+
buf[pos++] = (len >> 8) & 0xff
870+
buf[pos++] = len & 0xff
871+
} else {
872+
buf[pos++] = 0x5a
873+
buf[pos++] = (len >> 24) & 0xff
874+
buf[pos++] = (len >> 16) & 0xff
875+
buf[pos++] = (len >> 8) & 0xff
876+
buf[pos++] = len & 0xff
877+
}
878+
return pos
879+
}
880+
881+
/**
882+
* Encodes a byte string under the Conway `bounded_bytes` rule:
883+
* - ≤ 64 bytes → definite-length CBOR bytes
884+
* - > 64 bytes → indefinite-length byte string (`0x5f` + 64-byte chunks + `0xff`)
885+
*
886+
* Called unconditionally by the `BoundedBytes` CBOR node handler. Does not
887+
* depend on `CodecOptions` — the rule is always applied.
888+
*/
889+
const BOUNDED_BYTES_CHUNK_SIZE = 64
890+
const encodeBoundedBytesSync = (value: Uint8Array): Uint8Array => {
891+
const length = value.length
892+
if (length === 0) return new Uint8Array([0x40])
893+
if (length <= BOUNDED_BYTES_CHUNK_SIZE) {
894+
// Definite-length encoding
895+
let headerLen: number
896+
if (length < 24) headerLen = 1
897+
else if (length < 256) headerLen = 2
898+
else headerLen = 3
899+
const out = new Uint8Array(headerLen + length)
900+
if (length < 24) out[0] = 0x40 + length
901+
else if (length < 256) { out[0] = 0x58; out[1] = length }
902+
else { out[0] = 0x59; out[1] = length >> 8; out[2] = length & 0xff }
903+
out.set(value, headerLen)
904+
return out
905+
}
906+
// Indefinite-length chunked byte string for > 64 bytes
907+
let totalSize = 2 // 0x5f + 0xff
908+
for (let offset = 0; offset < length; offset += BOUNDED_BYTES_CHUNK_SIZE) {
909+
const chunkLen = Math.min(BOUNDED_BYTES_CHUNK_SIZE, length - offset)
910+
totalSize += chunkHeaderSize(chunkLen) + chunkLen
911+
}
912+
const result = new Uint8Array(totalSize)
913+
let pos = 0
914+
result[pos++] = 0x5f
915+
for (let offset = 0; offset < length; offset += BOUNDED_BYTES_CHUNK_SIZE) {
916+
const chunkLen = Math.min(BOUNDED_BYTES_CHUNK_SIZE, length - offset)
917+
pos = writeChunkHeader(result, pos, chunkLen)
918+
result.set(value.subarray(offset, offset + BOUNDED_BYTES_CHUNK_SIZE), pos)
919+
pos += chunkLen
920+
}
921+
result[pos] = 0xff
922+
return result
923+
}
924+
831925
const encodeBytesSync = (value: Uint8Array, options: CodecOptions): Uint8Array => {
832926
const length = value.length
833927
const useMinimal = options.mode === "canonical" || (options.mode === "custom" && options.useMinimalEncoding)
@@ -837,7 +931,7 @@ const encodeBytesSync = (value: Uint8Array, options: CodecOptions): Uint8Array =
837931
return new Uint8Array([0x40])
838932
}
839933

840-
// Optimize header encoding with direct buffer creation
934+
// Standard definite-length encoding
841935
let headerBytes: Uint8Array
842936
if (length < 24) {
843937
headerBytes = new Uint8Array([0x40 + length])
@@ -863,6 +957,30 @@ const encodeBytesSync = (value: Uint8Array, options: CodecOptions): Uint8Array =
863957
return result
864958
}
865959

960+
/**
961+
* `BoundedBytes` CBOR node — represents a PlutusData byte string that must comply
962+
* with the Conway CDDL constraint `bounded_bytes = bytes .size (0..64)`.
963+
*
964+
* The encoding rule is unconditional and options-independent:
965+
* - ≤ 64 bytes → definite-length CBOR bytes
966+
* - > 64 bytes → indefinite-length 64-byte chunked byte string (`0x5f` + chunks + `0xff`)
967+
*
968+
* Use `BoundedBytes.make` to construct the node; the encoder handles the rest.
969+
*
970+
* @since 2.0.0
971+
* @category model
972+
*/
973+
export const BoundedBytes = {
974+
/** Construct a BoundedBytes CBOR node from a raw byte array. */
975+
make: (bytes: Uint8Array): CBOR => ({ _tag: "BoundedBytes" as const, bytes }),
976+
/** Type guard for BoundedBytes CBOR nodes. */
977+
is: (value: CBOR): value is { _tag: "BoundedBytes"; bytes: Uint8Array } =>
978+
typeof value === "object" &&
979+
value !== null &&
980+
"_tag" in value &&
981+
(value as { _tag: unknown })._tag === "BoundedBytes"
982+
} as const
983+
866984
const encodeTextSync = (value: string, options: CodecOptions): Uint8Array => {
867985
// Fast path for empty strings
868986
if (value.length === 0) {

packages/evolution/src/Data.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -542,13 +542,15 @@ export const plutusDataToCBORValue = (data: Data): CBOR.CBOR => {
542542
return value
543543
},
544544
Bytes: (bytes): CBOR.CBOR => {
545-
// Bytes are already Uint8Array, return as is
546-
return bytes
545+
// Conway CDDL: bounded_bytes = bytes .size (0..64)
546+
// BoundedBytes enforces the chunking rule at the CBOR node level,
547+
// independent of codec options. See CBOR.BoundedBytes.
548+
return CBOR.BoundedBytes.make(bytes)
547549
},
548550
Constr: (constr): CBOR.CBOR => {
549551
// PlutusData Constr -> CBOR tags based on index
550552
const cborFields = constr.fields.map(plutusDataToCBORValue)
551-
const fieldsArray = cborFields // Now just a raw array
553+
const fieldsArray = cborFields
552554

553555
if (constr.index >= 0n && constr.index <= 6n) {
554556
// Direct encoding for constructor indices 0-6 (tags 121-127)

packages/evolution/test/CBOR.Aiken.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,4 +747,22 @@ describe("Aiken CBOR Encoding Compatibility", () => {
747747
"d8799fd8799f581cff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507a6ffd8799fd8799fd8799f581c64ff0185c80386d7ff02a2042efd97fbe6012dac0102751cfcc14507ffffffff"
748748
)
749749
})
750+
751+
// encode_bytearray_bounded_65: 65 bytes must be chunked (bounded_bytes = bytes .size (0..64))
752+
it("encode_bytearray_bounded_65: should chunk bytearray exceeding 64 bytes", () => {
753+
const value = new Uint8Array(65).fill(0xaa)
754+
const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS)
755+
expect(encoded).toBe(
756+
"5f5840aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa41aaff"
757+
)
758+
})
759+
760+
// encode_bytearray_exactly_64: 64 bytes stays definite-length (no chunking)
761+
it("encode_bytearray_exactly_64: should NOT chunk a 64-byte bytearray", () => {
762+
const value = new Uint8Array(64).fill(0xbb)
763+
const encoded = Data.toCBORHex(value, CBOR.AIKEN_DEFAULT_OPTIONS)
764+
expect(encoded).toBe(
765+
"5840bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
766+
)
767+
})
750768
})

0 commit comments

Comments
 (0)