diff --git a/.changeset/data-to-string.md b/.changeset/data-to-string.md new file mode 100644 index 000000000..cbe1e75e2 --- /dev/null +++ b/.changeset/data-to-string.md @@ -0,0 +1,11 @@ +--- +"@evolution-sdk/evolution": minor +--- + +`toString` and `toJSON` now render Plutus data. `toString` threw `Do not know how to serialize a BigInt` on any `InlineDatum`, `UTxO`, `TxOut`, `TransactionOutput`, `Redeemer`, `RedeemerMap`, `RedeemerArray` or `TransactionWitnessSet` whose data held an integer or a constructor, and so did a template string or `JSON.stringify` holding one. Data without integers rendered wrongly: a byte string as an object of byte values, and a map as `{}`. + +The new `Data.toJSON` returns data in its encoded form, as `DataSchema` writes it: an integer as a decimal string, a byte string as hex, a map as `[key, value]` pairs and a constructor as `{ index, fields }`. These classes use it in `toJSON`, so `toJSON().data` is now that encoded form rather than `Data`. `RedeemerValue`, which `RedeemerMap.get` returns, gains `toJSON` and `toString`. + +`Value.toJSON` writes `coin` as a decimal string, as `MultiAsset` already writes quantities, so `toString` no longer throws on a `BabbageTransactionOutput`. + +`console.log` and `util.inspect` still show the data itself, with `bigint` integers and `Uint8Array` bytes. diff --git a/packages/evolution/src/Data.ts b/packages/evolution/src/Data.ts index fed50159d..bc17671e9 100644 --- a/packages/evolution/src/Data.ts +++ b/packages/evolution/src/Data.ts @@ -1041,6 +1041,14 @@ export const hash = (data: Data): number => { */ export const equals: (a: Data, b: Data) => boolean = Schema.equivalence(DataSchema) +/** + * Plutus data in its JSON-safe encoded form. + * + * @since 2.0.0 + * @category conversions + */ +export const toJSON: (data: Data) => DataEncoded = Schema.encodeSync(DataSchema) + export const CDDLSchema = CBOR.CBORSchema /** diff --git a/packages/evolution/src/InlineDatum.ts b/packages/evolution/src/InlineDatum.ts index 9241e2a1a..1dec6950b 100644 --- a/packages/evolution/src/InlineDatum.ts +++ b/packages/evolution/src/InlineDatum.ts @@ -16,7 +16,7 @@ export class InlineDatum extends Schema.TaggedClass()("InlineDatum" * @category json */ toJSON() { - return { _tag: "InlineDatum" as const, data: this.data } + return { _tag: "InlineDatum" as const, data: PlutusData.toJSON(this.data) } } /** @@ -32,7 +32,7 @@ export class InlineDatum extends Schema.TaggedClass()("InlineDatum" * @category inspect */ [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { _tag: "InlineDatum" as const, data: this.data } } /** diff --git a/packages/evolution/src/Redeemer.ts b/packages/evolution/src/Redeemer.ts index 4fa54d066..9da4dc6ff 100644 --- a/packages/evolution/src/Redeemer.ts +++ b/packages/evolution/src/Redeemer.ts @@ -95,7 +95,7 @@ export class Redeemer extends Schema.Class("Redeemer")({ _tag: "Redeemer", tag: this.tag, index: this.index.toString(), - data: this.data, + data: PlutusData.toJSON(this.data), exUnits: this.exUnits.toJSON() } } @@ -117,7 +117,7 @@ export class Redeemer extends Schema.Class("Redeemer")({ * @category conversions */ [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), data: this.data } } /** diff --git a/packages/evolution/src/Redeemers.ts b/packages/evolution/src/Redeemers.ts index 3975d0614..07e964d18 100644 --- a/packages/evolution/src/Redeemers.ts +++ b/packages/evolution/src/Redeemers.ts @@ -66,6 +66,18 @@ export class RedeemerValue extends Schema.Class("RedeemerValue")( data: Schema.typeSchema(Data.DataSchema), exUnits: Redeemer.ExUnits }) { + toJSON() { + return { data: Data.toJSON(this.data), exUnits: this.exUnits.toJSON() } + } + + toString(): string { + return Inspectable.format(this.toJSON()) + } + + [Inspectable.NodeInspectSymbol](): unknown { + return { data: this.data, exUnits: this.exUnits } + } + [Equal.symbol](that: unknown): boolean { return ( that instanceof RedeemerValue && Data.equals(this.data, that.data) && Equal.equals(this.exUnits, that.exUnits) @@ -144,7 +156,7 @@ export class RedeemerMap extends Schema.TaggedClass()("RedeemerMap" _tag: "RedeemerMap" as const, entries: Array.from(this.value.entries()).map(([[tag, index], { data, exUnits }]) => ({ key: { tag, index: index.toString() }, - value: { data, exUnits: exUnits.toJSON() } + value: { data: Data.toJSON(data), exUnits: exUnits.toJSON() } })) } } @@ -154,7 +166,7 @@ export class RedeemerMap extends Schema.TaggedClass()("RedeemerMap" } [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), entries: Array.from(this.value.entries()).map(([key, value]) => ({ key, value })) } } [Equal.symbol](that: unknown): boolean { @@ -246,7 +258,7 @@ export class RedeemerArray extends Schema.TaggedClass()("Redeemer } [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), value: this.value } } [Equal.symbol](that: unknown): boolean { diff --git a/packages/evolution/src/TransactionWitnessSet.ts b/packages/evolution/src/TransactionWitnessSet.ts index dcc5bdd9e..55ac22554 100644 --- a/packages/evolution/src/TransactionWitnessSet.ts +++ b/packages/evolution/src/TransactionWitnessSet.ts @@ -210,7 +210,7 @@ export class TransactionWitnessSet extends Schema.Class(" nativeScripts: this.nativeScripts?.map((s) => Schema.encodeSync(NativeScripts.NativeScript)(s)), bootstrapWitnesses: this.bootstrapWitnesses?.map((b) => b.toJSON()), plutusV1Scripts: this.plutusV1Scripts, - plutusData: this.plutusData, + plutusData: this.plutusData?.map(PlutusData.toJSON), redeemers: this.redeemers?.toJSON(), plutusV2Scripts: this.plutusV2Scripts, plutusV3Scripts: this.plutusV3Scripts @@ -230,7 +230,7 @@ export class TransactionWitnessSet extends Schema.Class(" * @category inspect */ [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), plutusData: this.plutusData, redeemers: this.redeemers } } /** diff --git a/packages/evolution/src/TxOut.ts b/packages/evolution/src/TxOut.ts index 7729797bd..6dc258370 100644 --- a/packages/evolution/src/TxOut.ts +++ b/packages/evolution/src/TxOut.ts @@ -56,7 +56,7 @@ export class TransactionOutput extends Schema.TaggedClass()(" } [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), datumOption: this.datumOption } } [Equal.symbol](that: unknown): boolean { diff --git a/packages/evolution/src/Value.ts b/packages/evolution/src/Value.ts index 742779d12..173db4323 100644 --- a/packages/evolution/src/Value.ts +++ b/packages/evolution/src/Value.ts @@ -27,7 +27,7 @@ export class OnlyCoin extends Schema.TaggedClass("OnlyCoin")("OnlyCoin toJSON() { return { _tag: this._tag, - coin: this.coin + coin: this.coin.toString() } } @@ -36,7 +36,7 @@ export class OnlyCoin extends Schema.TaggedClass("OnlyCoin")("OnlyCoin } [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), coin: this.coin } } [Equal.symbol](that: unknown): boolean { @@ -55,7 +55,7 @@ export class WithAssets extends Schema.TaggedClass("WithAssets")("Wi toJSON() { return { _tag: this._tag, - coin: this.coin, + coin: this.coin.toString(), assets: this.assets } } @@ -65,7 +65,7 @@ export class WithAssets extends Schema.TaggedClass("WithAssets")("Wi } [Inspectable.NodeInspectSymbol](): unknown { - return this.toJSON() + return { ...this.toJSON(), coin: this.coin } } [Equal.symbol](that: unknown): boolean { diff --git a/packages/evolution/test/Data.ToString.test.ts b/packages/evolution/test/Data.ToString.test.ts new file mode 100644 index 000000000..cf1b0d3c7 --- /dev/null +++ b/packages/evolution/test/Data.ToString.test.ts @@ -0,0 +1,96 @@ +import { inspect } from "node:util" + +import { describe, expect, it } from "vitest" + +import * as Address from "../src/Address.js" +import * as AddressEras from "../src/AddressEras.js" +import * as AssetName from "../src/AssetName.js" +import * as Assets from "../src/Assets.js" +import * as Bytes from "../src/Bytes.js" +import * as Data from "../src/Data.js" +import * as InlineDatum from "../src/InlineDatum.js" +import * as MultiAsset from "../src/MultiAsset.js" +import * as PolicyId from "../src/PolicyId.js" +import * as Redeemer from "../src/Redeemer.js" +import * as Redeemers from "../src/Redeemers.js" +import * as TransactionHash from "../src/TransactionHash.js" +import * as TransactionOutput from "../src/TransactionOutput.js" +import * as TransactionWitnessSet from "../src/TransactionWitnessSet.js" +import * as TxOut from "../src/TxOut.js" +import * as UTxO from "../src/UTxO.js" +import * as Value from "../src/Value.js" + +const data = Data.constr(0n, [Data.int(42n), Data.bytearray("ab"), Data.map([[Data.int(1n), Data.int(2n)]])]) +const rendered = { index: "0", fields: ["42", "ab", [["1", "2"]]] } + +const addressHex = "60" + "11".repeat(28) +const address = Address.fromBytes(Bytes.fromHex(addressHex)) +const datumOption = new InlineDatum.InlineDatum({ data }) +const exUnits = new Redeemer.ExUnits({ mem: 1n, steps: 1n }) +const redeemer = new Redeemer.Redeemer({ tag: "spend", index: 0n, data, exUnits }) + +const values = { + InlineDatum: datumOption, + TxOut: new TxOut.TransactionOutput({ address, assets: Assets.fromLovelace(1_000_000n), datumOption }), + UTxO: new UTxO.UTxO({ + transactionId: TransactionHash.fromHex("00".repeat(32)), + index: 0n, + address, + assets: Assets.fromLovelace(1_000_000n), + datumOption + }), + BabbageTransactionOutput: new TransactionOutput.BabbageTransactionOutput({ + address: AddressEras.fromHex(addressHex), + amount: Value.onlyCoin(1_000_000n), + datumOption + }), + Redeemer: redeemer, + RedeemerMap: Redeemers.makeRedeemerMap([redeemer]), + RedeemerValue: new Redeemers.RedeemerValue({ data, exUnits }), + RedeemerArray: new Redeemers.RedeemerArray({ value: [redeemer] }), + TransactionWitnessSet: new TransactionWitnessSet.TransactionWitnessSet({ plutusData: [data] }) +} + +describe("toString on values holding Plutus data", () => { + it.each(Object.entries(values))("%s renders its data", (_, value) => { + expect(() => value.toString()).not.toThrow() + expect(() => `${value}`).not.toThrow() + expect(JSON.stringify(value)).toContain(JSON.stringify(rendered)) + }) + + it.each(Object.entries(values))("%s inspects its data as bigints and bytes", (_, value) => { + expect(inspect(value, { depth: null })).toContain("42n") + }) + + it("renders a bare integer datum", () => { + expect(new InlineDatum.InlineDatum({ data: Data.int(1n) }).toJSON()).toEqual({ _tag: "InlineDatum", data: "1" }) + }) +}) + +describe("Data.toJSON", () => { + it.each([ + ["an integer", Data.int(1n), "1"], + ["a negative integer", Data.int(-5n), "-5"], + ["an integer past 2^64", Data.int(2n ** 70n), (2n ** 70n).toString()], + ["a byte string", Data.bytearray("ab"), "ab"], + ["an empty byte string", Data.bytearray(""), ""], + ["a list", Data.list([Data.int(1n), Data.bytearray("ab")]), ["1", "ab"]], + ["a map", Data.map([[Data.bytearray("ab"), Data.int(2n)]]), [["ab", "2"]]], + ["a constructor", Data.constr(7n, [Data.int(1n)]), { index: "7", fields: ["1"] }] + ])("renders %s", (_, data, expected) => { + expect(Data.toJSON(data)).toEqual(expected) + }) +}) + +describe("Value.toJSON", () => { + const assets = MultiAsset.singleton(PolicyId.fromHex("0".repeat(56)), AssetName.fromHex("4d79546f6b656e"), 3n) + + it.each([ + ["OnlyCoin", Value.onlyCoin(5n)], + ["WithAssets", Value.withAssets(5n, assets)] + ])("%s writes coin as a string and inspects it as a bigint", (_, value) => { + expect(value.toJSON().coin).toBe("5") + expect(() => value.toString()).not.toThrow() + expect(inspect(value)).toContain("coin: 5n") + }) +})