Skip to content

Realm slot reads silently round valid field elements above Number.MAX_SAFE_INTEGER #8

Description

@Kewe63

Summary

RealmEdgeRpcProvider.getSlotValues() decodes 64-bit hexadecimal words using parseInt, returning JavaScript numbers. The valid field element 9007199254740993 is silently returned as 9007199254740992. getSlotValue() delegates to the same implementation and is affected too.

The response contains hexadecimal text, not a large JSON numeric literal. Precision is lost in the SDK conversion, and wrapping the returned value in BigInt does not restore it.

Affected revision and environment

  • Default branch: mainnet-beta, rechecked before filing.
  • Commit: a34d5fc84580b8ef88d77c7d72fdcc515c467a3a.
  • Source package: @psy-protocol/psy-sdk, manifest version 2.0.4.
  • Node 22.23.1, pnpm 9.15.9, TypeScript 4.9.5, Jest 29.7.0 / ts-jest 29.4.6.
  • Linux x86_64 / WSL2; unchanged workspace lockfile.

The test executes the exported provider classes from this checkout, not an independently downloaded registry build. No live RPC, wallet, funds, transaction, proving service or WASM execution is involved.

Expected behavior

Single and batch state reads should preserve the exact integer represented in a successful response. Their declared Felt representation already includes bigint:

The assertion compares integer value, not whether small exact values use number or bigint.

Actual behavior

Expected: 9007199254740993n
Received: 9007199254740992n

The same loss occurs in single and batch reads. The small-value control passes for both APIs.

Fixture validity

The fixture consists of four identical 64-bit hexadecimal words, 0020000000000001. This value is inside the Goldilocks field range (modulus 0xFFFFFFFF00000001) but outside JavaScript's safe-integer range. The control uses four words equal to 0000000000000007.

The earlier audit checked the pinned QHashOut serializer and the pinned plonky2-hwa GenericHashOut encoding (1dfe6112c3eca3668c15820755371a8a3e75f934): canonical u64 words are encoded little-endian, and QHashOut reverses the whole byte sequence before hexadecimal serialization.

Equal words avoid dependence on slot ordering. These are static fixtures, not an encode/decode round-trip through two SDK helpers. Dependency source inspection validates the format; it is not a claim that Rust/WASM serialization was executed.

Reproduction

In a disposable checkout of the pinned commit:

cd psy-ts-sdk
pnpm install --frozen-lockfile
# Save the test below as packages/psy-sdk/src/realm-edge-rpc/precision.regression.test.ts
unshare -Urn -- pnpm --filter @psy-protocol/psy-sdk exec jest --runInBand --no-cache --runTestsByPath src/realm-edge-rpc/precision.regression.test.ts

Install dependencies before entering the isolated Linux network namespace. The fixture implements the SDK's supported IHTTPClient interface and validates the real request method/params. It returns synthetic JSON-RPC text responses. No provider method or conversion logic is mocked; no network request is sent.

Complete executed regression test
import { describe, expect, it } from "@jest/globals";
import { RealmEdgeRpcProvider } from "./client";
import { RealmEdgeRPCCommand } from "./types";
import type { IHTTPClient, ISimpleHTTPRequest, ISimpleHTTPResponse } from "../http/types";

// Independent static QHashOut fixtures: four equal 64-bit words, so the test
// does not depend on choosing an endian/slot ordering convention accidentally.
// The pinned psy_common QHashOut serializer emits reversed HashOut bytes as hex.
// 0x0020000000000001 is 9007199254740993, inside the Goldilocks field range.
class SlotTransport implements IHTTPClient {
    constructor(private readonly leaf: string) {}
    async sendRequest(request: ISimpleHTTPRequest): Promise<ISimpleHTTPResponse> {
        const wire: { id: string; method: string; params: unknown } = JSON.parse(String(request.body));
        expect(wire.method).toBe(RealmEdgeRPCCommand.GetUserContractStateTreeLeafHash);
        expect(wire.params).toEqual([1, 1, 2, 0]);
        expect(request.responseType).toBe("text");
        return { statusCode: 200, body: JSON.stringify({ jsonrpc: "2.0", id: wire.id, result: this.leaf }) };
    }
}

describe("quality exact slot values", () => {
    it("control: small field elements survive public single and batch reads", async () => {
        const leaf = "0000000000000007000000000000000700000000000000070000000000000007";
        const client = new RealmEdgeRpcProvider("http://127.0.0.1:18005", new SlotTransport(leaf));
        try {
            expect(BigInt(await client.getSlotValue(1, 1, 2, 0))).toBe(7n);
            expect((await client.getSlotValues(1, 1, 2, [0, 1, 2, 3])).map(BigInt)).toEqual([7n, 7n, 7n, 7n]);
        } finally {
            client.destroy();
        }
    });
    it("single slot read must preserve an exact field element above the safe-number range", async () => {
        const leaf = "0020000000000001002000000000000100200000000000010020000000000001";
        const client = new RealmEdgeRpcProvider("http://127.0.0.1:18005", new SlotTransport(leaf));
        try {
            expect(BigInt(await client.getSlotValue(1, 1, 2, 0))).toBe(9007199254740993n);
        } finally {
            client.destroy();
        }
    });
    it("batch slot reads must preserve all four exact field elements", async () => {
        const leaf = "0020000000000001002000000000000100200000000000010020000000000001";
        const client = new RealmEdgeRpcProvider("http://127.0.0.1:18005", new SlotTransport(leaf));
        try {
            expect((await client.getSlotValues(1, 1, 2, [0, 1, 2, 3])).map(BigInt)).toEqual([
                9007199254740993n,
                9007199254740993n,
                9007199254740993n,
                9007199254740993n,
            ]);
        } finally {
            client.destroy();
        }
    });
});

Observed output

Rerun immediately before filing:

quality exact slot values
  ✓ control: small field elements survive public single and batch reads
  ✕ single slot read must preserve an exact field element above the safe-number range
  ✕ batch slot reads must preserve all four exact field elements

Expected: 9007199254740993n
Received: 9007199254740992n

Tests: 2 failed, 1 passed, 3 total
Process exit: 1

All four batch elements are rounded to 9007199254740992n when normalized for comparison. Two earlier independent isolated runs produced the same result. The two failing assertions represent one shared conversion defect, not two independent bugs.

Root cause

getSlotValues() extracts each 16-digit hexadecimal word and calls parseInt, which produces a number. That representation cannot exactly hold the demonstrated field element. Precision is lost before the value reaches the caller.

Impact and limitations

Suggested priority: Medium / P2, public read API data correctness. Consumers receive an incorrect state value for a valid large field element. This is not merely a different return type.

No balance loss, incorrect signing, consensus behavior or live-chain impact was exercised or claimed. The full package/WASM consumer workflow was not verified. Other numeric conversions are not claimed as additional findings.

To avoid this conversion, consumers can work from the raw leaf-hash response and parse the hexadecimal words exactly; converting an already-rounded slot result to BigInt is insufficient.

Related work and duplicate check

#6 is cache initialization and #7 is endpoint failover; neither addresses this numeric conversion. Fresh all-state tracker checks and searches for getSlotValues and precision found no equivalent report/PR. The earlier audit inspected accessible PR files, patches and relevant history; PR #4 changes a contract-state RPC height parameter, not this conversion. No matching fix was found in accessible history; private/external histories were not exhaustively reviewed.

Suggested fix direction

Decode the hexadecimal word directly into bigint, or return a number only after an explicit safe-integer check. Preserve exact values through single/batch reads and delegation, with small-value controls and static large-value regression fixtures. No production patch is included in this report.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions