diff --git a/.changeset/data-default-plutus-layout.md b/.changeset/data-default-plutus-layout.md index de2f7f79..820dc092 100644 --- a/.changeset/data-default-plutus-layout.md +++ b/.changeset/data-default-plutus-layout.md @@ -19,6 +19,6 @@ Data.toDatumHash(datum, CBOR.CML_DATA_DEFAULT_OPTIONS) Transaction.toCBORHex(tx, { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }) ``` -The transaction options cover witness datums and redeemer data. An inline datum in a new output is written with the `Data` default under every option set, as before, so it takes the new layout. +The transaction options cover inline datums, witness datums and redeemer data, so the second call keeps the old bytes for all three. `CBOR.CML_DATA_DEFAULT_OPTIONS` is deprecated in favor of `CBOR.PLUTUS_DATA_OPTIONS`. It keeps its bytes, but matches no tool exactly, so use it only to reproduce bytes and datum hashes written before this release. diff --git a/.changeset/inline-datum-tx-options.md b/.changeset/inline-datum-tx-options.md new file mode 100644 index 00000000..56f71ec7 --- /dev/null +++ b/.changeset/inline-datum-tx-options.md @@ -0,0 +1,16 @@ +--- +"@evolution-sdk/evolution": minor +--- + +A new inline datum is now written with the `plutusData` transaction options, as witness datums and redeemer data are. Before, it was written with `Data.DEFAULT_CBOR_OPTIONS` whatever options the caller passed to the transaction encoder. + +```ts +const datum = Data.map([[1n, 2n]]) +// Before: 24(h'a10102') under every option set +// Now: 24(h'bf0102ff'), the bytes Data.toCBORHex(datum, CBOR.CML_DATA_DEFAULT_OPTIONS) gives +Transaction.toCBORHex(tx, { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }) +``` + +The encoders and schemas of `TransactionBody`, `TxOut`, `TransactionOutput` and `DatumOption` take `CBOR.TxCodecOptions` and default to `CBOR.TX_DEFAULT_OPTIONS`. Plain `CBOR.CodecOptions` are still accepted, read as `CBOR.toTxCodecOptions` reads them. `DatumOption.makeFromCDDL` and `TxOut.makeFromCDDL` build the CDDL schema for given `plutusData` options. + +With no options, `CBOR.TX_DEFAULT_OPTIONS` or `CBOR.CML_DEFAULT_OPTIONS`, every byte stays the same. Under other options only the bytes inside tag 24 change, so the transaction id of a transaction with a new inline datum changes. A decoded inline datum keeps its bytes, and an inline datum added to a decoded transaction is written with the default options. diff --git a/.changeset/plutus-data-tx-options.md b/.changeset/plutus-data-tx-options.md index 3b799d9c..cfabb44b 100644 --- a/.changeset/plutus-data-tx-options.md +++ b/.changeset/plutus-data-tx-options.md @@ -4,7 +4,7 @@ Witness datums and redeemer data are now written with their own Plutus data options. Before, the witness set wrote them with the transaction options, so a datum in the witness set did not match the bytes its datum hash covers. -`CBOR.TxCodecOptions` holds two sets of options. `ledger` is for ledger structures: the body, the witness set, and the containers that hold datums and redeemers. `plutusData` is for Plutus data items: witness datums and redeemer data. Two presets come with it: +`CBOR.TxCodecOptions` holds two sets of options. `ledger` is for ledger structures: the body, the witness set, and the containers that hold datums and redeemers. `plutusData` is for Plutus data items: new inline datums, witness datums and redeemer data. Two presets come with it: - `CBOR.TX_DEFAULT_OPTIONS`: `ledger` is `CBOR.CML_DEFAULT_OPTIONS` and `plutusData` is `CBOR.PLUTUS_DATA_OPTIONS`, the `Data` default. - `CBOR.TX_CANONICAL_OPTIONS`: `CBOR.CANONICAL_OPTIONS` for both. @@ -19,4 +19,4 @@ Transaction.toCBORHex(tx, { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.P Plain `CBOR.CodecOptions` are still accepted, read as `CBOR.toTxCodecOptions` reads them: the options serve as both, except that `CBOR.CML_DEFAULT_OPTIONS` writes Plutus data with `CBOR.PLUTUS_DATA_OPTIONS`, as passing no options does. Decoded datums and redeemers keep their bytes, and a datum or redeemer added to a decoded transaction is written with the default Plutus data options. -Script transactions whose redeemer data or witness datums hold a non-empty list, map or constructor fields change bytes. Simple data such as `Data.constr(0n, [])` or an integer keeps its bytes. The builder wrote redeemer data definite, for example `d87982a101028103`, and now writes it in the data default with indefinite lists and constructor fields and a definite map: `d8799fa101029f03ffff`. To keep the old redeemer layout, pass `{ ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFINITE_OPTIONS }` to the encoders and to `Redeemers.toScriptDataHash`. +Script transactions whose redeemer data or witness datums hold a non-empty list, map or constructor fields change bytes. Simple data such as `Data.constr(0n, [])` or an integer keeps its bytes. The builder wrote redeemer data definite, for example `d87982a101028103`, and now writes it in the data default with indefinite lists and constructor fields and a definite map: `d8799fa101029f03ffff`. To keep the old redeemer layout, pass `{ ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFINITE_OPTIONS }` to the encoders and to `Redeemers.toScriptDataHash`. That call also writes new inline datums fully definite. diff --git a/docs/content/docs/introduction/important-defaults.mdx b/docs/content/docs/introduction/important-defaults.mdx index 7206c35c..6070ade5 100644 --- a/docs/content/docs/introduction/important-defaults.mdx +++ b/docs/content/docs/introduction/important-defaults.mdx @@ -95,7 +95,7 @@ Data.toDatumHash(datum, CBOR.CML_DATA_DEFAULT_OPTIONS) Transaction.toCBORHex(tx, { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }) ``` -The transaction options cover witness datums and redeemer data. An inline datum in a new output always uses the `Data` default. +The `plutusData` transaction options cover every new Plutus data item: inline datums, witness datums and redeemer data. A datum decoded from a transaction keeps its bytes. ### Scripts Are Double-CBOR Encoded diff --git a/packages/evolution-devnet/test/Transaction.InlineDatumOptions.test.ts b/packages/evolution-devnet/test/Transaction.InlineDatumOptions.test.ts new file mode 100644 index 00000000..c51741b9 --- /dev/null +++ b/packages/evolution-devnet/test/Transaction.InlineDatumOptions.test.ts @@ -0,0 +1,174 @@ +/** + * Devnet test: a new inline datum is written with the plutusData transaction + * options, and the node keeps it as written. + * + * The builder writes a transaction with an inline map datum. Each case + * rebuilds that transaction with a new InlineDatum, encodes it with + * Transaction.toCBORHex under one option set, signs it over the raw body with + * Transaction.addVKeyWitnessesBytes, and submits the bytes. The ledger returns + * the datum, which must be the bytes Data.toCBORHex writes under the case's + * plutusData options. + */ + +import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest" +import * as Cluster from "@evolution-sdk/devnet/Cluster" +import * as Config from "@evolution-sdk/devnet/Config" +import * as Genesis from "@evolution-sdk/devnet/Genesis" +import { Cardano, Client, preprod } from "@evolution-sdk/evolution" +import * as Address from "@evolution-sdk/evolution/Address" +import * as Bytes from "@evolution-sdk/evolution/Bytes" +import * as CBOR from "@evolution-sdk/evolution/CBOR" +import * as Data from "@evolution-sdk/evolution/Data" +import * as InlineDatum from "@evolution-sdk/evolution/InlineDatum" +import * as Transaction from "@evolution-sdk/evolution/Transaction" +import * as TransactionBody from "@evolution-sdk/evolution/TransactionBody" +import * as TransactionHash from "@evolution-sdk/evolution/TransactionHash" +import * as TransactionWitnessSet from "@evolution-sdk/evolution/TransactionWitnessSet" +import * as TxOut from "@evolution-sdk/evolution/TxOut" + +describe("Inline datum options (Devnet Submit)", () => { + let devnetCluster: Cluster.Cluster | undefined + let genesisUtxos: ReadonlyArray = [] + + const TEST_MNEMONIC = + "test test test test test test test test test test test test test test test test test test test test test test test sauce" + + // {1: [2]}: each option set below writes it differently, or with a + // different ledger layout around it + const datum = Data.map([[Data.int(1n), Data.list([Data.int(2n)])]]) + + const cases: ReadonlyArray<{ name: string; options: CBOR.TxCodecOptions; datumHex: string }> = [ + { name: "CBOR.TX_DEFAULT_OPTIONS", options: CBOR.TX_DEFAULT_OPTIONS, datumHex: "a1019f02ff" }, + { name: "CBOR.TX_CANONICAL_OPTIONS", options: CBOR.TX_CANONICAL_OPTIONS, datumHex: "a1018102" }, + { + name: "plutusData CML_DATA_DEFAULT_OPTIONS", + options: { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }, + datumHex: "bf019f02ffff" + }, + { + name: "plutusData CML_DATA_DEFINITE_OPTIONS", + options: { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFINITE_OPTIONS }, + datumHex: "a1018102" + } + ] + + const ogmiosUrl = () => `http://localhost:${devnetCluster!.ports.ogmios}` + + const createTestClient = () => { + if (!devnetCluster) throw new Error("Cluster not initialized") + return Client.make(Cluster.getChain(devnetCluster)) + .withKupmios({ kupoUrl: `http://localhost:${devnetCluster.ports.kupo}`, ogmiosUrl: ogmiosUrl() }) + .withSeed({ mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }) + } + + const ogmios = async (method: string, params: unknown) => { + const response = await fetch(ogmiosUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method, params, id: null }) + }) + return (await response.json()) as { result?: any; error?: unknown } + } + + beforeAll(async () => { + const tempClient = Client.make(preprod).withSeed({ mnemonic: TEST_MNEMONIC, accountIndex: 0, addressType: "Base" }) + const testAddressHex = Address.toHex(await tempClient.address()) + + const genesisConfig: Config.ShelleyGenesis = { + ...Config.DEFAULT_SHELLEY_GENESIS, + slotLength: 0.02, + epochLength: 50, + activeSlotsCoeff: 1.0, + initialFunds: { [testAddressHex]: 500_000_000_000 } + } + + genesisUtxos = await Genesis.calculateUtxosFromConfig(genesisConfig) + + devnetCluster = await Cluster.make({ + clusterName: "inline-datum-options-test", + shelleyGenesis: genesisConfig, + kupo: { enabled: true, logLevel: "Info" }, + ogmios: { enabled: true, logLevel: "info" } + }) + + await Cluster.start(devnetCluster) + await new Promise((resolve) => setTimeout(resolve, 3_000)) + }, 180_000) + + afterAll(async () => { + if (devnetCluster) { + await Cluster.stop(devnetCluster) + await Cluster.remove(devnetCluster) + } + }, 60_000) + + cases.forEach(({ datumHex, name, options }, caseIndex) => { + it(`writes and keeps an inline map datum under ${name}`, { timeout: 120_000 }, async () => { + const client = createTestClient() + const myAddress = await client.address() + expect(Data.toCBORHex(datum, options.plutusData)).toBe(datumHex) + + const signBuilder = await client + .newTx() + .payToAddress({ + address: myAddress, + assets: Cardano.Assets.fromLovelace(5_000_000n), + datum: new InlineDatum.InlineDatum({ data: datum }) + }) + .build(caseIndex === 0 ? { availableUtxos: [...genesisUtxos] } : undefined) + const built = await signBuilder.toTransaction() + + // A new InlineDatum in each output that holds one, and a fee that + // covers the few extra bytes, paid from the largest output + const feeBump = 10_000n + const changeIndex = built.body.outputs.reduce( + (best, output, i) => + Cardano.Assets.lovelaceOf(output.assets) > Cardano.Assets.lovelaceOf(built.body.outputs[best]!.assets) + ? i + : best, + 0 + ) + const outputs = built.body.outputs.map( + (output, i) => + new TxOut.TransactionOutput({ + ...output, + assets: + i === changeIndex ? Cardano.Assets.subtractLovelace(output.assets, feeBump) : output.assets, + datumOption: + output.datumOption?._tag === "InlineDatum" + ? new InlineDatum.InlineDatum({ data: output.datumOption.data }) + : output.datumOption + }) + ) + const datumIndex = outputs.findIndex((output) => output.datumOption?._tag === "InlineDatum") + expect(datumIndex).toBeGreaterThanOrEqual(0) + const tx = new Transaction.Transaction({ + ...built, + body: new TransactionBody.TransactionBody({ ...built.body, outputs, fee: built.body.fee + feeBump }) + }) + + const txHex = Transaction.toCBORHex(tx, options) + expect(txHex).toContain(`d818${(0x40 + datumHex.length / 2).toString(16)}${datumHex}`) + const txBytes = Bytes.fromHex(txHex) + const bodyBytes = Transaction.extractBodyBytes(txBytes) + const expectedTxId = TransactionHash.toHex(TransactionBody.toHashFromBytes(bodyBytes)) + + const walletWitnessSet = await client.signTx(txHex, { + utxos: caseIndex === 0 ? [...genesisUtxos] : await client.getUtxos(myAddress) + }) + const signedBytes = Transaction.addVKeyWitnessesBytes(txBytes, TransactionWitnessSet.toCBORBytes(walletWitnessSet)) + expect(Bytes.toHex(Transaction.extractBodyBytes(signedBytes))).toBe(Bytes.toHex(bodyBytes)) + + const submitted = await ogmios("submitTransaction", { transaction: { cbor: Bytes.toHex(signedBytes) } }) + expect(submitted.error).toBeUndefined() + expect(submitted.result.transaction.id).toBe(expectedTxId) + expect(await client.awaitTx(TransactionHash.fromHex(expectedTxId), 1000)).toBe(true) + + const utxos = await ogmios("queryLedgerState/utxo", { + outputReferences: [{ transaction: { id: expectedTxId }, index: datumIndex }] + }) + expect((utxos.result as Array<{ datum?: string }>).map((utxo) => utxo.datum)).toEqual([datumHex]) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + }) + }) +}) diff --git a/packages/evolution/src/CBOR.ts b/packages/evolution/src/CBOR.ts index 1221e492..26a595de 100644 --- a/packages/evolution/src/CBOR.ts +++ b/packages/evolution/src/CBOR.ts @@ -344,8 +344,8 @@ export const CARDANO_NODE_DATA_OPTIONS: CodecOptions = CML_DATA_DEFINITE_OPTIONS * * - `ledger`: the options for ledger structures: the body, the witness set, * and the containers that hold datums and redeemers. - * - `plutusData`: the options for Plutus data items: witness datums and - * redeemer data. + * - `plutusData`: the options for Plutus data items: new inline datums, + * witness datums and redeemer data. * * @since 2.0.0 * @category model diff --git a/packages/evolution/src/DatumOption.ts b/packages/evolution/src/DatumOption.ts index 6a417f8d..f388d5d4 100644 --- a/packages/evolution/src/DatumOption.ts +++ b/packages/evolution/src/DatumOption.ts @@ -60,6 +60,69 @@ export const CDDLSchema = Schema.Union( Schema.Tuple(Schema.Literal(1n), CBOR.tag(24, Schema.Uint8ArrayFromSelf)) // [1, tag(24, bytes)] - PlutusData as bytes in tag 24 ) +/** + * CDDL schema for DatumOption, as in {@link FromCDDL}, that writes a new + * inline datum with the `plutusData` options. A decoded inline datum keeps + * its original bytes. + * + * @since 2.0.0 + * @category schemas + */ +export const makeFromCDDL = (plutusData: CBOR.CodecOptions) => + Schema.transformOrFail(CDDLSchema, Schema.typeSchema(DatumOptionSchema), { + strict: true, + encode: (toA) => + E.gen(function* () { + const result = + toA._tag === "DatumHash" + ? ([0n, toA.hash] as const) // Encode as [0, Bytes32] + : ([ + 1n, + { + _tag: "Tag" as const, + tag: 24 as const, + // Write back the decoded bytes when present, so the datum keeps its original layout + value: OriginalBytes.get(toA) ?? PlutusData.toCBORBytes(toA.data, plutusData) + } + ] as const) // Encode as [1, tag(24, bytes)] + return yield* E.right(result) + }), + decode: ([tag, value], _, ast) => + E.gen(function* () { + if (tag === 0n) { + // Decode as DatumHash + return yield* E.right(new DatumHash.DatumHash({ hash: value }, { disableValidation: true })) + } else if (tag === 1n) { + // Decode as InlineDatum - value is now a CBOR tag 24 wrapper containing bytes + const taggedValue = value as { _tag: "Tag"; tag: number; value: Uint8Array } + if (taggedValue._tag !== "Tag" || taggedValue.tag !== 24) { + return yield* E.left( + new ParseResult.Type( + ast, + [tag, value], + `Invalid InlineDatum format: expected tag 24, got ${taggedValue._tag} with tag ${taggedValue.tag}` + ) + ) + } + return yield* E.right( + OriginalBytes.record( + new InlineDatum.InlineDatum( + { + data: PlutusData.fromCBORBytes(taggedValue.value) + }, + { disableValidation: true } + ), + taggedValue.value + ) + ) + } + return yield* E.left(new ParseResult.Type(ast, [tag, value], `Invalid DatumOption tag: ${tag}. Expected 0 or 1.`)) + }) + }).annotations({ + identifier: "DatumOption.DatumOptionCDDLSchema", + description: "Transforms CBOR structure to DatumOption" + }) + /** * CDDL schema for DatumOption. * datum_option = [0, Bytes32] / [1, #6.24(bytes)] @@ -68,87 +131,42 @@ export const CDDLSchema = Schema.Union( * - [0, Bytes32] represents a datum hash (tag 0 with 32-byte hash) * - [1, #6.24(bytes)] represents inline data (tag 1 with CBOR tag 24 containing plutus data as bytes) * + * A new inline datum is written with `Data.DEFAULT_CBOR_OPTIONS`. + * * @since 2.0.0 * @category schemas */ -export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(DatumOptionSchema), { - strict: true, - encode: (toA) => - E.gen(function* () { - const result = - toA._tag === "DatumHash" - ? ([0n, toA.hash] as const) // Encode as [0, Bytes32] - : ([ - 1n, - { - _tag: "Tag" as const, - tag: 24 as const, - // Write back the decoded bytes when present, so the datum keeps its original layout - value: OriginalBytes.get(toA) ?? PlutusData.toCBORBytes(toA.data) - } - ] as const) // Encode as [1, tag(24, bytes)] - return yield* E.right(result) - }), - decode: ([tag, value], _, ast) => - E.gen(function* () { - if (tag === 0n) { - // Decode as DatumHash - return yield* E.right(new DatumHash.DatumHash({ hash: value }, { disableValidation: true })) - } else if (tag === 1n) { - // Decode as InlineDatum - value is now a CBOR tag 24 wrapper containing bytes - const taggedValue = value as { _tag: "Tag"; tag: number; value: Uint8Array } - if (taggedValue._tag !== "Tag" || taggedValue.tag !== 24) { - return yield* E.left( - new ParseResult.Type( - ast, - [tag, value], - `Invalid InlineDatum format: expected tag 24, got ${taggedValue._tag} with tag ${taggedValue.tag}` - ) - ) - } - return yield* E.right( - OriginalBytes.record( - new InlineDatum.InlineDatum( - { - data: PlutusData.fromCBORBytes(taggedValue.value) - }, - { disableValidation: true } - ), - taggedValue.value - ) - ) - } - return yield* E.left(new ParseResult.Type(ast, [tag, value], `Invalid DatumOption tag: ${tag}. Expected 0 or 1.`)) - }) -}).annotations({ - identifier: "DatumOption.DatumOptionCDDLSchema", - description: "Transforms CBOR structure to DatumOption" -}) +export const FromCDDL = makeFromCDDL(PlutusData.DEFAULT_CBOR_OPTIONS) /** * CBOR bytes transformation schema for DatumOption. * Transforms between Uint8Array and DatumOption using CBOR encoding. * + * A new inline datum is written with the `plutusData` options, the rest with + * the `ledger` options. Plain options are read as `CBOR.toTxCodecOptions` + * reads them. + * * @since 2.0.0 * @category schemas */ -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.compose( - CBOR.FromBytes(options), // Uint8Array → CBOR - FromCDDL // CBOR → DatumOption +export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => { + const { ledger, plutusData } = CBOR.toTxCodecOptions(options) + return Schema.compose( + CBOR.FromBytes(ledger), // Uint8Array → CBOR + plutusData === PlutusData.DEFAULT_CBOR_OPTIONS ? FromCDDL : makeFromCDDL(plutusData) // CBOR → DatumOption ).annotations({ identifier: "DatumOption.FromCBORBytes", description: "Transforms CBOR bytes to DatumOption" }) +} /** - * CBOR hex transformation schema for DatumOption. - * Transforms between hex string and DatumOption using CBOR encoding. + * CBOR hex transformation schema for DatumOption, as in {@link FromCBORBytes}. * * @since 2.0.0 * @category schemas */ -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => Schema.compose( Schema.Uint8ArrayFromHex, // string → Uint8Array FromCBORBytes(options) // Uint8Array → DatumOption @@ -163,7 +181,10 @@ export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTION * @since 2.0.0 * @category encoding */ -export const toCBORBytes = (data: DatumOption, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORBytes = ( + data: DatumOption, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORBytes(options))(data) /** @@ -172,7 +193,10 @@ export const toCBORBytes = (data: DatumOption, options: CBOR.CodecOptions = CBOR * @since 2.0.0 * @category encoding */ -export const toCBORHex = (data: DatumOption, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORHex = ( + data: DatumOption, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORHex(options))(data) /** diff --git a/packages/evolution/src/Transaction.ts b/packages/evolution/src/Transaction.ts index b6d0a8a4..5b291b74 100644 --- a/packages/evolution/src/Transaction.ts +++ b/packages/evolution/src/Transaction.ts @@ -3,6 +3,7 @@ import { Effect as Eff, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema import * as AuxiliaryData from "./AuxiliaryData.js" import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" +import * as PlutusData from "./Data.js" import * as TransactionBody from "./TransactionBody.js" import * as TransactionWitnessSet from "./TransactionWitnessSet.js" @@ -104,14 +105,15 @@ const encodeArray = (items: ReadonlyArray, options: CBOR.CodecOption : CBOR.encodeArrayAsDefinite(items) /** - * CBOR bytes transformation schema for Transaction. The witness set is - * written as `TransactionWitnessSet.FromCBORBytes(options)` writes it, the - * rest with the `ledger` options. Plain options are read as - * `CBOR.toTxCodecOptions` reads them. + * CBOR bytes transformation schema for Transaction. The body and the witness + * set are written as `TransactionBody.FromCBORBytes(options)` and + * `TransactionWitnessSet.FromCBORBytes(options)` write them, the rest with + * the `ledger` options. Plain options are read as `CBOR.toTxCodecOptions` + * reads them. */ export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => { const txOptions = CBOR.toTxCodecOptions(options) - const { ledger } = txOptions + const { ledger, plutusData } = txOptions return Schema.transformOrFail(Schema.Uint8ArrayFromSelf, Schema.typeSchema(Transaction), { strict: true, decode: (bytes, parseOptions) => @@ -119,12 +121,17 @@ export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = encode: (tx, parseOptions, ast) => Eff.flatMap(ParseResult.encode(FromCDDL)(tx, parseOptions), (tuple) => ParseResult.try({ - // The witness set writes its Plutus data with their own options, so - // the tuple is joined from its encoded items + // The body and the witness set write their Plutus data with their + // own options, so the tuple is joined from its encoded items. The + // tuple holds the body with its inline datums in the default layout. try: () => encodeArray( tuple.map((item, i) => - i === 1 ? TransactionWitnessSet.toCBORBytes(tx.witnessSet, txOptions) : CBOR.toCBORBytes(item, ledger) + i === 0 && plutusData !== PlutusData.DEFAULT_CBOR_OPTIONS + ? TransactionBody.toCBORBytes(tx.body, txOptions) + : i === 1 + ? TransactionWitnessSet.toCBORBytes(tx.witnessSet, txOptions) + : CBOR.toCBORBytes(item, ledger) ), ledger ), diff --git a/packages/evolution/src/TransactionBody.ts b/packages/evolution/src/TransactionBody.ts index 658d99dd..ee2e322a 100644 --- a/packages/evolution/src/TransactionBody.ts +++ b/packages/evolution/src/TransactionBody.ts @@ -8,6 +8,7 @@ import * as Bytes from "./Bytes.js" import * as CBOR from "./CBOR.js" import * as Certificate from "./Certificate.js" import * as Coin from "./Coin.js" +import * as PlutusData from "./Data.js" import * as GovernanceAction from "./GovernanceAction.js" import * as KeyHash from "./KeyHash.js" import * as Mint from "./Mint.js" @@ -177,7 +178,6 @@ export class TransactionBody extends Schema.TaggedClass()("Tran // Pre-bind hot ParseResult helpers const encodeTxInput = ParseResult.encodeEither(TransactionInput.FromCDDL) const decodeTxInput = ParseResult.decodeEither(TransactionInput.FromCDDL) -const encodeTxOutput = ParseResult.encodeEither(TxOut.FromCDDL) const decodeTxOutput = ParseResult.decodeEither(TxOut.FromCDDL) const encodeCertificate = ParseResult.encodeEither(Certificate.FromCDDL) const decodeCertificate = ParseResult.decodeEither(Certificate.FromCDDL) @@ -211,319 +211,340 @@ export const CDDLSchema = Schema.declare( type CDDLSchema = typeof CDDLSchema.Type -export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(TransactionBody), { - strict: true, - encode: (toA) => - E.gen(function* () { - const record = new Map() - - // Required fields - // 0: inputs - always tagged as set - const inputsLen = toA.inputs.length - const inputsArr = new Array(inputsLen) - for (let i = 0; i < inputsLen; i++) { - inputsArr[i] = yield* encodeTxInput(toA.inputs[i]) - } - record.set(0n, CBOR.Tag.make({ tag: 258, value: inputsArr }, { disableValidation: true })) - - // 1: outputs - const outputsLen = toA.outputs.length - const outputsArr = new Array(outputsLen) - for (let i = 0; i < outputsLen; i++) { - outputsArr[i] = yield* encodeTxOutput(toA.outputs[i]) - } - record.set(1n, outputsArr) - - // 2: fee - record.set(2n, toA.fee) - - // Optional fields (assign directly when present) - if (toA.ttl !== undefined) record.set(3n, toA.ttl) - - if (toA.certificates && toA.certificates.length > 0) { - const len = toA.certificates.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* encodeCertificate(toA.certificates[i]) +// The CDDL transform that writes a new inline datum with the `plutusData` options +const makeFromCDDL = (plutusData: CBOR.CodecOptions) => { + const encodeTxOutput = ParseResult.encodeEither(TxOut.makeFromCDDL(plutusData)) + return Schema.transformOrFail(CDDLSchema, Schema.typeSchema(TransactionBody), { + strict: true, + encode: (toA) => + E.gen(function* () { + const record = new Map() + + // Required fields + // 0: inputs - always tagged as set + const inputsLen = toA.inputs.length + const inputsArr = new Array(inputsLen) + for (let i = 0; i < inputsLen; i++) { + inputsArr[i] = yield* encodeTxInput(toA.inputs[i]) } - record.set(4n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) - } - - if (toA.withdrawals) { - const map = new Map() - for (const [rewardAccount, coin] of toA.withdrawals.withdrawals.entries()) { - const accountBytes = yield* encodeRewardAccountBytes(rewardAccount) - map.set(accountBytes, coin) + record.set(0n, CBOR.Tag.make({ tag: 258, value: inputsArr }, { disableValidation: true })) + + // 1: outputs + const outputsLen = toA.outputs.length + const outputsArr = new Array(outputsLen) + for (let i = 0; i < outputsLen; i++) { + outputsArr[i] = yield* encodeTxOutput(toA.outputs[i]) } - record.set(5n, map) - } + record.set(1n, outputsArr) + + // 2: fee + record.set(2n, toA.fee) - if (toA.auxiliaryDataHash) record.set(7n, toA.auxiliaryDataHash.bytes) + // Optional fields (assign directly when present) + if (toA.ttl !== undefined) record.set(3n, toA.ttl) - if (toA.validityIntervalStart !== undefined) record.set(8n, toA.validityIntervalStart) + if (toA.certificates && toA.certificates.length > 0) { + const len = toA.certificates.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* encodeCertificate(toA.certificates[i]) + } + record.set(4n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) + } + + if (toA.withdrawals) { + const map = new Map() + for (const [rewardAccount, coin] of toA.withdrawals.withdrawals.entries()) { + const accountBytes = yield* encodeRewardAccountBytes(rewardAccount) + map.set(accountBytes, coin) + } + record.set(5n, map) + } - if (toA.mint) record.set(9n, yield* encodeMint(toA.mint)) + if (toA.auxiliaryDataHash) record.set(7n, toA.auxiliaryDataHash.bytes) - if (toA.scriptDataHash) record.set(11n, toA.scriptDataHash.hash) + if (toA.validityIntervalStart !== undefined) record.set(8n, toA.validityIntervalStart) - if (toA.collateralInputs) { - const len = toA.collateralInputs.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* encodeTxInput(toA.collateralInputs[i]) + if (toA.mint) record.set(9n, yield* encodeMint(toA.mint)) + + if (toA.scriptDataHash) record.set(11n, toA.scriptDataHash.hash) + + if (toA.collateralInputs) { + const len = toA.collateralInputs.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* encodeTxInput(toA.collateralInputs[i]) + } + record.set(13n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) } - record.set(13n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) - } - - if (toA.requiredSigners) { - const len = toA.requiredSigners.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = toA.requiredSigners[i].hash + + if (toA.requiredSigners) { + const len = toA.requiredSigners.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = toA.requiredSigners[i].hash + } + record.set(14n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) } - record.set(14n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) - } - if (toA.networkId !== undefined) record.set(15n, BigInt(toA.networkId)) + if (toA.networkId !== undefined) record.set(15n, BigInt(toA.networkId)) - if (toA.collateralReturn) { - record.set(16n, yield* encodeTxOutput(toA.collateralReturn)) - } + if (toA.collateralReturn) { + record.set(16n, yield* encodeTxOutput(toA.collateralReturn)) + } - if (toA.totalCollateral !== undefined) record.set(17n, toA.totalCollateral) + if (toA.totalCollateral !== undefined) record.set(17n, toA.totalCollateral) - if (toA.referenceInputs) { - const len = toA.referenceInputs.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* encodeTxInput(toA.referenceInputs[i]) + if (toA.referenceInputs) { + const len = toA.referenceInputs.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* encodeTxInput(toA.referenceInputs[i]) + } + record.set(18n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) } - record.set(18n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) - } - if (toA.votingProcedures) record.set(19n, yield* encodeVotingProcedures(toA.votingProcedures)) + if (toA.votingProcedures) record.set(19n, yield* encodeVotingProcedures(toA.votingProcedures)) - if (toA.proposalProcedures && toA.proposalProcedures.procedures.length > 0) { - const len = toA.proposalProcedures.procedures.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* encodeProposalProcedure(toA.proposalProcedures.procedures[i]) + if (toA.proposalProcedures && toA.proposalProcedures.procedures.length > 0) { + const len = toA.proposalProcedures.procedures.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* encodeProposalProcedure(toA.proposalProcedures.procedures[i]) + } + record.set(20n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) } - record.set(20n, CBOR.Tag.make({ tag: 258, value: arr }, { disableValidation: true })) - } - if (toA.currentTreasuryValue !== undefined) record.set(21n, toA.currentTreasuryValue) + if (toA.currentTreasuryValue !== undefined) record.set(21n, toA.currentTreasuryValue) - if (toA.donation !== undefined) record.set(22n, toA.donation) + if (toA.donation !== undefined) record.set(22n, toA.donation) - return record as CDDLSchema - }), - decode: (fromA) => - E.gen(function* () { - // Required fields - accept both tag-258 (Conway) and plain array (Babbage) - const inputsRaw = fromA.get(0n) - const taggedResult = decodeTaggedInputs(inputsRaw) - const inputs = E.isRight(taggedResult) - ? taggedResult.right.value - : yield* decodeUntaggedInputs(inputsRaw) - - // const inputsArray = inputsTag.value - // const inputsLen = inputsArray.length - // const inputs = new Array(inputsLen) - // for (let i = 0; i < inputsLen; i++) { - // inputs[i] = yield* decodeTxInput(inputsArray[i]) - // } - - const outputsArray = fromA.get(1n) as Array - const outputsLen = outputsArray.length - const outputs = new Array(outputsLen) - for (let i = 0; i < outputsLen; i++) { - outputs[i] = yield* decodeTxOutput(outputsArray[i]) - } - const fee = fromA.get(2n) as bigint - - // Optional fields - access as record properties - const ttl = fromA.get(3n) as bigint | undefined - - // Accept both tag-258 (Conway) and plain array (Babbage) for certificates. - // Mirrors the `inputs` pattern above: try tagged first, fall back to - // untagged, and fail decoding if neither matches. - const certificatesRaw = fromA.get(4n) - let certificatesArray: ReadonlyArray | undefined - if (certificatesRaw !== undefined) { - const taggedCertsResult = decodeTaggedCertificates(certificatesRaw) - certificatesArray = E.isRight(taggedCertsResult) - ? taggedCertsResult.right.value - : yield* decodeUntaggedCertificates(certificatesRaw) - } - let certificates: NonEmptyArray | undefined - if (certificatesArray) { - const len = certificatesArray.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* decodeCertificate(certificatesArray[i]) + return record as CDDLSchema + }), + decode: (fromA) => + E.gen(function* () { + // Required fields - accept both tag-258 (Conway) and plain array (Babbage) + const inputsRaw = fromA.get(0n) + const taggedResult = decodeTaggedInputs(inputsRaw) + const inputs = E.isRight(taggedResult) + ? taggedResult.right.value + : yield* decodeUntaggedInputs(inputsRaw) + + // const inputsArray = inputsTag.value + // const inputsLen = inputsArray.length + // const inputs = new Array(inputsLen) + // for (let i = 0; i < inputsLen; i++) { + // inputs[i] = yield* decodeTxInput(inputsArray[i]) + // } + + const outputsArray = fromA.get(1n) as Array + const outputsLen = outputsArray.length + const outputs = new Array(outputsLen) + for (let i = 0; i < outputsLen; i++) { + outputs[i] = yield* decodeTxOutput(outputsArray[i]) } - certificates = arr as NonEmptyArray - } - - let withdrawals: Withdrawals.Withdrawals | undefined - const withdrawalsMap = fromA.get(5n) as typeof Withdrawals.CDDLSchema.Type | undefined - if (withdrawalsMap) { - const decodedWithdrawals = new Map() - const entriesIter = (withdrawalsMap as ReadonlyMap).entries() - for (const [accountBytes, coinAmount] of entriesIter) { - const rewardAccount = yield* decodeRewardAccountBytes(accountBytes) - decodedWithdrawals.set(rewardAccount, coinAmount) + const fee = fromA.get(2n) as bigint + + // Optional fields - access as record properties + const ttl = fromA.get(3n) as bigint | undefined + + // Accept both tag-258 (Conway) and plain array (Babbage) for certificates. + // Mirrors the `inputs` pattern above: try tagged first, fall back to + // untagged, and fail decoding if neither matches. + const certificatesRaw = fromA.get(4n) + let certificatesArray: ReadonlyArray | undefined + if (certificatesRaw !== undefined) { + const taggedCertsResult = decodeTaggedCertificates(certificatesRaw) + certificatesArray = E.isRight(taggedCertsResult) + ? taggedCertsResult.right.value + : yield* decodeUntaggedCertificates(certificatesRaw) } - withdrawals = new Withdrawals.Withdrawals({ withdrawals: decodedWithdrawals }) - } - - const auxiliaryDataHashBytes = fromA.get(7n) as Uint8Array | undefined - const auxiliaryDataHash = auxiliaryDataHashBytes - ? yield* decodeAuxiliaryDataHash(auxiliaryDataHashBytes) - : undefined - const validityIntervalStart = fromA.get(8n) as bigint | undefined - const mintData = fromA.get(9n) as typeof Mint.CDDLSchema.Type | undefined - const mint = mintData ? yield* decodeMint(mintData) : undefined - const scriptDataHashBytes = fromA.get(11n) as Uint8Array | undefined - const scriptDataHash = scriptDataHashBytes ? yield* decodeScriptDataHash(scriptDataHashBytes) : undefined - - // Accept both tag-258 (Conway) and plain array (Babbage) for collateral inputs - const collateralInputsRaw = fromA.get(13n) as - | { _tag: "Tag"; tag: 258; value: ReadonlyArray } - | ReadonlyArray - | undefined - const collateralInputsArray = collateralInputsRaw - ? (collateralInputsRaw as any)._tag === "Tag" - ? (collateralInputsRaw as any).value - : collateralInputsRaw - : undefined - let collateralInputs: NonEmptyArray | undefined - if (collateralInputsArray) { - const len = collateralInputsArray.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* decodeTxInput(collateralInputsArray[i]) + let certificates: NonEmptyArray | undefined + if (certificatesArray) { + const len = certificatesArray.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* decodeCertificate(certificatesArray[i]) + } + certificates = arr as NonEmptyArray } - collateralInputs = arr as NonEmptyArray - } - - // Accept both tag-258 (Conway) and plain array (Babbage) for required signers - const requiredSignersRaw = fromA.get(14n) as - | { _tag: "Tag"; tag: 258; value: ReadonlyArray } - | ReadonlyArray - | undefined - const requiredSignersArray = requiredSignersRaw - ? (requiredSignersRaw as any)._tag === "Tag" - ? (requiredSignersRaw as any).value - : requiredSignersRaw - : undefined - let requiredSigners: NonEmptyArray | undefined - if (requiredSignersArray) { - const len = requiredSignersArray.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* decodeKeyHash(requiredSignersArray[i]) + + let withdrawals: Withdrawals.Withdrawals | undefined + const withdrawalsMap = fromA.get(5n) as typeof Withdrawals.CDDLSchema.Type | undefined + if (withdrawalsMap) { + const decodedWithdrawals = new Map() + const entriesIter = (withdrawalsMap as ReadonlyMap).entries() + for (const [accountBytes, coinAmount] of entriesIter) { + const rewardAccount = yield* decodeRewardAccountBytes(accountBytes) + decodedWithdrawals.set(rewardAccount, coinAmount) + } + withdrawals = new Withdrawals.Withdrawals({ withdrawals: decodedWithdrawals }) } - requiredSigners = arr as NonEmptyArray - } - const networkIdBigInt = fromA.get(15n) as bigint | undefined - const networkId = networkIdBigInt !== undefined ? (Number(networkIdBigInt) as NetworkId.NetworkId) : undefined - const collateralReturnData = fromA.get(16n) as typeof TxOut.CDDLSchema.Type | undefined - const collateralReturn = collateralReturnData ? yield* decodeTxOutput(collateralReturnData) : undefined - const totalCollateral = fromA.get(17n) as Coin.Coin | undefined - - // Accept both tag-258 (Conway) and plain array (Babbage) for reference inputs - const referenceInputsRaw = fromA.get(18n) as - | { _tag: "Tag"; tag: 258; value: ReadonlyArray } - | ReadonlyArray - | undefined - const referenceInputsArray: ReadonlyArray | undefined = referenceInputsRaw - ? (referenceInputsRaw as any)._tag === "Tag" - ? (referenceInputsRaw as any).value - : referenceInputsRaw - : undefined - let referenceInputs: NonEmptyArray | undefined - if (referenceInputsArray) { - const len = referenceInputsArray.length - const arr = new Array(len) - for (let i = 0; i < len; i++) { - arr[i] = yield* decodeTxInput(referenceInputsArray[i]) + + const auxiliaryDataHashBytes = fromA.get(7n) as Uint8Array | undefined + const auxiliaryDataHash = auxiliaryDataHashBytes + ? yield* decodeAuxiliaryDataHash(auxiliaryDataHashBytes) + : undefined + const validityIntervalStart = fromA.get(8n) as bigint | undefined + const mintData = fromA.get(9n) as typeof Mint.CDDLSchema.Type | undefined + const mint = mintData ? yield* decodeMint(mintData) : undefined + const scriptDataHashBytes = fromA.get(11n) as Uint8Array | undefined + const scriptDataHash = scriptDataHashBytes ? yield* decodeScriptDataHash(scriptDataHashBytes) : undefined + + // Accept both tag-258 (Conway) and plain array (Babbage) for collateral inputs + const collateralInputsRaw = fromA.get(13n) as + | { _tag: "Tag"; tag: 258; value: ReadonlyArray } + | ReadonlyArray + | undefined + const collateralInputsArray = collateralInputsRaw + ? (collateralInputsRaw as any)._tag === "Tag" + ? (collateralInputsRaw as any).value + : collateralInputsRaw + : undefined + let collateralInputs: NonEmptyArray | undefined + if (collateralInputsArray) { + const len = collateralInputsArray.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* decodeTxInput(collateralInputsArray[i]) + } + collateralInputs = arr as NonEmptyArray } - referenceInputs = arr as NonEmptyArray - } - const votingProceduresData = fromA.get(19n) as typeof VotingProcedures.CDDLSchema.Type | undefined - const votingProcedures = votingProceduresData ? yield* decodeVotingProcedures(votingProceduresData) : undefined - // Accept both tag-258 (Conway) and plain array (Babbage) for proposal procedures - const proposalProceduresRaw = fromA.get(20n) as - | { _tag: "Tag"; tag: 258; value: ReadonlyArray } - | ReadonlyArray - | undefined - const proposalProceduresArray: ReadonlyArray | undefined = proposalProceduresRaw - ? (proposalProceduresRaw as any)._tag === "Tag" - ? (proposalProceduresRaw as any).value - : proposalProceduresRaw - : undefined - const proposalProcedures = proposalProceduresArray - ? new ProposalProcedures.ProposalProcedures({ - procedures: yield* E.all(proposalProceduresArray.map((pp) => decodeProposalProcedure(pp))) - }) - : undefined - const currentTreasuryValue = fromA.get(21n) as Coin.Coin | undefined - const donation = fromA.get(22n) as Coin.Coin | undefined - - const result = new TransactionBody( - { - inputs, - outputs, - fee, - ttl, - certificates, - withdrawals, - auxiliaryDataHash, - validityIntervalStart, - mint, - scriptDataHash, - collateralInputs, - requiredSigners, - networkId, - collateralReturn, - totalCollateral, - referenceInputs, - votingProcedures, - proposalProcedures, - currentTreasuryValue, - donation - }, - { disableValidation: true } - ) - return result - }) -}) + + // Accept both tag-258 (Conway) and plain array (Babbage) for required signers + const requiredSignersRaw = fromA.get(14n) as + | { _tag: "Tag"; tag: 258; value: ReadonlyArray } + | ReadonlyArray + | undefined + const requiredSignersArray = requiredSignersRaw + ? (requiredSignersRaw as any)._tag === "Tag" + ? (requiredSignersRaw as any).value + : requiredSignersRaw + : undefined + let requiredSigners: NonEmptyArray | undefined + if (requiredSignersArray) { + const len = requiredSignersArray.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* decodeKeyHash(requiredSignersArray[i]) + } + requiredSigners = arr as NonEmptyArray + } + const networkIdBigInt = fromA.get(15n) as bigint | undefined + const networkId = networkIdBigInt !== undefined ? (Number(networkIdBigInt) as NetworkId.NetworkId) : undefined + const collateralReturnData = fromA.get(16n) as typeof TxOut.CDDLSchema.Type | undefined + const collateralReturn = collateralReturnData ? yield* decodeTxOutput(collateralReturnData) : undefined + const totalCollateral = fromA.get(17n) as Coin.Coin | undefined + + // Accept both tag-258 (Conway) and plain array (Babbage) for reference inputs + const referenceInputsRaw = fromA.get(18n) as + | { _tag: "Tag"; tag: 258; value: ReadonlyArray } + | ReadonlyArray + | undefined + const referenceInputsArray: ReadonlyArray | undefined = referenceInputsRaw + ? (referenceInputsRaw as any)._tag === "Tag" + ? (referenceInputsRaw as any).value + : referenceInputsRaw + : undefined + let referenceInputs: NonEmptyArray | undefined + if (referenceInputsArray) { + const len = referenceInputsArray.length + const arr = new Array(len) + for (let i = 0; i < len; i++) { + arr[i] = yield* decodeTxInput(referenceInputsArray[i]) + } + referenceInputs = arr as NonEmptyArray + } + const votingProceduresData = fromA.get(19n) as typeof VotingProcedures.CDDLSchema.Type | undefined + const votingProcedures = votingProceduresData ? yield* decodeVotingProcedures(votingProceduresData) : undefined + // Accept both tag-258 (Conway) and plain array (Babbage) for proposal procedures + const proposalProceduresRaw = fromA.get(20n) as + | { _tag: "Tag"; tag: 258; value: ReadonlyArray } + | ReadonlyArray + | undefined + const proposalProceduresArray: ReadonlyArray | undefined = proposalProceduresRaw + ? (proposalProceduresRaw as any)._tag === "Tag" + ? (proposalProceduresRaw as any).value + : proposalProceduresRaw + : undefined + const proposalProcedures = proposalProceduresArray + ? new ProposalProcedures.ProposalProcedures({ + procedures: yield* E.all(proposalProceduresArray.map((pp) => decodeProposalProcedure(pp))) + }) + : undefined + const currentTreasuryValue = fromA.get(21n) as Coin.Coin | undefined + const donation = fromA.get(22n) as Coin.Coin | undefined + + const result = new TransactionBody( + { + inputs, + outputs, + fee, + ttl, + certificates, + withdrawals, + auxiliaryDataHash, + validityIntervalStart, + mint, + scriptDataHash, + collateralInputs, + requiredSigners, + networkId, + collateralReturn, + totalCollateral, + referenceInputs, + votingProcedures, + proposalProcedures, + currentTreasuryValue, + donation + }, + { disableValidation: true } + ) + return result + }) + }) +} + +/** + * CDDL transformation schema for TransactionBody. A new inline datum is + * written with `Data.DEFAULT_CBOR_OPTIONS`. + * + * @since 2.0.0 + * @category schemas + */ +export const FromCDDL = makeFromCDDL(PlutusData.DEFAULT_CBOR_OPTIONS) /** * CBOR bytes transformation schema for TransactionBody. * Transforms between CBOR bytes and TransactionBody using Conway CDDL specification. * + * A new inline datum is written with the `plutusData` options, the rest with + * the `ledger` options. Plain options are read as `CBOR.toTxCodecOptions` + * reads them. + * * @since 2.0.0 * @category schemas */ -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.compose(CBOR.FromBytes(options), FromCDDL).annotations({ +export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => { + const { ledger, plutusData } = CBOR.toTxCodecOptions(options) + return Schema.compose( + CBOR.FromBytes(ledger), + plutusData === PlutusData.DEFAULT_CBOR_OPTIONS ? FromCDDL : makeFromCDDL(plutusData) + ).annotations({ identifier: "TransactionBody.FromCBORBytes", title: "TransactionBody from CBOR bytes", description: "Decode TransactionBody from CBOR-encoded bytes using Conway CDDL specification" }) +} /** - * CBOR hex transformation schema for TransactionBody. - * Transforms between CBOR hex string and TransactionBody using Conway CDDL specification. + * CBOR hex transformation schema for TransactionBody, as in {@link FromCBORBytes}. * * @since 2.0.0 * @category schemas */ -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.compose(CBOR.FromHex(options), FromCDDL).annotations({ +export const FromCBORHex = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => + Schema.compose(Schema.Uint8ArrayFromHex, FromCBORBytes(options)).annotations({ identifier: "TransactionBody.FromCBORHex", title: "TransactionBody from CBOR hex", description: "Decode TransactionBody from CBOR-encoded hex string using Conway CDDL specification" @@ -555,7 +576,10 @@ export const fromCBORHex = (hex: string, options: CBOR.CodecOptions = CBOR.CML_D * @since 2.0.0 * @category conversion */ -export const toCBORBytes = (data: TransactionBody, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORBytes = ( + data: TransactionBody, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORBytes(options))(data) /** @@ -564,7 +588,10 @@ export const toCBORBytes = (data: TransactionBody, options: CBOR.CodecOptions = * @since 2.0.0 * @category conversion */ -export const toCBORHex = (data: TransactionBody, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORHex = ( + data: TransactionBody, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORHex(options))(data) /** diff --git a/packages/evolution/src/TransactionOutput.ts b/packages/evolution/src/TransactionOutput.ts index b2ca9983..24c4225c 100644 --- a/packages/evolution/src/TransactionOutput.ts +++ b/packages/evolution/src/TransactionOutput.ts @@ -3,6 +3,7 @@ import { Either as E, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } import * as AddressEras from "./AddressEras.js" import * as BaseAddress from "./BaseAddress.js" import * as CBOR from "./CBOR.js" +import * as PlutusData from "./Data.js" import * as DatumHash from "./DatumHash.js" import * as DatumOption from "./DatumOption.js" import * as EnterpriseAddress from "./EnterpriseAddress.js" @@ -14,7 +15,6 @@ const encAddress = ParseResult.encodeEither(AddressEras.FromBytes) const decAddress = ParseResult.decodeUnknownEither(Schema.Union(BaseAddress.FromBytes, EnterpriseAddress.FromBytes)) const encValue = ParseResult.encodeEither(Value.FromCDDL) const decValue = ParseResult.decodeUnknownEither(Value.FromCDDL) -const encDatumOption = ParseResult.encodeEither(DatumOption.FromCDDL) const decDatumOption = ParseResult.decodeUnknownEither(DatumOption.FromCDDL) const decDatumHash = ParseResult.decodeEither(DatumHash.FromBytes) const encScriptRef = ParseResult.encodeEither(ScriptRef.FromCDDL) @@ -198,16 +198,10 @@ const BabbageTransactionOutputCDDL = Schema.MapFromSelf({ value: CBOR.CBORSchema }) -/** - * CDDL schema for Babbage transaction outputs - * - * @since 2.0.0 - * @category transformation - */ -export const FromBabbageTransactionOutputCDDLSchema = Schema.transformOrFail( - BabbageTransactionOutputCDDL, - Schema.typeSchema(BabbageTransactionOutput), - { +// Writes a new inline datum with the `plutusData` options. +const makeFromBabbageTransactionOutputCDDLSchema = (plutusData: CBOR.CodecOptions) => { + const encodeDatumOption = ParseResult.encodeEither(DatumOption.makeFromCDDL(plutusData)) + return Schema.transformOrFail(BabbageTransactionOutputCDDL, Schema.typeSchema(BabbageTransactionOutput), { strict: true, encode: (toI) => E.gen(function* () { @@ -215,7 +209,7 @@ export const FromBabbageTransactionOutputCDDLSchema = Schema.transformOrFail( const addressBytes = yield* encAddress(toI.address) const valueBytes = yield* encValue(toI.amount) // Prepare optional fields - const datumOptionBytes = toI.datumOption !== undefined ? yield* encDatumOption(toI.datumOption) : undefined + const datumOptionBytes = toI.datumOption !== undefined ? yield* encodeDatumOption(toI.datumOption) : undefined const scriptRefBytes = toI.scriptRef !== undefined ? yield* encScriptRef(toI.scriptRef) : undefined // Build result object with conditional properties @@ -254,7 +248,18 @@ export const FromBabbageTransactionOutputCDDLSchema = Schema.transformOrFail( { disableValidation: true } ) }) - } + }) +} + +/** + * CDDL schema for Babbage transaction outputs. A new inline datum is written + * with `Data.DEFAULT_CBOR_OPTIONS`. + * + * @since 2.0.0 + * @category transformation + */ +export const FromBabbageTransactionOutputCDDLSchema = makeFromBabbageTransactionOutputCDDLSchema( + PlutusData.DEFAULT_CBOR_OPTIONS ) export const CDDLSchema = Schema.Union(ShelleyTransactionOutputCDDL, BabbageTransactionOutputCDDL) @@ -268,28 +273,35 @@ export const CDDLSchema = Schema.Union(ShelleyTransactionOutputCDDL, BabbageTran export const FromCDDL = Schema.Union(FromShelleyTransactionOutputCDDLSchema, FromBabbageTransactionOutputCDDLSchema) /** - * CBOR bytes transformation schema for TransactionOutput. + * CBOR bytes transformation schema for TransactionOutput. A new inline datum + * is written with the `plutusData` options, the rest with the `ledger` + * options. Plain options are read as `CBOR.toTxCodecOptions` reads them. * * @since 2.0.0 * @category transformer */ -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.compose( - CBOR.FromBytes(options), // Uint8Array → CBOR - FromCDDL // CBOR → TransactionOutput +export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => { + const { ledger, plutusData } = CBOR.toTxCodecOptions(options) + return Schema.compose( + CBOR.FromBytes(ledger), // Uint8Array → CBOR + plutusData === PlutusData.DEFAULT_CBOR_OPTIONS + ? FromCDDL + : Schema.Union(FromShelleyTransactionOutputCDDLSchema, makeFromBabbageTransactionOutputCDDLSchema(plutusData)) // CBOR → TransactionOutput ).annotations({ identifier: "TransactionOutput.FromCBORBytes", title: "TransactionOutput from CBOR Bytes", description: "Transforms CBOR bytes (Uint8Array) to TransactionOutput" }) +} /** - * CBOR hex transformation schema for TransactionOutput. + * CBOR hex transformation schema for TransactionOutput, as in + * {@link FromCBORBytes}. * * @since 2.0.0 * @category transformer */ -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => Schema.compose( Schema.Uint8ArrayFromHex, // string → Uint8Array FromCBORBytes(options) // Uint8Array → TransactionOutput @@ -326,8 +338,10 @@ export const arbitrary = FastCheck.oneof( * @since 2.0.0 * @category encoding */ -export const toCBORBytes = (data: TransactionOutput, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.encodeSync(FromCBORBytes(options))(data) +export const toCBORBytes = ( + data: TransactionOutput, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORBytes(options))(data) /** * Convert TransactionOutput to CBOR hex. @@ -335,8 +349,10 @@ export const toCBORBytes = (data: TransactionOutput, options: CBOR.CodecOptions * @since 2.0.0 * @category encoding */ -export const toCBORHex = (data: TransactionOutput, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.encodeSync(FromCBORHex(options))(data) +export const toCBORHex = ( + data: TransactionOutput, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORHex(options))(data) /** * Parse TransactionOutput from CBOR bytes. diff --git a/packages/evolution/src/TxOut.ts b/packages/evolution/src/TxOut.ts index 4e4d0fec..7729797b 100644 --- a/packages/evolution/src/TxOut.ts +++ b/packages/evolution/src/TxOut.ts @@ -3,6 +3,7 @@ import { Either as E, Equal, FastCheck, Hash, Inspectable, ParseResult, Schema } import * as Address from "./Address.js" import * as Assets from "./Assets.js" import * as CBOR from "./CBOR.js" +import * as PlutusData from "./Data.js" import * as DatumHash from "./DatumHash.js" import * as DatumOption from "./DatumOption.js" import * as ScriptRef from "./ScriptRef.js" @@ -222,6 +223,102 @@ export const FromBabbageTransactionOutputCDDL = Schema.transformOrFail( } ) +/** + * CDDL transformation schema for transaction outputs, as in {@link FromCDDL}, + * that writes a new inline datum with the `plutusData` options. + * + * @since 2.0.0 + * @category transformation + */ +export const makeFromCDDL = (plutusData: CBOR.CodecOptions) => { + const encodeDatumOption = ParseResult.encodeEither(DatumOption.makeFromCDDL(plutusData)) + return Schema.transformOrFail(CDDLSchema, Schema.typeSchema(TransactionOutput), { + strict: true, + encode: (toI) => + E.gen(function* () { + // Determine if we can use Shelley format (more compact) + const canUseShelleyFormat = + toI.scriptRef === undefined && (toI.datumOption === undefined || toI.datumOption._tag === "DatumHash") + + if (canUseShelleyFormat) { + // Use Shelley format (array) + const addressBytes = yield* encAddress(toI.address) + const assetsBytes = yield* encAssets(toI.assets) + + if (toI.datumOption !== undefined && toI.datumOption._tag === "DatumHash") { + return [addressBytes, assetsBytes, toI.datumOption.hash] as const + } + + return [addressBytes, assetsBytes] as const + } else { + // Use Babbage format (map) + const outputMap = new Map() + const addressBytes = yield* encAddress(toI.address) + const assetsBytes = yield* encAssets(toI.assets) + const datumOptionBytes = toI.datumOption !== undefined ? yield* encodeDatumOption(toI.datumOption) : undefined + const scriptRefBytes = toI.scriptRef !== undefined ? yield* encScriptRef(toI.scriptRef) : undefined + + outputMap.set(0n, addressBytes) + outputMap.set(1n, assetsBytes) + if (datumOptionBytes !== undefined) { + outputMap.set(2n, datumOptionBytes) + } + if (scriptRefBytes !== undefined) { + outputMap.set(3n, scriptRefBytes) + } + return outputMap + } + }), + decode: (fromI) => + E.gen(function* () { + // Check if it's an array (Shelley) or map (Babbage) + if (Array.isArray(fromI)) { + // Shelley format + const [addressBytes, assetsBytes, datumHashBytes] = fromI + const address = yield* decAddress(addressBytes) + const assets = yield* decAssets(assetsBytes) + let datumOption: DatumOption.DatumOption | undefined + if (datumHashBytes !== undefined) { + const datumHash = yield* decDatumHash(datumHashBytes) + datumOption = datumHash + } + + return new TransactionOutput( + { + address, + assets, + datumOption, + scriptRef: undefined + }, + { disableValidation: true } + ) + } else { + // Babbage format (map) - cast to Map type + const outputMap = fromI as ReadonlyMap + const addressBytes = outputMap.get(0n) + const assetsBytes = outputMap.get(1n) + const datumOptionBytes = outputMap.get(2n) + const scriptRefBytes = outputMap.get(3n) + + const address = yield* decAddress(addressBytes) + const assets = yield* decAssets(assetsBytes) + const datumOption = datumOptionBytes !== undefined ? yield* decDatumOption(datumOptionBytes) : undefined + const scriptRef = scriptRefBytes !== undefined ? yield* decScriptRef(scriptRefBytes) : undefined + + return new TransactionOutput( + { + address, + assets, + datumOption, + scriptRef + }, + { disableValidation: true } + ) + } + }) + }) +} + /** * CDDL transformation schema for transaction outputs (supports both Shelley and Babbage formats) * @@ -231,118 +328,41 @@ export const FromBabbageTransactionOutputCDDL = Schema.transformOrFail( * * Decoding: Accepts both formats * + * A new inline datum is written with `Data.DEFAULT_CBOR_OPTIONS`. + * * @since 2.0.0 * @category transformation */ -export const FromCDDL = Schema.transformOrFail(CDDLSchema, Schema.typeSchema(TransactionOutput), { - strict: true, - encode: (toI) => - E.gen(function* () { - // Determine if we can use Shelley format (more compact) - const canUseShelleyFormat = - toI.scriptRef === undefined && (toI.datumOption === undefined || toI.datumOption._tag === "DatumHash") - - if (canUseShelleyFormat) { - // Use Shelley format (array) - const addressBytes = yield* encAddress(toI.address) - const assetsBytes = yield* encAssets(toI.assets) - - if (toI.datumOption !== undefined && toI.datumOption._tag === "DatumHash") { - return [addressBytes, assetsBytes, toI.datumOption.hash] as const - } - - return [addressBytes, assetsBytes] as const - } else { - // Use Babbage format (map) - const outputMap = new Map() - const addressBytes = yield* encAddress(toI.address) - const assetsBytes = yield* encAssets(toI.assets) - const datumOptionBytes = toI.datumOption !== undefined ? yield* encDatumOption(toI.datumOption) : undefined - const scriptRefBytes = toI.scriptRef !== undefined ? yield* encScriptRef(toI.scriptRef) : undefined - - outputMap.set(0n, addressBytes) - outputMap.set(1n, assetsBytes) - if (datumOptionBytes !== undefined) { - outputMap.set(2n, datumOptionBytes) - } - if (scriptRefBytes !== undefined) { - outputMap.set(3n, scriptRefBytes) - } - return outputMap - } - }), - decode: (fromI) => - E.gen(function* () { - // Check if it's an array (Shelley) or map (Babbage) - if (Array.isArray(fromI)) { - // Shelley format - const [addressBytes, assetsBytes, datumHashBytes] = fromI - const address = yield* decAddress(addressBytes) - const assets = yield* decAssets(assetsBytes) - let datumOption: DatumOption.DatumOption | undefined - if (datumHashBytes !== undefined) { - const datumHash = yield* decDatumHash(datumHashBytes) - datumOption = datumHash - } - - return new TransactionOutput( - { - address, - assets, - datumOption, - scriptRef: undefined - }, - { disableValidation: true } - ) - } else { - // Babbage format (map) - cast to Map type - const outputMap = fromI as ReadonlyMap - const addressBytes = outputMap.get(0n) - const assetsBytes = outputMap.get(1n) - const datumOptionBytes = outputMap.get(2n) - const scriptRefBytes = outputMap.get(3n) - - const address = yield* decAddress(addressBytes) - const assets = yield* decAssets(assetsBytes) - const datumOption = datumOptionBytes !== undefined ? yield* decDatumOption(datumOptionBytes) : undefined - const scriptRef = scriptRefBytes !== undefined ? yield* decScriptRef(scriptRefBytes) : undefined - - return new TransactionOutput( - { - address, - assets, - datumOption, - scriptRef - }, - { disableValidation: true } - ) - } - }) -}) +export const FromCDDL = makeFromCDDL(PlutusData.DEFAULT_CBOR_OPTIONS) /** - * CBOR bytes transformation schema for TransactionOutput. + * CBOR bytes transformation schema for TransactionOutput. A new inline datum + * is written with the `plutusData` options, the rest with the `ledger` + * options. Plain options are read as `CBOR.toTxCodecOptions` reads them. * * @since 2.0.0 * @category transformer */ -export const FromCBORBytes = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => - Schema.compose( - CBOR.FromBytes(options), // Uint8Array → CBOR - FromCDDL // CBOR → TransactionOutput +export const FromCBORBytes = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => { + const { ledger, plutusData } = CBOR.toTxCodecOptions(options) + return Schema.compose( + CBOR.FromBytes(ledger), // Uint8Array → CBOR + plutusData === PlutusData.DEFAULT_CBOR_OPTIONS ? FromCDDL : makeFromCDDL(plutusData) // CBOR → TransactionOutput ).annotations({ identifier: "TransactionOutput.FromCBORBytes", title: "TransactionOutput from CBOR Bytes", description: "Transforms CBOR bytes (Uint8Array) to TransactionOutput" }) +} /** - * CBOR hex transformation schema for TransactionOutput. + * CBOR hex transformation schema for TransactionOutput, as in + * {@link FromCBORBytes}. * * @since 2.0.0 * @category transformer */ -export const FromCBORHex = (options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const FromCBORHex = (options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS) => Schema.compose( Schema.Uint8ArrayFromHex, // string → Uint8Array FromCBORBytes(options) // Uint8Array → TransactionOutput @@ -369,7 +389,10 @@ export const arbitrary = FastCheck.record({ * @since 2.0.0 * @category encoding */ -export const toCBORBytes = (data: TransactionOutput, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORBytes = ( + data: TransactionOutput, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORBytes(options))(data) /** @@ -378,7 +401,10 @@ export const toCBORBytes = (data: TransactionOutput, options: CBOR.CodecOptions * @since 2.0.0 * @category encoding */ -export const toCBORHex = (data: TransactionOutput, options: CBOR.CodecOptions = CBOR.CML_DEFAULT_OPTIONS) => +export const toCBORHex = ( + data: TransactionOutput, + options: CBOR.TxCodecOptions | CBOR.CodecOptions = CBOR.TX_DEFAULT_OPTIONS +) => Schema.encodeSync(FromCBORHex(options))(data) /** diff --git a/packages/evolution/test/InlineDatum.TxCodecOptions.test.ts b/packages/evolution/test/InlineDatum.TxCodecOptions.test.ts new file mode 100644 index 00000000..ddf5b60d --- /dev/null +++ b/packages/evolution/test/InlineDatum.TxCodecOptions.test.ts @@ -0,0 +1,239 @@ +import { blake2b } from "@noble/hashes/blake2.js" +import { describe, expect, it } from "vitest" + +import * as Address from "../src/Address.js" +import * as Assets from "../src/Assets.js" +import * as Bytes from "../src/Bytes.js" +import * as CBOR from "../src/CBOR.js" +import * as Data from "../src/Data.js" +import * as DatumOption from "../src/DatumOption.js" +import * as InlineDatum from "../src/InlineDatum.js" +import * as OriginalBytes from "../src/OriginalBytes.js" +import * as PrivateKey from "../src/PrivateKey.js" +import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as TransactionInput from "../src/TransactionInput.js" +import * as TransactionOutput from "../src/TransactionOutput.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as TxOut from "../src/TxOut.js" +import * as VKey from "../src/VKey.js" + +// A new inline datum is written with the plutusData options, the rest of the +// transaction with the ledger options. A decoded inline datum keeps its bytes. + +/** CBOR byte string header for a length below 65536, then the bytes. */ +const bstr = (hex: string) => { + const n = hex.length / 2 + if (n < 24) return (0x40 + n).toString(16).padStart(2, "0") + hex + if (n < 256) return "58" + n.toString(16).padStart(2, "0") + hex + return "59" + n.toString(16).padStart(4, "0") + hex +} + +// [1, 24(datum bytes)] +const inlineHex = (datumHex: string) => "8201d818" + bstr(datumHex) + +const datums: ReadonlyArray = [ + ["fields", Data.constr(0n, [Data.int(1n), Bytes.fromHex("abcd"), Data.constr(1n, [Data.int(-5n)])])], + ["a list", Data.list([Data.int(1n), Data.list([Data.int(2n), Data.int(3n)]), Data.list([])])], + [ + "a map", + Data.map([ + [Data.int(3n), Data.list([Data.int(4n)])], + [Bytes.fromHex("01"), Data.map([[Data.int(1n), Data.int(2n)]])] + ]) + ] +] + +// Each option set and the options its Plutus data is written with +const optionSets: ReadonlyArray< + readonly [string, CBOR.TxCodecOptions | CBOR.CodecOptions | undefined, CBOR.CodecOptions] +> = [ + ["no options", undefined, Data.DEFAULT_CBOR_OPTIONS], + ["CBOR.CML_DEFAULT_OPTIONS", CBOR.CML_DEFAULT_OPTIONS, Data.DEFAULT_CBOR_OPTIONS], + ["CBOR.TX_DEFAULT_OPTIONS", CBOR.TX_DEFAULT_OPTIONS, Data.DEFAULT_CBOR_OPTIONS], + ["CBOR.TX_CANONICAL_OPTIONS", CBOR.TX_CANONICAL_OPTIONS, CBOR.CANONICAL_OPTIONS], + [ + "plutusData CML_DATA_DEFAULT_OPTIONS", + { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFAULT_OPTIONS }, + CBOR.CML_DATA_DEFAULT_OPTIONS + ], + [ + "plutusData CML_DATA_DEFINITE_OPTIONS", + { ledger: CBOR.CML_DEFAULT_OPTIONS, plutusData: CBOR.CML_DATA_DEFINITE_OPTIONS }, + CBOR.CML_DATA_DEFINITE_OPTIONS + ] +] + +const address = Address.fromBytes(Bytes.fromHex("60" + "bb".repeat(28))) + +const outputWith = (data: Data.Data) => + new TxOut.TransactionOutput({ + address, + assets: Assets.fromLovelace(2_000_000n), + datumOption: new InlineDatum.InlineDatum({ data }) + }) + +const bodyWith = (outputs: ReadonlyArray, collateralReturn?: Data.Data) => + new TransactionBody.TransactionBody({ + inputs: [ + new TransactionInput.TransactionInput({ + transactionId: TransactionHash.fromHex("aa".repeat(32)), + index: 0n + }) + ], + outputs: outputs.map(outputWith), + fee: 200_000n, + collateralReturn: collateralReturn === undefined ? undefined : outputWith(collateralReturn) + }) + +const txWith = (body: TransactionBody.TransactionBody) => + new Transaction.Transaction({ + body, + witnessSet: new TransactionWitnessSet.TransactionWitnessSet({}), + isValid: true, + auxiliaryData: null + }) + +// The bytes inside tag 24 of each inline datum of a decoded body +const writtenDatums = (body: TransactionBody.TransactionBody): Array => + [...body.outputs, ...(body.collateralReturn ? [body.collateralReturn] : [])].map((output) => + Bytes.toHex(OriginalBytes.get(output.datumOption!)!) + ) + +describe.each(optionSets)("under %s", (_, options, plutusData) => { + const ledger = CBOR.toTxCodecOptions(options ?? CBOR.TX_DEFAULT_OPTIONS).ledger + // The same ledger options with Plutus data in the default layout + const ledgerOnly: CBOR.TxCodecOptions = { ledger, plutusData: Data.DEFAULT_CBOR_OPTIONS } + + it.each(datums)("DatumOption writes a new inline datum with %s as Data does", (_, data) => { + const datum = new InlineDatum.InlineDatum({ data }) + const expected = inlineHex(Data.toCBORHex(data, plutusData)) + expect(DatumOption.toCBORHex(datum, options)).toBe(expected) + expect(Bytes.toHex(DatumOption.toCBORBytes(datum, options))).toBe(expected) + }) + + it.each(datums)("TxOut writes a new inline datum with %s as Data does", (_, data) => { + const output = TxOut.fromCBORHex(TxOut.toCBORHex(outputWith(data), options), ledger) + expect(Bytes.toHex(OriginalBytes.get(output.datumOption!)!)).toBe(Data.toCBORHex(data, plutusData)) + }) + + it.each(datums)("TransactionOutput writes a new inline datum with %s as Data does", (_, data) => { + // A new datum in an output decoded by the era-split module + const decoded = TransactionOutput.fromCBORHex(TxOut.toCBORHex(outputWith(data))) + const output = new TransactionOutput.BabbageTransactionOutput({ + ...decoded, + datumOption: new InlineDatum.InlineDatum({ data }) + }) + expect(TransactionOutput.toCBORHex(output, options)).toContain("d818" + bstr(Data.toCBORHex(data, plutusData))) + }) + + it("TransactionBody and Transaction write every new inline datum as Data does", () => { + const body = bodyWith( + datums.map(([, data]) => data), + datums[2][1] + ) + const expected = [...datums.map(([, data]) => data), datums[2][1]].map((data) => Data.toCBORHex(data, plutusData)) + const bodyHex = TransactionBody.toCBORHex(body, options) + expect(writtenDatums(TransactionBody.fromCBORHex(bodyHex, ledger))).toEqual(expected) + + const txBytes = Transaction.toCBORBytes(txWith(body), options) + expect(Bytes.toHex(Transaction.toCBORBytes(txWith(body), options))).toBe( + Transaction.toCBORHex(txWith(body), options) + ) + expect(Bytes.toHex(Transaction.extractBodyBytes(txBytes))).toBe(bodyHex) + expect(writtenDatums(TransactionBody.fromCBORHex(bodyHex, ledger))).toEqual(expected) + }) + + it("the body differs from the default layout only inside tag 24", () => { + const body = bodyWith( + datums.map(([, data]) => data), + datums[0][1] + ) + // Cut the default body at each tag 24 and put the datums written with + // plutusData in the cuts + const written = [...datums, datums[0]].map(([, data]) => data) + const parts = TransactionBody.toCBORHex(body, ledgerOnly).split( + new RegExp(written.map((data) => "d818" + bstr(Data.toCBORHex(data))).join("|")) + ) + expect(parts).toHaveLength(written.length + 1) + const expected = parts.reduce( + (hex, part, i) => hex + "d818" + bstr(Data.toCBORHex(written[i - 1], plutusData)) + part + ) + expect(TransactionBody.toCBORHex(body, options)).toBe(expected) + expect(Bytes.toHex(Transaction.extractBodyBytes(Transaction.toCBORBytes(txWith(body), options)))).toBe(expected) + }) + + it("a decoded inline datum keeps its bytes", () => { + // An indefinite map, which no option set here writes for this datum + const decoded = TxOut.fromCBORHex( + TxOut.toCBORHex(outputWith(Data.int(0n))).replace("d81841" + "00", "d81844bf0102ff") + ) + const tx = txWith(new TransactionBody.TransactionBody({ ...bodyWith([]), outputs: [decoded] })) + expect(Transaction.toCBORHex(tx, options)).toContain("d81844bf0102ff") + }) +}) + +describe("under the default options", () => { + it("a new inline datum keeps the bytes it had before plutusData reached it", () => { + const body = bodyWith([datums[2][1]]) + const bodyHex = + "a300d9010281825820" + + "aa".repeat(32) + + "000181a300581d60" + + "bb".repeat(28) + + "011a001e8480028201d8184aa2039f04ff4101a10102021a00030d40" + expect(TransactionBody.toCBORHex(body)).toBe(bodyHex) + expect(TransactionBody.toCBORHex(body, CBOR.CML_DEFAULT_OPTIONS)).toBe(bodyHex) + expect(Bytes.toHex(Transaction.extractBodyBytes(Transaction.toCBORBytes(txWith(body))))).toBe(bodyHex) + expect( + Bytes.toHex(Transaction.extractBodyBytes(Transaction.toCBORBytes(txWith(body), CBOR.TX_DEFAULT_OPTIONS))) + ).toBe(bodyHex) + }) +}) + +describe("a decoded transaction", () => { + // {0: 258([[txid, 0]]), 1: [{0: addr, 1: 1000000, 2: [1, 24(indefinite map {1: 2})]}], 2: 200000} + const bodyHex = + "a300d9010281825820" + + "aa".repeat(32) + + "000181a300581d60" + + "bb".repeat(28) + + "011a000f4240028201d81844bf0102ff021a00030d40" + const txHex = "84" + bodyHex + "a0f5f6" + + const witnessSetHex = () => { + const key = PrivateKey.fromBytes(new Uint8Array(32).fill(7)) + const vkey = VKey.toBytes(PrivateKey.toPublicKey(key)) + const signature = PrivateKey.sign(key, blake2b(Bytes.fromHex(bodyHex), { dkLen: 32 })) + return "a1008182" + bstr(Bytes.toHex(vkey)) + bstr(Bytes.toHex(signature.bytes)) + } + + it("keeps an inline datum's bytes through addVKeyWitnessesHex", () => { + const signed = Transaction.addVKeyWitnessesHex(txHex, witnessSetHex()) + expect(signed).not.toBe(txHex) + expect(Bytes.toHex(Transaction.extractBodyBytes(Bytes.fromHex(signed)))).toBe(bodyHex) + }) + + it.each(optionSets)("keeps an inline datum's bytes when encoded under %s", (_, options) => { + const tx = Transaction.fromCBORHex(txHex) + expect(Transaction.toCBORHex(tx, options)).toContain("d81844bf0102ff") + expect(Bytes.toHex(Transaction.toCBORBytesWithFormat(tx, Transaction.fromCBORHexWithFormat(txHex).format))).toBe( + txHex + ) + }) + + it("writes an inline datum added to it with the default plutusData", () => { + const tx = Transaction.fromCBORHex(txHex) + const data = datums[1][1] + const edited = new Transaction.Transaction({ + ...tx, + body: new TransactionBody.TransactionBody({ ...tx.body, outputs: [...tx.body.outputs, outputWith(data)] }) + }) + const { format } = Transaction.fromCBORHexWithFormat(txHex) + for (const hex of [Transaction.toCBORHexWithFormat(edited, format), Transaction.toCBORHex(edited)]) { + const body = Transaction.fromCBORHex(hex).body + expect(writtenDatums(body)).toEqual(["bf0102ff", Data.toCBORHex(data)]) + } + }) +}) diff --git a/packages/evolution/test/TransactionWitnessSet.DataOptions.test.ts b/packages/evolution/test/TransactionWitnessSet.DataOptions.test.ts index b753ea1b..ed7fb4d3 100644 --- a/packages/evolution/test/TransactionWitnessSet.DataOptions.test.ts +++ b/packages/evolution/test/TransactionWitnessSet.DataOptions.test.ts @@ -8,12 +8,16 @@ import * as CBOR from "../src/CBOR.js" import * as CostModel from "../src/CostModel.js" import * as Data from "../src/Data.js" import * as Ed25519Signature from "../src/Ed25519Signature.js" +import * as InlineDatum from "../src/InlineDatum.js" +import * as OriginalBytes from "../src/OriginalBytes.js" import * as PlutusV3 from "../src/PlutusV3.js" import * as Redeemer from "../src/Redeemer.js" import * as Redeemers from "../src/Redeemers.js" import * as ScriptDataHash from "../src/ScriptDataHash.js" import * as Transaction from "../src/Transaction.js" +import * as TransactionBody from "../src/TransactionBody.js" import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as TxOut from "../src/TxOut.js" import * as VKey from "../src/VKey.js" // Witness datums and redeemer data are written with the plutusData options, @@ -277,6 +281,29 @@ describe("byte identity with the single-options encoder", () => { (tx.witnessSet.plutusData ?? []).length > 0 || (tx.witnessSet.redeemers?.size ?? 0) > 0 const single = (tx: Transaction.Transaction, options: CBOR.CodecOptions) => CBOR.toCBORBytes(Schema.encodeSync(Transaction.FromCDDL)(tx) as unknown as CBOR.CBOR, options) + // The transaction with each inline datum's bytes recorded as plutusData + // writes them, so the single-options encoder writes them that way too + const withInlineDatumBytes = (tx: Transaction.Transaction, plutusData: CBOR.CodecOptions) => { + const recorded = (output: TxOut.TransactionOutput) => + output.datumOption?._tag === "InlineDatum" + ? new TxOut.TransactionOutput({ + ...output, + datumOption: OriginalBytes.record( + new InlineDatum.InlineDatum({ data: output.datumOption.data }), + Data.toCBORBytes(output.datumOption.data, plutusData) + ) + }) + : output + const { body } = tx + return new Transaction.Transaction({ + ...tx, + body: new TransactionBody.TransactionBody({ + ...body, + outputs: body.outputs.map(recorded), + collateralReturn: body.collateralReturn && recorded(body.collateralReturn) + }) + }) + } it("the sample covers datums and both redeemer formats", () => { expect(txs.filter((tx) => (tx.witnessSet.plutusData ?? []).length > 0).length).toBeGreaterThan(3) @@ -287,7 +314,9 @@ describe("byte identity with the single-options encoder", () => { it.each(presets)("%s: without Plutus data, or with plutusData set to the same options", (_, options) => { for (const tx of txs) { const txOptions = holdsPlutusData(tx) ? { ledger: options, plutusData: options } : options - expect(Transaction.toCBORBytes(tx, txOptions)).toStrictEqual(single(tx, options)) + expect(Transaction.toCBORBytes(tx, txOptions)).toStrictEqual( + single(withInlineDatumBytes(tx, CBOR.toTxCodecOptions(txOptions).plutusData), options) + ) expect(TransactionWitnessSet.toCBORBytes(tx.witnessSet, txOptions)).toStrictEqual( CBOR.toCBORBytes(Schema.encodeSync(TransactionWitnessSet.FromCDDL)(tx.witnessSet), options) )