diff --git a/.changeset/native-scripts-json-slot-range.md b/.changeset/native-scripts-json-slot-range.md new file mode 100644 index 00000000..ad4189cf --- /dev/null +++ b/.changeset/native-scripts-json-slot-range.md @@ -0,0 +1,5 @@ +--- +"@evolution-sdk/evolution": patch +--- + +`NativeScripts.toJSON` refuses a `slot` or `required` above `Number.MAX_SAFE_INTEGER` instead of truncating it. The cardano-cli JSON form carries both as JSON numbers, so a CDDL uint64 above 2^53-1 has no exact representation in it, and `Number(slot)` returned a different slot with no error. A script decoded from the chain may carry any uint64, so this was reachable on read, not only when authoring a script. `Hash.symbol` no longer routes through `toJSON`, so such a script still hashes and compares. diff --git a/packages/evolution/src/NativeScripts.ts b/packages/evolution/src/NativeScripts.ts index 26037b31..53f88302 100644 --- a/packages/evolution/src/NativeScripts.ts +++ b/packages/evolution/src/NativeScripts.ts @@ -164,7 +164,12 @@ export class NativeScript extends Schema.TaggedClass("NativeScript } [Hash.symbol](): number { - return Hash.hash(JSON.stringify(toJSON(this.script))) + // Hashed from the variant itself, not from `toJSON`: that form refuses a uint64 slot above + // `Number.MAX_SAFE_INTEGER`, and before it did, it truncated one — so two scripts differing + // only above that bound hashed equal. + return Hash.hash( + JSON.stringify(this.script, (_key, value) => (typeof value === "bigint" ? value.toString() : value)) + ) } } @@ -261,9 +266,27 @@ export const makeScriptNOfK = (required: bigint, scripts: ReadonlyArray { + // Refused rather than truncated: `Number(2n ** 53n + 1n)` silently returns a different slot, + // and a script decoded from the chain may legitimately carry any uint64. + if (value > JSON_SAFE_MAX) { + throw new Error(`${field} ${value} exceeds what the cardano-cli JSON form can represent exactly`) + } + return Number(value) +} + /** * Convert a NativeScript to JSON representation matching cardano-cli format * + * Throws if `slot` or `required` exceeds `Number.MAX_SAFE_INTEGER`, which the JSON form cannot + * carry exactly. + * * @since 2.0.0 * @category conversion */ @@ -277,12 +300,12 @@ export const toJSON = (script: NativeScriptVariants): any => { case "InvalidBefore": return { type: "after" as const, - slot: Number(script.slot) + slot: jsonNumber(script.slot, "slot") } case "InvalidHereafter": return { type: "before" as const, - slot: Number(script.slot) + slot: jsonNumber(script.slot, "slot") } case "ScriptAll": return { @@ -297,7 +320,7 @@ export const toJSON = (script: NativeScriptVariants): any => { case "ScriptNOfK": return { type: "atLeast" as const, - required: Number(script.required), + required: jsonNumber(script.required, "required"), scripts: script.scripts.map(toJSON) } } diff --git a/packages/evolution/test/NativeScripts.JSON.test.ts b/packages/evolution/test/NativeScripts.JSON.test.ts new file mode 100644 index 00000000..c2204329 --- /dev/null +++ b/packages/evolution/test/NativeScripts.JSON.test.ts @@ -0,0 +1,38 @@ +import { Equal, Hash } from "effect" +import { describe, expect, it } from "vitest" + +import * as NativeScripts from "../src/NativeScripts.js" + +describe("a uint64 the cardano-cli JSON form cannot carry", () => { + // ⚠️ `toJSON` used to do `Number(slot)`, so a slot above 2^53-1 came back as a DIFFERENT slot + // with no error. A script decoded from the chain may legitimately carry any uint64, so this is + // reachable on read, not only when authoring a script. + const tooBig = BigInt(Number.MAX_SAFE_INTEGER) + 1n + + it("refuses an out-of-range slot instead of truncating it", () => { + expect(() => NativeScripts.toJSON({ _tag: "InvalidHereafter", slot: tooBig })).toThrow(/exceeds/) + expect(() => NativeScripts.toJSON({ _tag: "InvalidBefore", slot: tooBig })).toThrow(/exceeds/) + }) + + it("refuses an out-of-range `required` instead of truncating it", () => { + expect(() => NativeScripts.toJSON({ _tag: "ScriptNOfK", required: tooBig, scripts: [] })).toThrow(/exceeds/) + }) + + it("accepts the largest value the form does carry", () => { + const max = BigInt(Number.MAX_SAFE_INTEGER) + expect(NativeScripts.toJSON({ _tag: "InvalidHereafter", slot: max })).toEqual({ + type: "before", + slot: Number.MAX_SAFE_INTEGER + }) + }) + + it("still hashes and compares such a script, rather than throwing", () => { + // `Hash.symbol` must not route through `toJSON`, or putting one of these in a HashSet would + // throw. Two scripts differing only above the bound must also not hash equal. + const a = new NativeScripts.NativeScript({ script: { _tag: "InvalidHereafter", slot: tooBig } }) + const b = new NativeScripts.NativeScript({ script: { _tag: "InvalidHereafter", slot: tooBig + 1n } }) + expect(() => Hash.hash(a)).not.toThrow() + expect(Hash.hash(a)).not.toBe(Hash.hash(b)) + expect(Equal.equals(a, b)).toBe(false) + }) +})