Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/native-scripts-json-slot-range.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 27 additions & 4 deletions packages/evolution/src/NativeScripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,12 @@ export class NativeScript extends Schema.TaggedClass<NativeScript>("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))
)
}
}

Expand Down Expand Up @@ -261,9 +266,27 @@ export const makeScriptNOfK = (required: bigint, scripts: ReadonlyArray<NativeSc
// JSON Conversion Utilities
// ============================================================================

/**
* Maximum integer a JSON number carries exactly. The cardano-cli JSON form uses JSON numbers for
* `slot` and `required`, so a CDDL uint64 above this has no representation in it.
*/
const JSON_SAFE_MAX = BigInt(Number.MAX_SAFE_INTEGER)

const jsonNumber = (value: bigint, field: string): number => {
// 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
*/
Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand Down
38 changes: 38 additions & 0 deletions packages/evolution/test/NativeScripts.JSON.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})