refactor(runtime,storage): deduplicate JS SDK host functions with JsCollection trait#2083
refactor(runtime,storage): deduplicate JS SDK host functions with JsCollection trait#2083
Conversation
- 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
There was a problem hiding this comment.
🤖 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), |
There was a problem hiding this comment.
💡 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), | |||
There was a problem hiding this comment.
💡 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; | ||
| /// |
There was a problem hiding this comment.
💡 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.
| } | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
💡 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`.
E2E Rust Apps FailedOne or more E2E workflows (e2e-kv-store, xcall-example) failed after retries. Please check the workflow logs for more details. |
E2E Blockchain Proposals FailedThe following proposal workflow(s) failed:
Please check the workflow logs for more details. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
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.
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.
| Interface::<MainStorage>::add_child_to(Id::root(), self) | ||
| .map(|_| ()) | ||
| .map_err(|e| e.to_string()) | ||
| } |
There was a problem hiding this comment.
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)
| } | ||
| // LWW: newer timestamp wins; on tie, incoming wins. | ||
| if incoming_ts >= existing_ts { | ||
| Ok(incoming.to_vec()) |
There was a problem hiding this comment.
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.
|
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. |
|
"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." |



Summary
new/new_with_id/id/save/load+ orphan-attach + recreate-on-missing patterns into a shared trait withjs_collection_wrapper!macro for all 9 JS wrapper typesimpl_crdt_new!andimpl_crdt_new_with_id!replace 16 identical ~20-line blocks with one-liner invocations; all 22load_js_*/save_js_*free functions eliminatedincoming[0]==1) to prevent non-deterministic Rust CRDT state loss; LWW honors timestamps;init_statevalidates schema pointer;new_with_iddelegates tojs_load(load-or-create) instead of unconditional overwriteNet reduction: -1,126 lines across
js.rsandjs_collections.rs.Test plan
cargo fmt— cleancargo clippy -p calimero-storage -p calimero-runtime— no new warnings (274 vs 275 before)cargo build -p merod --release— succeedsMade 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
JsCollectiontrait plusjs_collection_wrapper!macro incalimero-storageto centralize wrapper lifecycle (new/new_with_id, id, save/load), including orphan attachment and recreate-on-missing behavior.Refactors
js_collectionshost functions to use macros for*_new/*_new_with_id, switches collection operations toJsCollection::js_load/js_save, and adds new JS-facing host APIs:new_with_idvariants, GCounter aliases, plus new CRDT types (PNCounter,RGA) with (de)serialization helpers.Updates runtime/system imports to include
register_js_sdk_root_mergeandinit_state(schema pointer validation + empty result for now), raises default VM log limit to500, 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.