Skip to content

feat(indexer)!: implement state sync and utxo scan api - #1549

Merged
sdbondi merged 3 commits into
tari-project:developmentfrom
sdbondi:indexer-state-sync
Aug 22, 2025
Merged

feat(indexer)!: implement state sync and utxo scan api#1549
sdbondi merged 3 commits into
tari-project:developmentfrom
sdbondi:indexer-state-sync

Conversation

@sdbondi

@sdbondi sdbondi commented Aug 22, 2025

Copy link
Copy Markdown
Member

Description

feat(indexer)!: implement resumable state sync
feat(indexer/jrpc): utxo scan api
feat(indexer)!: sync epoch checkpoints
feat(indexer)!: implement transaction and event scanning to state sync worker
feat(validator/p2p)!: allow values for specific substate types to be returned over the wire
fix(validator/storage): minor improvement in DB codecs (perf, maint)
fix(consensus): bug causing new genesis block to be created after each restart
fix(bindings): remove export_to everywhere except where needed

Motivation and Context

This PR allows clients (wallets) to retrieve changes to UTXOs (either unspent or spent) with specific UTXO tags across shards.

REQUEST:

{
  "id": 999,
  "jsonrpc": "2.0",
  "method": "get_utxo_updates",
  "params":  {
    "shard_state_versions": [
      [1, 0],
      [2, 0],
      [3, 2],
      [4, 3],
      [5, 0]
    // ...
    ],
    "filter_tag_bytes": [145, 205],
    "resource_address": "resource_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914",
    "per_shard_limit": 10
  }
}

RESPONSE:

{
  "jsonrpc": "2.0",
  "result": {
    "utxo_updates": [
      {
        "Spent": {
          "state_version": 2,
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_905d2a8f49a8388098f222cf8821dea3b0f21ae8bce724b4be7d2408497ebd72",
            "version": 0
          }
        }
      },
      {
        "Unspent": {
          "shard": 200,
          "state_version": 1,
          "utxo": {
            "is_frozen": false,
            "output": {
              "output": {
                "encrypted_data": "3da321d20c063835754ec29e47f94b6f93a41dde7a7570ad637450b9e310866dfab29b7e8a275df2bcb3f823e2d115334b64942ed2d281eef5d560010544dc7343a96bf6055604cd9302cfd2365c0fad",
                "minimum_value_promise": 0,
                "public_nonce": "6612359ccaef9d4b8e02c9a2b55f0e44cf74254e9259803ee1c1988324f57778",
                "viewable_balance": null
              },
              "owner_public_key": "e8970bacf306d6528b94cb4c64b3c62db15f5d796fd9c304d98c84d439585e53",
              "tag": 205
            }
          },
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_a49d1cd0a73ea2fc9c52602962409c80aa06e41c7a24e6fcbd05e2976631bf6b",
            "version": 0
          }
        }
      },
      {
        "Spent": {
          "state_version": 2,
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_244bbc66c5d258a0170f67a5375eed94dba957708ecd7c4d24199b1a4143f977",
            "version": 0
          }
        }
      },
      {
        "Unspent": {
          "shard": 109,
          "state_version": 2,
          "utxo": {
            "is_frozen": false,
            "output": {
              "output": {
                "encrypted_data": "0fd43a83597b625faa509b52ee0d1f2eefbacfd405d6fe2b81f6af129699fb505539ae9e238dea5f4d4c15d88871c52ef6e5c07cd0dfc8d65c22cad93ba29aec59ca9713d1b9fec4e9a6cdd14427aca8",
                "minimum_value_promise": 0,
                "public_nonce": "0867b3a0e6a2311c3eee17d45494d70824319d3af979c07ea4f0bb24fbbf9f21",
                "viewable_balance": null
              },
              "owner_public_key": "b83e7cd1be584701eb249d042cdd5d18d03c9fd544fb1702bf21399e4f0d7e1e",
              "tag": 145
            }
          },
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_62b6ad9a98826fa405531a785163c2bfb7f5954f502e3962f42e6d8533c0f551",
            "version": 0
          }
        }
      },
      {
        "Unspent": {
          "shard": 209,
          "state_version": 1,
          "utxo": {
            "is_frozen": false,
            "output": {
              "output": {
                "encrypted_data": "c28df90e56189fca7693b23caaa0a081f5cd1c4d97e5077d8dce4beccb10472cdff5be38a484616783317c56161bdd2a5661834dade625500525641d17df7fd6be6a61cb1b580300780afad5fb6d89f8",
                "minimum_value_promise": 0,
                "public_nonce": "28786b065c038a7a0fcd340fd98f4790b64193e0d81e320a4453d2fc6c41726d",
                "viewable_balance": null
              },
              "owner_public_key": "fcefafa6e6651d7db462bf54e16085bff94f4cf1a3c39243645a5692ffe95f03",
              "tag": 145
            }
          },
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_70600bdee8fd5e0934a069f24cf6fa18f19590a2ece8a7dae5a015ff051eaa71",
            "version": 0
          }
        }
      },
      {
        "Unspent": {
          "shard": 196,
          "state_version": 1,
          "utxo": {
            "is_frozen": false,
            "output": {
              "output": {
                "encrypted_data": "6a69d1797b2aca836ed9cb88dd550b09318dda7901caec307655adec0d225b03ad52c0858da4db9e08eaf91022af08ddd79a92c28a8b4ce9ddd1522bb4f2692b6f6b6329da83e7f0f8038e5de7ac3203",
                "minimum_value_promise": 0,
                "public_nonce": "10dfff35b25e2a4675864ed0425dc043fe125485d445049b74eb46a7a1d2fc68",
                "viewable_balance": null
              },
              "owner_public_key": "d074e68cb391bd1af4d9f9843162f24222546e4f2e95193647958475bb3c7a05",
              "tag": 145
            }
          },
          "versioned_substate_id": {
            "substate_id": "utxo_6b51475af596886ffebc6028b648bdcd6982f65171b04481ad00f7d09e562914_d6d3300959e6ab8842b44021663ba309fb2c2e714159320d03e7a5e19fda4d5b",
            "version": 0
          }
        }
      }
    ]
  },
  "id": 999
}

How Has This Been Tested?

Manually

Breaking Changes

  • None
  • Requires data directory to be deleted
  • Other - Please specify

Summary by CodeRabbit

  • New Features

    • Network-wide state sync and background block scanning to keep the indexer up-to-date across epochs and shard groups.
  • API Changes

    • New JSON‑RPC endpoint for streaming UTXO updates; validator RPC now returns batched checkpoints and supports value-filtering. GraphQL event-saving endpoint removed.
  • Data / Migrations

    • Consolidated DB schema with unified event/substate/UTXO storage, checkpoints and key-value entries, and standardized timestamps.
  • Performance / Stability

    • Batched DB writes, incremental scanning, and simplified service ownership for more efficient startup and runtime.

@coderabbitai

coderabbitai Bot commented Aug 22, 2025

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

Walkthrough

Adds a network-wide state synchronization subsystem (worker, committee RPC pool, block scanner), rewrites indexer storage and schemas (events, utxos, checkpoints, key-values), moves many codecs to reader-based APIs, adjusts epoch/validator RPCs for multi-checkpoint and value-filtered sync, and updates client/GraphQL/JSON-RPC surfaces and types.

Changes

Cohort / File(s) Summary
Top-level dependency edits
Cargo.toml, clients/tari_indexer_client/Cargo.toml, crates/storage/Cargo.toml, applications/tari_indexer/Cargo.toml
Bumped/added deps (bitflags, serde_with), toggled default-features, added feature gating for client/TS.
Indexer bootstrap & service wiring
applications/tari_indexer/src/bootstrap.rs, applications/tari_indexer/src/lib.rs
Spawn NetworkWideStateSync, add DB network check, replace EventScanner→BlockScanner, remove many Arc wrappers, extend spawn_services signature.
Network-wide state sync
applications/tari_indexer/src/network_state_sync/*
New module: block_scanner, committee_client, config, error, event_filter, stats, sync_plan, sync_progress, worker; BlockScanner and NetworkWideStateSync implementations and re-exports.
Event scanner removal / manager changes
applications/tari_indexer/src/event_scanner.rs (deleted), applications/tari_indexer/src/event_manager.rs
EventScanner removed; EventManager simplified (no scanner), save_event removed, derives added.
Config and event filters
applications/tari_indexer/src/config.rs, applications/tari_indexer/src/network_state_sync/event_filter.rs
IndexerConfig.event_filters now uses shared EventFilter type; added serializable EventFilter with matches() helper.
GraphQL / JSON-RPC wiring
applications/tari_indexer/src/graphql/*, applications/tari_indexer/src/json_rpc/*
Handlers and server now take owned managers (no Arc); removed save_event GraphQL mutation; added get_utxo_updates JSON-RPC endpoint and reorganized groups.
Substate manager / scanner ownership
applications/tari_indexer/src/substate_manager.rs
SubstateScanner no longer Arc-wrapped; SubstateManager Debug+Clone; added get_stored_substates_by_filters and get_utxo_updates.
SQLite schema, migrations & models
applications/tari_indexer/src/storage_sqlite/migrations/*, .../models/*, .../schema.rs
Consolidated/rewrote migrations; added utxos, key_values, substate_transitions, epoch_checkpoints; events model -> EventDb (created_at); substates use PrimitiveDateTime and created_at/updated_at; payload-table removed.
SQLite store API refactor
applications/tari_indexer/src/storage_sqlite/store_factory.rs, .../serialization.rs
New key-value API, batched write ops (batch_insert_substate_transitions, batch_insert_utxo_updates, batch_insert_events), upsert_substate, insert_or_ignore_epoch_checkpoint; deserialize_hex_try_from added.
Indexer JSON client & types
clients/tari_indexer_client/src/lib.rs, .../types.rs
Client feature gating; added GetUtxoUpdatesRequest/Response and UtxoUpdate types; ListSubstateItem.timestamp → PrimitiveDateTime/TS string.
RocksDB codecs → streaming reader
crates/state_store_rocksdb/src/codecs/*, .../utils.rs, .../mod.rs
Many codecs changed to decode_reader<R: Read> streaming API, added ShardGroupCodec, removed some TupleBytesCodec/FixedByteLength helpers, removed bincode helpers from utils, added default decode delegating to reader.
Column families & store reader/writer
crates/state_store_rocksdb/src/column_families/*, .../reader.rs, .../writer.rs, .../store.rs
Replaced TupleBytesCodec with per-field or composite codecs (e.g., Epoch+ShardGroup); reader supports SubstateValueFilterFlags for conditional value loading; writer keys epoch checkpoints by (epoch, shard_group).
Epoch manager & network description
crates/epoch_manager/src/service/*, crates/epoch_manager/src/traits.rs
Added NetworkDescription, ShardGroupInfo, get_network_description and get_committee_info APIs; new request variants and re-exports.
P2P RPC / proto changes
crates/p2p/proto/rpc.proto, applications/tari_validator_node/src/p2p/rpc/*
GetCheckpoint→GetCheckpoints (from_epoch + num_to_return), Sync* include value_filters and use Epoch type, StreamSubstateSelection.All → AllSubstates; state sync tasks accept flags.
State-sync worker & client adjustments
applications/tari_indexer/src/network_state_sync/worker.rs, crates/rpc_state_sync/src/state_sync.rs
NetworkWideStateSync worker orchestrates checkpoints/state updates with filtering; RPC client handles multi-checkpoint responses and value_filters.
State-store API changes & tests
crates/state_store_rocksdb/*, crates/state_store_tests/*, crates/consensus_tests/*, crates/state_store/mod.rs
Public API: state_transitions_get_starting_at now takes SubstateValueFilterFlags; added epoch_checkpoint_get_all_from_epoch and epoch_checkpoint_get_by_shard_group; tests adjusted to new APIs/flags.
Misc types and TS bindings adjustments
crates/*, bindings/src/*, applications/tari_validator_node/web_ui/*
Added StateVersion newtype and exports; many TS export attributes simplified and TS binding files updated; various frontend typing updates; small API renames (is_utxo).
Migrations cleanup
applications/tari_indexer/src/storage_sqlite/migrations/*
Removed numerous discrete migration files (up/down) in favor of the consolidated initial schema and other migration reorganizations.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant Main as Indexer Main
  participant Bootstrap as Bootstrap
  participant Epoch as EpochManager
  participant Net as Networking
  participant Store as SqliteIndexerStore
  participant TM as TemplateManager
  participant Sync as NetworkWideStateSync

  Main->>Bootstrap: spawn_services(config, shutdown, keypair, global_db, consensus_constants)
  Bootstrap->>Store: open DB & check_store(network)
  Bootstrap->>Epoch: start epoch handles
  Bootstrap->>TM: start template manager
  Bootstrap->>Sync: new(epoch_manager, networking, store, template_manager, cfg)
  Bootstrap->>Sync: spawn(shutdown)
  Note over Sync: loop on tick or epoch change
  Sync->>Epoch: get_network_description()
  Sync->>Net: build ValidatorCommitteeRpcPools
  alt checkpoints
    Sync->>Net: fetch checkpoints (from_epoch, limit)
    Sync->>Store: insert_or_ignore_epoch_checkpoint(...)
  end
  alt state sync
    Sync->>Net: stream state updates (value_filters)
    Sync->>Store: batch_insert_substate_transitions(...)
    Sync->>Store: batch_insert_utxo_updates(...)
    Sync->>Store: batch_insert_events(...)
    Sync->>Store: upsert_substate(...)
    Sync->>TM: enqueue template changes
  end
Loading
sequenceDiagram
  autonumber
  participant Client
  participant RPC as JSON-RPC Server
  participant H as JsonRpcHandlers
  participant SM as SubstateManager
  participant DB as SqliteIndexerStore

  Client->>RPC: get_utxo_updates(request)
  RPC->>H: dispatch to handler
  H->>SM: get_utxo_updates(resource, shard, from_ver, tag_bytes, limit)
  SM->>DB: with_read_tx(get_utxo_updates(...))
  DB-->>SM: Vec<UtxoUpdate>
  SM-->>H: Vec<UtxoUpdate>
  H-->>RPC: GetUtxoUpdatesResponse
  RPC-->>Client: result
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120–180 minutes

Possibly related PRs

Poem

I tap my paws on ticking time,
New shards align, committees chime.
Blocks parade in steady sync,
Checkpoints saved with one soft wink.
UTXOs hop, events hum anew—
Carrots coded, rabbit cheers to you. 🥕🐇

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@sdbondi
sdbondi force-pushed the indexer-state-sync branch from cb4f5fb to 0ef0108 Compare August 22, 2025 06:53

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 23

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
applications/tari_indexer/src/storage_sqlite/models/substate.rs (2)

49-55: Avoid lossy cast: use u32::try_from for version.

Casting i32 → u32 can wrap negatives. Use a checked conversion and surface an error.

Apply:

-            version: row.version as u32,
+            version: u32::try_from(row.version)
+                .map_err(|_| anyhow::anyhow!("Negative version for substate {}", row.address))?,

33-44: Fix PrimitiveDateTime serialization and update downstream consumers

The switch from an i64 epoch value to time::PrimitiveDateTime means every layer that reads or writes timestamp must be updated. In particular:

Rust model & store
applications/tari_indexer/src/storage_sqlite/models/substate.rs now defines
rust pub timestamp: PrimitiveDateTime,
applications/tari_indexer/src/storage_sqlite/store_factory.rs maps the DB value into the DTO unchanged:
rust let timestamp = s.timestamp; Ok(ListSubstateItem { …, timestamp })

Indexer JSON-RPC → wallet SDK mapping
– In applications/tari_walletd/src/indexer_jrpc_impl.rs, you forward s.timestamp into SubstateListItem { timestamp } from the wallet SDK. Confirm that tari_ootle_wallet_sdk::network::SubstateListItem also uses PrimitiveDateTime (it should), and that its Serde support is configured (e.g. via #[serde(with = "time::serde::rfc3339")] or equivalent).

Client-side API types
– In clients/tari_indexer_client/src/types.rs, ListSubstateItem already has
rust pub timestamp: PrimitiveDateTime,

Web UI parsing
– In applications/tari_indexer/web_ui/src/routes/Substates/Substates.tsx at line 254:
tsx {new Date(Number(row.timestamp) * 1000).toDateString()}
This still treats timestamp as an epoch numeric value. You must either:
1. Serialize PrimitiveDateTime as a numeric epoch in the API (add a custom Serialize impl or #[serde] attribute), or
2. Update the UI to parse the ISO‐8601 string (e.g. new Date(row.timestamp).toDateString())

Action items

  • Add or verify #[serde(...)] attributes on all PrimitiveDateTime fields so Serde produces the desired format (epoch vs. RFC 3339 string).
  • Update the JSON-RPC layer (wallet SDK and client types) to match.
  • Fix the React UI to stop using Number(row.timestamp)*1000 when parsing.

This is a critical fix: without it, UIs will display NaN dates and API clients may fail to deserialize timestamps correctly.

crates/state_store_rocksdb/src/codecs/column.rs (1)

14-19: Docstring incorrectly states "32 bytes" — should be "4 bytes".

The Column key encodes a u32 (4 bytes), not 32 bytes. This can mislead future maintainers.

Apply this diff:

 /// A const key used to differentiate "columns" in a reused column family.
-/// This hard codes 32 bytes (big-endian) from the encoded bytes.
+/// This hard codes 4 bytes (big-endian) from the encoded bytes.
 /// It is not recommended to use this on a shared column family that uses prefix lookups, as the codec used would need
applications/tari_indexer/src/json_rpc/server.rs (2)

64-67: Avoid logging full request bodies; log metadata instead

Logging the entire JsonRpcExtractor at debug can leak sensitive payloads and will bloat logs. Prefer logging method, request id, and payload size; avoid parameters by default.

Apply this minimal change to stop logging full bodies:

-    debug!(target: LOG_TARGET, "🌐 JSON-RPC body: {:?}", value);
+    debug!(target: LOG_TARGET, "🌐 JSON-RPC body received (method={}, id={:?})", value.method, value.id);

If you want payload introspection in development, gate detailed logging behind a feature flag or env var and redact known sensitive fields.


98-126: Middleware can panic on body read and degrades performance by fully buffering the response

to_bytes(...).await.unwrap() will panic if the body stream errs. Also, the middleware buffers the entire response body for every request, which is expensive and breaks streaming semantics.

Prefer tower_http::trace for request/response tracing. It avoids full buffering and integrates with tracing. Replace the ad-hoc logger:

 use tower_http::cors::CorsLayer;
+use tower_http::trace::TraceLayer;
@@
-        .layer(middleware::from_fn(logger::middleware_fn))
+        .layer(TraceLayer::new_for_http())

If you must keep the custom logger temporarily, at least make it non-panicking and avoid buffering unless debug is enabled:

-        let (parts, body) = res.into_parts();
-        let body_bytes = to_bytes(body).await.unwrap();
-        debug!(target: LOG_TARGET, "🌐 Response: {}", String::from_utf8_lossy(&body_bytes));
-        Ok(Response::from_parts(parts, Body::from(body_bytes)))
+        let (parts, body) = res.into_parts();
+        if log::log_enabled!(log::Level::Debug) {
+            match to_bytes(body).await {
+                Ok(body_bytes) => {
+                    debug!(target: LOG_TARGET, "🌐 Response ({} bytes)", body_bytes.len());
+                    Ok(Response::from_parts(parts, Body::from(body_bytes)))
+                },
+                Err(e) => {
+                    warn!(target: LOG_TARGET, "🌐 Response body read failed: {}", e);
+                    Ok(Response::from_parts(parts, Body::empty()))
+                },
+            }
+        } else {
+            // Do not buffer; just return as-is when not debugging
+            Ok(Response::from_parts(parts, Body::empty()))
+        }

Note: The “empty body” fallback above still alters the response. The recommended fix is to use TraceLayer and remove full-body logging.

applications/tari_indexer/src/storage_sqlite/schema.rs (1)

14-24: Add missing migration for events schema changes

The Diesel schema in applications/tari_indexer/src/storage_sqlite/schema.rs has removed the version and timestamp columns and introduced created_at, but there is no corresponding SQL migration. Without this, existing databases will fail to migrate and data integrity will be lost.

Please add a new migration under
applications/tari_indexer/src/storage_sqlite/migrations/ (e.g. 2025-08-22-000000_alter_events_table) that:

  • Adds the created_at column, backfilling from timestamp for existing rows
    ALTER TABLE events ADD COLUMN created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
    UPDATE events SET created_at = timestamp;
  • Drops the deprecated columns
    ALTER TABLE events DROP COLUMN timestamp;
    ALTER TABLE events DROP COLUMN version;
  • Updates or adds an index if needed (e.g. on created_at) to maintain query performance.

After adding the migration, ensure:

  • All existing data is preserved and backfilled correctly.
  • Diesel schema (schema.rs) matches the final table definition.
  • Integration tests (and any CI migration checks) pass successfully.
applications/tari_indexer/src/lib.rs (1)

210-224: Address the TODO comment for proper shutdown handling.

The TODO comment on Line 218 indicates that shutdown handling during scanning needs to be implemented. This could lead to incomplete state or resource leaks if the scanner is in the middle of processing when shutdown is requested.

Would you like me to implement proper shutdown handling for the scanning loop? This would involve checking the shutdown signal within the scan operation and ensuring graceful termination.

Comment thread applications/tari_indexer/Cargo.toml
Comment thread applications/tari_indexer/src/graphql/model/events.rs
Comment thread applications/tari_indexer/src/json_rpc/handlers.rs
Comment thread applications/tari_indexer/src/json_rpc/server.rs
Comment thread applications/tari_indexer/src/network_state_sync/block_scanner.rs Outdated
Comment thread crates/state_store_rocksdb/src/codecs/column.rs
Comment thread crates/state_store_rocksdb/src/codecs/misc.rs
Comment thread crates/state_store_rocksdb/src/codecs/substate_lock.rs
Comment thread crates/state_store_rocksdb/src/column_families/lock_conflict.rs
@github-actions

Copy link
Copy Markdown

Test Results (CI)

418 tests  ±0   394 ✅ ±0   1h 15m 44s ⏱️ -19s
 69 suites ±0     0 💤 ±0 
  2 files   ±0    24 ❌ ±0 

For more details on these failures, see this check.

Results for commit 0ef0108. ± Comparison against base commit efc2ddf.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

♻️ Duplicate comments (5)
applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql (3)

17-29: Add FK on substate_transitions.substate_id → substates(address) with ON DELETE CASCADE.

This preserves referential integrity and enables cascaded cleanup during reorgs/resyncs. The past review already requested this; the shard/state_version index is now present (nice), but the FK is still missing.

Apply this diff within the table definition:

     value_hash    text      NULL,
-    created_at    timestamp not null default current_timestamp
+    created_at    timestamp not null default current_timestamp,
+    FOREIGN KEY (substate_id) REFERENCES substates (address) ON DELETE CASCADE
 );

107-115: Unify shard_group type: use INTEGER to match scanned_block_ids.

Inconsistent types (TEXT here vs INTEGER in scanned_block_ids) hurt joins and index reuse. This was flagged previously and still applies.

 CREATE TABLE epoch_checkpoints
 (
     id          INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
     epoch       BIGINT                            NOT NULL,
-    shard_group TEXT                              NOT NULL,
+    shard_group INTEGER                           NOT NULL,
     json_data   TEXT                              NOT NULL,
     created_at  DATETIME                          NOT NULL DEFAULT CURRENT_TIMESTAMP,
     updated_at  DATETIME                          NOT NULL DEFAULT CURRENT_TIMESTAMP
 );

119-136: Enforce UTXO uniqueness per (substate_id, version) and align indexes with get_utxo_updates.

  • Essential: prevent duplicates for the same UTXO logical revision.
  • Optional: add a lighter composite for tag-less scans if common.
 create table utxos
 (
@@
     created_at       timestamp not null default current_timestamp
 );
 
-CREATE INDEX utxos_shard_tag_resource_state_version_idx
-    ON utxos (shard, utxo_tag_byte, resource_address, state_version);
+CREATE INDEX utxos_shard_tag_resource_state_version_idx
+    ON utxos (shard, utxo_tag_byte, resource_address, state_version);
+
+-- Ensure one logical UTXO version per substate
+CREATE UNIQUE INDEX utxos_substate_id_version_uniq
+    ON utxos (substate_id, version);
+
+-- Optional: when tag filter is omitted
+--CREATE INDEX utxos_shard_resource_state_version_idx
+--    ON utxos (shard, resource_address, state_version);
applications/tari_indexer/src/network_state_sync/block_scanner.rs (1)

362-386: Fix timestamp fallback: log says UNIX_EPOCH but returns MAX; also avoid i64::MAX sentinel.

Returning PrimitiveDateTime::MAX contradicts the log (“Using UNIX_EPOCH”) and can poison ordering/retention logic with far-future timestamps. Fall back to UNIX_EPOCH consistently and drop the i64::MAX sentinel path.

-fn unix_epoch_to_primitive_date_time(timestamp: u64) -> PrimitiveDateTime {
-    let timestamp = i64::try_from(timestamp).unwrap_or_else(|e| {
-        // TODO: this is very possible because we trust that the timestamp is roughly correct, however
-        // it is purely informational and not enforced in consensus therefore could be any value and
-        // therefore cannot be relied for ordering (use (epoch,height) instead).
-        warn!(
-            target: LOG_TARGET,
-            "Failed to convert block timestamp to PrimitiveDateTime: {}",
-            e
-        );
-        i64::MAX // = August 17, 292278994, 07:12:55.807 UTC
-    });
-    OffsetDateTime::from_unix_timestamp(timestamp)
-        .map(|osdt| PrimitiveDateTime::new(osdt.date(), osdt.time()))
-        .unwrap_or_else(|e| {
-            warn!(
-                target: LOG_TARGET,
-                "Failed to convert block timestamp to OffsetDateTime: {}. Using UNIX_EPOCH",
-                e
-            );
-            // An error cannot be because the timestamp is too small, because we use an u64 and a zero unix
-            // timestamp represents a greater date (1970 AD) than the minimum (9999 BC)
-            PrimitiveDateTime::MAX
-        })
-}
+fn unix_epoch_to_primitive_date_time(ts: u64) -> PrimitiveDateTime {
+    let unix_epoch = {
+        let osdt = OffsetDateTime::UNIX_EPOCH;
+        PrimitiveDateTime::new(osdt.date(), osdt.time())
+    };
+    let Ok(ts_i64) = i64::try_from(ts) else {
+        warn!(target: LOG_TARGET, "Invalid block timestamp (u64->i64 overflow): {}. Using UNIX_EPOCH.", ts);
+        return unix_epoch;
+    };
+    match OffsetDateTime::from_unix_timestamp(ts_i64) {
+        Ok(osdt) => PrimitiveDateTime::new(osdt.date(), osdt.time()),
+        Err(e) => {
+            warn!(target: LOG_TARGET, "Invalid block timestamp value {}: {}. Using UNIX_EPOCH.", ts_i64, e);
+            unix_epoch
+        }
+    }
+}
crates/rpc_state_sync/src/state_sync.rs (1)

129-131: Duplicate not_found arm from a prior revision is resolved.

The earlier duplicated Err(RpcError::RequestFailed(err)) if err.is_not_found() => Ok(None) arm is gone; the match coverage is now clean.

🧹 Nitpick comments (30)
applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql (9)

31-33: Index for sync scans is present — minor nit: index name mismatch with covered columns.

The composite index on (shard, state_version) addresses the resumable scan path. However, the unique index name suggests only (substate_id, version) while it includes is_up. Consider renaming for clarity in future maintenance.

-create unique index substate_transitions_substate_id_version_uniq on substate_transitions (substate_id, version, is_up);
+create unique index substate_transitions_substate_id_version_is_up_uniq on substate_transitions (substate_id, version, is_up);

45-50: Drop redundant non-unique index on non_fungible_indexes.

The UNIQUE index (resource_address, idx) already provides an index. The additional non-unique index on the exact same columns duplicates storage and maintenance cost with no benefit.

--- a/applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql
+++ b/applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql
@@
 -- DB index for faster collection scan queries
-create index nft_indexes_resource on non_fungible_indexes (resource_address, idx);
+-- (removed) Redundant with uniq_nft_indexes

51-61: Consider FK on events.substate_id with ON DELETE SET NULL.

Events optionally reference a substate. An FK guards against typos and keeps rows consistent when substates are pruned. Using SET NULL maintains historical events.

     substate_id      text      NULL,
-    created_at       timestamp not null default current_timestamp
+    created_at       timestamp not null default current_timestamp,
+    FOREIGN KEY (substate_id) REFERENCES substates (address) ON DELETE SET NULL
 );

64-66: Add an index for events.substate_id (optional).

If queries fetch events by substate, this index avoids table scans.

 create index events_indexer on events (template_address, tx_hash);
+create index events_substate_id_idx on events (substate_id);

78-83: Drop redundant non-unique index on scanned_block_ids.

The UNIQUE (epoch, shard_group) already creates an index. The extra non-unique index on the same columns is redundant.

 create unique index scanned_block_ids_unique_committee on scanned_block_ids (epoch, shard_group);
-
--- DB index for faster retrieval of the latest block by committee
-create index scanned_block_ids_committee on scanned_block_ids (epoch, shard_group);

129-131: Normalize boolean columns to INTEGER with CHECK constraints.

SQLite doesn’t have a native BOOLEAN type; enforce 0/1 to avoid surprises and accidental non-boolean values.

-    is_spent         boolean   not NULL,
-    is_burnt         boolean   not NULL,
-    is_frozen        boolean   not NULL,
+    is_spent         INTEGER   not NULL CHECK (is_spent IN (0,1)),
+    is_burnt         INTEGER   not NULL CHECK (is_burnt IN (0,1)),
+    is_frozen        INTEGER   not NULL CHECK (is_frozen IN (0,1)),

95-102: updated_at columns won’t auto-update without triggers.

key_values.updated_at and epoch_checkpoints.updated_at default only on INSERT. If you rely on last-modified timestamps, add triggers.

Example triggers to add after the table definitions:

CREATE TRIGGER key_values_set_updated_at
AFTER UPDATE ON key_values
FOR EACH ROW BEGIN
  UPDATE key_values SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

CREATE TRIGGER epoch_checkpoints_set_updated_at
AFTER UPDATE ON epoch_checkpoints
FOR EACH ROW BEGIN
  UPDATE epoch_checkpoints SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;

Also applies to: 106-115


10-12: Consistency nit: prefer DATETIME type name across the schema.

You mix timestamp/DATETIME; in SQLite both map to NUMERIC affinity, but consistent naming improves readability and tooling expectations.

-    timestamp        timestamp not NULL,
-    updated_at       timestamp not null default current_timestamp,
-    created_at       timestamp not null default current_timestamp
+    timestamp        DATETIME  not NULL,
+    updated_at       DATETIME  not null default current_timestamp,
+    created_at       DATETIME  not null default current_timestamp

119-136: Optional: add an index to accelerate UTXO lookups by substate_id.

If you frequently check UTXO status by substate_id alone, a direct index helps. The unique (substate_id, version) also works for equality, but an explicit single-column index can be marginally smaller and planner-friendly if version is not involved.

 CREATE INDEX utxos_shard_tag_resource_state_version_idx
     ON utxos (shard, utxo_tag_byte, resource_address, state_version);
+CREATE INDEX utxos_substate_id_idx ON utxos (substate_id);
applications/tari_indexer/src/network_state_sync/block_scanner.rs (6)

49-51: Metric/log wording and counting are inconsistent; count updates, not blocks.

scan() logs “Scanned {} events” but count was based on blocks. Count substate updates instead and adjust the log to avoid confusion.

@@ pub async fn scan(&self) -> Result<usize, anyhow::Error> {
-        let mut block_count = 0;
+        let mut update_count = 0;
@@
-            "Scanned {} events",
-            block_count
+            "Scanned {} substate updates",
+            update_count
         );
-        Ok(block_count)
+        Ok(update_count)
@@ async fn scan_blocks_in_epoch(&self, epoch: Epoch) -> Result<usize, anyhow::Error> {
-        let mut count = 0;
+        let mut count = 0usize;
@@
-            info!(
+            info!(
                 target: LOG_TARGET,
                 "Scanned {} blocks in epoch={}",
                 new_blocks.len(),
                 epoch,
             );
 
-            count += new_blocks.len();
             for block_data in new_blocks {
                 let timestamp = unix_epoch_to_primitive_date_time(block_data.block.timestamp());
+                // Count the number of substate updates processed
+                count += block_data.diff.len();
                 self.store_substates_in_db(&block_data.diff, timestamp)?;
             }

Also applies to: 74-81, 99-106


335-340: Extract 10,000-substate ceiling into a named constant (and consider making it configurable).

Improves readability and makes future tuning simpler.

@@
-            // TODO: what should this limit be?
-            if num_substates > 10_000 {
+            // TODO: consider making this configurable
+            const MAX_SUBSTATES_PER_BLOCK: usize = 10_000;
+            if num_substates > MAX_SUBSTATES_PER_BLOCK {
                 return Err(anyhow::anyhow!(
-                    "Exceeded maximum number of substates (10,000). Got {}",
-                    num_substates
+                    "Exceeded maximum number of substates ({}). Got {}",
+                    MAX_SUBSTATES_PER_BLOCK,
+                    num_substates,
                 ));
             }

147-151: Avoid storing empty string for missing substate data; prefer NULL/Option.

Persisting "" for pruned substates conflates “no data” with “empty data”. If the schema allows, change NewSubstate.data to Option<String> and store None.

I can draft the schema/model tweak and the ripple changes if you want this in this PR.


155-160: Reduce log noise: don’t dump the entire substate payload at debug.

{:?} on NewSubstate likely includes the JSON blob and increases log volume. Log the address/version and sizes instead.

-                    debug!(
-                        target: LOG_TARGET,
-                        "Saving substate: {:?}",
-                        substate_row
-                    );
+                    debug!(
+                        target: LOG_TARGET,
+                        "Saving substate addr={}, ver={}, data_len={}",
+                        create.substate.substate_id,
+                        create.substate.version,
+                        substate_row.data.len(),
+                    );

11-16: Prefer importing time types from the time crate directly to reduce coupling.

Using tari_ootle_storage::time::{...} couples this module to storage. Importing from time keeps concerns separated.

-    time::{OffsetDateTime, PrimitiveDateTime},
+    time::{OffsetDateTime, PrimitiveDateTime},

If tari_ootle_storage intentionally re-exports time to pin versions, feel free to keep as-is.


162-163: Destroy updates are ignored; confirm if tombstoning/removal is required.

If the indexer should reflect deletions, consider recording a tombstone or removing the substate on Destroy.

Would you like me to propose a tx.delete_substate(...) or tx.upsert_tombstone(...) flow behind a feature flag?

crates/rpc_state_sync/src/state_sync.rs (8)

36-36: Confirm breadth of value_filters; consider narrowing or making configurable.

You’re requesting SubstateValueFilterFlags::all().bits() for every sync_state stream. That maximizes payload sizes and CPU at both ends. If only specific substate types need in-value materialization during state sync, prefer a targeted mask or a config-driven mask.

Apply either approach:

-                until_epoch: Some(checkpoint.epoch().into()),
-                value_filters: SubstateValueFilterFlags::all().bits(),
+                until_epoch: Some(checkpoint.epoch().into()),
+                // Prefer a curated mask or inject via config
+                value_filters: SubstateValueFilterFlags::COMPONENT
+                    .union(SubstateValueFilterFlags::RESOURCE)
+                    .bits(),

If COMPONENТ/RESOURCE is not the right combo, please wire a node config knob and thread it here.

Also applies to: 167-169


85-88: Connection creation is fine; consider hedged timeouts/reuse for robustness.

Current code dials per peer, per attempt. If practical, add a dial timeout and/or client reuse to avoid burning attempts on slow peers and to reduce handshake overhead.


109-131: Avoid expect in checkpoint pop; fold empty handling and capture metrics.

Minor: eliminate the expect by folding empty handling into the same arm. Also, recording the source (“store” vs “rpc”) and number of returned checkpoints will help observability.

-            Ok(GetCheckpointsResponse { checkpoints }) if checkpoints.is_empty() => Ok(None),
-            Ok(GetCheckpointsResponse { mut checkpoints }) => {
-                match EpochCheckpoint::try_from(checkpoints.pop().expect("checked is_empty")) {
+            Ok(GetCheckpointsResponse { mut checkpoints }) => {
+                let Some(last) = checkpoints.pop() else { return Ok(None); };
+                match EpochCheckpoint::try_from(last) {
                     Ok(checkpoint) => {
                         info!(target: LOG_TARGET, "🛜 Checkpoint: {checkpoint}");
                         self.validate_checkpoint(&checkpoint, prev_committee, prev_epoch)?;
-                        self.state_store.with_write_tx(|tx| checkpoint.save(tx))?;
-                        self.valid_checkpoints.insert(for_shard_group, checkpoint.clone());
+                        self.state_store.with_write_tx(|tx| checkpoint.save(tx))?;
+                        // Optional: record that this came from RPC, and how many were returned
+                        // self.stats.last_checkpoint_source = Some("rpc".into());
+                        // self.stats.last_checkpoint_count = Some(checkpoints_len);
                         Ok(Some(checkpoint))
                     },

232-244: Fix “new template(s)” log to report per-batch, not cumulative; pre-compute update count.

The message currently prints template_changes.len(), which is cumulative across the session and can be misleading; also compute the update count before consuming the iterator.

-            info!(target: LOG_TARGET, "🛜 Buffering {} state update(s) (state version: v{})", updates_for_state_version.len(), state_version);
-            for result in updates_for_state_version {
+            let num_updates = updates_for_state_version.len();
+            info!(target: LOG_TARGET, "🛜 Buffering {} state update(s) (state version: v{})", num_updates, state_version);
+            let prev_templates = template_changes.len();
+            for result in updates_for_state_version {
                 let update = result?;
                 let (tree_change, template_change) = extract_tree_and_template_changes(msg_epoch, &update)?;
                 debug!(target: LOG_TARGET, "🛜 -> state update (v{}) {}", state_version, update);
                 template_changes.extend(template_change);
                 tree_changes.push(tree_change);
                 updates.push(update);
             }
-
-            info!(target: LOG_TARGET, "🛜 Sync: {} state update(s), {} new template(s) (state version: v{})", updates.len(), template_changes.len(), state_version);
+            let new_templates = template_changes.len().saturating_sub(prev_templates);
+            info!(target: LOG_TARGET, "🛜 Sync: {} state update(s), {} new template(s) (state version: v{})", updates.len(), new_templates, state_version);

279-314: Early-exit the stream once the checkpoint state version is verified to avoid reading trailing items.

Not critical, but breaking the loop immediately after matching the checkpoint avoids extra polling round-trips if a peer keeps the stream open.

-                    if state_version == checkpoint_state_version {
+                    if state_version == checkpoint_state_version {
                         if local_state_root != checkpoint_shard_root {
                             error!(target: LOG_TARGET, "❌ State root mismatch ...");
                             // rollback!
                             return Err(RpcStateSyncError::StateRootMismatch {
                                 expected: checkpoint_shard_root,
                                 actual: local_state_root,
                             });
                         }
                         info!(target: LOG_TARGET, "🛜 ✅ State root for {shard} matches checkpoint: {local_state_root} (v{state_version})",);
-
-                        maybe_persisted_state_version = Some(state_version);
-                        store.set_state_version(state_version)?;
-                        // Done
-                        return Ok(());
+                        maybe_persisted_state_version = Some(state_version);
+                        store.set_state_version(state_version)?;
+                        // Signal to break out of the outer loop
+                        return Ok(());
                     }

And immediately after with_write_tx, break out if we just reached the checkpoint:

-            })?;
+            })?;
+            if last_state_version == checkpoint_state_version {
+                break;
+            }

61-62: Remove unused valid_checkpoints field and related maintenance.

Since you now prefer the persisted fast-path (EpochCheckpoint::get_by_shard_group), the in-memory valid_checkpoints map is no longer read anywhere in this file. Keeping it adds memory churn and dead code paths.

 struct RpcStateSyncClientProtocol<TConsensusSpec: ConsensusSpec> {
     epoch_manager: TConsensusSpec::EpochManager,
     state_store: TConsensusSpec::StateStore,
     client_factory: TariValidatorNodeRpcClientFactory,
     template_manager: TemplateManagerHandle,
-    valid_checkpoints: HashMap<ShardGroup, EpochCheckpoint>,
     stats: StateSyncStats,
 }
@@
         Self {
             epoch_manager,
             state_store,
             client_factory,
             template_manager,
-            valid_checkpoints: HashMap::new(),
             stats: StateSyncStats::default(),
         }
     }
@@
-                        self.state_store.with_write_tx(|tx| checkpoint.save(tx))?;
-                        self.valid_checkpoints.insert(for_shard_group, checkpoint.clone());
+                        self.state_store.with_write_tx(|tx| checkpoint.save(tx))?;
                         Ok(Some(checkpoint))
                     },
@@
-            // Clear the valid checkpoints cache
-            self.valid_checkpoints = HashMap::new();
             self.stats = StateSyncStats::default();
             return Err(err);
@@
-        // Clear the valid checkpoints cache
-        self.valid_checkpoints = HashMap::new();
         self.stats = StateSyncStats::default();

If another module uses valid_checkpoints, keep it and read from it before hitting the store; otherwise, delete.

Also applies to: 79-81, 123-124, 645-646, 653-654


461-467: Follow-up TODO: robust “first epoch” detection.

Relying on a single peer returning not_found could stall progress. Consider:

  • Ask f + 1 peers before concluding “no checkpoint”.
  • Query epoch_manager for “is_first_active_epoch(prev_epoch)”.

I can wire a hedged RPC (fanout to min quorum with early-cancel on first success).


525-528: Comment is outdated with current logic; clarify or adjust.

Because sync_global_shard persists the checkpoint under ShardGroup::all_shards and get_or_fetch_valid_epoch_checkpoint does a store fast-path, subsequent committee iterations won’t refetch from the network. Update the note accordingly.

-            // TODO: any checkpoint for the previous epoch will justify the global shard sync.
-            //       Currently we'll fetch the checkpoint again even if we already have it if there are more than one
-            // shard groups.
+            // NOTE: Any checkpoint for the previous epoch justifies the global shard sync.
+            // Since we persist under ShardGroup::all_shards, subsequent committees will hit the store fast-path
+            // and avoid additional network fetches.
crates/state_store_rocksdb/src/codecs/substate_lock.rs (7)

26-31: Make the error variant and message more specific to TransactionId.

Other codecs in this crate use MalformedData { operation, details } for fixed-size reads. Consider using that variant and naming the operation/type explicitly for clearer diagnostics.

Apply:

-        let buf = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::DecodeError {
-            source: anyhow!("SubstateLockKeyCodec: Invalid bytes for FixedHash"),
-        })?;
+        let buf = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::MalformedData {
+            operation: "decode TransactionId",
+            details: "Invalid bytes for TransactionId (expected 32)".to_string(),
+        })?;

33-38: Similarly, tailor the error to BlockId and prefer MalformedData.

This will keep errors consistent and self-explanatory across codecs.

Apply:

-        let buf = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::DecodeError {
-            source: anyhow!("SubstateLockKeyCodec: Invalid bytes for FixedHash"),
-        })?;
+        let buf = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::MalformedData {
+            operation: "decode BlockId",
+            details: "Invalid bytes for BlockId (expected 32)".to_string(),
+        })?;

40-46: Nice: added explicit context when delegating to SubstateIdCodec. Preserve the source error chain.

You already attach context, which is great. Tiny improvement: construct anyhow::Error from e instead of interpolating it into a string so the original error remains the source in the chain.

Apply:

-        self.substate_id_codec
-            .decode_reader(reader)
-            .map_err(|e| RocksDbStorageError::DecodeError {
-                source: anyhow!("SubstateLockKeyCodec: Failed to decode SubstateId: {}", e),
-            })
+        self.substate_id_codec
+            .decode_reader(reader)
+            .map_err(|e| RocksDbStorageError::DecodeError {
+                // Keep the original error as the source; attach context here
+                source: anyhow::Error::new(e).context("SubstateLockKeyCodec: Failed to decode SubstateId"),
+            })

Note: add use anyhow::Context; at the top if it’s not already imported.


48-53: Consistency: construct NodeHeight via From and consider MalformedData.

Other number codecs use NodeHeight::from(u64) and MalformedData. Adopt the same style for uniformity.

Apply:

-        let height = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::DecodeError {
-            source: anyhow!("SubstateLockKeyCodec: Invalid bytes for NodeHeight"),
-        })?;
-        Ok(NodeHeight(u64::from_be_bytes(height)))
+        let height = read_to_fixed(reader).ok_or_else(|| RocksDbStorageError::MalformedData {
+            operation: "decode NodeHeight",
+            details: "Invalid bytes for NodeHeight (expected 8)".to_string(),
+        })?;
+        Ok(NodeHeight::from(u64::from_be_bytes(height)))

70-81: Optionally reject trailing bytes to catch malformed keys early.

If these keys are decoded from exact RocksDB values, detecting extra bytes helps spot encode/decode mismatches sooner.

Apply:

-        Ok(SubstateLockKey {
-            block_id,
-            substate_id,
-            transaction_id,
-            block_height,
-        })
+        let key = SubstateLockKey {
+            block_id,
+            substate_id,
+            transaction_id,
+            block_height,
+        };
+        // Optional: ensure the reader is exhausted (no trailing bytes)
+        let mut _tmp = [0u8; 1];
+        match reader.read(&mut _tmp) {
+            Ok(0) => {}
+            Ok(_) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: "Trailing bytes after expected fields".to_string(),
+                })
+            }
+            Err(e) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: format!("I/O while checking trailing bytes: {e}"),
+                })
+            }
+        }
+        Ok(key)

98-110: Same optional trailing-bytes guard for this permutation.

Apply the same pattern as above after assembling the struct:

-        Ok(SubstateLockKey {
-            block_id,
-            substate_id,
-            transaction_id,
-            block_height,
-        })
+        let key = SubstateLockKey {
+            block_id,
+            substate_id,
+            transaction_id,
+            block_height,
+        };
+        let mut _tmp = [0u8; 1];
+        match reader.read(&mut _tmp) {
+            Ok(0) => {}
+            Ok(_) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: "Trailing bytes after expected fields".to_string(),
+                })
+            }
+            Err(e) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: format!("I/O while checking trailing bytes: {e}"),
+                })
+            }
+        }
+        Ok(key)

127-139: Mirror the trailing-bytes check on the third permutation for consistency.

Apply:

-        Ok(SubstateLockKey {
-            block_id,
-            substate_id,
-            transaction_id,
-            block_height,
-        })
+        let key = SubstateLockKey {
+            block_id,
+            substate_id,
+            transaction_id,
+            block_height,
+        };
+        let mut _tmp = [0u8; 1];
+        match reader.read(&mut _tmp) {
+            Ok(0) => {}
+            Ok(_) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: "Trailing bytes after expected fields".to_string(),
+                })
+            }
+            Err(e) => {
+                return Err(RocksDbStorageError::MalformedData {
+                    operation: "decode SubstateLockKey",
+                    details: format!("I/O while checking trailing bytes: {e}"),
+                })
+            }
+        }
+        Ok(key)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0ef0108 and 17199e3.

📒 Files selected for processing (6)
  • applications/tari_indexer/src/lib.rs (6 hunks)
  • applications/tari_indexer/src/network_state_sync/block_scanner.rs (1 hunks)
  • applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql (1 hunks)
  • crates/rpc_state_sync/src/state_sync.rs (10 hunks)
  • crates/state_store_rocksdb/src/codecs/misc.rs (9 hunks)
  • crates/state_store_rocksdb/src/codecs/substate_lock.rs (5 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/state_store_rocksdb/src/codecs/misc.rs
  • applications/tari_indexer/src/lib.rs
🧰 Additional context used
🧬 Code graph analysis (3)
applications/tari_indexer/src/network_state_sync/block_scanner.rs (4)
crates/storage/src/consensus_models/substate_change.rs (1)
  • substate (58-63)
applications/tari_indexer/src/storage_sqlite/store_factory.rs (1)
  • updates (619-636)
crates/storage/src/consensus_models/substate.rs (1)
  • as_versioned_substate_id_ref (382-384)
crates/engine_types/src/events.rs (1)
  • template_address (120-122)
crates/state_store_rocksdb/src/codecs/substate_lock.rs (2)
crates/state_store_rocksdb/src/utils.rs (1)
  • read_to_fixed (27-35)
crates/state_store_rocksdb/src/codecs/misc.rs (8)
  • decode_reader (22-24)
  • decode_reader (35-44)
  • decode_reader (61-70)
  • decode_reader (78-84)
  • decode_reader (92-98)
  • decode_reader (106-113)
  • decode_reader (121-128)
  • decode_reader (136-143)
crates/rpc_state_sync/src/state_sync.rs (2)
crates/storage/src/consensus_models/epoch_checkpoint.rs (1)
  • get_by_shard_group (181-187)
crates/p2p/src/conversions/rpc.rs (7)
  • try_from (29-37)
  • try_from (52-57)
  • try_from (73-79)
  • try_from (96-105)
  • try_from (132-141)
  • try_from (149-162)
  • try_from (182-187)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: check stable
  • GitHub Check: test
  • GitHub Check: machete
  • GitHub Check: check nightly
  • GitHub Check: clippy
🔇 Additional comments (7)
applications/tari_indexer/src/storage_sqlite/migrations/2023-02-16-145719_initial/up.sql (2)

1-16: Substates uniqueness/semantics look good for “latest-only” storage.

Unique(address) suggests substates holds the latest value while versions live in substate_transitions. This matches typical indexer patterns.


17-33: Drop the misplaced index‐tuning suggestion

The migration in up.sql creates only the substate_transitions table and two indexes on (substate_id, version, is_up) and (shard, state_version). The earlier feedback about right-sizing composite indexes for get_utxo_updates (which operates on the utxos table and its utxo_tag_byte/resource_address filters) does not apply here—this script neither touches the utxos table nor affects those query paths. Please ignore or relocate that comment to the relevant migration or query implementation for utxos.

Likely an incorrect or invalid review comment.

crates/rpc_state_sync/src/state_sync.rs (4)

26-26: RPC request/response rename adoption looks good.

The switch to GetCheckpointsRequest/GetCheckpointsResponse aligns with the new multi-checkpoint API. No issues spotted here.


97-105: Pre-RPC checkpoint fast-path via state store is a solid improvement.

Replacing the in-memory cache fast-path with a persisted read avoids redundant network calls after restarts. Looks good.


696-728: New helper cleanly encapsulates tree change + template extraction.

This replaces scattered logic and keeps the main loop focused. The use of Option with extend is neat and efficient.


417-425: Visibility reduction to private for sync_shard is safe

No external call sites to sync_shard were found outside of crates/rpc_state_sync/src/state_sync.rs (verified via ripgrep), so reducing its visibility to private will not break any modules or tests.

crates/state_store_rocksdb/src/codecs/substate_lock.rs (1)

4-5: Confirmed: All decode(&[u8]) calls for SubstateLockKey have been removed
I ran the grep across crates/**/*.rs and found zero occurrences of .decode( for SubstateLockKey. The only remaining use is the intended

.decode_reader(reader)

call in crates/state_store_rocksdb/src/codecs/substate_lock.rs, so downstream code is fully aligned with the streaming API.
Merging is good to go!

Comment thread applications/tari_indexer/src/network_state_sync/block_scanner.rs
@sdbondi
sdbondi merged commit 25b701e into tari-project:development Aug 22, 2025
11 of 13 checks passed
@sdbondi
sdbondi deleted the indexer-state-sync branch August 22, 2025 11:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants