Skip to content

fix: handle u64 integer precision loss across Rust-JS boundary#66

Merged
adiman9 merged 6 commits intomainfrom
integer-fix
Mar 19, 2026
Merged

fix: handle u64 integer precision loss across Rust-JS boundary#66
adiman9 merged 6 commits intomainfrom
integer-fix

Conversation

@adiman9
Copy link
Contributor

@adiman9 adiman9 commented Mar 19, 2026

Add serde_utils module with flexible deserializers that accept both string and numeric JSON representations of integers. Transform u64 values exceeding Number.MAX_SAFE_INTEGER to strings server-side before sending to JavaScript clients. Update the interpreter VM and resolver to return proper numeric JSON values instead of pre-stringifying u64s. The code generator now emits deserialize_with attributes for integer fields, and ore types are updated accordingly.

Add serde_utils module with flexible deserializers that accept both
string and numeric JSON representations of integers. Transform u64
values exceeding Number.MAX_SAFE_INTEGER to strings server-side before
sending to JavaScript clients. Update the interpreter VM and resolver
to return proper numeric JSON values instead of pre-stringifying u64s.
The code generator now emits deserialize_with attributes for integer
fields, and ore types are updated accordingly.
@vercel
Copy link

vercel bot commented Mar 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperstack-docs Ready Ready Preview, Comment Mar 19, 2026 2:43pm

Request Review

@greptile-apps
Copy link

greptile-apps bot commented Mar 19, 2026

Greptile Summary

This PR fixes u64 integer precision loss across the Rust-to-JavaScript boundary by introducing a serde_utils module with flexible deserializers, a recursive transform_large_u64_to_strings pass on outbound WebSocket frames, and updated code generation to emit deserialize_with attributes for all integer/timestamp fields. The interpreter VM and SlotHashResolver are updated to return Value::Number (instead of pre-stringifying), letting the boundary transformation decide what needs to be a string.

Key changes:

  • New rust/hyperstack-sdk/src/serde_utils.rs: Full matrix of deserializers for u64/i64/u32/i32 in Option<T>, Option<Option<T>>, Option<Vec<T>>, and Option<Option<Vec<T>>> shapes — handles JSON numbers, strings, and nulls transparently.
  • transform_large_u64_to_strings in frame.rs: Recursively converts values > MAX_SAFE_INTEGER (and < MIN_SAFE_INTEGER for i64) to strings before sending to clients, covering both the projector and snapshot paths.
  • interpreter/src/rust.rs code-gen: Emits correct serde_utils::deserialize_* attributes for generated struct fields, resolving previously flagged compile errors for i32/u32 and Vec cases.
  • SlotHashResolver now returns Value::Number(rng) instead of Value::String(rng.to_string()), and correctly extracts the bytes array from the slot_hash() object.

Issue found:

  • deserialize_option_option_i64::visit_f64 (line 200-202) performs an unchecked v as i64 cast with no range validation. Every other visit_f64 in the file guards against out-of-range values; this one silently saturates for NaN, Infinity, or large floats.

Confidence Score: 4/5

  • Safe to merge with one minor fix — the unchecked float cast in deserialize_option_option_i64::visit_f64 is a correctness issue but only affects the unlikely case of a float payload arriving for an i64 field.
  • The overall approach is sound and well-tested. The previously flagged issues (missing i64 vec deserializers, wrong i32/u32 deserializer return types, missing i64 negative-side guard) are all resolved. One new issue remains: deserialize_option_option_i64::visit_f64 lacks the bounds check present in every other float visitor in the file, which could produce silent data corruption for out-of-range float inputs.
  • rust/hyperstack-sdk/src/serde_utils.rs — specifically the deserialize_option_option_i64::visit_f64 implementation at line 200.

Important Files Changed

Filename Overview
rust/hyperstack-sdk/src/serde_utils.rs New module with comprehensive u64/i64/u32/i32 string-or-number deserializers. The deserialize_option_option_i64::visit_f64 at line 200-202 is the only implementation lacking a bounds check, silently casting any float (including NaN, Infinity, out-of-range) to i64. All other visit_f64 variants are guarded correctly.
rust/hyperstack-server/src/websocket/frame.rs Adds transform_large_u64_to_strings to convert u64 values exceeding Number.MAX_SAFE_INTEGER to JSON strings, plus a guard for negative i64 values below MIN_SAFE_INTEGER. Recursively walks objects and arrays. Logic is sound.
interpreter/src/rust.rs Adds deserialize_with_for_type and serde_attr_for_field helpers to emit correct #[serde(deserialize_with = "serde_utils::...")] attributes for integer/timestamp fields in generated code. use hyperstack_sdk::serde_utils is injected into generated files. All previously flagged issues (missing i32/u32 and vec-i64 variants) are now resolved.
interpreter/src/vm.rs U64FromLeBytes and U64FromBeBytes now emit Value::Number instead of Value::String, letting transform_large_u64_to_strings handle precision loss at the transport boundary rather than pre-stringifying unconditionally.
interpreter/src/resolvers.rs SlotHashResolver now correctly extracts the bytes field from the object returned by slot_hash(), and returns the rng result as Value::Number instead of Value::String, consistent with the new number-first strategy.
stacks/sdk/rust/src/ore/types.rs Ore types updated to use serde_utils deserializers for u64/i64 fields, correctly handling both string and numeric representations after the transport-layer transformation.
rust/hyperstack-server/src/projector.rs Imports and calls transform_large_u64_to_strings for outbound frames. No issues found.
rust/hyperstack-server/src/websocket/server.rs Imports transform_large_u64_to_strings for snapshot and live-streaming paths. No issues found.

