Skip to content

refactor(runtime,storage): deduplicate JS SDK host functions with JsCollection trait#2083

Closed
xilosada wants to merge 14 commits intomasterfrom
refactor/js-sdk-macro-dedup
Closed

refactor(runtime,storage): deduplicate JS SDK host functions with JsCollection trait#2083
xilosada wants to merge 14 commits intomasterfrom
refactor/js-sdk-macro-dedup

Conversation

@xilosada
Copy link
Copy Markdown
Member

@xilosada xilosada commented Mar 25, 2026

Summary

  • JsCollection trait + macro: Extract identical new/new_with_id/id/save/load + orphan-attach + recreate-on-missing patterns into a shared trait with js_collection_wrapper! macro for all 9 JS wrapper types
  • Host function macros: impl_crdt_new! and impl_crdt_new_with_id! replace 16 identical ~20-line blocks with one-liner invocations; all 22 load_js_*/save_js_* free functions eliminated
  • Critical fixes: Merge registry guard now checks JS SDK version byte (incoming[0]==1) to prevent non-deterministic Rust CRDT state loss; LWW honors timestamps; init_state validates schema pointer; new_with_id delegates to js_load (load-or-create) instead of unconditional overwrite

Net reduction: -1,126 lines across js.rs and js_collections.rs.

Test plan

  • cargo fmt — clean
  • cargo clippy -p calimero-storage -p calimero-runtime — no new warnings (274 vs 275 before)
  • cargo build -p merod --release — succeeds
  • E2E 2-player poker workflow (32 steps, 15 assertions) — all pass
  • E2E 3-player poker workflow (40 steps, 13 assertions) — all pass

Made with Cursor


Note

High Risk
Touches core runtime/storage behavior: refactors CRDT persistence/load paths, adds new WASM host imports, and changes merge logic for root documents, any of which can affect state correctness and compatibility.

Overview
Introduces a shared JsCollection trait plus js_collection_wrapper! macro in calimero-storage to centralize wrapper lifecycle (new/new_with_id, id, save/load), including orphan attachment and recreate-on-missing behavior.

