From 96a7a23fb269465c9fdc041ae87b52344e7f6eb0 Mon Sep 17 00:00:00 2001 From: jorbuedo Date: Wed, 21 Oct 2026 13:01:19 +0200 Subject: [PATCH] fix(native-scripts): refuse a JSON slot above 2^53-1 rather than truncate it The cardano-cli JSON form carries `slot` and `required` as JSON numbers, so a CDDL uint64 above `Number.MAX_SAFE_INTEGER` has no exact representation in it. `Number(script.slot)` returned a different slot and reported nothing. A script read off the chain may carry any uint64, so this is reachable on decode and not only when authoring one. `Hash.symbol` stops going through `toJSON`, which would now throw for exactly these scripts: a `HashSet` holding one would fail, and before the refusal two scripts differing only above the bound hashed equal. `fromJSON`, the inverse that found this, is left for its own change. --- .changeset/native-scripts-json-slot-range.md | 5 +++ packages/evolution/src/NativeScripts.ts | 31 +++++++++++++-- .../evolution/test/NativeScripts.JSON.test.ts | 38 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 .changeset/native-scripts-json-slot-range.md create mode 100644 packages/evolution/test/NativeScripts.JSON.test.ts diff --git a/.changeset/native-scripts-json-slot-range.md b/.changeset/native-scripts-json-slot-range.md new file mode 100644 index 000000000..ad4189cf1 --- /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 26037b31e..53f883028 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 000000000..c22043295 --- /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) + }) +})