Sequence Diagram

sequenceDiagram
    participant BC as Blockchain
    participant VM as Interpreter VM
    participant PR as Projector
    participant TR as transform_large_u64_to_strings
    participant WS as WebSocket Server
    participant JS as JavaScript Client
    participant SDK as Rust SDK Client

    BC->>VM: Account/instruction update (raw bytes)
    VM->>VM: U64FromLeBytes / U64FromBeBytes → Value::Number(u64)
    VM->>VM: SlotHashResolver → Value::Number(rng as u64)
    VM->>PR: Mutation batch (Value::Number for u64 fields)
    PR->>TR: transform_large_u64_to_strings(frame.data)
    TR->>TR: Walk JSON tree recursively
    TR->>TR: n > MAX_SAFE_INTEGER (2^53-1) → Value::String
    TR->>TR: n < MIN_SAFE_INTEGER (-(2^53-1)) → Value::String
    PR->>WS: Frame with large u64s as strings
    WS->>JS: JSON over WebSocket (strings for large integers)
    WS->>SDK: JSON over WebSocket (strings for large integers)
    SDK->>SDK: serde_utils::deserialize_option_u64 / deserialize_option_option_u64
    SDK->>SDK: Accepts both number and string representations
Loading

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: rust/hyperstack-sdk/src/serde_utils.rs
Line: 200-202

Comment:
**`visit_f64` in `deserialize_option_option_i64` has no bounds check**

Unlike every other `visit_f64` in this file — including `I64OrStringVisitor::visit_f64` (lines 61-67) which guards with `v >= i64::MIN as f64 && v <= i64::MAX as f64`, and `deserialize_option_option_u64`'s visitor (lines 165-171) which guards with `v >= 0.0 && v < (u64::MAX as f64)` — this implementation does an unchecked `v as i64` cast with zero range validation.

In Rust, casting an out-of-range `f64` to `i64` saturates silently: `1e30_f64 as i64 == i64::MAX`, `f64::INFINITY as i64 == i64::MAX`, and `f64::NAN as i64 == 0`. This means corrupt or unexpected float payloads will produce wrong integer values rather than returning a deserialization error.

```suggestion
        fn visit_f64<E: de::Error>(self, v: f64) -> Result<Option<Option<i64>>, E> {
            if v >= i64::MIN as f64 && v < (i64::MAX as f64) {
                Ok(Some(Some(v as i64)))
            } else {
                Err(E::custom(format!("f64 {v} out of i64 range")))
            }
        }
```

Note: the upper bound uses strict `<` rather than `<=` because `i64::MAX as f64` rounds up to `2^63` (one value above `i64::MAX`), so `<=` would admit that out-of-range value.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: "chore: remove dead e..."

adiman9 added 2 commits March 19, 2026 14:17
Live state-mode and list-mode bus updates bypassed
transform_large_u64_to_strings, causing JavaScript clients to receive
corrupted u64 values after the initial snapshot. Apply the transform
upstream in the projector before serialization so all downstream
consumers receive pre-transformed data.
…ec deserializers

- Add bounds check to I64OrStringVisitor::visit_f64 to reject values
  outside i64 range instead of silently saturating
- Tighten U64OrStringVisitor::visit_f64 upper bound (use < instead of
  <= to reject f64 values rounded up beyond u64::MAX)
- Add deserialize_option_vec_i64 and deserialize_option_option_vec_i64
  required by codegen for i64/timestamp array fields
adiman9 added 3 commits March 19, 2026 14:31
deserialize_with_for_type mapped i32 fields to deserialize_option_i64
and u32 fields to deserialize_option_u64, causing type-mismatch compile
errors since the field types are Option<i32>/Option<u32>.

Fix int_kind to emit the exact type suffix (i32/u32/i64/u64) and add
thin 32-bit wrapper functions to serde_utils that delegate to the 64-bit
deserializers and narrow via TryFrom, rejecting out-of-range values.
The 13 hardcoded extract_field! invocations created local variables that
were never referenced — all generated expression code accesses fields
through state.get()/computed_cache.get(), not bare identifiers. Also adds
#[allow(clippy::too_many_arguments)] to the generated eval function.
@adiman9 adiman9 merged commit e96e7fa into main Mar 19, 2026
10 checks passed
@adiman9 adiman9 deleted the integer-fix branch March 19, 2026 14:48
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.

1 participant