Refactors js_collections host functions to use macros for *_new/*_new_with_id, switches collection operations to JsCollection::js_load/js_save, and adds new JS-facing host APIs: new_with_id variants, GCounter aliases, plus new CRDT types (PNCounter, RGA) with (de)serialization helpers.

Updates runtime/system imports to include register_js_sdk_root_merge and init_state (schema pointer validation + empty result for now), raises default VM log limit to 500, and adds a guarded JS SDK root LWW merge registration in storage’s merge registry.

Written by Cursor Bugbot for commit c3c8595. This will update automatically on new commits. Configure here.

xilosada added 14 commits March 17, 2026 13:20
- apps/poker: Full Texas Hold'em engine (2-6 players)
  - Card, deck (Fisher-Yates + env::random_bytes), hand evaluator (7→best 5)
  - Game lifecycle: PreFlop→Flop→Turn→River→Showdown
  - 14 public API methods: join/leave, fold/check/call/raise, bot_play,
    claim_timeout, get_game_state/my_cards/hand_result/hand_history/stats, configure
  - 3 in-WASM bot strategies: Random, Caller, TAG (tight-aggressive)
  - Configurable init (blinds, buy-in, seats, timeout)
  - Hand history + per-player win tracking
  - 40 unit tests, 4 merobox E2E workflows (2-player, 3-player, raise, bots)
  - demo.sh: 5-node live match with formatted output

- apps/poker-bot: External bot client binary
  - PokerBot trait + 3 strategies (same as in-WASM)
  - JSON-RPC client for Calimero nodes
  - CLI: --node --context --key --strategy --name --reporter
  - Connects to running nodes, plays autonomously via polling
- crypto.rs: X25519 ECDH + AES-256-GCM for per-player card encryption
- Commit-reveal protocol: bots submit seed hashes, then reveal seeds
  Combined seeds produce deterministic, unmanipulable shuffle
- Dealer role: non-playing node that shuffles, encrypts cards per player,
  and reveals community cards per street from private storage
- Encrypted hole cards stored in shared state — only recipient can decrypt
- Secure mode: enable_secure_mode() switches from trusted to verified dealing
- Community cards hidden until dealer reveals (flop/turn/river methods)
- Folded cards never revealed — bots can't learn opponents' folded hands
- 44 unit tests (4 new crypto), poker-secure.yml workflow passes end-to-end
- WASM compatible: getrandom custom feature, no-std x25519-dalek
…entials parsing

- POKER.md: architecture, security model, next steps (TEE, UserStorage)
- demo.py: dynamic N-player support (dealer + N bots), fix key extraction
- Fix Credentials enum to handle Ethereum config from merobox
- demo.sh: kept as simple 3-bot launcher
- Self-contained HTML/CSS/JS spectator for watching live poker games
- Green felt table with player positions, card rendering, community board
- Real-time polling via JSON-RPC (get_game_state, get_hand_result, get_stats)
- Showdown reveal with all player cards and winner highlight
- Live scoreboard with chip bars
- No build step, no npm — just open in browser
- Connect to any running Calimero node by entering URL + context ID
- python3 demo.py --spectator auto-launches browser with spectator UI
- HTTP server serves spectator.html on port 8080 (configurable --spectator-port)
- URL params auto-fill node/context/key and start polling immediately
- No manual copy-paste of connection info needed
- Players spread: top, right, bottom-right, bottom-left, left, top-left
- Action badges on each player (FOLD red, CALL green, RAISE yellow, CHECK gray)
- Live action log below table showing every action with timestamp
- Phase changes logged (PREFLOP, FLOP with board, etc.)
- Clear log on new hand
- White 'D' badge on dealer
- Blue 'SB' badge on small blind
- Red 'BB' badge on big blind
- Handles heads-up (dealer=SB) and multi-way positions
- Badges update each hand as dealer rotates
- White 'D' button floats on the table near the dealer player
- Moves with CSS transition when dealer rotates between hands
- Players stay in fixed positions — only the button moves
- VRF(dealer_secret_key, hand_number) → deterministic random + proof
- No player seeds needed — zero pre-deal round-trips
- Proof published to shared state, anyone can verify
- Removed: commit_seed, reveal_seed, seed_commits, seed_reveals
- Removed: AlreadyCommitted, SeedMismatch, SeedsIncomplete errors
- 47 unit tests (5 new VRF tests), poker-secure workflow passes
- demo.py simplified (no seed steps)
- POKER.md updated with VRF design

The VRF output is deterministic from the dealer's key + hand number.
Nobody can predict it without the key (held in TEE).
Nobody can recompute the deck (no seeds published).
The proof lets anyone verify the output matches the input.
- BetStructure: NoLimit (0), PotLimit (1), FixedLimit (2)
  - NL: raise any amount ≥ current_bet + BB, up to stack
  - PL: raise capped at pot + current_bet + call amount
  - FL: fixed bet sizes (SB preflop/flop, BB turn/river), max 4 raises/round
- max_buy_in: enforced in join_table (0 = unlimited)
- rake_percent (0-10%) + rake_cap: deducted in award_pot
- round_raises tracked per betting round (reset on phase change)
- New errors: BuyInTooHigh, RaiseExceedsLimit
- All configurable via init() and configure()
- 47 tests pass, WASM builds clean
- set_blind_schedule(small_blinds, big_blinds, hands_per_level)
  e.g. [5,10,25,50,100], [10,20,50,100,200], 5 hands per level
- Blinds auto-advance in start_hand() — no external calls needed
- WASM enforces the schedule — dealer can't skip or change it
- Stays at the last level once schedule is exhausted
- 47 tests pass, WASM builds
…port

Add 31 new WASM host functions to bridge the JS SDK's QuickJS runtime
to Rust CRDT storage, enabling feature parity with the Rust SDK.

New host functions:
- *_new_with_id for all collections (map, vector, set, lww, counter)
- GCounter aliases (js_crdt_g_counter_*) mapping to existing counter
- PNCounter: full support (new, increment, decrement, value, serialize)
- RGA: full support (new, insert, delete, get_text, len, serialize)
- register_js_sdk_root_merge: registers LWW root merge for JS apps
- init_state: acknowledges state schema from JS SDK @init decorator

Storage layer:
- JsPnCounter wrapper (StorageCounter<true>) for positive-negative counter
- JsRga wrapper (ReplicatedGrowableArray) for replicated growable array
- JS SDK root merge function in merge registry

Also bumps max_logs from 100 to 500 for JS SDK's verbose CRDT init logging.

Validated with poker-js E2E: 2-player full hand + 3-player bet/fold/showdown.

Made-with: Cursor
The poker WASM app and bot client now live in the standalone
poker-example repository alongside the new poker-js implementation.

Made-with: Cursor
…ollection trait

- Add JsCollection trait with shared js_save/js_load default methods
  that handle orphan-attach and recreate-on-missing patterns
- Add js_collection_wrapper! macro to generate all 9 wrapper types
  (JsUnorderedMap, JsVector, JsUnorderedSet, JsLwwRegister, JsCounter,
  JsPnCounter, JsRga, JsUserStorage, JsFrozenStorage) with identical
  new/new_with_id/id/save/load boilerplate
- Add impl_crdt_new! and impl_crdt_new_with_id! macros replacing 16
  identical host function blocks
- Eliminate all 22 load_js_*/save_js_* free functions (~500 lines)
- Fix merge registry poison: add version-byte guard (incoming[0]==1)
  so JS SDK merge handler rejects Rust CRDT state instead of
  non-deterministically intercepting it
- Fix timestamp-ignoring LWW: honor timestamps (newer wins) instead
  of always accepting incoming
- Fix init_state: validate schema_ptr to catch OOB pointers from
  malicious WASM modules
- Fix new_with_id: delegate to js_load (load-or-create) instead of
  unconditionally overwriting existing data
- Change hot-path logging from debug! to trace!; document max_logs=500

Net reduction: -1,126 lines across js.rs and js_collections.rs.
E2E verified: both poker-js 2-player and 3-player workflows pass.

Made-with: Cursor
Copy link
Copy Markdown

@meroreviewer meroreviewer Bot left a comment

Choose a reason for hiding this comment

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

🤖 AI Code Reviewer

Reviewed by 3 agents | Quality score: 100% | Review time: 293.9s

💡 4 suggestions. See inline comments.


🤖 Generated by AI Code Reviewer | Review ID: review-c9b07cf4

dest_register_id,
panic_payload_to_string(payload.as_ref(), "unknown panic"),
),
Err(message) => self.write_error_message(dest_register_id, message),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Deserialization allows WASM to inject arbitrary CRDT state

The crdt_counter_deserialize function accepts borsh-encoded data from WASM and directly saves it as a counter; a malicious WASM app could craft serialized state to bypass normal CRDT operation constraints.

Suggested fix:

Document that this API is trusted for sync/migration purposes, or add validation that the deserialized state is consistent with CRDT invariants before saving.

@@ -65,4 +65,6 @@ pub struct ClientLocalSigner {
#[serde(untagged)]
pub enum Credentials {
Near(NearCredentials),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Catch-all credential variant may mask configuration errors

The Other(BTreeMap<String, String>) catch-all silently accepts any credential format; misconfigured credentials will be parsed but silently skipped at runtime rather than flagged early.

Suggested fix:

Consider logging a warning when `Other` credentials are encountered, or validate known protocol types explicitly to fail fast on misconfiguration.

const DEFAULT_MAX_REGISTER_SIZE_MIB: u64 = 100;
/// Default maximum number of log entries.
const DEFAULT_MAX_LOGS: u64 = 100;
///
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Magic constant 500 for DEFAULT_MAX_LOGS could be derived or configurable

The calculation (~26 fields × ~4 log lines = ~104 minimum) is documented but the final 500 value is a rough estimate; consider making this configurable at runtime.

Suggested fix:

If log limits are hit in production, exposing this via VMLimits configuration would allow tuning without code changes.

}
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 new_with_id correctly changed from create to load-or-create

The impl_crdt_new_with_id! macro now delegates to js_load which prevents data loss by returning existing instances; this is a good fix but the macro comment should clarify that js_load creates a new instance if not found to prevent future confusion.

Suggested fix:

Expand the doc comment to explicitly state the load-or-create semantics of `js_load`.

@github-actions
Copy link
Copy Markdown

E2E Rust Apps Failed

One or more E2E workflows (e2e-kv-store, xcall-example) failed after retries.

Please check the workflow logs for more details.

@github-actions
Copy link
Copy Markdown

E2E Blockchain Proposals Failed

The following proposal workflow(s) failed:

  • near

Please check the workflow logs for more details.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Deserialized JS collections may never persist
    • Added element_mut().update() call after deserialization to mark the element as dirty before calling js_save(), ensuring the data is persisted.
  • ✅ Fixed: JS root merge guard matches non-JS payloads
    • Strengthened the JS SDK root merge guard to validate both existing and incoming data structures including version byte, state length, and collections length fields.

Create PR

Or push these changes by commenting:

@cursor push 0ba9b62032
Preview (0ba9b62032)
diff --git a/crates/runtime/src/logic/host_functions/js_collections.rs b/crates/runtime/src/logic/host_functions/js_collections.rs
--- a/crates/runtime/src/logic/host_functions/js_collections.rs
+++ b/crates/runtime/src/logic/host_functions/js_collections.rs
@@ -5,13 +5,12 @@
 };
 use calimero_storage::{
     address::Id,
+    entities::Data,
     env::{with_runtime_env, RuntimeEnv},
-    interface::Interface,
     js::{
         JsCollection, JsCounter, JsFrozenStorage, JsLwwRegister, JsPnCounter, JsRga,
         JsUnorderedMap, JsUnorderedSet, JsUserStorage, JsVector,
     },
-    store::MainStorage,
 };
 use std::{
     convert::TryFrom,
@@ -1270,6 +1269,7 @@
             Ok(c) => c,
             Err(err) => return self.write_error_message(dest_register_id, err),
         };
+        counter.element_mut().update();
         match counter.js_save() {
             Ok(()) => {
                 self.write_register_bytes(dest_register_id, counter.id().as_bytes())?;
@@ -1449,6 +1449,7 @@
             Ok(c) => c,
             Err(err) => return self.write_error_message(dest_register_id, err),
         };
+        counter.element_mut().update();
         match counter.js_save() {
             Ok(()) => {
                 self.write_register_bytes(dest_register_id, counter.id().as_bytes())?;
@@ -1578,6 +1579,7 @@
             Ok(r) => r,
             Err(err) => return self.write_error_message(dest_register_id, err),
         };
+        rga.element_mut().update();
         match rga.js_save() {
             Ok(()) => {
                 self.write_register_bytes(dest_register_id, rga.id().as_bytes())?;

diff --git a/crates/storage/src/merge/registry.rs b/crates/storage/src/merge/registry.rs
--- a/crates/storage/src/merge/registry.rs
+++ b/crates/storage/src/merge/registry.rs
@@ -108,7 +108,11 @@
         // byte must be 1. Rust SDK roots are plain Borsh structs without this
         // prefix, so they will fail this guard and fall through to their own
         // registered merge function.
-        if incoming.len() < 5 || incoming[0] != 1 {
+        //
+        // IMPORTANT: We must check BOTH existing and incoming to avoid false positives.
+        // If only one side matches, this is not a valid JS-to-JS merge and we should
+        // reject to let the correct merge function handle it.
+        if !is_js_sdk_root_document(existing) || !is_js_sdk_root_document(incoming) {
             return Err("not a JS SDK root document".into());
         }
         // LWW: newer timestamp wins; on tie, incoming wins.
@@ -129,6 +133,40 @@
     let _ = registry.insert(sentinel, merge_fn);
 }
 
+/// Checks if the given bytes represent a JS SDK root document.
+///
+/// JS SDK root format: [version: u8=1][state: u32 len + borsh][collections: u32 len + borsh]
+/// Returns true if the data has the correct structure.
+fn is_js_sdk_root_document(data: &[u8]) -> bool {
+    // Minimum size: 1 (version) + 4 (state len) = 5 bytes
+    if data.len() < 5 {
+        return false;
+    }
+    // Version must be 1
+    if data[0] != 1 {
+        return false;
+    }
+    // Try to read the state length (u32 little-endian at offset 1)
+    let state_len = u32::from_le_bytes([data[1], data[2], data[3], data[4]]) as usize;
+    // After version (1) + state_len field (4) + state data, there should be collections length
+    let collections_offset = 1 + 4 + state_len;
+    // Minimum: need at least 4 more bytes for collections length
+    if data.len() < collections_offset + 4 {
+        return false;
+    }
+    // Read collections length
+    let collections_len = u32::from_le_bytes([
+        data[collections_offset],
+        data[collections_offset + 1],
+        data[collections_offset + 2],
+        data[collections_offset + 3],
+    ]) as usize;
+    // Total expected length
+    let expected_len = collections_offset + 4 + collections_len;
+    // The data should match exactly (or be at least that long for valid structure)
+    data.len() == expected_len
+}
+
 struct JsSdkRootMerge;
 
 /// Clear the merge registry (for testing only)

You can send follow-ups to this agent here.

Comment thread crates/storage/src/js.rs
Interface::<MainStorage>::add_child_to(Id::root(), self)
.map(|_| ())
.map_err(|e| e.to_string())
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Deserialized JS collections may never persist

High Severity

js_save treats Interface::save returning Ok(false) as success. After Borsh deserialization, Element.is_dirty is false, so _deserialize host functions call js_save but nothing is written. They still return success and an id, causing silent state loss or empty recreation on later loads.

Additional Locations (2)
Fix in Cursor Fix in Web

}
// LWW: newer timestamp wins; on tie, incoming wins.
if incoming_ts >= existing_ts {
Ok(incoming.to_vec())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

JS root merge guard matches non-JS payloads

High Severity

register_js_sdk_root_merge_fn accepts any payload with len >= 5 and first byte 1. try_merge_registered iterates merge functions in hash-map order and returns the first success, so this guard can falsely accept non-JS root bytes and force LWW instead of the correct CRDT merge.

Fix in Cursor Fix in Web

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 2, 2026

This pull request has been automatically marked as stale. If this pull request is still relevant, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize reviewing it yet. Your contribution is very much appreciated.

@github-actions github-actions Bot added the Stale label Apr 2, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 9, 2026

"This pull request has been automatically closed because it has been inactive for more than 7 days. Please reopen and see this PR through its review if it is essential."

@github-actions github-actions Bot closed this Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant