Skip to content

fix(native-scripts): refuse a JSON slot above 2^53-1 rather than truncate it - #711

Open
jorbuedo wants to merge 1 commit into
IntersectMBO:mainfrom
jorbuedo:slice/native-scripts-json-slot
Open

jorbuedo wants to merge 1 commit into
IntersectMBO:mainfrom
jorbuedo:slice/native-scripts-json-slot

Conversation

@jorbuedo

@jorbuedo jorbuedo commented Oct 22, 2026 •

Copy link
Copy Markdown

One of the four fixes asked for in the closing comment on
#619 — smallest fix plus its test, nothing else.

NativeScripts.toJSON rounds a slot above 2^53 without saying so. A refusal is enough here; fromJSON can be its own PR.

The defect

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, not only when
authoring one.

One consequence worth naming

Hash.symbol stops going through toJSON, which would now throw for exactly these scripts: a
HashSet holding one would fail. It hashes the variant directly instead — and before the refusal,
two scripts differing only above the bound hashed equal.

Contents

  • packages/evolution/src/NativeScripts.ts — the range refusal, and Hash.symbol off toJSON.
  • packages/evolution/test/NativeScripts.JSON.test.ts — at the bound, above it, and the two scripts
    that used to hash equal.
  • A patch changeset.

Deliberately left out: fromJSON and its NativeScriptJSON module, as asked.

Verification

All six CI tasks run locally off ci.yml against this base (lint, type-check, test, build,
check:dts, docgen): green. packages/evolution: 1918 passed, 75 skipped, 0 failed. The new test file fails on this base without the source change, so it does exercise it.
devnet's suite needs Docker and was not run here.

@solidsnakedev solidsnakedev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the refusal is right, and the bound is exact.

Two changes before merge:

  • The new Hash.symbol stringifies the raw variant, so a Buffer key hash and a Uint8Array with the same bytes now hash differently. Equal.equals compares hashes first (effect Equal.ts:47), so Equal.equals(makeScriptPubKey(bytes), makeScriptPubKey(Buffer.from(bytes))) is true on main and false here, and a script decoded from a Buffer no longer equals the same script decoded from hex. A structural hash fixes both: Hash.array(Array.from(keyHash)) as in AssetName.ts:66, Hash.hash for the slot and required, combined with the tag.
  • required is an int64 (conway.cddl:402), and the ledger allows a negative one (Allegra/Scripts.hs:174-175). Please also refuse a value below -Number.MAX_SAFE_INTEGER, with a test.

Smaller: throwing the module's NativeScriptError instead of a plain Error lets callers catch it by tag.

…cate 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.

`required` is an int64 and the ledger accepts a negative one, so the lower bound
is reachable the same way the upper one is, and is refused with it.

A script read off the chain may carry any uint64, so this is reachable on decode
and not only when authoring one. The refusal is the module's `NativeScriptError`,
so callers can catch it by tag.

`Hash.symbol` hashes the variant structurally instead of 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. Structurally means a key hash hashes from its bytes rather than from its
container, so a `Buffer` and a `Uint8Array` over the same bytes still hash alike
and `Equal.equals`, which compares hashes first, still holds for them.

`fromJSON`, the inverse that found this, is left for its own change.
@jorbuedo
jorbuedo force-pushed the slice/native-scripts-json-slot branch from 96a7a23 to a10ca13 Compare November 5, 2026 21:15
@jorbuedo

jorbuedo commented Nov 5, 2026

Copy link
Copy Markdown
Author

Both changes are in, and the smaller one too. Rebased onto fcf30275.

a Buffer key hash and a Uint8Array with the same bytes now hash differently

Confirmed before fixing it, because it is worth being exact about which side was wrong: Equal.equals(makeScriptPubKey(bytes), makeScriptPubKey(Buffer.from(bytes))) is true on main, was false on the branch you read, and is true again now. main got away with it because its hash went through toJSON, which hex-encodes the key hash, so the container never reached the digest — replacing that with JSON.stringify of the variant exposed it, since Buffer serializes as {"type":"Buffer","data":[…]} and Uint8Array as {"0":…}.

The hash is now structural, in a hashVariant that mirrors equalsVariant: Hash.array(Array.from(keyHash)) as in AssetName.ts:66, Hash.hash for slot and required, each combined with Hash.string(_tag). The tag is in it so InvalidBefore 5 and InvalidHereafter 5, which carry the same payload, stay apart. Three tests cover it: the flat case, the same key hash nested inside a ScriptNOfK, and that differing scripts still separate.

required is an int64 […] Please also refuse a value below -Number.MAX_SAFE_INTEGER, with a test

Done — the bound is now |value| > Number.MAX_SAFE_INTEGER, and the test asserts both that -(2^53) is refused and that required: -1n still comes out as -1, since that one the form carries exactly.

throwing the module's NativeScriptError instead of a plain Error

Done, with a test asserting the class rather than the message.

Gate, after a clean dist and .tsbuildinfo: lint, type-check, build, check:dts and docgen pass, and packages/evolution is 2030 passed / 75 skipped, 0 failed. Each of the four points above is red on the commit you reviewed and green now; the slot and NativeScriptError tests are also red on unfixed main.

fromJSON is still left out, as asked.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants