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.
Summary
RealmEdgeRpcProvider.getSlotValues()decodes 64-bit hexadecimal words usingparseInt, returning JavaScript numbers. The valid field element9007199254740993is silently returned as9007199254740992.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
BigIntdoes not restore it.Affected revision and environment
mainnet-beta, rechecked before filing.a34d5fc84580b8ef88d77c7d72fdcc515c467a3a.@psy-protocol/psy-sdk, manifest version2.0.4.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
Feltrepresentation already includesbigint:The assertion compares integer value, not whether small exact values use
numberorbigint.Actual behavior
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 (modulus0xFFFFFFFF00000001) but outside JavaScript's safe-integer range. The control uses four words equal to0000000000000007.The earlier audit checked the pinned QHashOut serializer and the pinned plonky2-hwa
GenericHashOutencoding (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:
Install dependencies before entering the isolated Linux network namespace. The fixture implements the SDK's supported
IHTTPClientinterface 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
Observed output
Rerun immediately before filing:
All four batch elements are rounded to
9007199254740992nwhen 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 callsparseInt, which produces anumber. 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
BigIntis 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
getSlotValuesandprecisionfound 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.