From f4a2ba4d448e40601afee2eef6bfec9c91d77f4f Mon Sep 17 00:00:00 2001 From: MartianGreed Date: Thu, 16 Apr 2026 13:29:39 +0200 Subject: [PATCH] refactor: db refactor (PR #62) --- .agents/skills/torii-maintainer/SKILL.md | 393 ++++++ .rustfmt.toml | 2 + AGENTS.md | 2 +- Cargo.lock | 382 ++---- Cargo.toml | 57 +- benches/perf_harness.rs | 91 +- bins/torii-arcade/Cargo.toml | 28 +- bins/torii-arcade/README.md | 273 ++-- bins/torii-arcade/src/config.rs | 35 + bins/torii-arcade/src/main.rs | 254 ++-- bins/torii-erc20-synth/Cargo.toml | 1 + bins/torii-erc20-synth/README.md | 112 +- bins/torii-erc20-synth/src/main.rs | 7 +- bins/torii-erc20/Cargo.toml | 6 +- bins/torii-erc20/README.md | 315 +---- bins/torii-erc20/src/main.rs | 17 +- bins/torii-introspect-bin/Cargo.toml | 30 +- bins/torii-introspect-bin/README.md | 188 ++- bins/torii-introspect-bin/src/config.rs | 36 +- bins/torii-introspect-bin/src/main.rs | 152 +-- bins/torii-introspect-synth/Cargo.toml | 13 +- bins/torii-introspect-synth/README.md | 86 ++ bins/torii-introspect-synth/src/main.rs | 93 +- bins/torii-tokens-synth/Cargo.toml | 1 + bins/torii-tokens-synth/README.md | 92 ++ bins/torii-tokens-synth/src/main.rs | 23 +- bins/torii-tokens/Cargo.toml | 11 +- bins/torii-tokens/README.md | 605 ++------- bins/torii-tokens/src/main.rs | 36 +- crates/arcade-sink/Cargo.toml | 2 + crates/arcade-sink/README.md | 129 ++ crates/arcade-sink/src/grpc_service.rs | 37 +- crates/arcade-sink/src/sink.rs | 63 +- crates/dojo/Cargo.toml | 25 +- crates/dojo/README.md | 128 ++ .../{ => postgres}/001_dojo_store.sql | 0 crates/dojo/src/decoder.rs | 300 +++-- crates/dojo/src/error.rs | 20 +- crates/dojo/src/event.rs | 42 +- crates/dojo/src/external_contract.rs | 6 +- crates/dojo/src/lib.rs | 1 + crates/dojo/src/store/json.rs | 24 +- crates/dojo/src/store/mod.rs | 26 +- crates/dojo/src/store/postgres.rs | 143 +- crates/dojo/src/store/sql.rs | 35 + crates/dojo/src/store/sqlite.rs | 98 +- crates/dojo/src/table.rs | 4 +- crates/introspect-postgres-sink/Cargo.toml | 36 - crates/introspect-postgres-sink/src/error.rs | 134 -- crates/introspect-postgres-sink/src/lib.rs | 22 - .../introspect-postgres-sink/src/processor.rs | 334 ----- crates/introspect-postgres-sink/src/sink.rs | 102 -- crates/introspect-postgres-sink/src/table.rs | 104 -- .../Cargo.toml | 28 +- crates/introspect-sql-sink/README.md | 130 ++ .../migrations/postgres}/001_domains.sql | 0 .../postgres}/002_metadata_function.sql | 0 .../migrations/postgres}/003_store.sql | 3 + .../migrations/sqlite}/001_init.sql | 0 .../migrations/sqlite/002_schema_state.sql | 12 + crates/introspect-sql-sink/src/backend.rs | 114 ++ crates/introspect-sql-sink/src/error.rs | 248 ++++ crates/introspect-sql-sink/src/lib.rs | 31 + crates/introspect-sql-sink/src/namespace.rs | 206 +++ .../src/postgres/append_only.rs | 106 ++ .../src/postgres/backend.rs | 147 ++ .../src/postgres}/create.rs | 89 +- .../src/postgres/handler.rs | 28 + .../src/postgres/insert.rs | 92 ++ .../src/postgres}/json.rs | 48 +- .../introspect-sql-sink/src/postgres/mod.rs | 16 + .../src/postgres}/query.rs | 295 ++-- .../src/postgres}/types.rs | 43 +- .../src/postgres}/upgrade.rs | 147 +- .../src/postgres}/utils.rs | 0 crates/introspect-sql-sink/src/processor.rs | 277 ++++ crates/introspect-sql-sink/src/runtime.rs | 58 + .../src/sink.rs | 70 +- .../src/sqlite/append_only.rs | 71 + .../introspect-sql-sink/src/sqlite/backend.rs | 190 +++ .../src/sqlite}/json.rs | 8 +- crates/introspect-sql-sink/src/sqlite/mod.rs | 12 + .../introspect-sql-sink/src/sqlite/record.rs | 230 ++++ .../introspect-sql-sink/src/sqlite/table.rs | 217 +++ .../introspect-sql-sink/src/sqlite/types.rs | 161 +++ crates/introspect-sql-sink/src/table.rs | 137 ++ crates/introspect-sql-sink/src/tables.rs | 155 +++ .../migrations/002_schema_state.sql | 6 - crates/introspect-sqlite-sink/src/lib.rs | 8 - .../introspect-sqlite-sink/src/processor.rs | 650 --------- crates/introspect-sqlite-sink/src/table.rs | 151 --- crates/introspect/Cargo.toml | 3 +- crates/introspect/README.md | 81 ++ crates/introspect/src/events.rs | 23 +- crates/introspect/src/postgres/global.rs | 2 +- crates/introspect/src/postgres/owned.rs | 2 +- crates/introspect/src/postgres/types.rs | 12 +- crates/introspect/src/schema.rs | 47 +- crates/introspect/src/tables.rs | 44 +- crates/pathfinder/Cargo.toml | 12 +- crates/pathfinder/README.md | 90 ++ crates/pathfinder/src/decoding.rs | 4 +- crates/pathfinder/src/extractor.rs | 142 +- crates/pathfinder/src/fetcher.rs | 120 +- crates/pathfinder/src/lib.rs | 2 +- crates/pathfinder/src/sqlite.rs | 15 +- crates/pathfinder/src/test.rs | 8 +- crates/postgres/Cargo.toml | 31 - crates/postgres/src/db.rs | 35 - crates/postgres/src/lib.rs | 4 - crates/postgres/src/metadata.rs | 72 - crates/postgres/src/migration.rs | 443 ------ crates/sql/Cargo.toml | 22 + crates/sql/README.md | 98 ++ crates/sql/src/connection.rs | 76 ++ crates/sql/src/lib.rs | 31 + crates/sql/src/migrate.rs | 218 +++ crates/sql/src/pool.rs | 302 +++++ crates/sql/src/postgres/migrate.rs | 227 ++++ crates/sql/src/postgres/mod.rs | 35 + crates/sql/src/postgres/types.rs | 38 + crates/sql/src/query.rs | 315 +++++ crates/sql/src/runtime.rs | 58 + crates/sql/src/sqlite/migrate.rs | 160 +++ crates/sql/src/sqlite/mod.rs | 68 + crates/sql/src/sqlite/types.rs | 51 + crates/sql/src/types.rs | 22 + crates/sqlite/Cargo.toml | 19 - crates/sqlite/src/db.rs | 73 - crates/sqlite/src/lib.rs | 4 - crates/sqlite/src/migration.rs | 320 ----- crates/starknet/Cargo.toml | 23 - crates/starknet/src/event.rs | 23 - crates/starknet/src/felt.rs | 714 ---------- crates/starknet/src/lib.rs | 10 - crates/starknet/src/serde.rs | 134 -- crates/starknet/src/starknet.rs | 73 - crates/testing/Cargo.toml | 2 +- crates/testing/README.md | 73 + crates/testing/src/event_reader.rs | 33 +- crates/testing/src/lib.rs | 2 +- crates/torii-common/Cargo.toml | 11 +- crates/torii-common/README.md | 70 + crates/torii-common/src/lib.rs | 185 ++- crates/torii-common/src/metadata.rs | 54 +- crates/torii-common/src/sql.rs | 241 ---- crates/torii-common/src/token_uri.rs | 8 +- crates/torii-common/src/utils.rs | 70 + crates/torii-config-common/README.md | 65 +- crates/torii-controllers-sink/Cargo.toml | 13 +- crates/torii-controllers-sink/README.md | 111 ++ crates/torii-controllers-sink/src/lib.rs | 152 ++- crates/torii-ecs-sink/Cargo.toml | 17 +- crates/torii-ecs-sink/README.md | 175 ++- crates/torii-ecs-sink/src/grpc_service.rs | 568 ++++---- crates/torii-ecs-sink/src/sink.rs | 193 +-- .../tests/sqlite_prepared_statements.rs | 3 +- .../torii-entities-historical-sink/Cargo.toml | 26 - .../torii-entities-historical-sink/src/lib.rs | 1199 ----------------- crates/torii-erc1155/Cargo.toml | 5 + crates/torii-erc1155/README.md | 130 ++ crates/torii-erc1155/src/balance_fetcher.rs | 63 +- crates/torii-erc1155/src/decoder.rs | 923 ++++++------- crates/torii-erc1155/src/grpc_service.rs | 30 +- crates/torii-erc1155/src/handlers.rs | 18 +- crates/torii-erc1155/src/identification.rs | 2 +- crates/torii-erc1155/src/lib.rs | 8 +- crates/torii-erc1155/src/sink.rs | 253 ++-- crates/torii-erc1155/src/storage.rs | 50 +- crates/torii-erc1155/src/synthetic.rs | 66 +- crates/torii-erc20/Cargo.toml | 45 +- crates/torii-erc20/README.md | 145 ++ crates/torii-erc20/src/balance_fetcher.rs | 84 +- crates/torii-erc20/src/decoder.rs | 355 +++-- crates/torii-erc20/src/grpc_service.rs | 106 +- crates/torii-erc20/src/handlers.rs | 4 +- crates/torii-erc20/src/identification.rs | 2 +- crates/torii-erc20/src/lib.rs | 4 +- crates/torii-erc20/src/sink.rs | 83 +- crates/torii-erc20/src/storage.rs | 121 +- crates/torii-erc20/src/synthetic.rs | 36 +- crates/torii-erc721/Cargo.toml | 3 + crates/torii-erc721/README.md | 133 ++ crates/torii-erc721/src/conversions.rs | 55 + crates/torii-erc721/src/decoder.rs | 874 ++++++------ crates/torii-erc721/src/grpc_service.rs | 12 +- crates/torii-erc721/src/handlers.rs | 21 +- crates/torii-erc721/src/identification.rs | 2 +- crates/torii-erc721/src/lib.rs | 8 +- crates/torii-erc721/src/sink.rs | 77 +- crates/torii-erc721/src/storage.rs | 564 ++++---- crates/torii-erc721/src/synthetic.rs | 106 +- crates/torii-log-sink/Cargo.toml | 31 +- crates/torii-log-sink/README.md | 226 ++-- crates/torii-log-sink/src/decoder.rs | 39 +- crates/torii-runtime-common/Cargo.toml | 1 + crates/torii-runtime-common/README.md | 94 +- crates/torii-runtime-common/src/database.rs | 50 +- crates/torii-sql-sink/Cargo.toml | 41 +- crates/torii-sql-sink/README.md | 300 ++--- crates/torii-sql-sink/src/decoder.rs | 67 +- crates/torii-sql-sink/src/lib.rs | 22 +- crates/torii-sql-sink/src/samples.rs | 48 +- crates/types/Cargo.toml | 16 + crates/types/README.md | 71 + crates/types/src/block.rs | 10 + crates/types/src/event.rs | 89 ++ crates/types/src/lib.rs | 2 + examples/eventbus_only_sink/main.rs | 34 +- examples/http_only_sink/main.rs | 45 +- examples/introspect/restart.rs | 121 +- examples/introspect/simple.rs | 65 - examples/pathfinder/main.rs | 2 +- sqlite-data.db-journal | Bin 0 -> 1642112 bytes src/README.md | 108 ++ src/etl/decoder/context.rs | 130 +- src/etl/decoder/mod.rs | 62 +- src/etl/engine_db.rs | 6 +- src/etl/envelope.rs | 66 +- src/etl/event.rs | 32 - src/etl/extractor/composite.rs | 8 +- src/etl/extractor/event.rs | 41 +- src/etl/extractor/event_common.rs | 47 +- src/etl/extractor/global_event.rs | 9 +- src/etl/extractor/mod.rs | 47 +- src/etl/extractor/sample.rs | 55 +- src/etl/extractor/starknet_helpers.rs | 56 +- src/etl/extractor/synthetic_adapter.rs | 4 +- src/etl/extractor/synthetic_erc20.rs | 18 +- src/etl/identification/registry.rs | 18 +- src/etl/identification/rule.rs | 2 +- src/etl/mod.rs | 10 +- src/lib.rs | 56 +- 233 files changed, 12196 insertions(+), 11258 deletions(-) create mode 100644 .agents/skills/torii-maintainer/SKILL.md create mode 100644 bins/torii-introspect-synth/README.md create mode 100644 bins/torii-tokens-synth/README.md create mode 100644 crates/arcade-sink/README.md create mode 100644 crates/dojo/README.md rename crates/dojo/migrations/{ => postgres}/001_dojo_store.sql (100%) create mode 100644 crates/dojo/src/store/sql.rs delete mode 100644 crates/introspect-postgres-sink/Cargo.toml delete mode 100644 crates/introspect-postgres-sink/src/error.rs delete mode 100644 crates/introspect-postgres-sink/src/lib.rs delete mode 100644 crates/introspect-postgres-sink/src/processor.rs delete mode 100644 crates/introspect-postgres-sink/src/sink.rs delete mode 100644 crates/introspect-postgres-sink/src/table.rs rename crates/{introspect-sqlite-sink => introspect-sql-sink}/Cargo.toml (53%) create mode 100644 crates/introspect-sql-sink/README.md rename crates/{introspect-postgres-sink/migrations => introspect-sql-sink/migrations/postgres}/001_domains.sql (100%) rename crates/{introspect-postgres-sink/migrations => introspect-sql-sink/migrations/postgres}/002_metadata_function.sql (100%) rename crates/{introspect-postgres-sink/migrations => introspect-sql-sink/migrations/postgres}/003_store.sql (94%) rename crates/{introspect-sqlite-sink/migrations => introspect-sql-sink/migrations/sqlite}/001_init.sql (100%) create mode 100644 crates/introspect-sql-sink/migrations/sqlite/002_schema_state.sql create mode 100644 crates/introspect-sql-sink/src/backend.rs create mode 100644 crates/introspect-sql-sink/src/error.rs create mode 100644 crates/introspect-sql-sink/src/lib.rs create mode 100644 crates/introspect-sql-sink/src/namespace.rs create mode 100644 crates/introspect-sql-sink/src/postgres/append_only.rs create mode 100644 crates/introspect-sql-sink/src/postgres/backend.rs rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/create.rs (84%) create mode 100644 crates/introspect-sql-sink/src/postgres/handler.rs create mode 100644 crates/introspect-sql-sink/src/postgres/insert.rs rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/json.rs (64%) create mode 100644 crates/introspect-sql-sink/src/postgres/mod.rs rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/query.rs (74%) rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/types.rs (83%) rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/upgrade.rs (83%) rename crates/{introspect-postgres-sink/src => introspect-sql-sink/src/postgres}/utils.rs (100%) create mode 100644 crates/introspect-sql-sink/src/processor.rs create mode 100644 crates/introspect-sql-sink/src/runtime.rs rename crates/{introspect-sqlite-sink => introspect-sql-sink}/src/sink.rs (57%) create mode 100644 crates/introspect-sql-sink/src/sqlite/append_only.rs create mode 100644 crates/introspect-sql-sink/src/sqlite/backend.rs rename crates/{introspect-sqlite-sink/src => introspect-sql-sink/src/sqlite}/json.rs (92%) create mode 100644 crates/introspect-sql-sink/src/sqlite/mod.rs create mode 100644 crates/introspect-sql-sink/src/sqlite/record.rs create mode 100644 crates/introspect-sql-sink/src/sqlite/table.rs create mode 100644 crates/introspect-sql-sink/src/sqlite/types.rs create mode 100644 crates/introspect-sql-sink/src/table.rs create mode 100644 crates/introspect-sql-sink/src/tables.rs delete mode 100644 crates/introspect-sqlite-sink/migrations/002_schema_state.sql delete mode 100644 crates/introspect-sqlite-sink/src/lib.rs delete mode 100644 crates/introspect-sqlite-sink/src/processor.rs delete mode 100644 crates/introspect-sqlite-sink/src/table.rs create mode 100644 crates/introspect/README.md create mode 100644 crates/pathfinder/README.md delete mode 100644 crates/postgres/Cargo.toml delete mode 100644 crates/postgres/src/db.rs delete mode 100644 crates/postgres/src/lib.rs delete mode 100644 crates/postgres/src/metadata.rs delete mode 100644 crates/postgres/src/migration.rs create mode 100644 crates/sql/Cargo.toml create mode 100644 crates/sql/README.md create mode 100644 crates/sql/src/connection.rs create mode 100644 crates/sql/src/lib.rs create mode 100644 crates/sql/src/migrate.rs create mode 100644 crates/sql/src/pool.rs create mode 100644 crates/sql/src/postgres/migrate.rs create mode 100644 crates/sql/src/postgres/mod.rs create mode 100644 crates/sql/src/postgres/types.rs create mode 100644 crates/sql/src/query.rs create mode 100644 crates/sql/src/runtime.rs create mode 100644 crates/sql/src/sqlite/migrate.rs create mode 100644 crates/sql/src/sqlite/mod.rs create mode 100644 crates/sql/src/sqlite/types.rs create mode 100644 crates/sql/src/types.rs delete mode 100644 crates/sqlite/Cargo.toml delete mode 100644 crates/sqlite/src/db.rs delete mode 100644 crates/sqlite/src/lib.rs delete mode 100644 crates/sqlite/src/migration.rs delete mode 100644 crates/starknet/Cargo.toml delete mode 100644 crates/starknet/src/event.rs delete mode 100644 crates/starknet/src/felt.rs delete mode 100644 crates/starknet/src/lib.rs delete mode 100644 crates/starknet/src/serde.rs delete mode 100644 crates/starknet/src/starknet.rs create mode 100644 crates/testing/README.md create mode 100644 crates/torii-common/README.md delete mode 100644 crates/torii-common/src/sql.rs create mode 100644 crates/torii-controllers-sink/README.md delete mode 100644 crates/torii-entities-historical-sink/Cargo.toml delete mode 100644 crates/torii-entities-historical-sink/src/lib.rs create mode 100644 crates/torii-erc1155/README.md create mode 100644 crates/torii-erc20/README.md create mode 100644 crates/torii-erc721/README.md create mode 100644 crates/torii-erc721/src/conversions.rs create mode 100644 crates/types/Cargo.toml create mode 100644 crates/types/README.md create mode 100644 crates/types/src/block.rs create mode 100644 crates/types/src/event.rs create mode 100644 crates/types/src/lib.rs delete mode 100644 examples/introspect/simple.rs create mode 100644 sqlite-data.db-journal create mode 100644 src/README.md delete mode 100644 src/etl/event.rs diff --git a/.agents/skills/torii-maintainer/SKILL.md b/.agents/skills/torii-maintainer/SKILL.md new file mode 100644 index 00000000..354aaab7 --- /dev/null +++ b/.agents/skills/torii-maintainer/SKILL.md @@ -0,0 +1,393 @@ +--- +name: torii-maintainer +description: "Use when working on Torii (Starknet indexer): adding or debugging a sink / decoder / extractor / identification rule / command handler, tracing the ETL loop, cursor commit, EventBus / CommandBus / SubscriptionManager flow, gRPC / HTTP surface, contract identification, or introspect / dojo / token pipelines. Keywords: torii, ETL, sink, decoder, extractor, envelope, event bus, command bus, engine db, contract identifier, identification rule, introspect, dojo, erc20, erc721, erc1155, arcade, starknet indexer, cursor commit, prefetch queue." +globs: ["**/Cargo.toml", "src/etl/**", "crates/**"] +user-invocable: true +--- + +# torii-maintainer + +Deep architectural knowledge for the Torii Starknet indexer. Every +per-crate README exists to fill in the details; this skill is the map. + +## 1. When to use this skill + +Trigger on any of: + +- "writing a new sink / decoder / extractor / identification rule / command handler" +- "events lost after restart" / "cursor seems wrong" / "duplicate processing" +- "sink never receives envelope" / "decoder returns nothing" / "decoder fallback is slow" +- "auto-identification cache miss" / "`torii_registry_identify_jobs_total{status='dropped'}` is increasing" +- "gRPC 404 after adding a service" / "reflection does not see my service" +- "adding TLS / a topic / a REST route / a Prometheus metric" +- anything touching `src/lib.rs::run`, `src/etl/...`, `crates/torii-*-sink/`, `crates/dojo/`, `crates/introspect*/`, `crates/torii-erc{20,721,1155}/`. + +Do **not** use for: generic Rust questions (prefer `m01-ownership`, `m06-error-handling`, `m07-concurrency`, `m11-ecosystem`), CLI UX (`domain-cli`), or web design (`domain-web`). + +## 2. System map + +```text + ┌─────────────────────┐ + │ bins/*/main.rs │ + │ (build ToriiConfig │ + │ via builder) │ + └──────────┬──────────┘ + │ + v + ┌────────────────────────────────────────────────────────────────────┐ + │ torii::run(config) (src/lib.rs:613) │ + │ │ + │ Task 1 — producer Task 2 — identifier Task 3 — consumer│ + │ extractor.extract() identify_contracts prefetch_rx │ + │ → mpsc (optional) → decoder │ + │ capacity = on contract_addresses context │ + │ max_prefetch_batches .decode_events│ + │ → multi_sink │ + │ .process │ + │ → commit_cursor + │ │ + │ side channels: │ + │ EventBus ─ SubscriptionManager ─ gRPC clients │ + │ CommandBus ─ CommandHandlers (async, fire-and-forget) │ + │ │ + │ persistence: │ + │ EngineDb (head, cursor, contract→decoder cache) │ + │ │ + │ surface: │ + │ axum HTTP /health /metrics + sink-merged routes │ + │ tonic gRPC torii.Torii + sink services + reflection │ + │ optional TLS listener │ + └────────────────────────────────────────────────────────────────────┘ +``` + +**Cursor-commit invariant** (`src/lib.rs:1113-1127`): the extractor's +cursor is committed **only after** `multi_sink.process(...)` returns +`Ok`. A crash mid-batch replays the last window; it never loses it. + +## 3. Trait contracts + +All four traits live in the root `torii` crate's `src/etl/`. Each sink or +decoder you ship impls one or more of these. + +### `Sink` — `src/etl/sink/mod.rs:74` + +```rust +#[async_trait] +pub trait Sink: Send + Sync { + fn name(&self) -> &str; + fn interested_types(&self) -> Vec; + async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> anyhow::Result<()>; + fn topics(&self) -> Vec; + fn build_routes(&self) -> Router; + async fn initialize(&mut self, event_bus: Arc, context: &SinkContext) -> anyhow::Result<()>; +} +``` + +Invariants: + +- `interested_types` is **advisory** today — the `Sink::process` call receives *every* envelope, not just matching ones. Filter in `process` by `envelope.type_id`. +- `topics` is consumed at startup by `GrpcState` (`src/grpc.rs:134`) and published via `ListTopics`. +- `build_routes` output is `.merge`d into the main HTTP router by `MultiSink::build_routes` (`src/etl/sink/multi.rs:87`). +- `initialize` sees the `EventBus` + `SinkContext`. **This is your only chance to capture them by `Arc` for later use** — after `MultiSink` wraps the sink in `Arc` you lose mutable access. See `src/lib.rs:645` for the call site. +- `SinkContext { database_root, command_bus }` (`src/etl/sink/mod.rs:23`) — use `context.command_bus` to dispatch work to `CommandHandler`s. + +### `Decoder` — `src/etl/decoder/mod.rs:96` + +```rust +#[async_trait] +pub trait Decoder: Send + Sync { + fn decoder_name(&self) -> &str; + async fn decode(&self, keys: &[Felt], data: &[Felt], context: EventContext) -> anyhow::Result>; + async fn decode_event(&self, event: &StarknetEvent) -> anyhow::Result> { /* default */ } + async fn decode_events(&self, events: &[StarknetEvent]) -> anyhow::Result> { /* default */ } +} +``` + +Invariants: + +- `decoder_name` produces a stable `DecoderId` via `xxh3_64`. **Never** change the name of a decoder in production — the registry cache is keyed on it and changing it leaves stale mappings referencing nothing (the routing logic in `DecoderContext::decode` evicts them, but you pay a full re-identification round per contract). +- Each decoder should return *only* envelopes it wants to produce. Returning an empty `Vec` is the "not interested" signal. +- `EventContext` = `{ from_address, block_number, transaction_hash }`; add more via `envelope.metadata` if a sink needs it. + +### `Extractor` — `src/etl/extractor/mod.rs:299` + +```rust +#[async_trait] +pub trait Extractor: Send + Sync { + fn set_start_block(&mut self, start_block: u64); + async fn extract(&mut self, cursor: Option, engine_db: &EngineDb) -> Result; + fn is_finished(&self) -> bool; + async fn commit_cursor(&mut self, _cursor: &str, _engine_db: &EngineDb) -> Result<()> { Ok(()) } + fn as_any(&self) -> &dyn std::any::Any; +} +``` + +Return-value semantics: + +- Non-empty batch: process and call again. +- Empty batch + `is_finished() = false`: at the head; producer sleeps `cycle_interval` (see `src/lib.rs:976`) before retrying. +- Empty batch + `is_finished() = true`: shutdown ETL loop (`src/lib.rs:1035`). + +### `ContractIdentifier` — `src/etl/identification/registry.rs:32` + +```rust +#[async_trait] +pub trait ContractIdentifier: Send + Sync { + async fn identify_contracts(&self, contract_addresses: &[Felt]) -> Result>>; + fn shared_cache(&self) -> Arc>>>; +} +``` + +The only production impl is `ContractRegistry` (`src/etl/identification/registry.rs:64`), which: +- Batches RPC calls (`MAX_BATCH_SIZE = 500`) via `JsonRpcClient::batch_requests`. +- Keeps two caches: positive (`Arc>>>`) and bounded negative (`NegativeCache`, capacity `100_000`). +- Persists positives to `EngineDb` via `set_contract_decoders_batch`; loads them on startup via `load_from_db`. + +### `IdentificationRule` — `src/etl/identification/rule.rs:52` + +```rust +pub trait IdentificationRule: Send + Sync { + fn name(&self) -> &str; + fn decoder_ids(&self) -> Vec; + fn identify_by_abi(&self, contract_address: Felt, class_hash: Felt, abi: &ContractAbi) -> Result>; +} +``` + +One rule per contract shape. See `crates/torii-erc20/src/identification.rs` for the reference impl. + +### `CommandHandler` — `src/command.rs:35` + +```rust +#[async_trait] +pub trait CommandHandler: Send + Sync { + fn supports(&self, command: &dyn Command) -> bool; + fn attach_event_bus(&self, _event_bus: Arc) {} + async fn handle_command(&self, command: Box) -> Result<()>; +} +``` + +The `CommandBus` (`src/command.rs:148`) is a bounded mpsc that dispatches `Box` to the **single** matching handler. Ambiguous matches are rejected (`CommandDispatchError::Ambiguous`). + +## 4. ETL loop anatomy + +`src/lib.rs::run` (line 613 → 1293) spawns three tokio tasks: + +1. **Producer** (`src/lib.rs:856-980`) — loops `extractor.extract(cursor, engine_db)`, pushes `PrefetchedBatch { batch, cursor, extractor_finished }` into an `mpsc` of capacity `etl_concurrency.max_prefetch_batches`. Also enqueues unique `contract_addresses` onto the identifier's channel (non-blocking `try_send`; full queue → `torii_registry_identify_jobs_total{status=dropped}`). +2. **Identifier** (`src/lib.rs:824-848`) — optional; present only if `ToriiConfigBuilder::with_contract_identifier` was called. Consumes addresses and calls `ContractIdentifier::identify_contracts`. Populates the registry cache; `DecoderContext::decode` reads through it on every event. +3. **Consumer** (`src/lib.rs:985-1141`) — awaits a `PrefetchedBatch`, decodes via `DecoderContext.decode_events`, processes via `MultiSink.process`, then **only on success** calls `extractor.commit_cursor`. + +**Graceful shutdown** (`src/lib.rs:1156-1269`): +- `CancellationToken` flipped by SIGINT/SIGTERM. +- Server gets 15 s to drain (`SERVER_SHUTDOWN_TIMEOUT_SECS = 15`). +- ETL loop gets `config.shutdown_timeout` s (default 30) to finish the in-flight batch. + +## 5. EventBus, CommandBus, SubscriptionManager + +| Component | File | Purpose | +|---|---|---| +| `SubscriptionManager` | `src/grpc.rs:42` | `Arc>>` — one entry per connected gRPC client | +| `ClientSubscription` | `src/grpc.rs:31` | `{ topics: HashMap, tx: mpsc::Sender }` | +| `EventBus` | `src/etl/sink/mod.rs:188` | Sink-facing façade over `SubscriptionManager`; exposes `publish_protobuf(topic, type_id, data, decoded, update_type, filter_fn)` | +| `GrpcState` | `src/grpc.rs:134` | Carries `subscription_manager` + `topics: Vec` | +| `ToriiService` | `src/grpc.rs:156` | `torii.Torii` service (`GetVersion`, `ListTopics`, `SubscribeToTopicsStream`, `SubscribeToTopics`) | +| `CommandBus` / `CommandBusSender` | `src/command.rs:148 / :71` | Bounded mpsc, routes `Box` to matching `CommandHandler` | + +Key routing rules: + +- `EventBus::publish_protobuf` takes both `&Any` and `&T decoded`; the `filter_fn: Fn(&T, &filters) -> bool` runs *once per connected client* — keep it cheap. This avoids re-decoding the payload per client. +- A topic is visible to `ListTopics` only if a registered sink includes it in `topics()`. +- `SubscriptionManager::update_subscriptions` is additive for new topics and honours `unsubscribe_topics` for removal. +- `CommandBus::dispatch` fails if zero or >1 handlers match (`CommandDispatchError::Unsupported | Ambiguous`). + +## 6. Storage stack + +Three layers; don't confuse them: + +- **`EngineDb`** (`src/etl/engine_db.rs:33`) — cursor, head, extractor state, contract→decoder cache. One per binary. `sqlx::Pool` (not `torii-sql`). Embedded schema under `sql/engine_schema*.sql`. +- **`torii-sql`** (`crates/sql/`) — shared abstraction for every *sink's* DB. `DbBackend::{Postgres, Sqlite}`, `PoolExt`, `SchemaMigrator`. Token sinks + `introspect-sql-sink` + `torii-controllers-sink` + `torii-ecs-sink` all route through it. +- **Sink-private storage** — `Erc20Storage`, `Erc721Storage`, `Erc1155Storage`, `ControllersStore`, `IntrospectDb`. Each owns its own schema and connection pool. + +Binary plumbing to resolve URLs: `torii-runtime-common::database::{resolve_single_db_setup, resolve_token_db_setup}` and `backend_from_url_or_path`. **Never mix** a Postgres engine with SQLite storage — `resolve_token_db_setup` rejects that combination. + +## 7. Sink authoring recipe + +1. Define a body type implementing `TypedBody` (or use the `typed_body_impl!` macro from `src/etl/envelope.rs`). +2. Decide the `TypeId` — call `TypeId::new("your_sink")`; make it a `const` at the top of the file. +3. Implement `Decoder` if your sink needs one (see `crates/torii-erc20/src/decoder.rs`). +4. Implement `Sink`: + - `name()` returns a short stable name. + - `interested_types()` lists your `TypeId`(s). + - `process(envelopes, batch)` — downcast matching envelopes, persist, optionally `EventBus::publish_protobuf` and/or `grpc_service.broadcast`. Use `batch.is_live(threshold)` to skip broadcasting during historical backfill. + - `topics()` returns the topic descriptors (`TopicInfo::new`). Keep filter keys stable. + - `build_routes()` returns an `axum::Router`. + - `initialize()` captures `event_bus` + `context.command_bus` into `self`. +5. If you have a gRPC service, expose it via `get_grpc_service_impl(&self) -> Arc` so binaries can mount it via `ToriiConfigBuilder::with_grpc_router(...)` before calling `torii::run`. Reference: `crates/torii-sql-sink/README.md`. +6. If you have custom reflection, set `with_custom_reflection(true)` and register both `TORII_DESCRIPTOR_SET` and your `FILE_DESCRIPTOR_SET` bytes in a `tonic_reflection::server::Builder`. + +## 8. Decoder authoring recipe + +1. Pick a stable `decoder_name` (e.g. `"erc20"`); the `DecoderId` is `xxh3_64(name)` — keep the name **forever**. +2. In `decode(keys, data, ctx)`: + - If the event's first key isn't one you care about, `return Ok(Vec::new())`. + - Deserialise; return one or more `Envelope`s via `EventMsg::to_envelope(ctx)` (see `src/etl/envelope.rs:118`). +3. Register with `ToriiConfigBuilder::add_decoder(Arc::new(MyDecoder::new()))`. +4. **For auto-discovery**: implement `IdentificationRule` returning the same `DecoderId::new("erc20")` and register the rule on a `ContractRegistry`. +5. **For performance**: encourage binaries to populate `ContractFilter::map_contract(addr, vec![decoder_id])` for known contracts. Routing priority in `DecoderContext::decode` (`src/etl/decoder/context.rs:313`): blacklist → explicit mapping → registry cache → fallback (try all decoders). + +## 9. Binary authoring recipe + +Every binary follows this skeleton — see `bins/torii-erc20/src/main.rs` for the minimal form: + +1. `clap::Parser` config → validate via `torii-config-common`. +2. Resolve DB setup via `torii-runtime-common::database::resolve_{single,token}_db_setup`. +3. Construct provider (`JsonRpcClient`) and extractor. +4. Create storage + sink + gRPC service; capture `Arc` for router assembly. +5. Create decoder(s) + `IdentificationRule`(s); build a `ContractRegistry` and call `load_from_db().await`. +6. Build `tonic::transport::server::Router` with all sink gRPC services + optional reflection. +7. Build `ToriiConfig`: + - `.add_sink_boxed(Box::new(sink))` + - `.add_decoder(Arc::new(decoder))` + - `.with_grpc_router(grpc_router)` + `.with_custom_reflection(true)` if you composed reflection yourself + - `.with_contract_identifier(registry)` for auto-discovery + - `.with_command_handler(Box::new(handler))` for async work + - `.with_tls(ToriiTlsConfig::new(cert, key))` if needed + - `.etl_concurrency(EtlConcurrencyConfig { max_prefetch_batches })` +8. `torii::run(config).await`. + +**Synthetic pair**: for every binary that backs a production workload there is usually a `-synth` sibling (`bins/torii-erc20-synth`, `bins/torii-tokens-synth`, `bins/torii-introspect-synth`) that swaps the RPC extractor for a deterministic `SyntheticExtractor` and writes a `RunReport` JSON. Use those for perf regression testing; do **not** add new knobs to the production binary for profiling purposes. + +## 10. Bug classes & runbooks + +### 10.1 "Events missing after restart / duplicate processing" +- Check `torii_cursor_commit_failures_total`. If non-zero, look at the `extractor.commit_cursor` error in logs. +- Re-read the invariant: cursor is committed *after* `multi_sink.process` succeeds. If a sink panics, the cursor is *not* advanced → batch replays. +- If a sink has side-effects (e.g. sending to an external API) and you require exactly-once, add idempotency on the sink side — the pipeline gives at-least-once for side-effects between extract and cursor-commit. + +### 10.2 "Sink never receives an envelope" +- Confirm the decoder's `decode()` actually returns an envelope for the event. Start at `src/etl/decoder/context.rs:313` and trace which branch is taken (blacklist / mapping / registry / fallback). +- Match `TypeId` — the `envelope.type_id` must match one of `sink.interested_types()`. Mismatched `TypeId::new("…")` strings are the most common cause. +- If the contract isn't in an explicit mapping and no registry is configured, only the **fallback all-decoders** path runs. See `has_registry` in `DecoderContext` (`context.rs:49`). + +### 10.3 "Decoder fallback is slow" +- With many decoders, fallback tries all of them serially per event. Fix by registering a `ContractRegistry` + `IdentificationRule` to cache the mapping after first contact, or by adding an explicit `ContractFilter::map_contract`. +- `torii_registry_identify_duration_seconds` histogram shows how long identification itself takes; `torii_rpc_chunk_duration_seconds{extractor="registry"}` shows the RPC portion. + +### 10.4 "Identification queue full / dropped" +- `torii_registry_identify_jobs_total{status="dropped"}` increasing means the producer's `identify_tx.try_send` is failing on a full channel. +- Increase `etl_concurrency.max_prefetch_batches` (the identify queue is sized at `prefetch_capacity.saturating_mul(2).max(8)`) or reduce batch size. + +### 10.5 "gRPC 404 after adding a service" +- You must pass the router to Torii via `.with_grpc_router(router)`; Torii will mount the core service into it. If you also composed reflection yourself, set `.with_custom_reflection(true)` or you'll get duplicate-service errors. +- Confirm the service descriptor set is fed to `tonic_reflection` — otherwise `grpcurl list` won't show it. + +### 10.6 "Sink panics abort the pipeline" +- They don't. `MultiSink::process` (`src/etl/sink/multi.rs:51-66`) logs the error, increments `torii_sink_failures_total{sink=""}`, but continues. The cursor is **not** advanced because the multi-sink returns its own error to the ETL loop if *any* sink failed. +- If you need all-or-nothing semantics, wrap your sink to convert transient errors into retries, or sequence multiple sinks behind an ordered façade (see `bins/torii-arcade` / `bins/torii-introspect-bin`). + +### 10.7 "EngineDb locked / PG pool exhausted" +- Engine DB is a single pool with `max_connections = 5` for real SQLite files and `1` for `:memory:` (`src/etl/engine_db.rs:80-88`). Do not hold an `EngineDb` transaction across `extract` / `decode` / `process`. +- Postgres sinks pool separately; check `max_db_connections` in binary configs. + +### 10.8 "TLS listener won't start" +- Both `--tls-cert` and `--tls-key` must be present (enforced in `torii-introspect-bin/src/config.rs` and `torii-arcade/src/config.rs`). The PEM files are re-loaded at startup only; hot reload is not supported. + +### 10.9 "Can't change decoder name without breaking clients" +- The `DecoderId` hash is persisted in the engine DB. If you change `decoder_name`, on next startup `DecoderContext::decode` will see stale cache entries pointing at an unknown `DecoderId`, evict them (`context.rs:341-360`), and fall back to all-decoders for the affected contracts until re-identified. This is safe but slow — renaming a decoder costs a full re-identification pass. + +## 11. Observability reference + +Emitted from `src/lib.rs::run` unless otherwise noted. All use the `metrics` facade backed by `metrics-exporter-prometheus`. + +| Metric | Type | Labels | Meaning | +|---|---|---|---| +| `torii_etl_cycle_total` | counter | `status={ok,empty,decode_error,sink_error,extract_error}` | One increment per ETL cycle | +| `torii_etl_cycle_duration_seconds` | histogram | — | End-to-end cycle latency | +| `torii_etl_cycle_gap_blocks` | gauge | — | `chain_head - last_block` | +| `torii_etl_last_success_timestamp_seconds` | gauge | — | Last successful batch wall time | +| `torii_etl_inflight_cycles` | gauge | — | 0 or 1 | +| `torii_etl_prefetch_queue_depth` | gauge | — | Prefetched batches waiting | +| `torii_etl_prefetch_stall_seconds` | histogram | — | Producer vs consumer pressure | +| `torii_events_extracted_total` | counter | — | Raw events out of the extractor | +| `torii_events_decoded_total` | counter | — | Events that reached decode | +| `torii_decode_envelopes_total` | counter | — | Envelopes produced | +| `torii_events_processed_total` | counter | — | Events after sink processing | +| `torii_transactions_processed_total` | counter | — | Transactions after sink processing | +| `torii_extract_batch_size_total` | counter | `unit={events,blocks,transactions}` | Batch fan-out | +| `torii_decode_failures_total` | counter | `stage` | Decode errors | +| `torii_cursor_commit_failures_total` | counter | — | Commit failures (data-safety canary) | +| `torii_sink_process_duration_seconds` | histogram | `sink` | Per-sink latency | +| `torii_sink_failures_total` | counter | `sink` | Per-sink failure count | +| `torii_registry_identify_duration_seconds` | histogram | — | Identification worker cycle | +| `torii_registry_identify_jobs_total` | counter | `status={enqueued,dropped,closed}` | Identify-queue health | +| `torii_rpc_chunk_duration_seconds` | histogram | `extractor`, `method` | Chunked RPC latency | +| `torii_rpc_parallelism` | gauge | — | Configured concurrency | +| `torii_command_dispatch_total` | counter | `command`, `status={enqueued,unsupported,ambiguous,dropped_full,closed}` | CommandBus dispatch outcomes | +| `torii_command_handle_total` | counter | `command`, `status={ok,error}` | Handler outcomes | +| `torii_command_handle_duration_seconds` | histogram | `command` | Handler latency | +| `torii_observability_enabled` | gauge | — | Seeded to 1 when metrics are on | +| `torii_uptime_seconds` | gauge | — | Set from `/health` | +| `torii_build_info` | gauge | `version` | Build info beacon | + +Enable / disable via `TORII_METRICS_ENABLED` (`src/metrics.rs:20`). + +## 12. Crate map + +Root library: + +- [`src/README.md`](../../../src/README.md) — `torii` (root library). Owns `ToriiConfig`, `run()`, `EngineDb`, `DecoderContext`, `MultiSink`, the three tokio tasks, CommandBus, SubscriptionManager. + +Shared / core: + +- [`crates/torii-common/README.md`](../../../crates/torii-common/README.md) — U256/Felt blob codecs, metadata + token-URI helpers. +- [`crates/types/README.md`](../../../crates/types/README.md) — `StarknetEvent`, `EventContext`, `BlockContext`. +- [`crates/torii-config-common/README.md`](../../../crates/torii-config-common/README.md) — CLI validation helpers. +- [`crates/torii-runtime-common/README.md`](../../../crates/torii-runtime-common/README.md) — DB setup + sink init helpers. +- [`crates/sql/README.md`](../../../crates/sql/README.md) — `torii-sql` (SQLite + Postgres abstraction). +- [`crates/testing/README.md`](../../../crates/testing/README.md) — `torii-test-utils` (event fixtures + FakeProvider). +- [`crates/pathfinder/README.md`](../../../crates/pathfinder/README.md) — Pathfinder SQLite reader + optional `etl` extractor. + +Sinks: + +- [`crates/torii-sql-sink/README.md`](../../../crates/torii-sql-sink/README.md) — **reference sink**; SQL operations + gRPC + HTTP + EventBus. +- [`crates/torii-log-sink/README.md`](../../../crates/torii-log-sink/README.md) — in-memory log ring, the minimum viable sink. +- [`crates/torii-controllers-sink/README.md`](../../../crates/torii-controllers-sink/README.md) — GraphQL-driven Cartridge controller sync. +- [`crates/arcade-sink/README.md`](../../../crates/arcade-sink/README.md) — Arcade projections from Dojo introspect events. +- [`crates/torii-ecs-sink/README.md`](../../../crates/torii-ecs-sink/README.md) — legacy `world.World` gRPC facade. + +Tokens: + +- [`crates/torii-erc20/README.md`](../../../crates/torii-erc20/README.md) — full ERC20 stack (decoder + rule + sink + service). +- [`crates/torii-erc721/README.md`](../../../crates/torii-erc721/README.md) — full ERC721 stack; owner-of tracking + EIP-4906. +- [`crates/torii-erc1155/README.md`](../../../crates/torii-erc1155/README.md) — full ERC1155 stack; transfer-history-first. + +Domain / introspect: + +- [`crates/dojo/README.md`](../../../crates/dojo/README.md) — `DojoDecoder`, `DojoEvent`, external-contract registration. +- [`crates/introspect/README.md`](../../../crates/introspect/README.md) — `IntrospectMsg` (13 schema-mutation variants). +- [`crates/introspect-sql-sink/README.md`](../../../crates/introspect-sql-sink/README.md) — Postgres / SQLite materializer for introspect events. + +Binaries: + +- [`bins/torii-erc20/README.md`](../../../bins/torii-erc20/README.md) — canonical minimal binary. +- [`bins/torii-erc20-synth/README.md`](../../../bins/torii-erc20-synth/README.md) — ERC20 perf harness (Postgres only). +- [`bins/torii-tokens/README.md`](../../../bins/torii-tokens/README.md) — unified ERC20 + ERC721 + ERC1155. +- [`bins/torii-tokens-synth/README.md`](../../../bins/torii-tokens-synth/README.md) — multi-standard perf harness with subcommands. +- [`bins/torii-introspect-bin/README.md`](../../../bins/torii-introspect-bin/README.md) — full-stack Dojo + tokens (`torii-server`). +- [`bins/torii-introspect-synth/README.md`](../../../bins/torii-introspect-synth/README.md) — Dojo introspect perf harness. +- [`bins/torii-arcade/README.md`](../../../bins/torii-arcade/README.md) — Cartridge Arcade backend. + +## Verification cues before acting on memory + +Before recommending a file:line referenced above, verify it still exists: + +```bash +rg -n "^pub trait Sink\\b" src/etl/sink/mod.rs +rg -n "^pub trait Decoder\\b" src/etl/decoder/mod.rs +rg -n "^pub trait Extractor\\b" src/etl/extractor/mod.rs +rg -n "^pub trait ContractIdentifier" src/etl/identification/registry.rs +rg -n "^pub trait IdentificationRule" src/etl/identification/rule.rs +rg -n "pub async fn run" src/lib.rs +``` + +If any of these no longer match, the skill is drifting — open the +per-crate README to re-anchor before editing. diff --git a/.rustfmt.toml b/.rustfmt.toml index 5207c2aa..d01043dc 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -7,3 +7,5 @@ use_small_heuristics = "Default" # Stable options only reorder_imports = true reorder_modules = true + +imports_granularity = "Module" diff --git a/AGENTS.md b/AGENTS.md index e828a058..388800db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ -[Agent Skills Index]|root: ./agents|IMPORTANT: Prefer retrieval-led reasoning over pre-training for any tasks covered by skills.|skills|create-a-plan:{create-a-plan.md},create-pr:{create-pr.md} +[Agent Skills Index]|root: ./agents|IMPORTANT: Prefer retrieval-led reasoning over pre-training for any tasks covered by skills.|skills|create-a-plan:{create-a-plan.md},create-pr:{create-pr.md},torii-maintainer:{SKILL.md} # Agent Instructions diff --git a/Cargo.lock b/Cargo.lock index 16dbbecd..0e4c899f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -58,9 +58,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alphanumeric-sort" -version = "1.5.5" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "774ffdfeac16e9b4d75e41225dc2545d9c2082a0634b5d7f6f70e168546eecb1" +checksum = "d7789d0c06f5a946e0d860f8a3cd055dd714d44657c16116cac8ee47da03f280" [[package]] name = "android_system_properties" @@ -145,12 +145,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -[[package]] -name = "assert_matches" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" - [[package]] name = "async-stream" version = "0.3.6" @@ -481,9 +475,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "jobserver", @@ -616,9 +610,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0758edba32d61d1fd9f4d69491b47604b91ee2f7e6b33de7e54ca4ebe55dc3" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "colorchoice" @@ -878,9 +872,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1005a6d4446f5120ef475ad3d2af2b30c49c2c9c6904258e3bb30219bebed5e4" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", ] @@ -919,18 +913,6 @@ dependencies = [ "syn", ] -[[package]] -name = "darrentsung_debug_parser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf488eca7807ce3c8e64bee95c3fbf8f1935c905b3b73835e75db16fc458fdc4" -dependencies = [ - "anyhow", - "html-escape", - "nom", - "ordered-float", -] - [[package]] name = "data-encoding" version = "2.10.0" @@ -967,12 +949,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - [[package]] name = "digest" version = "0.10.7" @@ -1031,7 +1007,7 @@ dependencies = [ [[package]] name = "dojo-introspect" version = "0.1.0" -source = "git+https://github.com/dojoengine/dojo-introspect?rev=aadc3c9#aadc3c980706596a4a083413813a0a3ab01fded7" +source = "git+https://github.com/dojoengine/dojo-introspect?rev=c45d711#c45d711236c856bc60d567e43965b2043d5cb2b0" dependencies = [ "async-trait", "introspect-rust-macros", @@ -1187,9 +1163,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -1476,7 +1452,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.13.0", + "indexmap 2.13.1", "slab", "tokio", "tokio-util", @@ -1601,15 +1577,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "html-escape" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] - [[package]] name = "http" version = "1.4.0" @@ -1972,9 +1939,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1989,7 +1956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" dependencies = [ "ahash", - "indexmap 2.13.0", + "indexmap 2.13.1", "is-terminal", "itoa", "log", @@ -2012,7 +1979,7 @@ dependencies = [ [[package]] name = "introspect-events" version = "0.1.2" -source = "git+https://github.com/cartridge-gg/introspect?rev=34e93c1#34e93c10c867c53c622cce03abb6431c9dae0ef5" +source = "git+https://github.com/cartridge-gg/introspect?rev=b89ee9e#b89ee9ead02df170195bb4f67a48e4308edab855" dependencies = [ "cainome-cairo-serde", "introspect-types", @@ -2024,7 +1991,7 @@ dependencies = [ [[package]] name = "introspect-rust-macros" version = "0.1.0" -source = "git+https://github.com/cartridge-gg/introspect?rev=34e93c1#34e93c10c867c53c622cce03abb6431c9dae0ef5" +source = "git+https://github.com/cartridge-gg/introspect?rev=b89ee9e#b89ee9ead02df170195bb4f67a48e4308edab855" dependencies = [ "paste", "proc-macro2", @@ -2036,7 +2003,7 @@ dependencies = [ [[package]] name = "introspect-types" version = "0.1.2" -source = "git+https://github.com/cartridge-gg/introspect?rev=34e93c1#34e93c10c867c53c622cce03abb6431c9dae0ef5" +source = "git+https://github.com/cartridge-gg/introspect?rev=b89ee9e#b89ee9ead02df170195bb4f67a48e4308edab855" dependencies = [ "blake3", "convert_case", @@ -2332,7 +2299,7 @@ dependencies = [ "hyper", "hyper-rustls", "hyper-util", - "indexmap 2.13.0", + "indexmap 2.13.1", "ipnet", "metrics", "metrics-util", @@ -2374,12 +2341,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2435,16 +2396,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2615,15 +2566,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -2718,7 +2660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.13.0", + "indexmap 2.13.1", ] [[package]] @@ -2728,7 +2670,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset 0.5.7", - "indexmap 2.13.0", + "indexmap 2.13.1", ] [[package]] @@ -2921,26 +2863,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "pretty_assertions_sorted" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95d32882f2adbdfd30312733271b83c527ee8007bf78dc21afe510463ac6a0" -dependencies = [ - "darrentsung_debug_parser", - "pretty_assertions", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -3723,9 +3645,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3814,7 +3736,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.13.1", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -3988,8 +3910,7 @@ dependencies = [ [[package]] name = "sqlx" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "sqlx-core", "sqlx-macros", @@ -4001,8 +3922,7 @@ dependencies = [ [[package]] name = "sqlx-core" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "base64 0.22.1", "bigdecimal", @@ -4017,7 +3937,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink 0.10.0", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "memchr", "once_cell", @@ -4038,8 +3958,7 @@ dependencies = [ [[package]] name = "sqlx-macros" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "proc-macro2", "quote", @@ -4051,8 +3970,7 @@ dependencies = [ [[package]] name = "sqlx-macros-core" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "dotenvy", "either", @@ -4076,8 +3994,7 @@ dependencies = [ [[package]] name = "sqlx-mysql" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "atoi", "base64 0.22.1", @@ -4119,8 +4036,7 @@ dependencies = [ [[package]] name = "sqlx-postgres" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "atoi", "base64 0.22.1", @@ -4158,8 +4074,7 @@ dependencies = [ [[package]] name = "sqlx-sqlite" version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +source = "git+https://github.com/bengineer42/sqlx?rev=22a01d3#22a01d31a28ea4adf5d14d8c773df881feda5e86" dependencies = [ "atoi", "flume", @@ -4242,7 +4157,7 @@ dependencies = [ "flate2", "foldhash 0.1.5", "hex", - "indexmap 2.13.0", + "indexmap 2.13.1", "num-traits", "serde", "serde_json", @@ -4359,6 +4274,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "starknet-types-raw" +version = "0.1.0" +source = "git+https://github.com/cartridge-gg/starknet-types-raw?branch=main#79e192ffe34aad18e737c6630eb69ee295a49027" +dependencies = [ + "num-traits", + "serde", + "starknet", + "starknet-types-core", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4614,9 +4540,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" dependencies = [ "bytes", "libc", @@ -4631,9 +4557,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4738,7 +4664,7 @@ version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.13.1", "toml_datetime", "toml_parser", "winnow", @@ -4849,6 +4775,7 @@ dependencies = [ "itertools 0.14.0", "metrics", "metrics-exporter-prometheus", + "primitive-types 0.14.0", "prost 0.13.5", "prost-types 0.13.5", "resolve-path", @@ -4857,6 +4784,7 @@ dependencies = [ "serde_json", "sqlx", "starknet", + "starknet-types-raw", "tempfile", "tokio", "tokio-rustls", @@ -4872,11 +4800,13 @@ dependencies = [ "torii-erc20", "torii-erc721", "torii-introspect", - "torii-introspect-postgres-sink", + "torii-introspect-sql-sink", "torii-log-sink", "torii-pathfinder", + "torii-sql", "torii-sql-sink", "torii-test-utils", + "torii-types", "tower 0.5.3", "tower-http 0.5.2", "tracing", @@ -4900,17 +4830,16 @@ dependencies = [ "torii-arcade-sink", "torii-common", "torii-config-common", + "torii-controllers-sink", "torii-dojo", "torii-ecs-sink", - "torii-entities-historical-sink", "torii-erc1155", "torii-erc20", "torii-erc721", - "torii-introspect-postgres-sink", - "torii-introspect-sqlite-sink", + "torii-introspect-sql-sink", "torii-pathfinder", "torii-runtime-common", - "torii-sqlite", + "torii-sql", "tracing", "tracing-subscriber", "url", @@ -4933,9 +4862,11 @@ dependencies = [ "tonic-build", "torii", "torii-common", + "torii-dojo", "torii-erc721", "torii-introspect", "torii-runtime-common", + "torii-sql", "tracing", ] @@ -4946,12 +4877,12 @@ dependencies = [ "anyhow", "async-trait", "base64 0.22.1", - "itertools 0.14.0", + "primitive-types 0.14.0", "reqwest", "serde", "serde_json", - "sqlx", "starknet", + "starknet-types-raw", "tokio", "tracing", "urlencoding", @@ -4971,15 +4902,17 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "hex", "metrics", "reqwest", "serde", "serde_json", "sqlx", - "starknet", + "starknet-types-raw", "tokio", "torii", "torii-runtime-common", + "torii-sql", "tracing", ] @@ -4998,14 +4931,15 @@ dependencies = [ "sqlx", "starknet", "starknet-types-core", + "starknet-types-raw", "thiserror 2.0.18", "tokio", "tonic-build", "torii", "torii-common", "torii-introspect", - "torii-postgres", - "torii-sqlite", + "torii-sql", + "torii-types", "tracing", ] @@ -5029,7 +4963,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "starknet", + "starknet-types-raw", "tokio", "tokio-stream", "tonic", @@ -5038,25 +4972,7 @@ dependencies = [ "torii-dojo", "torii-introspect", "torii-runtime-common", - "tracing", -] - -[[package]] -name = "torii-entities-historical-sink" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "introspect-types", - "serde", - "serde_json", - "sqlx", - "starknet", - "thiserror 2.0.18", - "tokio", - "torii", - "torii-introspect", - "torii-runtime-common", + "torii-sql", "tracing", ] @@ -5071,18 +4987,23 @@ dependencies = [ "chrono", "futures", "hex", + "primitive-types 0.14.0", "prost 0.13.5", "prost-types 0.13.5", "rusqlite", "serde", "serde_json", "starknet", + "starknet-types-raw", + "thiserror 2.0.18", "tokio", "tokio-postgres", "tonic", "tonic-build", "torii", "torii-common", + "torii-sql", + "torii-types", "tracing", ] @@ -5098,18 +5019,22 @@ dependencies = [ "futures", "hex", "metrics", + "primitive-types 0.14.0", "prost 0.13.5", "prost-types 0.13.5", "rusqlite", "serde", "serde_json", "starknet", + "starknet-types-raw", "tokio", "tokio-postgres", "tonic", "tonic-build", "torii", "torii-common", + "torii-sql", + "torii-types", "tracing", ] @@ -5127,6 +5052,7 @@ dependencies = [ "torii", "torii-erc20", "torii-runtime-common", + "torii-sql", "tracing", "tracing-subscriber", "url", @@ -5147,6 +5073,7 @@ dependencies = [ "torii", "torii-erc20", "torii-runtime-common", + "torii-sql", "tracing", "tracing-subscriber", ] @@ -5162,18 +5089,21 @@ dependencies = [ "chrono", "futures", "hex", + "primitive-types 0.14.0", "prost 0.13.5", "prost-types 0.13.5", "rusqlite", "serde", "serde_json", "starknet", + "starknet-types-raw", "tokio", "tokio-postgres", "tonic", "tonic-build", "torii", "torii-common", + "torii-sql", "tracing", ] @@ -5192,12 +5122,13 @@ dependencies = [ "serde", "serde_json", "sqlx", - "starknet", "starknet-types-core", + "starknet-types-raw", "thiserror 2.0.18", "tonic-build", "torii", "torii-common", + "torii-sql", ] [[package]] @@ -5218,46 +5149,19 @@ dependencies = [ "torii-controllers-sink", "torii-dojo", "torii-ecs-sink", - "torii-entities-historical-sink", "torii-erc1155", "torii-erc20", "torii-erc721", - "torii-introspect-postgres-sink", - "torii-introspect-sqlite-sink", + "torii-introspect-sql-sink", "torii-runtime-common", - "torii-sqlite", + "torii-sql", "tracing", "tracing-subscriber", "url", ] [[package]] -name = "torii-introspect-postgres-sink" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "hex", - "introspect-types", - "itertools 0.14.0", - "metrics", - "serde", - "serde_json", - "sqlx", - "starknet", - "starknet-types-core", - "thiserror 2.0.18", - "tokio", - "torii", - "torii-common", - "torii-introspect", - "torii-postgres", - "tracing", - "xxhash-rust", -] - -[[package]] -name = "torii-introspect-sqlite-sink" +name = "torii-introspect-sql-sink" version = "0.1.0" dependencies = [ "anyhow", @@ -5270,14 +5174,16 @@ dependencies = [ "serde", "serde_json", "sqlx", - "starknet", "starknet-types-core", + "starknet-types-raw", "thiserror 2.0.18", "tokio", "torii", + "torii-dojo", "torii-introspect", - "torii-sqlite", + "torii-sql", "tracing", + "xxhash-rust", ] [[package]] @@ -5294,12 +5200,12 @@ dependencies = [ "serde_json", "sqlx", "starknet", - "starknet-types-core", + "starknet-types-raw", "tokio", "torii", "torii-dojo", - "torii-introspect-postgres-sink", - "torii-runtime-common", + "torii-introspect-sql-sink", + "torii-types", "tracing", "tracing-subscriber", ] @@ -5315,7 +5221,7 @@ dependencies = [ "prost-types 0.13.5", "serde", "serde_json", - "starknet", + "starknet-types-raw", "tokio", "tokio-stream", "tonic", @@ -5334,43 +5240,36 @@ dependencies = [ "rand 0.8.5", "rusqlite", "serde", - "starknet", + "starknet-types-raw", "thiserror 2.0.18", "torii", - "torii-starknet", + "torii-types", "zstd", ] [[package]] -name = "torii-postgres" +name = "torii-runtime-common" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "crc", - "futures", - "hex", - "serde", - "serde_json", - "sqlx", - "starknet-types-core", - "thiserror 2.0.18", "tokio", + "tokio-postgres", "torii", - "torii-common", + "torii-sql", "tracing", - "xxhash-rust", ] [[package]] -name = "torii-runtime-common" +name = "torii-sql" version = "0.1.0" dependencies = [ - "anyhow", - "tokio", - "tokio-postgres", - "torii", - "tracing", + "async-trait", + "crc", + "futures", + "itertools 0.14.0", + "log", + "sqlx", + "starknet-types-raw", ] [[package]] @@ -5388,7 +5287,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "starknet", + "starknet-types-raw", "tokio", "tonic", "tonic-build", @@ -5397,28 +5296,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "torii-sqlite" -version = "0.1.0" -dependencies = [ - "async-trait", - "futures", - "sqlx", - "torii-common", -] - -[[package]] -name = "torii-starknet" -version = "0.1.0" -dependencies = [ - "assert_matches", - "bincode", - "pretty_assertions_sorted", - "serde", - "serde_json", - "starknet", -] - [[package]] name = "torii-test-utils" version = "0.1.0" @@ -5430,8 +5307,8 @@ dependencies = [ "resolve-path", "serde", "serde_json", - "starknet", "starknet-types-core", + "starknet-types-raw", ] [[package]] @@ -5453,6 +5330,7 @@ dependencies = [ "torii-erc20", "torii-erc721", "torii-runtime-common", + "torii-sql", "tracing", "tracing-subscriber", "url", @@ -5476,10 +5354,20 @@ dependencies = [ "torii-erc20", "torii-erc721", "torii-runtime-common", + "torii-sql", "tracing", "tracing-subscriber", ] +[[package]] +name = "torii-types" +version = "0.1.0" +dependencies = [ + "starknet", + "starknet-types-raw", + "thiserror 2.0.18", +] + [[package]] name = "tower" version = "0.4.13" @@ -5768,12 +5656,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5969,7 +5851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -5982,7 +5864,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.13.1", "semver", ] @@ -6407,7 +6289,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap 2.13.1", "prettyplease", "syn", "wasm-metadata", @@ -6438,7 +6320,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -6457,7 +6339,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.13.1", "log", "semver", "serde", @@ -6469,9 +6351,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -6488,12 +6370,6 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 4c14aeb6..1a198d3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,19 +9,16 @@ members = [ "crates/torii-controllers-sink", "crates/arcade-sink", "crates/torii-ecs-sink", - "crates/torii-entities-historical-sink", "crates/torii-erc20", "crates/torii-erc721", "crates/torii-erc1155", + "crates/sql", "crates/introspect", "crates/dojo", - "crates/introspect-postgres-sink", - "crates/introspect-sqlite-sink", + "crates/introspect-sql-sink", "crates/testing", - "crates/postgres", - "crates/sqlite", "crates/pathfinder", - "crates/starknet", + "crates/types", "bins/torii-erc20", "bins/torii-erc20-synth", "bins/torii-introspect-bin", @@ -72,6 +69,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } metrics = "0.24" metrics-exporter-prometheus = "0.17" +log = "0.4.29" # CLI clap = { version = "4.5", features = ["derive"] } @@ -80,13 +78,11 @@ clap = { version = "4.5", features = ["derive"] } starknet = "0.17" # Database -sqlx = { version = "0.8.6", features = [ +sqlx = { git = "https://github.com/bengineer42/sqlx", version = "0.8.6", features = [ "runtime-tokio", - "sqlite", - "postgres", - "any", -] } +], rev = "22a01d3" } # Used patched version of sqlx with SqliteConnectOptions support (PR in progress: https://github.com/launchbadge/sqlx/pull/4209) crc = "3.4.0" +rusqlite = { version = "0.32.1", features = ["bundled"] } # Hashing blake3 = "1.8.3" @@ -110,15 +106,16 @@ hex = "0.4" primitive-types = { version = "0.14.0", features = ["serde"] } starknet-types-core = "0.2.0" bigdecimal = "0.4.10" +starknet-types-raw = { git = "https://github.com/cartridge-gg/starknet-types-raw", branch = "main" } # Build tonic-build = "0.12" # Introspect -introspect-events = { git = "https://github.com/cartridge-gg/introspect", rev = "34e93c1" } -introspect-types = { git = "https://github.com/cartridge-gg/introspect", rev = "34e93c1" } -introspect-rust-macros = { git = "https://github.com/cartridge-gg/introspect", rev = "34e93c1" } -dojo-introspect = { git = "https://github.com/dojoengine/dojo-introspect", rev = "aadc3c9" } +introspect-events = { git = "https://github.com/cartridge-gg/introspect", rev = "b89ee9e" } +introspect-types = { git = "https://github.com/cartridge-gg/introspect", rev = "b89ee9e" } +introspect-rust-macros = { git = "https://github.com/cartridge-gg/introspect", rev = "b89ee9e" } +dojo-introspect = { git = "https://github.com/dojoengine/dojo-introspect", rev = "c45d711" } # Utils @@ -133,25 +130,21 @@ torii-config-common = { path = "crates/torii-config-common" } torii-runtime-common = { path = "crates/torii-runtime-common" } # Internal crates +torii-sql = { path = "crates/sql" } torii-sql-sink.path = "crates/torii-sql-sink" torii-log-sink.path = "crates/torii-log-sink" torii-controllers-sink.path = "crates/torii-controllers-sink" torii-arcade-sink.path = "crates/arcade-sink" torii-ecs-sink.path = "crates/torii-ecs-sink" -torii-entities-historical-sink.path = "crates/torii-entities-historical-sink" torii-erc20.path = "crates/torii-erc20" torii-erc721.path = "crates/torii-erc721" torii-erc1155.path = "crates/torii-erc1155" torii-dojo.path = "./crates/dojo" torii-introspect.path = "crates/introspect" -torii-introspect-postgres-sink.path = "./crates/introspect-postgres-sink" -torii-introspect-sqlite-sink.path = "./crates/introspect-sqlite-sink" +torii-introspect-sql-sink.path = "./crates/introspect-sql-sink" torii-test-utils.path = "crates/testing" -torii-postgres.path = "crates/postgres" -torii-sqlite.path = "crates/sqlite" torii-pathfinder.path = "crates/pathfinder" -torii-starknet.path = "crates/starknet" - +torii-types.path = "crates/types" [lib] name = "torii" @@ -177,10 +170,6 @@ path = "examples/http_only_sink/main.rs" name = "test_block_extractor" path = "examples/test_block_extractor/main.rs" -[[example]] -name = "introspect_simple" -path = "examples/introspect/simple.rs" - [[example]] name = "introspect_restart" path = "examples/introspect/restart.rs" @@ -204,8 +193,9 @@ prost-types.workspace = true rustls-pemfile = "2.0" serde_json.workspace = true serde.workspace = true -sqlx.workspace = true +sqlx = { workspace = true, features = ["sqlite"] } starknet.workspace = true +starknet-types-raw = { workspace = true, features = ["events"] } tokio-stream.workspace = true tokio-rustls = { version = "0.26", default-features = false, features = [ "logging", @@ -218,6 +208,7 @@ tonic-reflection.workspace = true tonic-web.workspace = true tonic.workspace = true torii-common.workspace = true +torii-types = { workspace = true, features = ["field"] } tower.workspace = true tower-http.workspace = true tracing.workspace = true @@ -238,20 +229,24 @@ tower.workspace = true resolve-path.workspace = true itertools.workspace = true dojo-introspect.workspace = true +primitive-types.workspace = true + +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } torii-sql-sink.workspace = true torii-log-sink.workspace = true torii-erc20.workspace = true torii-erc721.workspace = true torii-erc1155.workspace = true -torii-dojo.workspace = true +torii-dojo = { workspace = true, features = ["postgres", "sqlite"] } torii-introspect.workspace = true torii-common.workspace = true -torii-introspect-postgres-sink.workspace = true torii-test-utils.workspace = true +torii-introspect-sql-sink = { workspace = true, features = [ + "postgres", + "sqlite", +] } torii-pathfinder.workspace = true - - # Example dependencies diff --git a/benches/perf_harness.rs b/benches/perf_harness.rs index e0ffbdf7..7d38713a 100644 --- a/benches/perf_harness.rs +++ b/benches/perf_harness.rs @@ -1,8 +1,10 @@ use async_trait::async_trait; -use axum::{body::Body, http::Request, Router}; +use axum::body::Body; +use axum::http::Request; +use axum::Router; use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; -use starknet::core::types::{EmittedEvent, Felt, U256}; -use starknet::macros::selector; +use primitive_types::U256; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -16,17 +18,18 @@ use torii::etl::engine_db::{EngineDb, EngineDbConfig}; use torii::etl::envelope::{Envelope, TypeId, TypedBody}; use torii::etl::extractor::{ExtractionBatch, RetryPolicy}; use torii::etl::sink::{EventBus, MultiSink, Sink, SinkContext, TopicInfo}; -use torii::etl::Decoder; -use torii::grpc::{proto::TopicSubscription, SubscriptionManager}; +use torii::etl::{Decoder, StarknetEvent}; +use torii::grpc::proto::TopicSubscription; +use torii::grpc::SubscriptionManager; use torii::http::create_http_router; use torii_common::{blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob}; use torii_erc1155::decoder::Erc1155Decoder; -use torii_erc20::{ - decoder::Erc20Decoder, - storage::{Erc20Storage, TransferData, TransferDirection}, -}; +use torii_erc20::decoder::Erc20Decoder; +use torii_erc20::storage::{Erc20Storage, TransferData, TransferDirection}; use torii_erc721::decoder::Erc721Decoder; +const TRANSFER_SELECTOR: Felt = Felt::selector("Transfer"); + #[derive(Debug)] struct BenchBody { value: u64, @@ -57,15 +60,20 @@ impl Decoder for MockDecoder { self.name } - async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result> { - if event.from_address != self.contract { + async fn decode( + &self, + _keys: &[Felt], + _data: &[Felt], + context: torii::etl::EventContext, + ) -> anyhow::Result> { + if context.from_address != self.contract { return Ok(Vec::new()); } Ok(vec![Envelope::new( - format!("{}:{}", self.name, event.block_number.unwrap_or_default()), + format!("{}:{}", self.name, context.block_number), Box::new(BenchBody { - value: event.block_number.unwrap_or_default(), + value: context.block_number, }), HashMap::new(), )]) @@ -131,43 +139,41 @@ fn make_engine_db(rt: &Runtime) -> EngineDb { }) } -fn make_erc20_transfer_event(seed: u64) -> EmittedEvent { - EmittedEvent { +fn make_erc20_transfer_event(seed: u64) -> StarknetEvent { + StarknetEvent { from_address: Felt::from(0x1000 + seed), keys: vec![ - selector!("Transfer"), + TRANSFER_SELECTOR, Felt::from(0x2000 + seed), Felt::from(0x3000 + seed), ], data: vec![Felt::from(10_000 + seed), Felt::ZERO], - block_hash: None, - block_number: Some(1_000_000 + seed), + block_number: 1_000_000 + seed, transaction_hash: Felt::from(0x4000 + seed), } } -fn make_erc721_transfer_event(seed: u64) -> EmittedEvent { - EmittedEvent { +fn make_erc721_transfer_event(seed: u64) -> StarknetEvent { + StarknetEvent { from_address: Felt::from(0x1100 + seed), keys: vec![ - selector!("Transfer"), + TRANSFER_SELECTOR, Felt::from(0x2200 + seed), Felt::from(0x3300 + seed), Felt::from(seed), Felt::ZERO, ], data: vec![], - block_hash: None, - block_number: Some(2_000_000 + seed), + block_number: 2_000_000 + seed, transaction_hash: Felt::from(0x4400 + seed), } } -fn make_erc1155_transfer_single_event(seed: u64) -> EmittedEvent { - EmittedEvent { +fn make_erc1155_transfer_single_event(seed: u64) -> StarknetEvent { + StarknetEvent { from_address: Felt::from(0x1200 + seed), keys: vec![ - selector!("TransferSingle"), + TRANSFER_SELECTOR, Felt::from(0x2300 + seed), Felt::from(0x3400 + seed), Felt::from(0x4500 + seed), @@ -178,23 +184,28 @@ fn make_erc1155_transfer_single_event(seed: u64) -> EmittedEvent { Felt::from(100 + seed), Felt::ZERO, ], - block_hash: None, - block_number: Some(3_000_000 + seed), + block_number: 3_000_000 + seed, transaction_hash: Felt::from(0x4600 + seed), } } -fn make_context_event(contract: Felt, seed: u64) -> EmittedEvent { - EmittedEvent { +fn make_context_event(contract: Felt, seed: u64) -> StarknetEvent { + StarknetEvent { from_address: contract, - keys: vec![selector!("Transfer")], + keys: vec![TRANSFER_SELECTOR], data: Vec::new(), - block_hash: None, - block_number: Some(10_000 + seed), + block_number: 10_000 + seed, transaction_hash: Felt::from(0x5000 + seed), } } +fn primitive_u256_from_words(low: u128, high: u128) -> U256 { + let mut bytes = [0_u8; 32]; + bytes[..16].copy_from_slice(&low.to_le_bytes()); + bytes[16..].copy_from_slice(&high.to_le_bytes()); + U256::from_little_endian(&bytes) +} + fn make_transfer_batch(size: usize, offset: u64) -> Vec { (0..size) .map(|i| { @@ -204,7 +215,7 @@ fn make_transfer_batch(size: usize, offset: u64) -> Vec { token: Felt::from(0x5000 + ((i + offset) % 4)), from: Felt::from(0x6000 + ((i + offset) % 1024)), to: Felt::from(0x7000 + ((i + offset + 7) % 1024)), - amount: U256::from_words((i + offset) as u128, 0), + amount: primitive_u256_from_words((i + offset) as u128, 0), block_number: 4_000_000 + i + offset, tx_hash: Felt::from(0x8000 + i + offset), timestamp: None, @@ -253,7 +264,7 @@ fn benchmark_common_conversions(c: &mut Criterion) { group.bench_function("felt_roundtrip", |b| { b.iter(|| { let blob = felt_to_blob(black_box(felt)); - black_box(blob_to_felt(black_box(&blob))) + black_box(blob_to_felt::(black_box(&blob))) }); }); @@ -261,15 +272,15 @@ fn benchmark_common_conversions(c: &mut Criterion) { group.bench_function("u256_roundtrip_small", |b| { b.iter(|| { let blob = u256_to_blob(black_box(small)); - black_box(blob_to_u256(black_box(&blob))) + black_box(blob_to_u256::(black_box(&blob))) }); }); - let large = U256::from_words(u128::MAX - 7, u128::MAX - 11); + let large = primitive_u256_from_words(u128::MAX - 7, u128::MAX - 11); group.bench_function("u256_roundtrip_large", |b| { b.iter(|| { let blob = u256_to_blob(black_box(large)); - black_box(blob_to_u256(black_box(&blob))) + black_box(blob_to_u256::(black_box(&blob))) }); }); @@ -325,7 +336,7 @@ fn benchmark_decoders(c: &mut Criterion) { b.to_async(&rt).iter(|| async { black_box( erc20_decoder - .decode(black_box(&batch_events)) + .decode_events(black_box(&batch_events)) .await .expect("erc20 batch decode failed"), ) @@ -435,7 +446,7 @@ fn benchmark_decoder_context(c: &mut Criterion) { b.to_async(&rt).iter(|| async { black_box( context_registry - .decode(black_box(&batch_events)) + .decode_events(black_box(&batch_events)) .await .expect("context batch decode failed"), ) diff --git a/bins/torii-arcade/Cargo.toml b/bins/torii-arcade/Cargo.toml index 0ec65787..4ed729d1 100644 --- a/bins/torii-arcade/Cargo.toml +++ b/bins/torii-arcade/Cargo.toml @@ -9,20 +9,22 @@ name = "torii-arcade" path = "src/main.rs" [dependencies] -torii = { path = "../../" } -torii-common = { path = "../../crates/torii-common" } +torii.workspace = true +torii-common.workspace = true torii-config-common.workspace = true -torii-arcade-sink = { path = "../../crates/arcade-sink" } -torii-dojo = { path = "../../crates/dojo" } -torii-ecs-sink = { path = "../../crates/torii-ecs-sink" } -torii-entities-historical-sink.workspace = true -torii-erc20 = { path = "../../crates/torii-erc20" } -torii-erc721 = { path = "../../crates/torii-erc721" } -torii-erc1155 = { path = "../../crates/torii-erc1155" } -torii-introspect-postgres-sink = { path = "../../crates/introspect-postgres-sink" } -torii-introspect-sqlite-sink = { path = "../../crates/introspect-sqlite-sink" } +torii-controllers-sink.workspace = true +torii-arcade-sink.workspace = true +torii-dojo.workspace = true +torii-ecs-sink.workspace = true +torii-erc20.workspace = true +torii-erc721.workspace = true +torii-erc1155.workspace = true +torii-introspect-sql-sink = { workspace = true, features = [ + "postgres", + "sqlite", +] } torii-runtime-common.workspace = true -torii-sqlite.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } torii-pathfinder = { workspace = true, features = ["etl"] } tokio = { version = "1", features = ["full"] } @@ -32,7 +34,7 @@ starknet = "0.17" url = "2.5" clap = { version = "4.5", features = ["derive", "env"] } anyhow = "1.0" -sqlx = { version = "0.8", features = [ +sqlx = { workspace = true, features = [ "postgres", "sqlite", "runtime-tokio-rustls", diff --git a/bins/torii-arcade/README.md b/bins/torii-arcade/README.md index e2474858..b02d5536 100644 --- a/bins/torii-arcade/README.md +++ b/bins/torii-arcade/README.md @@ -1,155 +1,122 @@ -# torii-arcade - -`torii-arcade` is a Cartridge Arcade metatorii backend that runs the following in one process: - -- Dojo introspect indexing for the Arcade world -- `world.World` ECS gRPC reads -- `arcade.v1.Arcade` low-latency marketplace and inventory reads -- ERC20 / ERC721 / ERC1155 token indexing and gRPC reads -- Core `torii.Torii` subscriptions and metrics endpoint - -## Defaults - -- RPC: `https://api.cartridge.gg/x/starknet/mainnet` -- Primary world: `0x2d26295d6c541d64740e1ae56abc079b82b22c35ab83985ef8bd15dc0f9edfb` -- Default indexing seed set: upstream `torii-arcade-metatorii.toml` worlds and token contracts -- Default Dojo introspect set: upstream `WORLD:*` contracts except the primary world contract - `0x2d26295d6c541d64740e1ae56abc079b82b22c35ab83985ef8bd15dc0f9edfb`, which is excluded from - introspect decoding by default because it currently emits incompatible historical Dojo record events -- Metadata mode: `inline` -- Well-known ERC20s: enabled by default -- Token URI + image cache: enabled by default in inline metadata mode, with images cached under - `./data/image-cache` -- DB directory: `./torii-data` - -SQLite local defaults: - -- engine: `./torii-data/arcade-engine.db` -- dojo / ecs: `./torii-data/arcade-introspect.db` -- erc20: `./torii-data/arcade-erc20.db` -- erc721: `./torii-data/arcade-erc721.db` -- erc1155: `./torii-data/arcade-erc1155.db` - -If `--database-url` or `--storage-database-url` is PostgreSQL and `--token-storage-database-url` is omitted, token storage defaults to the same PostgreSQL database. - -`torii-arcade` requires one backend per runtime. Mixed SQLite/PostgreSQL configurations are rejected at startup. - -## Start - -From the repository root: - -```bash -cargo run --bin torii-arcade -- --from-block 0 -``` - -Useful overrides: - -```bash -cargo run --bin torii-arcade -- \ - --from-block 0 \ - --port 3000 \ - --erc721 0x1e1c477f2ef896fd638b50caa31e3aa8f504d5c6cb3c09c99cd0b72523f07f7 \ - --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7,0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D -``` - -If you need to opt a world back into Dojo introspect explicitly for investigation: - -```bash -cargo run --bin torii-arcade -- \ - --from-block 0 \ - --introspect-contracts 0x2d26295d6c541d64740e1ae56abc079b82b22c35ab83985ef8bd15dc0f9edfb,0x8b4838140a3cbd36ebe64d4b5aaf56a30cc3753c928a79338bf56c53f506c5 -``` - -PostgreSQL: - -```bash -cargo run --bin torii-arcade -- \ - --database-url postgres://torii:torii@localhost:5432/torii \ - --storage-database-url postgres://torii:torii@localhost:5432/torii \ - --from-block 0 +# torii-arcade (binary) + +**Cartridge Arcade backend.** Combines Dojo introspect indexing, the +`world.World` ECS gRPC, the `arcade.v1.Arcade` projection gRPC, token +indexers (ERC20/721/1155), controller sync, and core `torii.Torii` +subscriptions into one process with sensible Arcade-specific defaults. + +## Role in Torii + +Production binary for the Cartridge Arcade UI. Pre-ships a curated list +of world contracts + token contracts in its CLI defaults, so operators +usually run it with `cargo run --bin torii-arcade` and override only the +backend URLs. Compared to `bins/torii-introspect-bin` it adds the +`arcade-sink` projection layer. + +## Architecture + +```text ++-----------------------------+ +| clap::Parser Config | defaults: Arcade world +| (src/config.rs) | 0x07a0…04ff7 + 9 more Dojo contracts, +| | 17 ERC721, 46 ERC20, 1 ERC1155 +| | --introspect-contracts override possible +| | --controllers / --observability +| | --pathfinder-path for bulk backfill ++-------------+---------------+ + | + v ++------------------------------------------------------+ +| main() | +| | +| Backend URL resolution (config.engine_database_url, | +| storage_database_url, token_storage_urls): | +| - single --database-url (postgres): shared pool | +| - --storage-database-url overrides just storage | +| - SQLite defaults under --db-dir: | +| arcade-engine.db, arcade-introspect.db, | +| arcade-erc20.db, arcade-erc721.db, | +| arcade-erc1155.db | +| | +| Extractor: EventExtractor (optionally backed by | +| torii-pathfinder for backfill when | +| --pathfinder-path is set) | +| | +| Decoders: | +| DojoDecoder, Erc20Decoder, Erc721Decoder, | +| Erc1155Decoder | +| | +| Sinks (ordered — arcade projections depend on | +| introspect tables already being up to date): | +| 1. IntrospectDb (introspect-sql-sink) | +| 2. EcsSink (legacy world.World) | +| 3. ArcadeSink (Arcade projections + | +| arcade.v1 gRPC) | +| 4. ControllersSink (--controllers) | +| 5. Erc20Sink / Erc721Sink / Erc1155Sink | +| | +| Contract identification: | +| explicit mappings for every contract in defaults | +| ContractRegistry + Erc*Rule (load_from_db on | +| startup) | +| | +| EtlConcurrencyConfig | +| max_prefetch_batches = --max-prefetch-batches | +| | +| grpc_router: WorldServer + ArcadeServer + | +| Erc20Server + Erc721Server + Erc1155Server + | +| composed reflection over five descriptor sets | +| | +| optional TLS | +| torii::run(config) | ++------------------------------------------------------+ ``` -## Local TLS + ALPN - -For browser-compatible local HTTPS, use `mkcert` instead of a raw self-signed certificate. -Modern browsers require a trusted local CA and `subjectAltName` entries for `localhost`. - -Install and trust the local development CA: - -```bash -brew install mkcert nss -mkcert -install -mkdir -p certs -mkcert -cert-file certs/dev-cert.pem -key-file certs/dev-key.pem localhost 127.0.0.1 ::1 -``` - -Start `torii-arcade` with TLS enabled: - -```bash -cargo run --bin torii-arcade -- \ - --from-block 0 \ - --tls-cert ./certs/dev-cert.pem \ - --tls-key ./certs/dev-key.pem -``` - -The local listener advertises ALPN for `h2` and `http/1.1`. Native gRPC clients will negotiate -HTTP/2 automatically; HTTPS health and metrics endpoints remain available on the same port. - -Verify the local HTTPS listener: - -```bash -curl https://localhost:3000/health -grpcurl -insecure localhost:3000 list -``` - -## Operational validation - -The mixed backend can be checked end-to-end from the repository root: - -```bash -./scripts/test-arcade-backend-e2e.sh -``` - -The script verifies: - -- gRPC reflection -- `world.World` -- `arcade.v1.Arcade` -- `torii.sinks.erc20.Erc20` -- `torii.sinks.erc721.Erc721` -- `Worlds`, `RetrieveEntities`, `RetrieveEventMessages`, `RetrieveEvents` -- `ListGames`, `ListEditions`, `ListCollections`, `ListListings`, `ListSales` -- token service stats for the mixed runtime - -## Exposed gRPC services - -- `torii.Torii` -- `world.World` -- `arcade.v1.Arcade` -- `torii.sinks.erc20.Erc20` when ERC20 contracts are configured -- `torii.sinks.erc721.Erc721` when ERC721 contracts are configured -- `torii.sinks.erc1155.Erc1155` when ERC1155 contracts are configured - -## Arcade service surface - -`arcade.v1.Arcade` is backed by projection tables maintained in the introspect storage database: - -- `ListGames` -- `ListEditions` -- `ListCollections` -- `ListListings` -- `ListSales` -- `GetPlayerInventory` - -The service bootstraps its projections from existing `ARCADE-*` introspect tables on startup and then incrementally refreshes them from live introspect envelopes, so it works both on a warm database and during active indexing. - -## Current scope - -This binary is the mixed indexing/runtime entrypoint. It intentionally keeps the extraction model explicit and event-based for throughput: - -- Dojo indexing defaults to the upstream `torii-arcade-metatorii.toml` `WORLD:*` contracts -- Dojo introspect decoding defaults to the compatible subset of those contracts; excluded contracts are blacklisted before decode so they do not trigger decoder fallback -- token indexing defaults to the upstream `torii-arcade-metatorii.toml` ERC20 / ERC721 / ERC1155 contracts -- `--include-well-known` is opt-in and disabled by default so the runtime matches the upstream seed config unless explicitly extended - -Dynamic token enrollment is not part of this first cut. +## Deep Dive + +### CLI highlights (most flags have Arcade-specific defaults) + +| Flag | Default | Purpose | +|---|---|---| +| `--rpc-url` (env `STARKNET_RPC_URL`) | Cartridge mainnet | RPC | +| `--world-address` (env `ARCADE_WORLD_ADDRESS`) | `0x07a079…04ff7` | Primary Arcade world | +| `--dojo-contracts` | 10 curated Arcade-related worlds | Event source Dojo contracts | +| `--introspect-contracts` | falls back to `--dojo-contracts` | Override which contracts feed introspect decoding | +| `--erc20` / `--erc721` / `--erc1155` | baked-in defaults (46 / 17 / 1) | Token contracts | +| `--include-well-known` | `true` | Pre-map ETH + STRK | +| `--from-block`, `--to-block` | `0` / none | Range | +| `--db-dir` | `./torii-data` | SQLite root; files prefixed with `arcade-` | +| `--database-url` (env `DATABASE_URL`) | — | Engine DB URL (Postgres or SQLite) | +| `--storage-database-url` (env `STORAGE_DATABASE_URL`) | — | Introspect storage URL | +| `--token-storage-database-url` (env `TOKEN_STORAGE_DATABASE_URL`) | — | Per-token URL override | +| `--port` | `3000` | gRPC + HTTP | +| `--tls-cert`, `--tls-key` | — | PEM pair | +| `--controllers`, `--controllers-api-url` | off / `https://api.cartridge.gg/query` | Opt-in Cartridge controller sync | +| `--observability` | off | Metrics | +| `--event-chunk-size`, `--event-block-batch-size` | `1000` / `10000` | `starknet_getEvents` tuning | +| `--max-prefetch-batches` | `2` | ETL prefetch queue depth | +| `--rpc-parallelism` | `0` (auto) | Chunked-RPC parallelism | +| `--max-db-connections` | auto | Pool ceiling | +| `--ignore-saved-state` | off | Force reprocessing | +| `--index-external-contracts` | `true` | React to Dojo `ExternalContractRegistered` events | +| `--allow-unsafe-latest-schema-bootstrap` | off | Dev-only schema bootstrap | +| `--metadata-mode` | `inline` | `inline` or `deferred` | +| `--historical` | `[]` | `ModelName` or `0xAddr:ModelName` for `_historical` tables | +| `--pathfinder-path` | — | Optional Pathfinder SQLite DB for bulk backfill | + +### Sink ordering + +Arcade relies on the introspect tables being populated before the +projection step runs, so the sink pipeline is ordered (see +`src/main.rs`). A failure in an earlier sink aborts the batch; the +cursor is not committed and the batch replays on the next cycle. + +### Workspace dependencies + +`torii`, `torii-dojo`, `torii-introspect`, `torii-introspect-sql-sink`, `torii-ecs-sink`, `torii-arcade-sink`, `torii-erc20`, `torii-erc721`, `torii-erc1155`, `torii-controllers-sink`, `torii-common`, `torii-runtime-common`, `torii-config-common`, `torii-sql`, `torii-pathfinder` (feature `etl`), `torii-types`, plus `clap`, `tonic`, `tonic-reflection`, `tokio`, `tracing`, `starknet`, `anyhow`, `url`. + +### Extension Points + +- Add a new Arcade projection → extend `ArcadeSink` / `ArcadeService`, not this binary. +- Swap to Pathfinder backfill → pass `--pathfinder-path`; the extractor is swapped to `PathfinderCombinedExtractor` so it streams archival data then flips to the live RPC extractor. +- Private Arcade namespace → point `--database-url` at a dedicated Postgres + keep storage URL at the same target; `resolve_token_db_setup` will keep everything on one backend. diff --git a/bins/torii-arcade/src/config.rs b/bins/torii-arcade/src/config.rs index 661d5b1b..bbc2e13a 100644 --- a/bins/torii-arcade/src/config.rs +++ b/bins/torii-arcade/src/config.rs @@ -2,6 +2,7 @@ use anyhow::{bail, Result}; use clap::{Parser, ValueEnum}; use starknet::core::types::Felt; use std::path::{Path, PathBuf}; +use torii_controllers_sink::DEFAULT_API_QUERY_URL as DEFAULT_CONTROLLERS_API_URL; const DEFAULT_RPC_URL: &str = "https://api.cartridge.gg/x/starknet/mainnet"; const DEFAULT_WORLD_ADDRESS: &str = @@ -147,6 +148,14 @@ pub struct Config { #[arg(long, env = "TORII_TLS_KEY")] pub tls_key: Option, + /// Cartridge-compatible GraphQL API used to fetch controller usernames. + #[arg(long, default_value = DEFAULT_CONTROLLERS_API_URL)] + pub controllers_api_url: String, + + /// Enable controller synchronization into the arcade storage database. + #[arg(long)] + pub controllers: bool, + #[arg(long)] pub observability: bool, @@ -471,6 +480,32 @@ mod tests { assert!(cfg.tls_config().is_err()); } + #[test] + fn controllers_sync_defaults_to_disabled() { + let cfg = Config::parse_from(["torii-arcade"]); + + assert_eq!(cfg.controllers_api_url, DEFAULT_CONTROLLERS_API_URL); + assert!(!cfg.controllers); + } + + #[test] + fn controllers_flag_enables_sync() { + let cfg = Config::parse_from(["torii-arcade", "--controllers"]); + + assert!(cfg.controllers); + } + + #[test] + fn controllers_api_url_overrides_default() { + let cfg = Config::parse_from([ + "torii-arcade", + "--controllers-api-url", + "https://example.com/query", + ]); + + assert_eq!(cfg.controllers_api_url, "https://example.com/query"); + } + #[test] fn postgres_storage_defaults_to_shared_postgres_for_tokens() { let cfg = Config::parse_from([ diff --git a/bins/torii-arcade/src/main.rs b/bins/torii-arcade/src/main.rs index f2896b91..6e920bea 100644 --- a/bins/torii-arcade/src/main.rs +++ b/bins/torii-arcade/src/main.rs @@ -3,11 +3,11 @@ mod config; use anyhow::{Error, Result}; use clap::Parser; use config::{Config, MetadataMode}; -use sqlx::postgres::PgPoolOptions; -use sqlx::sqlite::SqlitePoolOptions; +use sqlx::{sqlite::SqliteConnectOptions, Executor}; use starknet::core::types::Felt; use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; use tonic::codec::CompressionEncoding; @@ -18,23 +18,21 @@ use torii::etl::extractor::{ ContractEventConfig, EventExtractor, EventExtractorConfig, Extractor, RetryPolicy, }; use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; -use torii::etl::EngineDb; -use torii::etl::TypeId; +use torii::etl::{EngineDb, TypeId}; use torii::EtlConcurrencyConfig; use torii_arcade_sink::proto::arcade::arcade_server::ArcadeServer; use torii_arcade_sink::{ArcadeSink, FILE_DESCRIPTOR_SET as ARCADE_DESCRIPTOR_SET}; use torii_common::{MetadataFetcher, TokenUriService}; use torii_config_common::apply_observability_env; +use torii_controllers_sink::ControllersSink; use torii_dojo::decoder::DojoDecoder; use torii_dojo::external_contract::{ contract_type_from_decoder_ids, RegisterExternalContractCommandHandler, RegisteredContractType, SharedContractTypeRegistry, SharedDecoderRegistry, }; -use torii_dojo::store::postgres::PgStore; -use torii_dojo::store::sqlite::SqliteStore; +use torii_dojo::store::DojoStoreTrait; use torii_ecs_sink::proto::world::world_server::WorldServer; use torii_ecs_sink::{EcsSink, FILE_DESCRIPTOR_SET as ECS_DESCRIPTOR_SET}; -use torii_entities_historical_sink::EntitiesHistoricalSink; use torii_erc1155::proto::erc1155_server::Erc1155Server; use torii_erc1155::{ Erc1155Decoder, Erc1155MetadataCommandHandler, Erc1155Service, Erc1155Sink, Erc1155Storage, @@ -50,14 +48,11 @@ use torii_erc721::{ Erc721Decoder, Erc721MetadataCommandHandler, Erc721Service, Erc721Sink, Erc721Storage, FILE_DESCRIPTOR_SET as ERC721_DESCRIPTOR_SET, }; -use torii_introspect_postgres_sink::processor::IntrospectPgDb; -use torii_introspect_sqlite_sink::processor::IntrospectSqliteDb; +use torii_introspect_sql_sink::{IntrospectDb, NamespaceMode}; use torii_pathfinder::extractor::PathfinderCombinedExtractor; -use torii_runtime_common::database::{ - validate_uniform_backends, DatabaseBackend, DEFAULT_SQLITE_MAX_CONNECTIONS, -}; +use torii_runtime_common::database::{validate_uniform_backends, DEFAULT_SQLITE_MAX_CONNECTIONS}; use torii_runtime_common::token_support::{resolve_installed_token_support, InstalledTokenSupport}; -use torii_sqlite::{is_sqlite_memory_path, sqlite_connect_options}; +use torii_sql::{DbConnectionOptions, DbPool, DbPoolOptions, SqlitePool}; type StarknetProvider = starknet::providers::jsonrpc::JsonRpcClient; @@ -99,9 +94,9 @@ async fn load_persisted_contract_registries( let mut contract_types = contract_type_registry.write().await; for (contract, decoder_ids, _) in mappings { - decoders.insert(contract, decoder_ids.clone()); + decoders.insert(contract.into(), decoder_ids.clone()); if let Some(contract_type) = contract_type_from_decoder_ids(&decoder_ids) { - contract_types.insert(contract, contract_type); + contract_types.insert(contract.into(), contract_type); } } @@ -225,6 +220,37 @@ fn advertised_token_services(installed_token_support: InstalledTokenSupport) -> services } +fn database_connection_options(database_url: &str) -> Result { + if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { + return DbConnectionOptions::from_str(database_url).map_err(anyhow::Error::msg); + } + + if database_url == ":memory:" || database_url == "sqlite::memory:" { + return Ok(DbConnectionOptions::Sqlite(SqliteConnectOptions::from_str( + "sqlite::memory:", + )?)); + } + + let options = if database_url.starts_with("sqlite:") { + SqliteConnectOptions::from_str(database_url)? + } else { + SqliteConnectOptions::new().filename(database_url) + }; + + if database_url.starts_with("sqlite:") && database_url.contains("mode=") { + Ok(DbConnectionOptions::Sqlite(options)) + } else { + Ok(DbConnectionOptions::Sqlite(options.create_if_missing(true))) + } +} + +async fn configure_sqlite_pool(pool: &SqlitePool) -> Result<()> { + pool.execute("PRAGMA journal_mode=WAL").await?; + pool.execute("PRAGMA synchronous=NORMAL").await?; + pool.execute("PRAGMA foreign_keys=ON").await?; + Ok(()) +} + #[tokio::main] async fn main() -> Result<()> { let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() @@ -257,7 +283,7 @@ async fn run_indexer(config: Config) -> Result<()> { ("erc1155", &erc1155_db_url), ], "torii-arcade does not support mixed storage backends in one runtime; configure all databases as either SQLite or PostgreSQL", - )?; + ).map_err(|err| anyhow::anyhow!(err))?; let provider = starknet::providers::jsonrpc::JsonRpcClient::new( starknet::providers::jsonrpc::HttpTransport::new( @@ -332,6 +358,17 @@ async fn run_indexer(config: Config) -> Result<()> { "disabled" } ); + tracing::info!( + "Controllers sync: {}", + if config.controllers { + "enabled" + } else { + "disabled" + } + ); + if config.controllers { + tracing::info!("Controllers API URL: {}", config.controllers_api_url); + } tracing::info!( "External contract indexing: {}", if config.index_external_contracts { @@ -394,64 +431,26 @@ async fn run_indexer(config: Config) -> Result<()> { &erc1155_addresses, &config, )?; - - let (dojo_decoder, introspect_sink): ( - Arc, - Box, - ) = match backend { - DatabaseBackend::Postgres => { - let max_db_connections = config.max_db_connections.unwrap_or(5); - let pool = Arc::new( - PgPoolOptions::new() - .max_connections(max_db_connections) - .connect(&storage_database_url) - .await?, - ); - - let decoder = DojoDecoder::, _>::new(pool.clone(), (*provider).clone()); - let sink = IntrospectPgDb::new(pool.clone(), ()); - decoder.store.initialize().await?; - decoder.load_tables(&[]).await?; - - ( - Arc::new(decoder) as Arc, - Box::new(sink), - ) - } - DatabaseBackend::Sqlite => { - let options = sqlite_connect_options(&storage_database_url)?; - let max_db_connections = match config.max_db_connections { - Some(limit) => limit.max(1), - None if is_sqlite_memory_path(&storage_database_url) => 1, - None => DEFAULT_SQLITE_MAX_CONNECTIONS, - }; - let pool = Arc::new( - SqlitePoolOptions::new() - .max_connections(max_db_connections) - .connect_with(options) - .await?, - ); - - sqlx::query("PRAGMA journal_mode=WAL") - .execute(pool.as_ref()) - .await?; - sqlx::query("PRAGMA synchronous=NORMAL") - .execute(pool.as_ref()) - .await?; - sqlx::query("PRAGMA foreign_keys=ON") - .execute(pool.as_ref()) - .await?; - - let decoder = DojoDecoder::, _>::new(pool.clone(), (*provider).clone()); - decoder.store.initialize().await?; - decoder.load_tables(&[]).await?; - - ( - Arc::new(decoder) as Arc, - Box::new(IntrospectSqliteDb::new(pool.clone(), ())), - ) - } + let conn_options = database_connection_options(&engine_database_url)?; + let max_connections = match config.max_db_connections { + Some(n) => n, + None => match &conn_options { + DbConnectionOptions::Postgres(_) => 10, + DbConnectionOptions::Sqlite(ops) if ops.is_in_memory() => 1, + DbConnectionOptions::Sqlite(_) => DEFAULT_SQLITE_MAX_CONNECTIONS, + }, }; + let pool_options = DbPoolOptions::new().max_connections(max_connections); + let pool = pool_options.connect_any_with(conn_options).await?; + if let DbPool::Sqlite(pool) = &pool { + configure_sqlite_pool(pool).await?; + } + let dojo_decoder = DojoDecoder::new(pool.clone(), provider.clone()); + let introspect_sink = IntrospectDb::new(pool, NamespaceMode::Address); + + dojo_decoder.initialize().await?; + dojo_decoder.load_tables(&[]).await?; + introspect_sink.initialize_introspect_sql_sink().await?; let ecs_sink = EcsSink::new( &storage_database_url, @@ -479,20 +478,8 @@ async fn run_indexer(config: Config) -> Result<()> { .register_encoded_file_descriptor_set(ECS_DESCRIPTOR_SET) .register_encoded_file_descriptor_set(ARCADE_DESCRIPTOR_SET); - let historical_sink = Box::new( - EntitiesHistoricalSink::new( - &storage_database_url, - config.max_db_connections, - (), - historical_models, - ) - .await?, - ); - let arcade_projection_pipeline = ArcadeProjectionPipeline::new(vec![ - introspect_sink, - historical_sink, - Box::new(arcade_sink), - ]); + let arcade_projection_pipeline = + ArcadeProjectionPipeline::new(vec![Box::new(introspect_sink), Box::new(arcade_sink)]); let mut torii_config = torii::ToriiConfig::builder() .port(config.port) @@ -504,7 +491,7 @@ async fn run_indexer(config: Config) -> Result<()> { }) .engine_database_url(engine_database_url) .with_extractor(extractor) - .add_decoder(dojo_decoder) + .add_decoder(Arc::new(dojo_decoder)) .add_sink_boxed(Box::new(ecs_sink)) .add_sink_boxed(Box::new(arcade_projection_pipeline)); @@ -514,7 +501,14 @@ async fn run_indexer(config: Config) -> Result<()> { if config.index_external_contracts { torii_config = torii_config - .with_registry_cache(decoder_registry.clone()) + .with_registry_cache(Arc::new(RwLock::new( + decoder_registry + .read() + .await + .iter() + .map(|(contract, decoder_ids)| ((*contract).into(), decoder_ids.clone())) + .collect(), + ))) .with_command_handler(Box::new(RegisterExternalContractCommandHandler::new( registry_engine_db.clone(), decoder_registry.clone(), @@ -522,13 +516,30 @@ async fn run_indexer(config: Config) -> Result<()> { ))); } + if config.controllers { + torii_config = torii_config.add_sink_boxed(Box::new( + ControllersSink::new( + &storage_database_url, + config.max_db_connections, + Some(config.controllers_api_url.clone()), + ) + .await?, + )); + } + if !excluded_dojo_contracts.is_empty() { - torii_config = torii_config.blacklist_contracts(excluded_dojo_contracts.clone()); + torii_config = torii_config.blacklist_contracts( + excluded_dojo_contracts + .iter() + .copied() + .map(Into::into) + .collect(), + ); } let dojo_decoder_id = DecoderId::new("dojo-introspect"); for contract in &introspect_contracts { - torii_config = torii_config.map_contract(*contract, vec![dojo_decoder_id]); + torii_config = torii_config.map_contract((*contract).into(), vec![dojo_decoder_id]); } for contract in &excluded_dojo_contracts { @@ -545,7 +556,8 @@ async fn run_indexer(config: Config) -> Result<()> { let mut token_uri_services = Vec::new(); if install_erc20 { - let storage = Arc::new(Erc20Storage::new(&erc20_db_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&erc20_db_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&erc20_db_url, erc20_pool).await?); let grpc_service = Erc20Service::new(storage.clone()); let sink = Box::new( Erc20Sink::new(storage.clone()) @@ -569,13 +581,14 @@ async fn run_indexer(config: Config) -> Result<()> { reflection_builder.register_encoded_file_descriptor_set(ERC20_DESCRIPTOR_SET); let decoder_id = DecoderId::new("erc20"); for address in &erc20_addresses { - torii_config = torii_config.map_contract(*address, vec![decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![decoder_id]); } erc20_grpc_service = Some(grpc_service); } if install_erc721 { - let storage = Arc::new(Erc721Storage::new(&erc721_db_url).await?); + let erc721_pool = Erc721Storage::connect_pool(&erc721_db_url).await?; + let storage = Arc::new(Erc721Storage::from_pool(&erc721_db_url, erc721_pool).await?); let grpc_service = Erc721Service::new(storage.clone()); let mut sink = Erc721Sink::new(storage.clone()).with_grpc_service(grpc_service.clone()); if config.metadata_mode == MetadataMode::Inline { @@ -604,13 +617,16 @@ async fn run_indexer(config: Config) -> Result<()> { reflection_builder.register_encoded_file_descriptor_set(ERC721_DESCRIPTOR_SET); let decoder_id = DecoderId::new("erc721"); for address in &erc721_addresses { - torii_config = torii_config.map_contract(*address, vec![decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![decoder_id]); } erc721_grpc_service = Some(grpc_service); } if install_erc1155 { - let storage = Arc::new(Erc1155Storage::new(&erc1155_db_url).await?); + let erc1155_pool = DbPoolOptions::new() + .connect_any_with(database_connection_options(&erc1155_db_url)?) + .await?; + let storage = Arc::new(Erc1155Storage::new(erc1155_pool, &erc1155_db_url).await?); let grpc_service = Erc1155Service::new(storage.clone()); let mut sink = Erc1155Sink::new(storage.clone()) .with_grpc_service(grpc_service.clone()) @@ -640,7 +656,7 @@ async fn run_indexer(config: Config) -> Result<()> { reflection_builder.register_encoded_file_descriptor_set(ERC1155_DESCRIPTOR_SET); let decoder_id = DecoderId::new("erc1155"); for address in &erc1155_addresses { - torii_config = torii_config.map_contract(*address, vec![decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![decoder_id]); } erc1155_grpc_service = Some(grpc_service); } @@ -817,7 +833,7 @@ fn append_unique_contract_configs( for &address in addresses { if seen.insert(address) { configs.push(ContractEventConfig { - address, + address: address.into(), from_block, to_block, }); @@ -827,8 +843,10 @@ fn append_unique_contract_configs( #[cfg(test)] mod tests { - use super::advertised_token_services; + use super::{advertised_token_services, configure_sqlite_pool, database_connection_options}; + use std::path::Path; use torii_runtime_common::token_support::InstalledTokenSupport; + use torii_sql::{DbConnectionOptions, SqlitePool}; #[test] fn advertised_token_services_include_installed_services_without_explicit_targets() { @@ -858,4 +876,42 @@ mod tests { assert_eq!(services, vec!["torii.sinks.erc721.Erc721"]); } + + #[test] + fn database_connection_options_accepts_plain_sqlite_path() { + let options = database_connection_options("./torii-data/arcade-engine.db").unwrap(); + + let DbConnectionOptions::Sqlite(options) = options else { + panic!("expected sqlite connection options"); + }; + assert_eq!( + options.get_filename(), + Path::new("./torii-data/arcade-engine.db") + ); + } + + #[test] + fn database_connection_options_accepts_in_memory_sqlite() { + let options = database_connection_options(":memory:").unwrap(); + + let DbConnectionOptions::Sqlite(options) = options else { + panic!("expected sqlite connection options"); + }; + assert!(options.is_in_memory()); + } + + #[test] + fn database_connection_options_preserves_postgres_urls() { + let options = + database_connection_options("postgres://torii:torii@localhost:5432/torii").unwrap(); + + assert!(matches!(options, DbConnectionOptions::Postgres(_))); + } + + #[tokio::test] + async fn configure_sqlite_pool_applies_pragmas_outside_transaction() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + + configure_sqlite_pool(&pool).await.unwrap(); + } } diff --git a/bins/torii-erc20-synth/Cargo.toml b/bins/torii-erc20-synth/Cargo.toml index 662f622b..a7f8c85e 100644 --- a/bins/torii-erc20-synth/Cargo.toml +++ b/bins/torii-erc20-synth/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" torii = { path = "../../" } torii-erc20 = { path = "../../crates/torii-erc20" } torii-runtime-common.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } # Async runtime tokio = { version = "1", features = ["full"] } diff --git a/bins/torii-erc20-synth/README.md b/bins/torii-erc20-synth/README.md index f3806145..ab89c9f0 100644 --- a/bins/torii-erc20-synth/README.md +++ b/bins/torii-erc20-synth/README.md @@ -1,34 +1,104 @@ -# torii-erc20-synth +# torii-erc20-synth (binary) -Deterministic end-to-end ERC20 ingestion workload for profiling extractor -> decode -> sink -> DB write. +**Deterministic end-to-end ERC20 ingestion profiler.** Drives the ERC20 +pipeline with a hand-rolled `SyntheticErc20Extractor` so the extract step +runs at local-memory speed and every other stage — decode, sink, cursor +commit — is measured cleanly. Always Postgres: the profiler assumes a +single uniform backend so results are comparable between runs. -## Quick start (local Postgres) +## Role in Torii + +Performance regression harness for `crates/torii-erc20`. The live binary +(`bins/torii-erc20`) has the full auto-identification + RPC path; the +synth variant replaces both with a deterministic generator so the numbers +reflect the DB path, not the RPC. Output is a machine-readable JSON +report plus (with `--features profiling`) a flamegraph, both under +`perf/runs//`. + +## Architecture + +```text ++--------------------------+ +| clap::Parser Config | --db-url postgres://… +| (block_count, tx_per_ | --from-block, --block-count +| block, approval_ratio, | --tx-per-block, --blocks-per-batch +| token_count, …) | --approval-ratio-bps, --token-count ++------------+-------------+ --wallet-count, --seed + | --reset-schema (drops erc20 schema) + v ++--------------------------+ +| EngineDb | postgres: DROP SCHEMA CASCADE for engine + +| + Erc20Storage | erc20 (when --reset-schema), then re-init +| + Erc20Sink | +| + SyntheticErc20Metadata| names "Synthetic ERC20 AB12CD" etc. +| CommandHandler | → CommandBus (parallelism=1, retries=1) ++------------+-------------+ + | + v ++--------------------------+ +| SyntheticErc20Extractor | deterministic RNG from --seed +| (crates/torii-erc20: | emits ExtractionBatch{events, blocks, tx} +| src/synthetic.rs) | ++------------+-------------+ + | + v + Tight loop (no spawn): extractor.extract(...) → + DecoderContext.decode_events → sink.process → + extractor.commit_cursor → record StageSample + (extract_ms, decode_ms, sink_ms, commit_ms, loop_total_ms) + | + v ++--------------------------+ +| RunReport (JSON) | stage p50/p95/p99/max, slow cycles, +| perf/runs// | blocking-suspect heuristic, DB summary +| | Also emits flamegraph if `profiling` feat ++--------------------------+ +``` + +## Deep Dive + +### CLI + +| Flag | Default | Purpose | +|---|---|---| +| `--db-url` (env `DATABASE_URL`) | `postgres://torii:torii@localhost:5432/torii` | **Postgres only** — required for comparability | +| `--from-block` | `1_000_000` | Synthetic starting block | +| `--block-count` | `200` | Total blocks generated in this run | +| `--tx-per-block` | `1_000` | Transactions (≈ events) per block | +| `--blocks-per-batch` | `1` | Blocks in each `extract()` batch | +| `--approval-ratio-bps` | `2_000` (=20%) | Fraction of events that are approvals vs transfers | +| `--token-count` | `16` | Distinct token contracts in the workload | +| `--wallet-count` | `20_000` | Unique wallet addresses | +| `--seed` | `42` | RNG seed — same seed → same events | +| `--output-root` | `perf/runs` | Per-run output dir | +| `--reset-schema` | `true` | `DROP SCHEMA IF EXISTS engine CASCADE` + `erc20 CASCADE` before the run | + +### Quick start ```bash docker compose up -d postgres cargo run -p torii-erc20-synth --release ``` -Artifacts are written to `perf/runs//`: +Artifacts land in `perf/runs//`: -- `report.json` -- `report.md` -- `context.env` -- `flamegraph.svg` (when built with `--features profiling`) +- `report.json` — canonical stage / throughput / slow-cycle report +- `report.md` — human-readable rendering +- `context.env` — the exact env + CLI used +- `flamegraph.svg` — only when built with `--features profiling` -## Useful flags +### Internal shape -```bash -cargo run -p torii-erc20-synth --release -- \ - --block-count 200 \ - --tx-per-block 1000 \ - --approval-ratio-bps 2000 \ - --blocks-per-batch 1 \ - --db-url postgres://torii:torii@localhost:5432/torii -``` +- Uses `torii_runtime_common::sink::initialize_sink_with_command_handlers` + `drop_postgres_schemas` (this binary is the reason the schema-drop helper exists). +- `SyntheticErc20MetadataCommandHandler` generates names like `Synthetic ERC20 AB12CD` / `SAB12CD` / `decimals = 18` directly into storage, bypassing metadata fetching so measurements aren't polluted by network I/O. +- Records a `StageSample` per loop iteration; writes a `RunReport` JSON + (opt-in) flamegraph at exit. -Disable schema reset for append-mode experiments: +### Workspace dependencies -```bash -cargo run -p torii-erc20-synth -- --reset-schema false -``` +`torii`, `torii-erc20`, `torii-runtime-common`, plus `clap`, `tokio`, `tracing`, `serde`, `serde_json`, `anyhow`. Feature `profiling` pulls `pprof`. + +### Extension Points + +- New workload shape → extend `SyntheticErc20Config` in `crates/torii-erc20/src/synthetic.rs` and add CLI flags here. +- New report field → add it to `RunReport` in `main.rs`; the JSON is the spec. +- Switch backend → not supported; use `bins/torii-erc20` for SQLite. diff --git a/bins/torii-erc20-synth/src/main.rs b/bins/torii-erc20-synth/src/main.rs index fbd4eb2c..71c6e6f4 100644 --- a/bins/torii-erc20-synth/src/main.rs +++ b/bins/torii-erc20-synth/src/main.rs @@ -154,7 +154,6 @@ struct ConfigSnapshot { seed: u64, db_url_redacted: String, reset_schema: bool, - pg_pool_size_env: Option, } #[derive(Debug, Serialize)] @@ -246,7 +245,8 @@ async fn main() -> Result<()> { fs::create_dir_all(&output_dir) .with_context(|| format!("failed to create output dir {}", output_dir.display()))?; - let storage = Arc::new(Erc20Storage::new(&cfg.db_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&cfg.db_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&cfg.db_url, erc20_pool).await?); let mut sink = Erc20Sink::new(storage.clone()).with_metadata_pipeline( SYNTH_METADATA_COMMAND_PARALLELISM, @@ -304,7 +304,7 @@ async fn main() -> Result<()> { } let decode_start = Instant::now(); - let envelopes = decoder_context.decode(&batch.events).await?; + let envelopes = decoder_context.decode_events(&batch.events).await?; let decode_ms = ms(decode_start.elapsed()); let sink_start = Instant::now(); @@ -486,7 +486,6 @@ async fn build_report( seed: cfg.seed, db_url_redacted: redact_db_url(&cfg.db_url), reset_schema: cfg.reset_schema, - pg_pool_size_env: std::env::var("TORII_ERC20_PG_POOL_SIZE").ok(), }, totals: Totals { cycles: samples.len(), diff --git a/bins/torii-erc20/Cargo.toml b/bins/torii-erc20/Cargo.toml index 9dd48af1..261cd729 100644 --- a/bins/torii-erc20/Cargo.toml +++ b/bins/torii-erc20/Cargo.toml @@ -9,10 +9,12 @@ path = "src/main.rs" [dependencies] # Core dependencies -torii = { path = "../../" } -torii-erc20 = { path = "../../crates/torii-erc20" } +torii.workspace = true +torii-erc20.workspace = true torii-runtime-common.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } + # Async runtime tokio = { version = "1", features = ["full"] } diff --git a/bins/torii-erc20/README.md b/bins/torii-erc20/README.md index cb0f5bb3..ac276cbd 100644 --- a/bins/torii-erc20/README.md +++ b/bins/torii-erc20/README.md @@ -1,265 +1,88 @@ -# Torii ERC20 - Starknet ERC20 Token Indexer +# torii-erc20 (binary) -A production-ready indexer for ERC20 tokens on Starknet. +Stand-alone **ERC20 indexer**. The simplest canonical wiring of the Torii +stack: one decoder, one sink, one gRPC service, one extractor. Start here +if you are learning how binaries are assembled. -## Features +## Role in Torii -- ✅ **Explicit Contract Mapping**: Pre-configure known tokens (ETH, STRK, custom) -- ✅ **Auto-Discovery**: Automatically detect and index new ERC20 contracts -- ✅ **Strict Mode**: Disable auto-discovery for controlled production deployments -- ✅ **Real-time Balance Tracking**: Maintain up-to-date balances for all addresses -- ✅ **Transfer History**: Complete audit trail of all ERC20 transfers -- ✅ **SQLite Storage**: Efficient local database with full history -- ✅ **gRPC Subscriptions**: Real-time updates via Torii's event bus - -## Installation - -```bash -# From workspace root -cargo build --release --bin torii-erc20 - -# Binary will be at: -# target/release/torii-erc20 -``` - -## Usage - -### Basic Usage (Auto-Discovery Enabled) - -```bash -# Start indexing from block 100,000 -torii-erc20 --from-block 100000 -``` - -This will: -- Index ETH and STRK tokens (explicit mappings) -- Auto-discover other ERC20 contracts via ABI heuristics -- Store data in `./erc20-data.db` -- Start gRPC server on port 3000 - -### Strict Mode (Production) - -```bash -# Only index explicitly configured contracts -torii-erc20 --no-auto-discovery -``` - -This mode: -- **Disables** auto-discovery -- Only indexes ETH and STRK -- Useful for production where you want strict control - -### Custom Contracts - -```bash -# Index specific contracts only -torii-erc20 \ - --no-auto-discovery \ - --contracts 0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7,0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d -``` - -### Network Selection - -```bash -# Mainnet -torii-erc20 --rpc-url https://api.cartridge.gg/x/starknet/mainnet - -# Sepolia (default) -torii-erc20 --rpc-url https://api.cartridge.gg/x/starknet/sepolia -``` - -### All Options - -```bash -torii-erc20 \ - --rpc-url \ - --from-block \ - --to-block \ - --db-path ./my-data.db \ - --database-url postgres://torii:torii@localhost:5432/torii \ - --port 3000 \ - --no-auto-discovery \ - --contracts , -``` - -`--database-url` (or `DATABASE_URL`) enables PostgreSQL-backed storage for both engine state and ERC20 data. If unset, local SQLite files are used. - -Runtime bootstrap in this binary is shared through `torii-runtime-common` (`resolve_single_db_setup`) so the database wiring matches other Torii binaries. +Replaces a per-contract subgraph for ERC20 tokens. Runs a +`BlockRangeExtractor` over the configured RPC, feeds events through +`Erc20Decoder` / `Erc20Sink`, serves `torii.sinks.erc20.Erc20` and core +`torii.Torii` gRPC + HTTP on the same port. Uses the same `torii::run` +orchestrator as every other binary. ## Architecture -``` -┌─────────────────────────────────────────────────────────────┐ -│ Torii ERC20 Binary │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ ┌────────────────────────────────┐ │ -│ │ BlockRange │─────▶│ ContractRegistry │ │ -│ │ Extractor │ │ - ETH (explicit) │ │ -│ └──────────────┘ │ - STRK (explicit) │ │ -│ │ │ - ERC20Rule (auto-discovery) │ │ -│ │ └────────────────────────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────┐ ┌────────────────────────────────┐ │ -│ │ Events │─────▶│ MultiDecoder + Registry │ │ -│ │ │ │ (lazy identification) │ │ -│ └──────────────┘ └────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────┐ │ -│ │ ERC20 Decoder │ │ -│ │ (Transfer events) │ │ -│ └────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────┐ │ -│ │ ERC20 Sink │ │ -│ │ - Store transfers │ │ -│ │ - Update balances │ │ -│ └────────────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌────────────────────────────────┐ │ -│ │ SQLite Storage │ │ -│ │ - transfers table │ │ -│ │ - balances table │ │ -│ └────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +```text ++------------------------+ +--------------------------+ +| clap::Parser Config | -----> | resolve_single_db_setup | +| (src/config.rs) | | (runtime-common) | ++------------------------+ +-------------+------------+ + | + v ++-----------------------------------------------------------+ +| main() — build ToriiConfig | +| | +| - Erc20Storage::connect_pool(storage_url) | +| - JsonRpcClient over --rpc-url | +| - BlockRangeExtractor (from_block → to_block, batch=50) | +| - Erc20Decoder + Erc20Sink + Erc20Service | +| - Erc20MetadataCommandHandler | +| parallelism=1, queue=4096, max_retries=3 | +| - auto-discovery (unless --no-auto-discovery): | +| ContractRegistry::new(provider, engine_db) | +| .with_rule(Erc20Rule) → registry.load_from_db() | +| - explicit mappings for well-known ETH / STRK / SURVIVOR | +| - grpc_router = Server::builder() | +| .add_service(Erc20Server::new(service)) | +| + reflection (TORII_DESCRIPTOR_SET + | +| erc20 FILE_DESCRIPTOR_SET) | +| | +| torii::run(config).await | ++-----------------------------------------------------------+ ``` -## Database Schema +## Deep Dive -### Transfers Table +### CLI (source: `src/config.rs`) -```sql -CREATE TABLE transfers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - token TEXT NOT NULL, - from_addr TEXT NOT NULL, - to_addr TEXT NOT NULL, - amount TEXT NOT NULL, - block_number INTEGER NOT NULL, - tx_hash TEXT NOT NULL, - timestamp INTEGER DEFAULT (strftime('%s', 'now')), - UNIQUE(token, tx_hash, from_addr, to_addr) -); -``` +| Flag | Env | Default | Purpose | +|---|---|---|---| +| `--rpc-url` | `STARKNET_RPC_URL` | Cartridge mainnet | Starknet JSON-RPC endpoint | +| `--from-block` | — | `0` | Backfill starting block | +| `--to-block` | — | none (follow head) | Stop block for finite runs | +| `--db-path` | — | `./erc20-data.db` | SQLite storage; engine.db derives next to it | +| `--database-url` | `DATABASE_URL` | — | Postgres URL / SQLite URL override | +| `--no-auto-discovery` | — | off | Strict mode: only explicitly mapped contracts | +| `--contracts` | — | `[]` | Comma-separated hex addresses | +| `--port` | — | `3000` | TCP port for gRPC + HTTP | +| `--cycle-interval` | — | `3` | Seconds between extractor idle retries | -### Balances Table +Pre-mapped contracts (added unless overridden): +ETH (`0x049D36…004dC7`), STRK (`0x04718f…c938D`), SURVIVOR +(`0x042DD7…2Ec86B`). See `Config::well_known_contracts` in `src/config.rs`. -```sql -CREATE TABLE balances ( - token TEXT NOT NULL, - address TEXT NOT NULL, - balance TEXT NOT NULL, - updated_at INTEGER DEFAULT (strftime('%s', 'now')), - PRIMARY KEY (token, address) -); -``` +### Internal modules -## Contract Identification +- `config.rs` — `Config` struct + the well-known contract list. +- `main.rs` — canonical build order: logging → config → storage → provider → decoder → rule → (optional) `ContractRegistry.load_from_db` → explicit mappings → sink → metadata command handler → gRPC router → `torii::run`. -### Explicit Mapping (Highest Priority) - -Well-known contracts are explicitly mapped: -- **Mainnet ETH**: `0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7` -- **Mainnet STRK**: `0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d` -- **Sepolia ETH**: Same as mainnet -- **Sepolia STRK**: Same as mainnet - -### Auto-Discovery (When Enabled) - -The ERC20Rule automatically identifies contracts with: -- `transfer()`, `balance_of()`, `total_supply()` functions -- `Transfer` event signature - -### Performance - -**Lazy Identification:** -- Contracts identified on first event (network call) -- Subsequent events from same contract use cached result -- No pre-iteration of events needed -- Lock-per-event approach (see performance notes in code for optimization path) - -## Example Queries - -```bash -# Connect to database -sqlite3 erc20-data.db - -# Get total transfers -SELECT COUNT(*) FROM transfers; - -# Get transfers for ETH -SELECT * FROM transfers -WHERE token = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7' -LIMIT 10; - -# Get top 10 addresses by balance for a token -SELECT address, balance -FROM balances -WHERE token = '0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7' -ORDER BY CAST(balance AS INTEGER) DESC -LIMIT 10; - -# Get unique tokens indexed -SELECT COUNT(DISTINCT token) FROM transfers; -``` - -## Development - -### Running Tests - -```bash -cargo test --bin torii-erc20 -``` - -### Adding Custom Tokens - -Edit `src/config.rs` to add more well-known contracts: - -```rust -pub fn well_known_contracts(&self) -> Vec<(Felt, &'static str)> { - vec![ - (Felt::from_hex_unchecked("0x..."), "MY_TOKEN"), - // ... - ] -} -``` - -## Performance Considerations - -- **Lock overhead**: Currently locks mutex per event. See code comments for optimization path if this becomes a bottleneck. -- **Balance calculation**: Current implementation uses simple string storage. Production should use proper u256 arithmetic. -- **Database**: SQLite performs well for moderate loads. For high-throughput production, consider PostgreSQL. - -## Performance Profiling - -The binary includes built-in timing instrumentation that measures each phase of the ETL loop: -- Extract time (RPC calls) -- Decode time (includes contract identification) -- Sink processing time (database writes) -- Total loop time - -Example output: -``` -📦 Batch #1: Extracted 1234 events from 10 blocks (extract_time: 450.23ms) - ✓ Decoded into 156 envelopes (decode_time: 89.45ms) - ✓ Processed through sink (sink_time: 23.12ms) | Total loop: 562.80ms -``` +### Workspace dependencies -For detailed CPU profiling with flamegraphs, see **[PROFILING.md](PROFILING.md)**. +`torii`, `torii-erc20`, `torii-runtime-common`, `torii-sql` (features: +`postgres`, `sqlite`), `starknet`, `tonic`, `tonic-reflection`, `clap`, +`tokio`, `tracing`, `url`, `anyhow`. -## Future Improvements +Opt-in feature: `profiling` — enables `pprof`; writes a flamegraph on +shutdown (see `PROFILING.md` in this directory for details). -- [ ] Proper u256 balance arithmetic -- [ ] Token metadata (name, symbol, decimals) -- [ ] HTTP API for querying balances -- [ ] WebSocket subscriptions for real-time updates -- [ ] PostgreSQL support -- [ ] Dashboard UI +### Extension Points -## License +- **Historical only**: `--from-block N --to-block M`; extractor sets `is_finished() = true` and the ETL loop exits cleanly. +- **Live only**: start near the chain head without `--to-block`. +- **Custom tokens**: add to `Config::well_known_contracts` (pre-map) or pass `--contracts 0xaaa,0xbbb`. Combine with `--no-auto-discovery` to pin indexing. +- **Extra auto-identification**: register another `IdentificationRule` on the `ContractRegistry` before `torii::run`. +- **Skip metadata fetching**: drop the `with_metadata_pipeline` call on the sink; only the metadata enrichment is affected. -MIT +See also `bins/torii-erc20-synth` for the paired profiling harness. diff --git a/bins/torii-erc20/src/main.rs b/bins/torii-erc20/src/main.rs index 82cc1bb3..110effca 100644 --- a/bins/torii-erc20/src/main.rs +++ b/bins/torii-erc20/src/main.rs @@ -38,7 +38,7 @@ use torii::etl::decoder::DecoderId; use torii::etl::extractor::{BlockRangeConfig, BlockRangeExtractor}; use torii_runtime_common::database::resolve_single_db_setup; #[cfg(feature = "profiling")] -use torii_runtime_common::database::{backend_from_url_or_path, DatabaseBackend}; +use torii_sql::DbBackend; // Import from the library crate use torii_erc20::proto::erc20_server::Erc20Server; @@ -89,7 +89,8 @@ async fn main() -> Result<()> { // Create storage let db_setup = resolve_single_db_setup(&config.db_path, config.database_url.as_deref()); - let storage = Arc::new(Erc20Storage::new(&db_setup.storage_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&db_setup.storage_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&db_setup.storage_url, erc20_pool).await?); tracing::info!("Database initialized"); // Create Starknet provider @@ -164,14 +165,14 @@ async fn main() -> Result<()> { let erc20_decoder_id = DecoderId::new("erc20"); for (address, name) in config.well_known_contracts() { tracing::info!("Mapping {} at {:#x} to ERC20 decoder", name, address); - torii_config = torii_config.map_contract(address, vec![erc20_decoder_id]); + torii_config = torii_config.map_contract(address.into(), vec![erc20_decoder_id]); } // Add custom contracts with explicit mappings for contract_str in &config.contracts { let address = Felt::from_hex(contract_str)?; tracing::info!("Mapping custom contract {:#x} to ERC20 decoder", address); - torii_config = torii_config.map_contract(address, vec![erc20_decoder_id]); + torii_config = torii_config.map_contract(address.into(), vec![erc20_decoder_id]); } // Log mode: no_auto_discovery affects whether unmapped contracts are tried @@ -219,10 +220,10 @@ async fn main() -> Result<()> { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let db_backend = match backend_from_url_or_path(&db_setup.storage_url) { - DatabaseBackend::Postgres => "postgres", - DatabaseBackend::Sqlite => "sqlite", - }; + let db_backend = db_setup + .storage_url + .parse::() + .map_err(anyhow::Error::new)?; let filename = format!("flamegraph-torii-erc20-block-range-{db_backend}-{ts}.svg"); let file = std::fs::File::create(&filename).unwrap(); report.flamegraph(file).unwrap(); diff --git a/bins/torii-introspect-bin/Cargo.toml b/bins/torii-introspect-bin/Cargo.toml index c11c32b8..17b36331 100644 --- a/bins/torii-introspect-bin/Cargo.toml +++ b/bins/torii-introspect-bin/Cargo.toml @@ -9,20 +9,21 @@ name = "torii-server" path = "src/main.rs" [dependencies] -torii = { path = "../../" } -torii-common = { path = "../../crates/torii-common" } +torii.workspace = true +torii-common.workspace = true torii-config-common.workspace = true torii-controllers-sink.workspace = true -torii-dojo = { path = "../../crates/dojo" } -torii-erc20 = { path = "../../crates/torii-erc20" } -torii-erc721 = { path = "../../crates/torii-erc721" } -torii-erc1155 = { path = "../../crates/torii-erc1155" } -torii-ecs-sink = { path = "../../crates/torii-ecs-sink" } -torii-entities-historical-sink.workspace = true -torii-introspect-postgres-sink = { path = "../../crates/introspect-postgres-sink" } -torii-introspect-sqlite-sink = { path = "../../crates/introspect-sqlite-sink" } +torii-dojo.workspace = true +torii-erc20.workspace = true +torii-erc721.workspace = true +torii-erc1155.workspace = true +torii-ecs-sink.workspace = true +torii-introspect-sql-sink = { workspace = true, features = [ + "postgres", + "sqlite", +] } torii-runtime-common.workspace = true -torii-sqlite = { path = "../../crates/sqlite" } +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } tokio = { version = "1", features = ["full"] } tracing = "0.1" @@ -31,7 +32,12 @@ starknet = "0.17" url = "2.5" clap = { version = "4.5", features = ["derive", "env"] } anyhow = "1.0" -sqlx = { version = "0.8", features = ["postgres", "sqlite", "runtime-tokio-rustls", "any"] } +sqlx = { workspace = true, features = [ + "postgres", + "sqlite", + "runtime-tokio-rustls", + "any", +] } tonic.workspace = true tonic-reflection.workspace = true tonic-web.workspace = true diff --git a/bins/torii-introspect-bin/README.md b/bins/torii-introspect-bin/README.md index ef6cdc14..ea3ce50d 100644 --- a/bins/torii-introspect-bin/README.md +++ b/bins/torii-introspect-bin/README.md @@ -1,86 +1,126 @@ -# Torii Server - -Dojo introspect and token indexer backed by PostgreSQL or SQLite. - -## Run - -```bash -cargo run --bin torii-server -- \ - --contract 0x123...,0x456... \ - --storage-database-url postgres://torii:torii@localhost:5432/torii -``` - -## Mixed Dojo + Token Indexing - -```bash -cargo run --bin torii-server -- \ - --from-block 0 \ - --contract 0x0000 \ - --erc20 0x001,0x002 \ - --erc721 0x003,0x0045 \ - --chunk-size 1000 \ - --batch-size 10000 -``` - -Use `--contract`/`--contracts` for Dojo introspect targets and `--erc20`, `--erc721`, `--erc1155` -for token contracts. The extractor deduplicates overlapping addresses and runs them in one ETL -pipeline. - -## Throughput Tuning - -The introspect indexer now exposes the same core ETL parallelism knobs as `torii-tokens`. - -```bash -cargo run --bin torii-server -- \ - --contract 0x123...,0x456... \ - --storage-database-url postgres://torii:torii@localhost:5432/torii \ - --rpc-parallelism 4 \ - --max-prefetch-batches 8 +# torii-introspect-bin (binary `torii-server`) + +**Canonical full-stack Torii binary.** Indexes Dojo introspect events, +optional token transfers (ERC20/721/1155), and optional controller +usernames into a shared SQL backend. Exposes the legacy `world.World` +gRPC API plus every token gRPC service on one port. The most complete +example of how the pipeline fits together. + +## Role in Torii + +Production deployments of Dojo worlds point at this binary. It combines: + +1. `torii-dojo::DojoDecoder` consuming the world contract's events, +2. `introspect-sql-sink` materialising schema + records, +3. `torii-ecs-sink` serving the legacy `world.World` gRPC, +4. Optional `torii-erc20/721/1155` sinks + decoders + rules for token + data referenced by models, +5. Optional `torii-controllers-sink` for Cartridge controller metadata, +6. Optional TLS for the listener. + +Read `bins/torii-erc20` first — this binary is the same pattern scaled +up. + +## Architecture + +```text ++-----------------------------+ +| clap::Parser Config | argument group: --contract + --erc20 + +| (src/config.rs) | --erc721 + --erc1155 (at least one) +| | --database-url / --storage-database-url +| | --historical (for _historical tables) +| | --tls-cert / --tls-key +| | --controllers (enable sync) +| | --observability / --ignore-saved-state +| | --index-external-contracts (default on) ++-------------+---------------+ + | + v ++--------------------------------------------------------+ +| main() | +| | +| resolve engine + storage URLs | +| (Postgres uniform or SQLite under --db-dir) | +| | +| EventExtractor over --rpc-url | +| chunk=1000, block-batch=10000 | +| cursor-resume unless --ignore-saved-state | +| | +| Decoders: | +| DojoDecoder (over dojo-introspect schema fetcher) | +| + optional Erc20Decoder / Erc721Decoder / | +| Erc1155Decoder | +| | +| Sinks (installed by config): | +| - IntrospectDb | +| (IntrospectPgDb or IntrospectSqliteDb) | +| - EcsSink (reads the introspect tables; serves | +| legacy world.World gRPC) | +| - ControllersSink (--controllers) | +| - Erc20Sink/Erc721Sink/Erc1155Sink + services | +| | +| ContractRegistry with Erc*Rule registered; | +| load_from_db() on startup; | +| handles Dojo ExternalContractRegistered via | +| RegisterExternalContractCommand (index_external_ | +| contracts flag) | +| | +| EtlConcurrencyConfig | +| max_prefetch_batches = --max-prefetch-batches | +| | +| grpc_router adds all token servers + WorldServer (ECS) | +| + reflection composing TORII + token + ecs + arcade | +| descriptor sets | +| | +| optional TLS listener (both --tls-cert and --tls-key) | +| | +| torii::run(config).await | ++--------------------------------------------------------+ ``` -Notes: - -- `--rpc-parallelism`: concurrent chunked RPC requests (`0` = auto). -- `--chunk-size`: events per `starknet_getEvents` request. -- `--batch-size`: block range queried per iteration. -- `--max-prefetch-batches`: extracted batches buffered ahead of decode/store. +## Deep Dive -## Local TLS + ALPN +### CLI highlights -For browser-compatible local HTTPS, use `mkcert` instead of a raw self-signed certificate. -Modern browsers require a trusted local CA and `subjectAltName` entries for `localhost`. +| Flag | Default | Purpose | +|---|---|---| +| `--contract` / `--contracts` | required* | Dojo world contract addresses (`*` required if no token list) | +| `--erc20`, `--erc721`, `--erc1155` | `[]` | Token contracts to pin | +| `--rpc-url` (env `STARKNET_RPC_URL`) | Cartridge mainnet | JSON-RPC endpoint | +| `--from-block`, `--to-block` | `0` / none | Range | +| `--db-dir` | `./torii-data` | SQLite root | +| `--database-url` (env `DATABASE_URL`) | — | Engine DB URL | +| `--storage-database-url` (env `STORAGE_DATABASE_URL`) | — | Storage DB URL (**Postgres only** for introspect-pg path) | +| `--port` | `3000` | gRPC + HTTP | +| `--tls-cert`, `--tls-key` | — | PEM files; must come as a pair | +| `--controllers` | off | Install `torii-controllers-sink` | +| `--controllers-api-url` | `https://api.cartridge.gg/query` | GraphQL URL for controllers sink | +| `--observability` | off | Sets `TORII_METRICS_ENABLED` | +| `--event-chunk-size` / `--event-block-batch-size` | `1000` / `10000` | `starknet_getEvents` tuning | +| `--max-prefetch-batches` | `2` | ETL prefetch queue depth | +| `--cycle-interval` | `3` | Idle retry seconds | +| `--rpc-parallelism` | `0` (auto) | Chunked-RPC parallelism | +| `--max-db-connections` | auto | Pool size ceiling | +| `--ignore-saved-state` | off | Force reprocessing from `--from-block` | +| `--index-external-contracts` | `true` | React to Dojo `ExternalContractRegistered` events at runtime | +| `--historical` | `[]` | `ModelName` or `0xAddr:ModelName` entries to mirror into append-only `_historical` tables | -Install and trust the local development CA: +Argument group enforces that **at least one** of `--contract`, +`--erc20`, `--erc721`, `--erc1155` is provided. -```bash -brew install mkcert nss -mkcert -install -mkdir -p certs -mkcert -cert-file certs/dev-cert.pem -key-file certs/dev-key.pem localhost 127.0.0.1 ::1 -``` - -Start `torii-server` with TLS enabled: +### Internal modules -```bash -cargo run --bin torii-server -- \ - --contract 0x123...,0x456... \ - --tls-cert ./certs/dev-cert.pem \ - --tls-key ./certs/dev-key.pem -``` +- `config.rs` — `Config`, `parse_historical_models`, backend resolution (`storage_backend`, `engine_database_url`, `storage_database_url`), TLS pair validation. +- `main.rs` — sink assembly, registry wiring, optional TLS, gRPC reflection composition, cursor-resume logic. -The local listener advertises ALPN for `h2` and `http/1.1`. Native gRPC clients will negotiate -HTTP/2 automatically; HTTPS health and metrics endpoints remain available on the same port. +### Workspace dependencies -Verify the local HTTPS listener: +`torii`, `torii-dojo`, `torii-introspect`, `torii-introspect-sql-sink` (postgres + sqlite), `torii-ecs-sink`, `torii-controllers-sink`, `torii-erc20` / `torii-erc721` / `torii-erc1155`, `torii-arcade-sink` (for its descriptor set — the `ArcadeServer` is only mounted if enabled in custom builds), `torii-common`, `torii-runtime-common`, `torii-config-common`, `torii-sql` (postgres + sqlite). Plus `clap`, `tonic`, `tonic-reflection`, `tokio`, `tracing`, `starknet`, `anyhow`, `url`. -```bash -curl https://localhost:3000/health -grpcurl -insecure localhost:3000 list -``` +### Extension Points -## Notes +- Add another sink → build it, push `Box` into the builder, add its descriptor set to the reflection composition. +- Change extractor → replace `EventExtractor` with `BlockRangeExtractor` or a custom one; the rest of the pipeline doesn't care. +- Add Postgres schemas → `introspect-sql-sink::NamespaceMode` decides the layout; flip at binary level. -- `--storage-database-url` must be PostgreSQL when provided. -- If `--storage-database-url` is omitted, SQLite files are created under `--db-dir` for introspect and token storages. -- `--observability` controls `TORII_METRICS_ENABLED` through shared helpers. -- Config validation and observability wiring are shared through `torii-config-common`. +Paired profiler: `bins/torii-introspect-synth`. diff --git a/bins/torii-introspect-bin/src/config.rs b/bins/torii-introspect-bin/src/config.rs index 2af7c2bf..dd14b5c5 100644 --- a/bins/torii-introspect-bin/src/config.rs +++ b/bins/torii-introspect-bin/src/config.rs @@ -1,13 +1,9 @@ use anyhow::{bail, Result}; use clap::{ArgGroup, Parser}; use starknet::core::types::Felt; +use std::collections::HashSet; use std::path::{Path, PathBuf}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StorageBackend { - Postgres, - Sqlite, -} +use torii_sql::DbBackend; /// Dojo introspect indexer backed by PostgreSQL or SQLite. /// @@ -187,11 +183,11 @@ impl Config { models } - pub fn storage_backend(&self) -> StorageBackend { + pub fn storage_backend(&self) -> DbBackend { if self.storage_database_url.is_some() { - StorageBackend::Postgres + DbBackend::Postgres } else { - StorageBackend::Sqlite + DbBackend::Sqlite } } @@ -210,7 +206,10 @@ impl Config { Ok(url.clone()) } Some(_) => bail!("--storage-database-url must be a PostgreSQL URL"), - None => Ok(db_dir.join("introspect.db").to_string_lossy().to_string()), + None => Ok(format!( + "sqlite://{}", + db_dir.join("introspect.db").to_string_lossy() + )), } } @@ -225,6 +224,21 @@ impl Config { } } +pub fn parse_historical_models( + historical: &[String], + contracts: &[Felt], +) -> Result> { + let mut models = HashSet::with_capacity(historical.len()); + for model in historical { + let parts: Vec<&str> = model.splitn(2, ':').collect(); + match parts.len() { + 1 => contracts.iter().for_each(|&addr| {models.insert((addr, parts[0].to_string()));}), + 2 => {models.insert((Felt::from_hex(parts[0])?, parts[1].to_string()));}, + _ => bail!("Invalid historical model format: {model}. Expected format is either `ModelName` or `0xContractAddress:ModelName`"), + } + } + Ok(models) +} #[cfg(test)] mod tests { use super::*; @@ -272,7 +286,7 @@ mod tests { fn sqlite_is_default_when_storage_database_url_is_omitted() { let cfg = Config::parse_from(["torii-server", "--contract", "0x1"]); - assert_eq!(cfg.storage_backend(), StorageBackend::Sqlite); + assert_eq!(cfg.storage_backend(), DbBackend::Sqlite); assert!(cfg .storage_database_url(Path::new("./torii-data")) .unwrap() diff --git a/bins/torii-introspect-bin/src/main.rs b/bins/torii-introspect-bin/src/main.rs index ea102237..72e51d77 100644 --- a/bins/torii-introspect-bin/src/main.rs +++ b/bins/torii-introspect-bin/src/main.rs @@ -4,12 +4,13 @@ mod config; use anyhow::Result; use clap::Parser; -use config::{Config, StorageBackend}; +use config::Config; use sqlx::postgres::PgPoolOptions; -use sqlx::sqlite::SqlitePoolOptions; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use starknet::core::types::Felt; use std::collections::{HashMap, HashSet}; use std::path::Path; +use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; use tonic::codec::CompressionEncoding; @@ -27,11 +28,9 @@ use torii_dojo::external_contract::{ contract_type_from_decoder_ids, RegisterExternalContractCommandHandler, RegisteredContractType, SharedContractTypeRegistry, SharedDecoderRegistry, }; -use torii_dojo::store::postgres::PgStore; -use torii_dojo::store::sqlite::SqliteStore; +use torii_dojo::store::DojoStoreTrait; use torii_ecs_sink::proto::world::world_server::WorldServer; use torii_ecs_sink::{EcsSink, FILE_DESCRIPTOR_SET as ECS_DESCRIPTOR_SET}; -use torii_entities_historical_sink::EntitiesHistoricalSink; use torii_erc1155::proto::erc1155_server::Erc1155Server; use torii_erc1155::{ Erc1155Decoder, Erc1155MetadataCommandHandler, Erc1155Service, Erc1155Sink, Erc1155Storage, @@ -47,13 +46,14 @@ use torii_erc721::{ Erc721Decoder, Erc721MetadataCommandHandler, Erc721Service, Erc721Sink, Erc721Storage, FILE_DESCRIPTOR_SET as ERC721_DESCRIPTOR_SET, }; -use torii_introspect_postgres_sink::processor::IntrospectPgDb; -use torii_introspect_sqlite_sink::processor::IntrospectSqliteDb; +use torii_introspect_sql_sink::{IntrospectPgDb, IntrospectSqliteDb, NamespaceMode}; use torii_runtime_common::database::{ resolve_token_db_setup, TokenDbSetup, DEFAULT_SQLITE_MAX_CONNECTIONS, }; use torii_runtime_common::token_support::{resolve_installed_token_support, InstalledTokenSupport}; -use torii_sqlite::{is_sqlite_memory_path, sqlite_connect_options}; +use torii_sql::{DbBackend, DbPoolOptions}; + +use crate::config::parse_historical_models; type StarknetProvider = starknet::providers::jsonrpc::JsonRpcClient; @@ -201,9 +201,9 @@ async fn load_persisted_contract_registries( let mut contract_types = contract_type_registry.write().await; for (contract, decoder_ids, _) in mappings { - decoders.insert(contract, decoder_ids.clone()); + decoders.insert(contract.into(), decoder_ids.clone()); if let Some(contract_type) = contract_type_from_decoder_ids(&decoder_ids) { - contract_types.insert(contract, contract_type); + contract_types.insert(contract.into(), contract_type); } } @@ -233,7 +233,7 @@ fn append_unique_contract_configs( for &address in addresses { if seen.insert(address) { configs.push(ContractEventConfig { - address, + address: address.into(), from_block, to_block, }); @@ -283,7 +283,7 @@ fn apply_contract_mappings( contract, decoder_ids ); - torii_config = torii_config.map_contract(contract, decoder_ids); + torii_config = torii_config.map_contract(contract.into(), decoder_ids); } torii_config @@ -353,7 +353,8 @@ async fn configure_token_support( token_db_setup.expect("token DB setup must exist when token support is configured"); if installed_token_support.erc20 { - let storage = Arc::new(Erc20Storage::new(&db_setup.erc20_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&db_setup.erc20_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&db_setup.erc20_url, erc20_pool).await?); tracing::info!("ERC20 database initialized: {}", db_setup.erc20_url); let decoder: Arc = Arc::new(Erc20Decoder::new()); @@ -383,7 +384,8 @@ async fn configure_token_support( } if installed_token_support.erc721 { - let storage = Arc::new(Erc721Storage::new(&db_setup.erc721_url).await?); + let erc721_pool = Erc721Storage::connect_pool(&db_setup.erc721_url).await?; + let storage = Arc::new(Erc721Storage::from_pool(&db_setup.erc721_url, erc721_pool).await?); tracing::info!("ERC721 database initialized: {}", db_setup.erc721_url); let decoder: Arc = Arc::new(Erc721Decoder::new()); @@ -418,7 +420,10 @@ async fn configure_token_support( } if installed_token_support.erc1155 { - let storage = Arc::new(Erc1155Storage::new(&db_setup.erc1155_url).await?); + let erc1155_pool = DbPoolOptions::new() + .connect_any(&db_setup.erc1155_url) + .await?; + let storage = Arc::new(Erc1155Storage::new(erc1155_pool, &db_setup.erc1155_url).await?); tracing::info!("ERC1155 database initialized: {}", db_setup.erc1155_url); let decoder: Arc = Arc::new(Erc1155Decoder::new()); @@ -492,7 +497,8 @@ async fn run_indexer(config: Config) -> Result<()> { erc1155: !token_targets.erc1155.is_empty(), }, ); - let historical_models = config.historical_models(); + let historical_model_names = config.historical_models(); + let historical_models = parse_historical_models(&historical_model_names, &contracts)?; let token_db_setup = if installed_token_support.any() { Some(resolve_token_db_setup( db_dir, @@ -638,12 +644,12 @@ async fn run_indexer(config: Config) -> Result<()> { }, )); - if matches!(backend, StorageBackend::Sqlite) { + if matches!(backend, DbBackend::Sqlite) { tokio::fs::create_dir_all(db_dir).await?; } match backend { - StorageBackend::Postgres => { + DbBackend::Postgres => { run_with_postgres( &config, &storage_database_url, @@ -656,13 +662,13 @@ async fn run_indexer(config: Config) -> Result<()> { decoder_registry.clone(), contract_type_registry.clone(), installed_external_decoders.clone(), - historical_models.clone(), + historical_models, provider, extractor, ) .await?; } - StorageBackend::Sqlite => { + DbBackend::Sqlite => { run_with_sqlite( &config, &storage_database_url, @@ -675,7 +681,7 @@ async fn run_indexer(config: Config) -> Result<()> { decoder_registry.clone(), contract_type_registry.clone(), installed_external_decoders.clone(), - historical_models.clone(), + historical_models, provider, extractor, ) @@ -699,22 +705,23 @@ async fn run_with_postgres( decoder_registry: SharedDecoderRegistry, contract_type_registry: SharedContractTypeRegistry, installed_external_decoders: HashSet, - historical_models: Vec, + historical_models: HashSet<(Felt, String)>, provider: StarknetProvider, extractor: Box, ) -> Result<()> { let token_provider = Arc::new(provider.clone()); let max_db_connections = config.max_db_connections.unwrap_or(5); - let pool = Arc::new( - PgPoolOptions::new() - .max_connections(max_db_connections) - .connect(storage_database_url) - .await?, - ); + let pool = PgPoolOptions::new() + .max_connections(max_db_connections) + .connect(storage_database_url) + .await?; + + let mut decoder = DojoDecoder::new(pool.clone(), provider); + let introspect_sink = IntrospectPgDb::new(pool.clone(), NamespaceMode::Address); - let decoder = DojoDecoder::, _>::new(pool.clone(), provider); - let introspect_sink = IntrospectPgDb::new(pool.clone(), ()); - decoder.store.initialize().await?; + decoder.append_historical(historical_models); + decoder.initialize().await?; + introspect_sink.initialize_introspect_sql_sink().await?; decoder.load_tables(&[]).await?; let decoder: Arc = Arc::new(decoder); @@ -736,23 +743,21 @@ async fn run_with_postgres( .add_decoder(decoder) .add_sink_boxed(Box::new( OrderedSinkPipeline::new("introspect-projection-pipeline") - .push(Box::new(introspect_sink)) - .push(Box::new( - EntitiesHistoricalSink::new( - storage_database_url, - config.max_db_connections, - (), - historical_models, - ) - .await?, - )), + .push(Box::new(introspect_sink)), )); if let Some(tls) = config.tls_config()? { torii_config = torii_config.with_tls(tls); } if config.index_external_contracts { torii_config = torii_config - .with_registry_cache(decoder_registry.clone()) + .with_registry_cache(Arc::new(RwLock::new( + decoder_registry + .read() + .await + .iter() + .map(|(contract, decoder_ids)| ((*contract).into(), decoder_ids.clone())) + .collect(), + ))) .with_command_handler(Box::new(RegisterExternalContractCommandHandler::new( registry_engine_db.clone(), decoder_registry.clone(), @@ -879,36 +884,33 @@ async fn run_with_sqlite( decoder_registry: SharedDecoderRegistry, contract_type_registry: SharedContractTypeRegistry, installed_external_decoders: HashSet, - historical_models: Vec, + historical_models: HashSet<(Felt, String)>, provider: StarknetProvider, extractor: Box, ) -> Result<()> { let token_provider = Arc::new(provider.clone()); - let options = sqlite_connect_options(storage_database_url)?; + let options = SqliteConnectOptions::from_str(storage_database_url)?.create_if_missing(true); let max_db_connections = match config.max_db_connections { Some(limit) => limit.max(1), - None if is_sqlite_memory_path(storage_database_url) => 1, + None if options.is_in_memory() => 1, None => DEFAULT_SQLITE_MAX_CONNECTIONS, }; - let pool = Arc::new( - SqlitePoolOptions::new() - .max_connections(max_db_connections) - .connect_with(options) - .await?, - ); + let pool = SqlitePoolOptions::new() + .max_connections(max_db_connections) + .connect_with(options) + .await?; sqlx::query("PRAGMA journal_mode=WAL") - .execute(pool.as_ref()) + .execute(&pool) .await?; sqlx::query("PRAGMA synchronous=NORMAL") - .execute(pool.as_ref()) - .await?; - sqlx::query("PRAGMA foreign_keys=ON") - .execute(pool.as_ref()) + .execute(&pool) .await?; + sqlx::query("PRAGMA foreign_keys=ON").execute(&pool).await?; - let decoder = DojoDecoder::, _>::new(pool.clone(), provider); - decoder.store.initialize().await?; + let mut decoder = DojoDecoder::new(pool.clone(), provider); + decoder.append_historical(historical_models); + decoder.initialize().await?; decoder.load_tables(&[]).await?; let decoder: Arc = Arc::new(decoder); @@ -929,24 +931,23 @@ async fn run_with_sqlite( .with_extractor(extractor) .add_decoder(decoder) .add_sink_boxed(Box::new( - OrderedSinkPipeline::new("introspect-projection-pipeline") - .push(Box::new(IntrospectSqliteDb::new(pool.clone(), ()))) - .push(Box::new( - EntitiesHistoricalSink::new( - storage_database_url, - config.max_db_connections, - (), - historical_models, - ) - .await?, - )), + OrderedSinkPipeline::new("introspect-projection-pipeline").push(Box::new( + IntrospectSqliteDb::new(pool.clone(), NamespaceMode::Address), + )), )); if let Some(tls) = config.tls_config()? { torii_config = torii_config.with_tls(tls); } if config.index_external_contracts { torii_config = torii_config - .with_registry_cache(decoder_registry.clone()) + .with_registry_cache(Arc::new(RwLock::new( + decoder_registry + .read() + .await + .iter() + .map(|(contract, decoder_ids)| ((*contract).into(), decoder_ids.clone())) + .collect(), + ))) .with_command_handler(Box::new(RegisterExternalContractCommandHandler::new( registry_engine_db.clone(), decoder_registry.clone(), @@ -1064,7 +1065,8 @@ async fn run_with_sqlite( #[cfg(test)] mod tests { use super::{ecs_token_storage_urls, InstalledTokenSupport}; - use torii_runtime_common::database::{DatabaseBackend, TokenDbSetup}; + use torii_runtime_common::database::TokenDbSetup; + use torii_sql::DbBackend; fn token_db_setup() -> TokenDbSetup { TokenDbSetup { @@ -1072,10 +1074,10 @@ mod tests { erc20_url: "./torii-data/erc20.db".to_string(), erc721_url: "./torii-data/erc721.db".to_string(), erc1155_url: "./torii-data/erc1155.db".to_string(), - engine_backend: DatabaseBackend::Sqlite, - erc20_backend: DatabaseBackend::Sqlite, - erc721_backend: DatabaseBackend::Sqlite, - erc1155_backend: DatabaseBackend::Sqlite, + engine_backend: DbBackend::Sqlite, + erc20_backend: DbBackend::Sqlite, + erc721_backend: DbBackend::Sqlite, + erc1155_backend: DbBackend::Sqlite, } } diff --git a/bins/torii-introspect-synth/Cargo.toml b/bins/torii-introspect-synth/Cargo.toml index 8caedd45..174444e5 100644 --- a/bins/torii-introspect-synth/Cargo.toml +++ b/bins/torii-introspect-synth/Cargo.toml @@ -8,10 +8,9 @@ name = "torii-introspect-synth" path = "src/main.rs" [dependencies] -torii = { path = "../../" } -torii-runtime-common.workspace = true -torii-dojo = { path = "../../crates/dojo" } -torii-introspect-postgres-sink = { path = "../../crates/introspect-postgres-sink" } +torii.workspace = true +torii-dojo = { workspace = true, features = ["postgres"] } +torii-introspect-sql-sink = { workspace = true, features = ["postgres"] } tokio = { version = "1", features = ["full"] } tracing = "0.1" @@ -22,11 +21,13 @@ async-trait = "0.1" chrono = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] } +sqlx = { workspace = true, features = ["runtime-tokio-rustls", "postgres"] } starknet = "0.17" -starknet-types-core.workspace = true dojo-introspect.workspace = true introspect-types.workspace = true +starknet-types-raw.workspace = true +torii-types.workspace = true + [lints] workspace = true diff --git a/bins/torii-introspect-synth/README.md b/bins/torii-introspect-synth/README.md new file mode 100644 index 00000000..1b0c4d52 --- /dev/null +++ b/bins/torii-introspect-synth/README.md @@ -0,0 +1,86 @@ +# torii-introspect-synth (binary) + +**Deterministic Dojo introspect profiler.** Generates a stream of +synthetic `ModelWithSchemaRegistered` + `StoreSetRecord` / +`StoreUpdateMember` events, pumps them through `DojoDecoder` and +`introspect-sql-sink`, and writes a timing report. Postgres-only, same +family as `torii-erc20-synth` / `torii-tokens-synth`. + +## Role in Torii + +Perf regression harness for the introspect path. Keeps the decoder + +`IntrospectPgDb` hot without requiring a live Dojo world — useful when +changing schema generation, JSON serialisation, or the per-record write +path in `introspect-sql-sink`. + +## Architecture + +```text ++--------------------------+ +| clap::Parser Config | --db-url (Postgres) +| | --from-block, --block-count +| | --records-per-block +| | --blocks-per-batch, --seed +| | --output-root, --reset-schema ++------------+-------------+ + | + v ++--------------------------+ +| Synthetic extractor | bakes a single model: +| (in main.rs) | NAMESPACE="synthetic" +| | MODEL_NAME="position" +| | TABLE_NAME="synthetic-position" +| | FROM_ADDRESS=0x100 +| | emits ModelWithSchemaRegistered first, +| | then --records-per-block StoreSetRecord / +| | StoreUpdateMember events per block, +| | deterministic via --seed ++------------+-------------+ + | + v ++--------------------------+ +| DojoDecoder + | fetcher = in-process DojoSchemaFetcher that +| IntrospectPgDb | returns the synthetic schema (no RPC); +| | DecoderContext routes to DojoDecoder ++------------+-------------+ + | + v + tight loop: extract → decode → sink.process → commit_cursor + record StageSample (extract/decode/sink/commit/loop_total) + | + v ++--------------------------+ +| RunReport JSON | perf/synthetic-runs// ++--------------------------+ +``` + +## Deep Dive + +### CLI + +| Flag | Default | Purpose | +|---|---|---| +| `--db-url` (env `DATABASE_URL`) | `postgres://torii:torii@localhost:5432/torii` | **Postgres only** | +| `--from-block` | `1_000_000` | Synthetic starting block | +| `--block-count` | `50` | Blocks produced in the run | +| `--records-per-block` | `250` | `StoreSetRecord` / `StoreUpdateMember` events per block | +| `--blocks-per-batch` | `1` | Blocks per extract cycle | +| `--seed` | `42` | Deterministic RNG seed | +| `--output-root` | `perf/synthetic-runs` | Artifact root | +| `--reset-schema` | `true` | Drop and recreate the synthetic schema before the run | + +### Internal shape + +- Hand-rolls a `DojoSchemaFetcher` that returns a static schema for the synthetic model — no RPC is called. +- Uses `IntrospectPgDb` directly (not `introspect-sql-sink::runtime::IntrospectDb` dispatch) to keep the critical path narrow. +- Persists extractor state under `EXTRACTOR_TYPE = "synthetic_introspect"` / `STATE_KEY = "last_block"` in `EngineDb` so restarts pick up where they stopped (unless `--reset-schema`). + +### Workspace dependencies + +`torii`, `torii-dojo`, `torii-introspect`, `torii-introspect-sql-sink` (feature `postgres`), `torii-types`, `dojo-introspect`, `introspect-types`, `sqlx` (postgres), `clap`, `serde`, `async-trait`, `tokio`, `starknet-types-raw`, `tracing`, `anyhow`. + +### Extension Points + +- New event mix → widen the synthetic extractor's per-block generator (add more `StoreUpdateRecord` variants, more models, etc.). +- Additional model → bump `NAMESPACE` / `MODEL_NAME` constants and the fetcher's schema map. +- Switch to SQLite → not supported; the binary targets `IntrospectPgDb` explicitly. diff --git a/bins/torii-introspect-synth/src/main.rs b/bins/torii-introspect-synth/src/main.rs index 9f71e3e6..bf59961f 100644 --- a/bins/torii-introspect-synth/src/main.rs +++ b/bins/torii-introspect-synth/src/main.rs @@ -5,14 +5,12 @@ use dojo_introspect::events::{ModelWithSchemaRegistered, StoreSetRecord, StoreUp use dojo_introspect::selector::compute_selector_from_namespace_and_name; use dojo_introspect::serde::primitive; use dojo_introspect::{DojoIntrospectResult, DojoSchema, DojoSchemaFetcher}; -use introspect_types::utils::{ascii_str_to_felt, string_to_cairo_serialize_byte_array}; +use introspect_types::utils::string_to_cairo_serialize_byte_array; use introspect_types::CairoEventInfo; use serde::Serialize; use sqlx::postgres::PgPoolOptions; use sqlx::{PgPool, Row}; -use starknet::core::types::EmittedEvent; -use starknet::core::utils::get_selector_from_name; -use starknet_types_core::felt::Felt; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::fs; use std::path::PathBuf; @@ -26,8 +24,9 @@ use torii::etl::sink::{EventBus, Sink, SinkContext}; use torii::etl::Decoder; use torii::grpc::SubscriptionManager; use torii_dojo::decoder::DojoDecoder; -use torii_dojo::store::postgres::PgStore; -use torii_introspect_postgres_sink::IntrospectPgDb; +use torii_dojo::store::DojoStoreTrait; +use torii_introspect_sql_sink::IntrospectPgDb; +use torii_types::event::StarknetEvent; const EXTRACTOR_TYPE: &str = "synthetic_introspect"; const STATE_KEY: &str = "last_block"; @@ -173,24 +172,31 @@ impl SyntheticIntrospectExtractor { } fn table_id(&self) -> Felt { - compute_selector_from_namespace_and_name(NAMESPACE, MODEL_NAME) + compute_selector_from_namespace_and_name(NAMESPACE, MODEL_NAME).into() } fn score_selector(&self) -> Felt { - get_selector_from_name("score").expect("valid selector") + Felt::selector("score") } - fn model_registration_event(&self, block_number: u64) -> EmittedEvent { - let mut keys = vec![ModelWithSchemaRegistered::SELECTOR]; - keys.extend(string_to_cairo_serialize_byte_array(MODEL_NAME)); - keys.extend(string_to_cairo_serialize_byte_array(NAMESPACE)); - - EmittedEvent { + fn model_registration_event(&self, block_number: u64) -> StarknetEvent { + let mut keys = vec![ModelWithSchemaRegistered::SELECTOR.into()]; + keys.extend( + string_to_cairo_serialize_byte_array(MODEL_NAME) + .into_iter() + .map(Felt::from), + ); + keys.extend( + string_to_cairo_serialize_byte_array(NAMESPACE) + .into_iter() + .map(Felt::from), + ); + + StarknetEvent { from_address: FROM_ADDRESS, keys, data: encode_legacy_schema(), - block_hash: Some(block_hash_for(block_number)), - block_number: Some(block_number), + block_number, transaction_hash: tx_hash_for(block_number, 0), } } @@ -221,16 +227,15 @@ impl SyntheticIntrospectExtractor { entity_id: Felt, owner: Felt, initial_score: Felt, - ) -> EmittedEvent { + ) -> StarknetEvent { let mut data = encode_array([owner]); data.extend(encode_array([initial_score])); - EmittedEvent { + StarknetEvent { from_address: FROM_ADDRESS, - keys: vec![StoreSetRecord::SELECTOR, self.table_id(), entity_id], + keys: vec![StoreSetRecord::SELECTOR.into(), self.table_id(), entity_id], data, - block_hash: Some(block_hash_for(block_number)), - block_number: Some(block_number), + block_number, transaction_hash: tx_hash_for(block_number, record_tx_index(record_index, false)), } } @@ -241,18 +246,17 @@ impl SyntheticIntrospectExtractor { record_index: usize, entity_id: Felt, final_score: Felt, - ) -> EmittedEvent { - EmittedEvent { + ) -> StarknetEvent { + StarknetEvent { from_address: FROM_ADDRESS, keys: vec![ - StoreUpdateMember::SELECTOR, + StoreUpdateMember::SELECTOR.into(), self.table_id(), entity_id, self.score_selector(), ], data: encode_array([final_score]), - block_hash: Some(block_hash_for(block_number)), - block_number: Some(block_number), + block_number, transaction_hash: tx_hash_for(block_number, record_tx_index(record_index, true)), } } @@ -398,7 +402,10 @@ struct NeverFetchSchema; #[async_trait] impl DojoSchemaFetcher for NeverFetchSchema { - async fn schema(&self, contract_address: Felt) -> DojoIntrospectResult { + async fn schema( + &self, + contract_address: starknet::core::types::Felt, + ) -> DojoIntrospectResult { panic!("provider fetch should not be called in synthetic introspect run: {contract_address:#x}"); } } @@ -418,14 +425,12 @@ async fn main() -> Result<()> { fs::create_dir_all(&output_dir) .with_context(|| format!("failed to create output dir {}", output_dir.display()))?; - let pool = Arc::new( - PgPoolOptions::new() - .max_connections(5) - .connect(&config.db_url) - .await?, - ); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&config.db_url) + .await?; if config.reset_schema { - reset_schema(pool.as_ref()).await?; + reset_schema(&pool).await?; } let started = Instant::now(); @@ -447,8 +452,8 @@ async fn main() -> Result<()> { .await?, ); - let decoder = DojoDecoder::, _>::new(pool.clone(), NeverFetchSchema); - decoder.store.initialize().await?; + let decoder = DojoDecoder::new(pool.clone(), NeverFetchSchema); + decoder.initialize().await?; decoder.load_tables(&[]).await?; let decoder: Arc = Arc::new(decoder); let decoder_context = @@ -476,7 +481,7 @@ async fn main() -> Result<()> { } total_events += batch.events.len(); - let envelopes = decoder_context.decode(&batch.events).await?; + let envelopes = decoder_context.decode_events(&batch.events).await?; total_envelopes += envelopes.len(); sink.process(&envelopes, &batch).await?; @@ -486,7 +491,7 @@ async fn main() -> Result<()> { } } - let verification = verify_run(pool.as_ref(), &config).await?; + let verification = verify_run(&pool, &config).await?; let summary = Summary { run_id: run_id.clone(), duration_ms: started.elapsed().as_millis(), @@ -638,7 +643,7 @@ async fn verify_run(pool: &PgPool, config: &Config) -> Result { WHERE "entity_id" = $1 "# )) - .bind(verified_entity_id.to_bytes_be().to_vec()) + .bind(verified_entity_id.to_be_bytes().to_vec()) .fetch_one(pool) .await?; @@ -657,7 +662,7 @@ async fn verify_run(pool: &PgPool, config: &Config) -> Result { } fn encode_legacy_schema() -> Vec { - let mut schema = vec![ascii_str_to_felt(MODEL_NAME)]; + let mut schema = vec![Felt::from_short_ascii_str_unchecked(MODEL_NAME)]; schema.extend(encode_attributes(&[])); schema.extend(encode_columns()); schema @@ -668,14 +673,14 @@ fn encode_columns() -> Vec { columns.extend(encode_column( "owner", &["key"], - primitive::CONTRACT_ADDRESS_FELT, + primitive::CONTRACT_ADDRESS_FELT.into(), )); - columns.extend(encode_column("score", &[], primitive::U32_FELT)); + columns.extend(encode_column("score", &[], primitive::U32_FELT.into())); columns } fn encode_column(name: &str, attributes: &[&str], primitive_type: Felt) -> Vec { - let mut column = vec![ascii_str_to_felt(name)]; + let mut column = vec![Felt::from_short_ascii_str_unchecked(name)]; column.extend(encode_attributes(attributes)); column.extend(vec![Felt::ZERO, primitive_type]); column @@ -686,7 +691,7 @@ fn encode_attributes(attributes: &[&str]) -> Vec { encoded.extend( attributes .iter() - .map(|attribute| ascii_str_to_felt(attribute)), + .map(|attribute| Felt::from_short_ascii_str_unchecked(attribute)), ); encoded } diff --git a/bins/torii-tokens-synth/Cargo.toml b/bins/torii-tokens-synth/Cargo.toml index 00a651f9..297b9d1c 100644 --- a/bins/torii-tokens-synth/Cargo.toml +++ b/bins/torii-tokens-synth/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" torii = { path = "../../" } torii-runtime-common.workspace = true torii-common.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } torii-erc20 = { path = "../../crates/torii-erc20" } torii-erc721 = { path = "../../crates/torii-erc721" } torii-erc1155 = { path = "../../crates/torii-erc1155" } diff --git a/bins/torii-tokens-synth/README.md b/bins/torii-tokens-synth/README.md new file mode 100644 index 00000000..4e02b605 --- /dev/null +++ b/bins/torii-tokens-synth/README.md @@ -0,0 +1,92 @@ +# torii-tokens-synth (binary) + +**Deterministic multi-standard token profiler.** Drives the ERC20, +ERC721, and ERC1155 pipelines with hand-rolled synthetic extractors and +produces a uniform `RunReport` JSON, so the three sinks (and the +combination) can be benchmarked side by side. Postgres-only, like +`torii-erc20-synth`. + +## Role in Torii + +One-stop perf harness for all three token sinks. Same shape as +`torii-erc20-synth` but with clap subcommands so you pick which sink(s) +to exercise. + +## Architecture + +```text ++--------------------------+ +| clap::Subcommand Cli | subcommands: +| | erc20 Erc20ProfileConfig +| | erc721 Erc721ProfileConfig +| | erc1155 Erc1155ProfileConfig +| | all AllProfileConfig ++------------+-------------+ + | + v ++----------------------------------------+ +| CommonConfig (flattened into each) | +| --db-url (Postgres only) | +| --from-block, --block-count | +| --tx-per-block, --blocks-per-batch | +| --token-count, --wallet-count | +| --seed, --output-root | +| --reset-schema | ++------------+---------------------------+ + | + +--- Erc20SpecificConfig (--approval-ratio-bps) + +--- Erc721SpecificConfig (--approval-ratio-bps, + | --approval-for-all-ratio-bps, + | --metadata-update-ratio-bps, + | --batch-metadata-update-ratio-bps, + | --max-token-id) + +--- Erc1155SpecificConfig (--transfer-single-ratio-bps, + --transfer-batch-ratio-bps, + --min-batch-size, --max-batch-size, + --approval-for-all-ratio-bps, + --uri-ratio-bps, --token-id-count) + +subcommand.run(): + drop_postgres_schemas(engine, erc20, erc721, erc1155) when --reset-schema + open storages for installed standards + register SyntheticErc*MetadataCommandHandler + + SyntheticErc*TokenUriCommandHandler (hand-rolled, no HTTP) + extractor = Synthetic{Erc20|Erc721|Erc1155}Extractor (deterministic) + tight loop: extract → decode → sink.process → commit_cursor → StageSample + write RunReport JSON to perf/runs// +``` + +## Deep Dive + +### Subcommands & flags + +| Subcommand | Exercises | Extra flags | +|---|---|---| +| `erc20` | Erc20 sink alone | `--approval-ratio-bps` | +| `erc721` | Erc721 sink alone | `--approval-ratio-bps`, `--approval-for-all-ratio-bps`, `--metadata-update-ratio-bps`, `--batch-metadata-update-ratio-bps`, `--max-token-id` | +| `erc1155` | Erc1155 sink alone | `--transfer-single-ratio-bps`, `--transfer-batch-ratio-bps`, `--min-batch-size`, `--max-batch-size`, `--approval-for-all-ratio-bps`, `--uri-ratio-bps`, `--token-id-count` | +| `all` | All three concurrently | Combines all of the above | + +Common flags on every subcommand: `--db-url` (default +`postgres://torii:torii@localhost:5432/torii`), `--from-block`, +`--block-count`, `--tx-per-block`, `--blocks-per-batch`, `--token-count`, +`--wallet-count`, `--seed`, `--output-root`, `--reset-schema`. + +### Internal shape + +- Hand-rolled `CommandHandler` implementations for metadata + token-URI so the perf numbers reflect storage only (no reqwest, no JSON parsing): + - `SyntheticErc20MetadataCommandHandler` + - `SyntheticErc721MetadataCommandHandler` / `SyntheticErc721TokenUriCommandHandler` + - `SyntheticErc1155MetadataCommandHandler` / `SyntheticErc1155TokenUriCommandHandler` +- Reuses `drop_postgres_schemas` + `initialize_sink_with_command_handlers` from `torii-runtime-common`. +- Output shape is identical to `torii-erc20-synth` (`RunReport` JSON: `config`, `totals`, `throughput`, `stage_latency_ms`, `stage_share_percent`, `slowest_cycles`, `blocking_suspects`, `db`). + +### Workspace dependencies + +`torii`, `torii-erc20`, `torii-erc721`, `torii-erc1155`, `torii-common`, `torii-runtime-common`, `torii-sql`, plus `clap` (with `derive`), `tokio`, `tracing`, `serde`, `serde_json`, `anyhow`. Feature `profiling` pulls `pprof`. + +### Extension Points + +- New rate-shape for a sink → add fields to the `*SpecificConfig` + propagate through the matching `Synthetic*Config` in the token crate. +- Cross-sink scenarios → the `all` subcommand already runs all three; for more specific combinations add a new subcommand variant. +- This binary is Postgres-only by design. For SQLite perf, fork one; do not parameterise here. diff --git a/bins/torii-tokens-synth/src/main.rs b/bins/torii-tokens-synth/src/main.rs index 792c4a14..c11d1c11 100644 --- a/bins/torii-tokens-synth/src/main.rs +++ b/bins/torii-tokens-synth/src/main.rs @@ -16,7 +16,7 @@ use torii::etl::engine_db::{EngineDb, EngineDbConfig}; use torii::etl::extractor::SyntheticExtractor; use torii::etl::sink::Sink; use torii::etl::{Decoder, DecoderContext}; -use torii_common::{TokenUriResult, TokenUriStore}; +use torii_common::{bytes_to_u256, u256_to_bytes, TokenUriResult, TokenUriStore}; use torii_erc1155::handlers::{FetchErc1155MetadataCommand, RefreshErc1155TokenUriCommand}; use torii_erc1155::{ Erc1155Decoder, Erc1155Sink, Erc1155Storage, SyntheticErc1155Config, SyntheticErc1155Extractor, @@ -30,6 +30,7 @@ use torii_erc721::{ Erc721Decoder, Erc721Sink, Erc721Storage, SyntheticErc721Config, SyntheticErc721Extractor, }; use torii_runtime_common::sink::{drop_postgres_schemas, initialize_sink_with_command_handlers}; +use torii_sql::DbPoolOptions; const SYNTH_COMMAND_QUEUE_SIZE: usize = 4096; const SYNTH_METADATA_COMMAND_PARALLELISM: usize = 1; @@ -162,8 +163,8 @@ impl CommandHandler for SyntheticErc721TokenUriCommandHandler { self.storage .store_token_uri(&TokenUriResult { - contract: command.contract, - token_id: command.token_id, + contract: command.contract.into(), + token_id: bytes_to_u256(&u256_to_bytes(command.token_id)), uri: Some(format!( "synthetic://erc721/{:#x}/{}", command.contract, command.token_id @@ -220,7 +221,7 @@ impl CommandHandler for SyntheticErc1155TokenUriCommandHandler { self.storage .store_token_uri(&TokenUriResult { contract: command.contract, - token_id: command.token_id, + token_id: bytes_to_u256(&u256_to_bytes(command.token_id)), uri: Some(format!( "synthetic://erc1155/{:#x}/{}", command.contract, command.token_id @@ -507,7 +508,8 @@ async fn run_erc20_profile(config: Erc20ProfileConfig) -> Result<()> { fs::create_dir_all(&output_dir) .with_context(|| format!("failed to create output dir {}", output_dir.display()))?; - let storage = Arc::new(Erc20Storage::new(&config.common.db_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&config.common.db_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&config.common.db_url, erc20_pool).await?); let mut sink = Erc20Sink::new(storage.clone()).with_metadata_pipeline( SYNTH_METADATA_COMMAND_PARALLELISM, @@ -565,7 +567,7 @@ async fn run_erc20_profile(config: Erc20ProfileConfig) -> Result<()> { } let decode_start = Instant::now(); - let envelopes = decoder_context.decode(&batch.events).await?; + let envelopes = decoder_context.decode_events(&batch.events).await?; let decode_ms = ms(decode_start.elapsed()); let sink_start = Instant::now(); @@ -703,7 +705,7 @@ async fn run_erc721_profile(config: Erc721ProfileConfig) -> Result<()> { } let decode_start = Instant::now(); - let envelopes = decoder_context.decode(&batch.events).await?; + let envelopes = decoder_context.decode_events(&batch.events).await?; let decode_ms = ms(decode_start.elapsed()); let sink_start = Instant::now(); @@ -780,7 +782,10 @@ async fn run_erc1155_profile(config: Erc1155ProfileConfig) -> Result<()> { fs::create_dir_all(&output_dir) .with_context(|| format!("failed to create output dir {}", output_dir.display()))?; - let storage = Arc::new(Erc1155Storage::new(&config.common.db_url).await?); + let erc1155_pool = DbPoolOptions::new() + .connect_any(&config.common.db_url) + .await?; + let storage = Arc::new(Erc1155Storage::new(erc1155_pool, &config.common.db_url).await?); let mut sink = Erc1155Sink::new(storage.clone()) .with_metadata_commands() @@ -843,7 +848,7 @@ async fn run_erc1155_profile(config: Erc1155ProfileConfig) -> Result<()> { } let decode_start = Instant::now(); - let envelopes = decoder_context.decode(&batch.events).await?; + let envelopes = decoder_context.decode_events(&batch.events).await?; let decode_ms = ms(decode_start.elapsed()); let sink_start = Instant::now(); diff --git a/bins/torii-tokens/Cargo.toml b/bins/torii-tokens/Cargo.toml index b6a98319..8ae30c72 100644 --- a/bins/torii-tokens/Cargo.toml +++ b/bins/torii-tokens/Cargo.toml @@ -10,13 +10,14 @@ path = "src/main.rs" [dependencies] # Core dependencies -torii = { path = "../../" } -torii-common = { path = "../../crates/torii-common" } +torii.workspace = true +torii-common.workspace = true torii-config-common.workspace = true torii-runtime-common.workspace = true -torii-erc20 = { path = "../../crates/torii-erc20" } -torii-erc721 = { path = "../../crates/torii-erc721" } -torii-erc1155 = { path = "../../crates/torii-erc1155" } +torii-erc20.workspace = true +torii-erc721.workspace = true +torii-erc1155.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } # Async runtime tokio = { version = "1", features = ["full"] } diff --git a/bins/torii-tokens/README.md b/bins/torii-tokens/README.md index a4c4361c..04a1389d 100644 --- a/bins/torii-tokens/README.md +++ b/bins/torii-tokens/README.md @@ -1,486 +1,119 @@ -# Torii Tokens - -Unified Starknet token indexer for ERC20, ERC721, and ERC1155 tokens. - -## Quick Start - -```bash -# Block-range mode: auto-discovers all token contracts -torii-tokens --from-block 100000 - -# Enable observability (metrics endpoint + collection) -torii-tokens --observability --from-block 100000 - -# Event mode: index specific contracts only -torii-tokens --mode event --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7 --from-block 100000 -``` - -## Features - -- **Multi-token support**: ERC20, ERC721, and ERC1155 in a single indexer -- **Balance tracking**: Real-time balance computation with on-chain verification -- **Auto-discovery**: Automatically identifies token contracts by ABI inspection (block-range mode) -- **gRPC subscriptions**: Real-time streaming of token events -- **Historical queries**: Paginated queries for transfers, approvals, and ownership -- **Flexible extraction**: Block-range mode for full indexing, event mode for targeted contracts - -## Installation - -```bash -# From the repository root -cargo build --release -p torii-tokens - -# Binary will be at target/release/torii-tokens -``` - -## Usage - -### Block Range Mode (default) - -Fetches ALL events from each block. Best for full chain indexing with auto-discovery. - -```bash -# Auto-discover all token contracts from block 100000 -torii-tokens --from-block 100000 - -# Index with explicit contracts (will also auto-discover others) -torii-tokens --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7 --from-block 0 - -# Index multiple token types -torii-tokens \ - --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7 \ - --erc721 0x...nft \ - --erc1155 0x...game_items \ - --from-block 0 -``` - -### Event Mode - -Uses `starknet_getEvents` with per-contract cursors. Best for indexing specific contracts. - -```bash -# Index specific ERC20 contracts -torii-tokens --mode event \ - --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7 \ - --from-block 0 - -# Index multiple contracts -torii-tokens --mode event \ - --erc20 0x049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7,0x04718f5a0Fc34cC1AF16A1cdee98fFB20C31f5cD61D6Ab07201858f4287c938D \ - --from-block 0 -``` - -### Custom Configuration - -```bash -# Custom RPC and port -torii-tokens \ - --rpc-url https://your-rpc.example.com \ - --port 8080 \ - --from-block 0 - -# Custom database directory -torii-tokens --db-dir /path/to/data --from-block 0 -``` - -### Throughput Tuning - -For large backfills, tune concurrency in this order: `--rpc-parallelism`, then queue depth. - -```bash -# Conservative baseline -torii-tokens --from-block 0 \ - --batch-size 1000 \ - --rpc-parallelism 4 \ - --max-prefetch-batches 8 \ - --metadata-mode deferred - -# More aggressive (if RPC endpoint can sustain it) -torii-tokens --from-block 0 \ - --batch-size 1000 \ - --rpc-parallelism 6 \ - --max-prefetch-batches 8 \ - --metadata-mode deferred -``` - -Notes: - -- `--rpc-parallelism`: concurrent chunked RPC requests (`0` = auto). -- `--max-prefetch-batches`: extracted batches buffered ahead of decode/store. -- `--metadata-mode deferred`: reduce metadata-side RPC/load during backfill. -- `--metadata-parallelism`, `--metadata-queue-capacity`, `--metadata-max-retries` control async metadata workers (ERC20), queue depth, and capped retry attempts. -- `--metadata-queue-capacity` also controls the token-URI request queue for ERC721/ERC1155 in `inline` mode (increase this if you see `Dropping token URI requests: queue is full`). - -### CLI Options - -| Option | Default | Description | -|--------|---------|-------------| -| `--mode` | `block-range` | Extraction mode (`block-range` or `event`) | -| `--rpc-url` | Cartridge mainnet | Starknet RPC endpoint | -| `--from-block` | `0` | Starting block number | -| `--to-block` | None | Ending block (None = follow chain head) | -| `--db-dir` | `./torii-data` | Directory for database files | -| `--database-url` | None | Engine DB URL/path (e.g. `postgres://...`) | -| `--port` | `3000` | HTTP/gRPC server port | -| `--observability` | `false` | Enable observability (Prometheus metrics endpoint + metric collection) | -| `--erc20` | None | ERC20 contract addresses (comma-separated) | -| `--erc721` | None | ERC721 contract addresses (comma-separated) | -| `--erc1155` | None | ERC1155 contract addresses (comma-separated) | -| `--batch-size` | `50` | Blocks per batch (block-range mode) | -| `--event-chunk-size` | `1000` | Events per RPC request (event mode) | -| `--event-block-batch-size` | `10000` | Block range per iteration (event mode) | -| `--max-prefetch-batches` | `2` | Number of extracted batches prefetched ahead | -| `--rpc-parallelism` | `0` | Concurrent chunked RPC requests (`0` = auto) | -| `--metadata-mode` | `inline` | Metadata behavior (`inline` or `deferred`) | -| `--metadata-parallelism` | `8` | Async metadata workers (ERC20 metadata pipeline) | -| `--metadata-queue-capacity` | `2048` | Metadata queue size (ERC20 metadata jobs + ERC721/ERC1155 token-URI request queue in `inline` mode) | -| `--metadata-max-retries` | `5` | Max metadata retry attempts (capped backoff) | - -### Metadata Mode - -- `inline` (default): metadata jobs run while indexing. -- `deferred`: disables ERC721 metadata/token-URI fetch setup to maximize ingest throughput. - -Current behavior details: - -- ERC20 metadata is fetched asynchronously in background workers and is configured by metadata pipeline flags. -- ERC721/ERC1155 token-URI fetch requests use `--metadata-queue-capacity` for queue depth in inline mode. -- ERC1155 token-URI service is disabled in deferred mode; contract metadata fetch can still occur via sink metadata fetcher. - -## Extraction Modes - -### Block Range Mode - -- Fetches complete block data in batches -- Inspects contract ABIs to identify token types automatically -- Single cursor tracks overall progress -- Best for full chain indexing - -### Event Mode - -- Each contract has its own cursor -- Adding a new contract starts fresh from `--from-block` -- Existing contracts resume from their saved position -- Best for targeted indexing with lower resource usage - -**Adding contracts in event mode:** -```bash -# Initial run -torii-tokens --mode event --erc20 0x...ETH,0x...STRK --from-block 0 - -# Add USDC later - ETH and STRK resume from their cursors, USDC starts from block 0 -torii-tokens --mode event --erc20 0x...ETH,0x...STRK,0x...USDC --from-block 0 -``` - -## Database and Cursors - -The indexer maintains internal cursors to track indexing progress. On restart, indexing resumes from the last processed position. - -**To reset and re-index from scratch, delete the database directory:** - -```bash -rm -rf ./torii-data -torii-tokens --from-block 0 -``` - -The indexer creates separate SQLite databases for each component: - -``` - -Set `--database-url` (or `DATABASE_URL`) to run engine + token storages on PostgreSQL. If unset, the local SQLite files below are used. -./torii-data/ - engine.db # ETL state, cursors, statistics - erc20.db # ERC20 transfers, approvals, balances - erc721.db # ERC721 transfers, ownership - erc1155.db # ERC1155 transfers, balances -``` - -Database resolution logic here is shared via `torii-runtime-common` (`resolve_token_db_setup`) to keep SQLite/PostgreSQL behavior consistent across binaries. - -Each database uses WAL mode for performance and crash safety. - -## gRPC API Reference - -### Available Services - -```bash -# List all services -grpcurl -plaintext localhost:3000 list - -# Services: -# - grpc.reflection.v1.ServerReflection -# - torii.Torii (EventBus subscriptions) -# - torii.sinks.erc20.Erc20 -# - torii.sinks.erc721.Erc721 -# - torii.sinks.erc1155.Erc1155 -``` - -### Address Encoding - -All addresses and U256 values are encoded as **base64-encoded big-endian bytes**. - -```bash -# Convert hex address to base64 -printf '%s' "049D36570D4e46f48e99674bd3fcc84644DdD6b96F7C741B1562B82f9e004dC7" | xxd -r -p | base64 -# Output: BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc= - -# Convert base64 back to hex -echo "BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc=" | base64 -d | xxd -p -``` - ---- - -### ERC20 Service - -**Service:** `torii.sinks.erc20.Erc20` - -#### GetStats - -```bash -grpcurl -plaintext localhost:3000 torii.sinks.erc20.Erc20/GetStats -``` - -#### GetTransfers - -```bash -# Get all transfers (first 100) -grpcurl -plaintext -d '{}' localhost:3000 torii.sinks.erc20.Erc20/GetTransfers - -# Filter by wallet (matches from OR to) -grpcurl -plaintext -d '{ - "filter": {"wallet": "BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc="}, - "limit": 10 -}' localhost:3000 torii.sinks.erc20.Erc20/GetTransfers - -# Filter by token contract -grpcurl -plaintext -d '{ - "filter": {"tokens": ["BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc="]}, - "limit": 10 -}' localhost:3000 torii.sinks.erc20.Erc20/GetTransfers - -# Filter by block range -grpcurl -plaintext -d '{ - "filter": {"blockFrom": "100000", "blockTo": "200000"}, - "limit": 10 -}' localhost:3000 torii.sinks.erc20.Erc20/GetTransfers - -# Pagination -grpcurl -plaintext -d '{ - "cursor": {"blockNumber": "150000", "id": "42"}, - "limit": 10 -}' localhost:3000 torii.sinks.erc20.Erc20/GetTransfers -``` - -#### GetApprovals - -```bash -# Filter by account (matches owner OR spender) -grpcurl -plaintext -d '{ - "filter": {"account": "BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc="}, - "limit": 10 -}' localhost:3000 torii.sinks.erc20.Erc20/GetApprovals -``` - -#### SubscribeTransfers - -```bash -# Subscribe to all transfers -grpcurl -plaintext -d '{"clientId": "my-client"}' \ - localhost:3000 torii.sinks.erc20.Erc20/SubscribeTransfers - -# Subscribe with wallet filter -grpcurl -plaintext -d '{ - "clientId": "my-client", - "filter": {"wallet": "BJ02Vw1ORvSOmWdL0/zIRkTd1rlvfHQbFWK4L54ATcc="} -}' localhost:3000 torii.sinks.erc20.Erc20/SubscribeTransfers -``` - -#### SubscribeApprovals - -```bash -grpcurl -plaintext -d '{"clientId": "my-client"}' \ - localhost:3000 torii.sinks.erc20.Erc20/SubscribeApprovals -``` - -**ERC20 Filter Fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `wallet` | bytes | Matches `from` OR `to` | -| `from` | bytes | Exact sender address | -| `to` | bytes | Exact receiver address | -| `tokens` | bytes[] | Token contract whitelist | -| `direction` | enum | `DIRECTION_ALL`, `DIRECTION_SENT`, `DIRECTION_RECEIVED` | -| `blockFrom` | uint64 | Minimum block number | -| `blockTo` | uint64 | Maximum block number | - ---- - -### ERC721 Service - -**Service:** `torii.sinks.erc721.Erc721` - -#### GetStats - -```bash -grpcurl -plaintext localhost:3000 torii.sinks.erc721.Erc721/GetStats -``` - -#### GetTransfers - -```bash -# Filter by wallet -grpcurl -plaintext -d '{ - "filter": {"wallet": "...base64..."}, - "limit": 10 -}' localhost:3000 torii.sinks.erc721.Erc721/GetTransfers - -# Filter by specific NFT token IDs -grpcurl -plaintext -d '{ - "filter": {"tokenIds": ["AQ==", "Ag==", "Aw=="]}, - "limit": 10 -}' localhost:3000 torii.sinks.erc721.Erc721/GetTransfers -``` - -#### GetOwnership - -```bash -# Get all NFTs owned by an address -grpcurl -plaintext -d '{ - "filter": {"owner": "...base64..."}, - "limit": 100 -}' localhost:3000 torii.sinks.erc721.Erc721/GetOwnership -``` - -#### GetOwner - -```bash -# Get owner of a specific NFT -grpcurl -plaintext -d '{ - "token": "...nft_contract_base64...", - "tokenId": "AQ==" -}' localhost:3000 torii.sinks.erc721.Erc721/GetOwner -``` - -#### SubscribeTransfers - -```bash -grpcurl -plaintext -d '{ - "clientId": "my-client", - "filter": {"tokenIds": ["AQ==", "Ag=="]} -}' localhost:3000 torii.sinks.erc721.Erc721/SubscribeTransfers -``` - -**ERC721 Filter Fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `wallet` | bytes | Matches `from` OR `to` | -| `from` | bytes | Exact sender address | -| `to` | bytes | Exact receiver address | -| `tokens` | bytes[] | NFT contract whitelist | -| `tokenIds` | bytes[] | Specific NFT token IDs | -| `blockFrom` | uint64 | Minimum block number | -| `blockTo` | uint64 | Maximum block number | - ---- - -### ERC1155 Service - -**Service:** `torii.sinks.erc1155.Erc1155` - -#### GetStats - -```bash -grpcurl -plaintext localhost:3000 torii.sinks.erc1155.Erc1155/GetStats -``` - -#### GetTransfers - -```bash -# Filter by wallet -grpcurl -plaintext -d '{ - "filter": {"wallet": "...base64..."}, - "limit": 10 -}' localhost:3000 torii.sinks.erc1155.Erc1155/GetTransfers - -# Filter by operator -grpcurl -plaintext -d '{ - "filter": {"operator": "...base64..."}, - "limit": 10 -}' localhost:3000 torii.sinks.erc1155.Erc1155/GetTransfers - -# Filter by specific token IDs -grpcurl -plaintext -d '{ - "filter": {"tokenIds": ["AQ==", "Ag=="]}, - "limit": 10 -}' localhost:3000 torii.sinks.erc1155.Erc1155/GetTransfers -``` - -#### SubscribeTransfers - -```bash -grpcurl -plaintext -d '{ - "clientId": "my-client", - "filter": {"tokens": ["...game_items_contract..."]} -}' localhost:3000 torii.sinks.erc1155.Erc1155/SubscribeTransfers -``` - -**ERC1155 Filter Fields:** - -| Field | Type | Description | -|-------|------|-------------| -| `wallet` | bytes | Matches `from` OR `to` | -| `from` | bytes | Exact sender address | -| `to` | bytes | Exact receiver address | -| `operator` | bytes | Exact operator address | -| `tokens` | bytes[] | Token contract whitelist | -| `tokenIds` | bytes[] | Specific token IDs | -| `blockFrom` | uint64 | Minimum block number | -| `blockTo` | uint64 | Maximum block number | - ---- - -### Core Torii Service - -**Service:** `torii.Torii` - -EventBus-based subscriptions that work across all sinks. - -#### ListTopics - -```bash -grpcurl -plaintext localhost:3000 torii.Torii/ListTopics -``` - -#### SubscribeToTopicsStream - -```bash -# Subscribe to ERC20 transfers via EventBus -grpcurl -plaintext -d '{ - "clientId": "my-client", - "topics": [{"topic": "erc20.transfer"}] -}' localhost:3000 torii.Torii/SubscribeToTopicsStream -``` - -## Environment Variables - -| Variable | Description | -|----------|-------------| -| `STARKNET_RPC_URL` | Default RPC URL (overridden by `--rpc-url`) | -| `RUST_LOG` | Log level (e.g., `info`, `debug`, `torii=debug`) | - -## Logging - -Control log verbosity with `RUST_LOG`: - -```bash -# Default info level -torii-tokens --from-block 0 - -# Debug logging for torii components -RUST_LOG=torii=debug torii-tokens --from-block 0 - -# Trace all SQL queries -RUST_LOG=torii=debug,sqlx=trace torii-tokens --from-block 0 -``` +# torii-tokens (binary) + +Unified **ERC20 + ERC721 + ERC1155 indexer**. Combines all three token +sinks behind one process, one port, one engine DB. Supports three +extraction modes (`block-range`, `event`, `global-event`) so operators +can pick full-chain indexing or per-contract cursors depending on their +use case. + +## Role in Torii + +The production-grade consolidation of `bins/torii-erc20` + equivalent +ERC721/ERC1155 binaries. Most deployments want all three standards in +one place; this is that binary. Every token sink is optional — omit the +`--erc20` / `--erc721` / `--erc1155` / `--include-well-known` flags and +that sink is not installed. See `torii-runtime-common::token_support` for +the boolean trio that drives the install logic. + +## Architecture + +```text ++---------------------------+ +| clap::Parser Config | --mode (block-range | event | global-event) +| (src/config.rs) | --erc20, --erc721, --erc1155 lists +| | --include-well-known (ETH/STRK) +| | --observability (Prometheus) +| | --database-url, --storage-database-url ++--------------+------------+ + | + v ++---------------------------+ +| resolve_token_db_setup | engine.db + erc20.db + erc721.db + erc1155.db +| (torii-runtime-common) | or single Postgres URL for all ++--------------+------------+ + | + v ++------------------------------------------------+ +| main() | +| | +| Extractor selected by --mode: | +| BlockRangeExtractor (one global cursor) | +| EventExtractor (per-contract cursors) | +| GlobalEventExtractor (one cursor via | +| starknet_getEvents) | +| | +| Decoders registered (all three always, but | +| only sinks actually consuming are installed):| +| Erc20Decoder → Erc20Sink + service | +| Erc721Decoder → Erc721Sink + service | +| Erc1155Decoder → Erc1155Sink + service | +| | +| Contract identification: | +| - explicit mappings for each sink's list | +| - well-known ETH + STRK (if requested) | +| - ContractRegistry with Erc20Rule, | +| Erc721Rule, Erc1155Rule | +| - rpc_parallelism = --rpc-parallelism | +| | +| Metadata pipelines: | +| N workers × queue_capacity × max_retries | +| (parallelism=8, queue=2048, retries=5) | +| | +| EtlConcurrencyConfig { | +| max_prefetch_batches: --max-prefetch-batches | +| } | +| | +| grpc_router = builder | +| .add_service(Erc20Server) | +| .add_service(Erc721Server) | +| .add_service(Erc1155Server) | +| + composed reflection (TORII + all 3 descr.) | +| | +| torii::run(config).await | ++------------------------------------------------+ +``` + +## Deep Dive + +### CLI highlights (full list in `src/config.rs`) + +| Flag | Default | Purpose | +|---|---|---| +| `--mode` | `block-range` | Extraction mode: `block-range` / `event` / `global-event` | +| `--rpc-url` (env `STARKNET_RPC_URL`) | Cartridge mainnet | JSON-RPC endpoint | +| `--from-block`, `--to-block` | `0` / none | Range bounds | +| `--db-dir` | `./torii-data` | Root when using SQLite (`engine.db`, `erc20.db`, `erc721.db`, `erc1155.db`) | +| `--database-url` (env `DATABASE_URL`) | — | Postgres URL or SQLite override for the engine DB | +| `--storage-database-url` (env `STORAGE_DATABASE_URL`) | — | Token-storage override (else uses `--database-url`, else `--db-dir`) | +| `--port` | `3000` | gRPC + HTTP port | +| `--observability` | off | Sets `TORII_METRICS_ENABLED=true` via `torii-config-common` | +| `--erc20` / `--erc721` / `--erc1155` | `[]` | Comma-separated contract lists | +| `--include-well-known` | off | Pre-map ETH + STRK | +| `--batch-size` | `50` | Blocks per `BlockRangeExtractor` batch | +| `--event-chunk-size` | `1000` | Events per `starknet_getEvents` page | +| `--event-block-batch-size` | `10000` | Blocks per event-mode iteration | +| `--event-bootstrap-blocks` | `20000` | Scan depth for auto-discovery in event mode | +| `--max-prefetch-batches` | `2` | ETL prefetch queue depth | +| `--cycle-interval` | `3` | Idle retry seconds | +| `--rpc-parallelism` | `0` (auto) | Max concurrent chunked RPC requests | +| `--metadata-parallelism` | `8` | Async metadata fetcher workers | +| `--metadata-queue-capacity` | `2048` | Metadata command queue | +| `--metadata-max-retries` | `5` | Metadata fetch retries | +| `--metadata-mode` | `inline` | `inline` or `deferred` | +| `--metadata-backfill-only` | off | Reserved for future metadata-only workflows | + +### Internal modules + +- `config.rs` — `Config`, `ExtractionMode`, `MetadataMode`, `well_known_erc20_contracts`, `has_tokens`, unit tests for flag parsing. +- `main.rs` — `resolve_token_db_setup` → per-standard storage + sink + decoder + rule → `ContractRegistry.load_from_db` → `ToriiConfig` build → `torii::run`. Composes gRPC reflection from four descriptor sets. + +### Workspace dependencies + +`torii`, `torii-erc20`, `torii-erc721`, `torii-erc1155`, `torii-runtime-common`, `torii-config-common`, `torii-sql` (postgres + sqlite), `starknet`, `tonic`, `tonic-reflection`, `clap`, `tokio`, `tracing`, `anyhow`, `url`. + +### Extension Points + +- Add another token standard → register its decoder + rule + sink + gRPC service + descriptor set; extend `InstalledTokenSupport` in `torii-runtime-common` if the install matrix gets more complex. +- Change the balance model for a single standard → modify the corresponding sink; this binary is pure wiring. + +Paired profiler: `bins/torii-tokens-synth`. diff --git a/bins/torii-tokens/src/main.rs b/bins/torii-tokens/src/main.rs index 3d49c1ff..1e46aba6 100644 --- a/bins/torii-tokens/src/main.rs +++ b/bins/torii-tokens/src/main.rs @@ -61,7 +61,8 @@ use torii_common::{MetadataFetcher, TokenUriService}; use torii_config_common::apply_observability_env; use torii_runtime_common::database::resolve_token_db_setup; #[cfg(feature = "profiling")] -use torii_runtime_common::database::DatabaseBackend; +use torii_sql::DbBackend; +use torii_sql::DbPoolOptions; // Import from ERC20 library crate use torii_erc20::proto::erc20_server::Erc20Server; @@ -97,13 +98,13 @@ async fn contracts_from_registry( for (contract, decoder_ids, _) in mappings { if decoder_ids.contains(&erc20_id) { - erc20.push(contract); + erc20.push(contract.into()); } if decoder_ids.contains(&erc721_id) { - erc721.push(contract); + erc721.push(contract.into()); } if decoder_ids.contains(&erc1155_id) { - erc1155.push(contract); + erc1155.push(contract.into()); } } @@ -188,7 +189,7 @@ async fn bootstrap_registry_for_event_mode( continue; } - let contract_addresses: Vec = batch + let contract_addresses: Vec<_> = batch .events .iter() .map(|event| event.from_address) @@ -445,7 +446,7 @@ async fn run_indexer(config: Config) -> Result<()> { for addr in &all_erc20_addresses { event_configs.push(ContractEventConfig { - address: *addr, + address: (*addr).into(), from_block: config.from_block, to_block, }); @@ -453,7 +454,7 @@ async fn run_indexer(config: Config) -> Result<()> { for addr in &all_erc721_addresses { event_configs.push(ContractEventConfig { - address: *addr, + address: (*addr).into(), from_block: config.from_block, to_block, }); @@ -461,7 +462,7 @@ async fn run_indexer(config: Config) -> Result<()> { for addr in &all_erc1155_addresses { event_configs.push(ContractEventConfig { - address: *addr, + address: (*addr).into(), from_block: config.from_block, to_block, }); @@ -539,7 +540,8 @@ async fn run_indexer(config: Config) -> Result<()> { if create_erc20 { enabled_types.push("ERC20"); - let storage = Arc::new(Erc20Storage::new(&db_setup.erc20_url).await?); + let erc20_pool = Erc20Storage::connect_pool(&db_setup.erc20_url).await?; + let storage = Arc::new(Erc20Storage::from_pool(&db_setup.erc20_url, erc20_pool).await?); tracing::info!("ERC20 database initialized: {}", db_setup.erc20_url); let decoder = Arc::new(Erc20Decoder::new()); @@ -570,7 +572,7 @@ async fn run_indexer(config: Config) -> Result<()> { let erc20_decoder_id = DecoderId::new("erc20"); for address in &all_erc20_addresses { - torii_config = torii_config.map_contract(*address, vec![erc20_decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![erc20_decoder_id]); } if all_erc20_addresses.is_empty() { @@ -586,7 +588,8 @@ async fn run_indexer(config: Config) -> Result<()> { if create_erc721 { enabled_types.push("ERC721"); - let storage = Arc::new(Erc721Storage::new(&db_setup.erc721_url).await?); + let erc721_pool = Erc721Storage::connect_pool(&db_setup.erc721_url).await?; + let storage = Arc::new(Erc721Storage::from_pool(&db_setup.erc721_url, erc721_pool).await?); tracing::info!("ERC721 database initialized: {}", db_setup.erc721_url); let decoder = Arc::new(Erc721Decoder::new()); @@ -625,7 +628,7 @@ async fn run_indexer(config: Config) -> Result<()> { let erc721_decoder_id = DecoderId::new("erc721"); for address in &all_erc721_addresses { - torii_config = torii_config.map_contract(*address, vec![erc721_decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![erc721_decoder_id]); } if all_erc721_addresses.is_empty() { @@ -641,7 +644,10 @@ async fn run_indexer(config: Config) -> Result<()> { if create_erc1155 { enabled_types.push("ERC1155"); - let storage = Arc::new(Erc1155Storage::new(&db_setup.erc1155_url).await?); + let erc1155_pool = DbPoolOptions::new() + .connect_any(&db_setup.erc1155_url) + .await?; + let storage = Arc::new(Erc1155Storage::new(erc1155_pool, &db_setup.erc1155_url).await?); tracing::info!("ERC1155 database initialized: {}", db_setup.erc1155_url); let decoder = Arc::new(Erc1155Decoder::new()); @@ -679,7 +685,7 @@ async fn run_indexer(config: Config) -> Result<()> { let erc1155_decoder_id = DecoderId::new("erc1155"); for address in &all_erc1155_addresses { - torii_config = torii_config.map_contract(*address, vec![erc1155_decoder_id]); + torii_config = torii_config.map_contract((*address).into(), vec![erc1155_decoder_id]); } if all_erc1155_addresses.is_empty() { @@ -782,7 +788,7 @@ async fn run_indexer(config: Config) -> Result<()> { .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let db_backend = if db_setup.erc20_backend == DatabaseBackend::Postgres { + let db_backend = if db_setup.erc20_backend == DbBackend::Postgres { "postgres" } else { "sqlite" diff --git a/crates/arcade-sink/Cargo.toml b/crates/arcade-sink/Cargo.toml index 1c182a02..cb3b8447 100644 --- a/crates/arcade-sink/Cargo.toml +++ b/crates/arcade-sink/Cargo.toml @@ -6,8 +6,10 @@ edition = "2021" [dependencies] torii = { path = "../.." } torii-common = { path = "../torii-common" } +torii-dojo = { workspace = true } torii-introspect = { path = "../introspect" } torii-erc721 = { path = "../torii-erc721" } +torii-sql = { path = "../sql", features = ["postgres", "sqlite"] } torii-runtime-common.workspace = true anyhow.workspace = true diff --git a/crates/arcade-sink/README.md b/crates/arcade-sink/README.md new file mode 100644 index 00000000..03e49b4b --- /dev/null +++ b/crates/arcade-sink/README.md @@ -0,0 +1,129 @@ +# arcade-sink (torii-arcade-sink) + +Projects **Cartridge Arcade** entities (Games, Editions, Collections, +Listings, Sales) out of Dojo/Introspect events. The sink watches +`IntrospectMsg` envelopes from the `torii-dojo` decoder for mutations of +Arcade-model tables, plans refresh/delete operations, and calls into an +`ArcadeService` backed by the introspect SQL store + the ERC721 store to +keep a live read-model that the `arcade.v1` gRPC service serves. + +## Role in Torii + +`bins/torii-arcade` is the only consumer. It runs a full Dojo+token +pipeline and this sink sits near the end of it: introspect-sql-sink +materialises the raw Dojo tables, arcade-sink reads from them +(`ArcadeService::refresh_*`) to build Arcade-specific projections, and +exposes them over gRPC to the Arcade UI. ERC721 data is joined in for NFT +edition metadata. + +## Architecture + +```text +DojoDecoder (torii-dojo) + | + v DojoBody { DojoEvent::Introspect(IntrospectMsg) } + | ++--------------------------------+ +| ArcadeSink | +| | +| build_batch_plan(envelopes): | +| - InsertsFields / Delete- | +| Records / CreateTable / | +| RenamePrimary → plan ops | +| - detects requires_full_ | +| rebuild or reload of | +| tracked_tables | +| | +| process(): | +| if full_rebuild: | +| service.bootstrap_from_ | +| source() | +| for op in ops: | +| TrackedTable.refresh() | +| TrackedTable.delete() | +| | +| tracked_tables: | +| HashMap | +| Game | Edition | | +| Collection | Listing | Sale| ++----------+---------------------+ + | + v ++--------------------------------+ +| ArcadeService | +| Arc> | +| (arcade + erc721 DBs) | +| | +| refresh_game/edition/… | +| delete_game/edition/… | +| bootstrap_from_source() | ++--------------------------------+ + | + v + gRPC: arcade.v1 service (proto/arcade.v1.proto) + via tonic + descriptor set (arcade_descriptor) +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `ArcadeSink` | `src/sink.rs` | 16 | Sink — holds `Arc` + `RwLock>` | +| `ArcadeSink::new(db_url, erc721_db_url, max_conns)` | `src/sink.rs` | 111 | Opens the arcade + erc721 pools | +| `ArcadeService` | `src/grpc_service.rs` | — | gRPC service backing the `arcade.v1` RPCs | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs` | 14 | Bytes for gRPC reflection | +| `proto::arcade` | `src/lib.rs` | 4 | Generated `arcade.v1` types | + +Internal: `ProjectionOp` (Refresh / Delete), `ProjectionBatchPlan`, `TrackedTable` (5 known Arcade models + their hard-coded table IDs), `from_table_name()` fallback by model name (`ARCADE-Game`, `ARCADE-Edition`, `ARCADE-Collection`, `ARCADE-Order`/`ARCADE-Listing`, `ARCADE-Sale`). + +### Internal Modules + +- `lib.rs` — re-exports + `tonic::include_proto!("arcade.v1")` + descriptor bytes. +- `sink.rs` — `ArcadeSink`, `ArcadeSink::build_batch_plan`, `TrackedTable` mapping + async `refresh` / `delete` dispatchers. +- `grpc_service.rs` — `ArcadeService`; SQL reader + writer; implements the `arcade.v1` service. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"arcade"` | +| `interested_types` | `[DOJO_TYPE_ID]` — consumes all Dojo envelopes and filters for `IntrospectMsg::Inserts/Delete/CreateTable/RenamePrimary` | +| `process(envelopes, _batch)` | Builds a `ProjectionBatchPlan` (maybe with `reload_tracked_tables` or `requires_full_rebuild`), then calls `refresh_*` / `delete_*` on the service per op | +| `topics` | `Vec::new()` — no EventBus topic | +| `build_routes` | `Router::new()` — no HTTP routes (gRPC only) | +| `initialize` | No-op (pools already open from `new`) | + +The gRPC service is **not** registered through `Sink::build_routes` — like +every gRPC-exposing sink, the binary wires it into tonic's router via +`ToriiConfigBuilder::with_grpc_router(Server::builder().add_service(...))` +before calling `torii::run`. + +### Protobuf & gRPC + +- Proto: `proto/arcade.v1.proto`. +- `build.rs` invokes `tonic_build` to generate the module and the descriptor set (`arcade_descriptor`). +- The descriptor is exported so binaries can compose reflection: + `ToriiConfigBuilder::with_custom_reflection(true)` + a user-built `tonic_reflection` registering `TORII_DESCRIPTOR_SET` + `arcade_sink::FILE_DESCRIPTOR_SET` + any token-sink descriptors. + +### Storage + +Two SQL pools: +- **Arcade DB**: Arcade-specific tables populated by `bootstrap_from_source` + `refresh_*`. +- **ERC721 DB**: reads NFT ownership/metadata written by `torii-erc721`. + +Both use sqlx's `Any` backend, so the binary can pick SQLite or PostgreSQL +via `torii-runtime-common::resolve_*_db_setup`. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-arcade` only. +- **Downstream deps**: `torii`, `torii-common`, `torii-dojo`, `torii-introspect`, `torii-erc721`, `torii-sql`, `tonic`, `prost`, `sqlx`, `starknet`, `async-trait`, `tokio`, `tracing`. + +### Extension Points + +- New tracked table → add an `ARCADE-Foo` table ID to `TrackedTable::known_table_ids` + a `TrackedTable::Foo` variant + `refresh_foo` / `delete_foo` on `ArcadeService`. +- New projection operation → extend `ProjectionOp` and add a branch in `build_batch_plan_from_tables` + `process`. +- New RPCs → add to `proto/arcade.v1.proto` and `ArcadeService`. The descriptor set regenerates automatically on build. diff --git a/crates/arcade-sink/src/grpc_service.rs b/crates/arcade-sink/src/grpc_service.rs index 205cf48c..835dc66c 100644 --- a/crates/arcade-sink/src/grpc_service.rs +++ b/crates/arcade-sink/src/grpc_service.rs @@ -4,14 +4,13 @@ use std::sync::Arc; use anyhow::{anyhow, Result}; use serde_json::Value; -use sqlx::{ - any::AnyPoolOptions, sqlite::SqliteConnectOptions, Any, ConnectOptions, Pool, QueryBuilder, Row, -}; +use sqlx::{any::AnyPoolOptions, Any, Pool, QueryBuilder, Row}; use starknet::core::types::{Felt, U256}; use tonic::{Request, Response, Status}; use torii_common::{blob_to_u256, u256_to_blob}; use torii_erc721::{storage::OwnershipCursor, Erc721Storage}; use torii_runtime_common::database::DEFAULT_SQLITE_MAX_CONNECTIONS; +use torii_sql::DbPool; use crate::proto::arcade::{ arcade_server::Arcade, Collection, Edition, Game, GetPlayerInventoryRequest, @@ -119,11 +118,14 @@ impl ArcadeService { .connect(&database_url) .await?; + let erc721_pool: DbPool = Erc721Storage::connect_pool(erc721_database_url).await?; + let erc721 = Arc::new(Erc721Storage::from_pool(erc721_database_url, erc721_pool).await?); + let service = Self { state: Arc::new(ArcadeState { pool, backend, - erc721: Arc::new(Erc721Storage::new(erc721_database_url).await?), + erc721, }), }; @@ -1754,7 +1756,7 @@ fn row_string(row: &sqlx::any::AnyRow, column: &str) -> Result { fn row_felt_hex(row: &sqlx::any::AnyRow, column: &str) -> Result { if let Ok(bytes) = row.try_get::, _>(column) { - return Ok(felt_hex(Felt::from_bytes_be_slice(&bytes))); + return Ok(felt_hex(felt_from_bytes(&bytes)?)); } let value = row @@ -1887,7 +1889,13 @@ fn felt_from_hex(value: &str) -> Result { } fn felt_from_bytes(value: &[u8]) -> Result { - Ok(Felt::from_bytes_be_slice(value)) + if value.len() > 32 { + return Err(anyhow!("felt bytes exceed 32 bytes")); + } + + let mut padded = [0u8; 32]; + padded[32 - value.len()..].copy_from_slice(value); + Ok(Felt::from_bytes_be(&padded)) } fn decimal_to_bytes(value: &str) -> Result> { @@ -1914,7 +1922,8 @@ fn decimal_to_bytes(value: &str) -> Result> { } } - Ok(u256_to_blob(blob_to_u256(&bytes))) + let value: U256 = blob_to_u256(&bytes); + Ok(u256_to_blob(value)) } fn u256_to_bytes(value: U256) -> Vec { @@ -1928,16 +1937,14 @@ fn sqlite_url(path: &str) -> Result { if path.starts_with("sqlite:") { return Ok(path.to_string()); } - let options = SqliteConnectOptions::from_str(&format!("sqlite://{path}")) - .or_else(|_| Ok::<_, sqlx::Error>(SqliteConnectOptions::new().filename(path)))?; - if let Some(parent) = options - .get_filename() - .parent() - .filter(|path| !path.as_os_str().is_empty()) - { + let path = std::path::Path::new(path); + if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } - Ok(options.to_url_lossy().to_string()) + if !path.exists() { + std::fs::File::create(path)?; + } + Ok(format!("sqlite://{}", path.display())) } fn internal_status(error: impl std::fmt::Display) -> Status { diff --git a/crates/arcade-sink/src/sink.rs b/crates/arcade-sink/src/sink.rs index e95ec912..374b76e3 100644 --- a/crates/arcade-sink/src/sink.rs +++ b/crates/arcade-sink/src/sink.rs @@ -1,17 +1,15 @@ use std::collections::HashMap; -use std::sync::Arc; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use anyhow::Result; use async_trait::async_trait; use starknet::core::types::Felt; use torii::axum::Router; -use torii::etl::{ - envelope::{Envelope, TypeId}, - extractor::ExtractionBatch, - sink::{EventBus, Sink, SinkContext, TopicInfo}, -}; -use torii_introspect::events::{IntrospectBody, IntrospectMsg}; +use torii::etl::envelope::{Envelope, TypeId}; +use torii::etl::extractor::ExtractionBatch; +use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; +use torii_dojo::{DojoBody, DojoEvent, DOJO_TYPE_ID}; +use torii_introspect::events::IntrospectMsg; use crate::grpc_service::ArcadeService; @@ -237,19 +235,22 @@ impl ArcadeSink { }; for envelope in envelopes { - if envelope.type_id != TypeId::new("introspect") { + if envelope.type_id != DOJO_TYPE_ID { continue; } - let Some(body) = envelope.downcast_ref::() else { + let Some(body) = envelope.downcast_ref::() else { + continue; + }; + let DojoEvent::Introspect(msg) = &body.msg else { continue; }; - if Self::is_schema_rebuild_msg(&plan.tracked_tables, &body.msg) { + if Self::is_schema_rebuild_msg(&plan.tracked_tables, msg) { plan.requires_full_rebuild = true; } - match &body.msg { + match msg { IntrospectMsg::CreateTable(table) => { Self::update_tracked_table(&mut plan.tracked_tables, table.id, &table.name); plan.reload_tracked_tables = true; @@ -271,7 +272,15 @@ impl ArcadeSink { Self::tracked_table_for_id(&plan.tracked_tables, insert.table) { for record in &insert.records { - let entity_id = Felt::from_bytes_be_slice(&record.id); + let entity_id = Felt::from_bytes_be(&record.id); + if entity_id == Felt::ZERO && record.id.iter().any(|byte| *byte != 0) { + tracing::warn!( + target: "torii_arcade_sink::sink", + tracked_table = ?tracked_table, + "Skipping malformed entity id in introspect insert record" + ); + continue; + } plan.projection_ops .push(ProjectionOp::Refresh(tracked_table, entity_id)); } @@ -314,7 +323,7 @@ impl Sink for ArcadeSink { } fn interested_types(&self) -> Vec { - vec![TypeId::new("introspect")] + vec![DOJO_TYPE_ID] } async fn process(&self, envelopes: &[Envelope], _batch: &ExtractionBatch) -> Result<()> { @@ -372,17 +381,14 @@ mod tests { use std::str::FromStr; use anyhow::Result; - use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, - Row, - }; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use sqlx::Row; use starknet::core::types::Felt; use tempfile::tempdir; use torii::etl::extractor::ExtractionBatch; - use torii::etl::{Envelope, MetaData}; - use torii_introspect::events::{ - InsertsFields, IntrospectBody, IntrospectMsg, Record, RenamePrimary, - }; + use torii::etl::{Envelope, EventContext, EventMsg}; + use torii_dojo::DojoEvent; + use torii_introspect::events::{InsertsFields, IntrospectMsg, Record, RenamePrimary}; use super::*; @@ -391,14 +397,11 @@ mod tests { "0x8c6f6a3efadfd0f4a8408d7ad1b4e37f4f733fe10b8a5ef9a76fd4d8be3b5b"; fn introspect_envelope(msg: IntrospectMsg) -> Envelope { - Envelope::from(IntrospectBody { - metadata: MetaData { - block_number: Some(1), - transaction_hash: Felt::ZERO, - from_address: Felt::ZERO, - }, - msg, - }) + Envelope::from(DojoEvent::Introspect(msg).to_body(EventContext { + block_number: 1, + transaction_hash: Felt::ZERO.into(), + from_address: Felt::ZERO.into(), + })) } fn tracked_tables() -> HashMap { diff --git a/crates/dojo/Cargo.toml b/crates/dojo/Cargo.toml index 0e09b633..e2942159 100644 --- a/crates/dojo/Cargo.toml +++ b/crates/dojo/Cargo.toml @@ -11,27 +11,32 @@ serde.workspace = true starknet-types-core.workspace = true starknet.workspace = true tokio.workspace = true -torii-common.workspace = true -torii.workspace = true anyhow.workspace = true tracing.workspace = true thiserror.workspace = true hex.workspace = true itertools.workspace = true - -dojo-introspect.workspace = true -introspect-types.workspace = true -torii-introspect.workspace = true -torii-postgres.workspace = true -torii-sqlite.workspace = true sqlx = { workspace = true, features = [ - "postgres", - "sqlite", "runtime-tokio-rustls", "macros", "migrate", ] } +starknet-types-raw.workspace = true +dojo-introspect.workspace = true +introspect-types.workspace = true + +torii-introspect.workspace = true +torii-sql.workspace = true +torii-types.workspace = true +torii-common.workspace = true +torii.workspace = true + + +[features] +postgres = ["sqlx/postgres"] +sqlite = ["sqlx/sqlite"] + [build-dependencies] tonic-build.workspace = true diff --git a/crates/dojo/README.md b/crates/dojo/README.md new file mode 100644 index 00000000..96ee1d80 --- /dev/null +++ b/crates/dojo/README.md @@ -0,0 +1,128 @@ +# torii-dojo + +Decoder and type library for **Dojo ECS** worlds. Translates Cairo-level +events emitted by the `dojo::world::World` contract into typed +`IntrospectMsg` payloads that downstream sinks (`introspect-sql-sink`, +`torii-ecs-sink`, `arcade-sink`) materialise. Also owns the +`ExternalContractRegistered` event + a `CommandHandler` that wires runtime +contract registrations back into the `ContractRegistry`. + +## Role in Torii + +`DojoDecoder` is the *one* decoder every Dojo-aware binary registers. It +listens to the world contract for schema mutations (table create/update, +record inserts/deletes, event declarations) and emits `DojoEvent` +envelopes tagged with `DOJO_TYPE_ID`. Any sink that declares +`interested_types() == [DOJO_TYPE_ID]` gets the entire Dojo event stream. + +The secondary responsibility — `ExternalContractRegistered` — +is how a live Dojo world advertises that a deployed contract should be +indexed as an ERC20/721/1155 token: the sink that consumes the event +dispatches a `RegisterExternalContractCommand` through the command bus; +the paired handler updates the `SharedDecoderRegistry` so the +`ContractRegistry` immediately starts routing that contract to the right +decoder. + +## Architecture + +```text ++--------------------------------+ +| StarknetEvent | +| (from Dojo world contract) | ++---------------+----------------+ + | + v ++------------------------------------------+ +| DojoDecoder | +| | +| async fn decode(keys, data, ctx): | +| select on event selector → | +| ModelRegistered, ModelWithSchemaReg- | +| istered, ModelUpgraded, | +| EventRegistered, EventUpgraded, | +| EventEmitted, StoreSetRecord, | +| StoreUpdateRecord, StoreUpdateMember, | +| StoreDelRecord, ExternalContract | +| Registered | +| | +| for table events: look up / insert | +| DojoTableInfo in self.tables | +| (RwLock>)| +| | +| for record events: translate via | +| DojoRecordEvent::event_to_msg() | +| into IntrospectMsg | +| | +| for schema-refresh: fetch schema via | +| DojoSchemaFetcher (F) | ++--------+---------------------------------+ + | | + | | + v v ++----------------+ +------------------------+ +| DojoEvent | | DojoEvent::External | +| ::Introspect | | ContractRegistered | +| (IntrospectMsg| | → EventBody | +| — 13 variants| | → consumed by sinks | +| from torii- | | that dispatch | +| introspect) | | RegisterExternal- | ++-------+--------+ | ContractCommand | + | +-----------+------------+ + | | + | v + | +------------------------+ + | | RegisterExternal | + | | ContractCommand | + | | Handler | + | | updates Shared | + | | {Contract,Decoder} | + | | Registry | + | +------------------------+ + | + v + envelopes (DOJO_TYPE_ID) flow to: + - introspect-sql-sink (persists schema + records) + - torii-ecs-sink (entity/event-message classification) + - arcade-sink (Arcade projections) +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `DojoDecoder` | `src/decoder.rs` | 55 | Decoder generic over a `DojoStoreTrait` impl + a `DojoSchemaFetcher` | +| `DojoEvent`, `DojoBody` | `src/decoder.rs` | 35 / 40 | Envelope union: `Introspect(IntrospectMsg)` \| `ExternalContractRegistered(...)` | +| `DOJO_TYPE_ID` | `src/decoder.rs` | 32 | `TypeId::new("dojo")` — the single type id every Dojo-aware sink subscribes to | +| `DojoTableEvent` | `src/decoder.rs` | 79 | Async trait for model registration events | +| `DojoRecordEvent` | `src/decoder.rs` | 88 | Sync trait for record mutations | +| `DojoTable` | `src/table.rs` | 14 | Dojo model metadata (columns, primary key, key/value field separation) | +| `ExternalContractRegistered`, `ExternalContractRegisteredEvent`, `ExternalContractRegisteredBody` | `src/external_contract.rs` | 73 / 31 | On-chain registration of external token contracts | +| `RegisterExternalContractCommand` | `src/external_contract.rs` | — | Torii command dispatched on registration | +| `RegisterExternalContractCommandHandler` | `src/external_contract.rs` | — | `CommandHandler` that updates the shared registries | +| `SharedContractTypeRegistry`, `SharedDecoderRegistry` | `src/external_contract.rs` | 28 / 29 | `Arc>>` handles shared between sink and handler | +| `RegisteredContractType` | `src/external_contract.rs` | 20 | `World` / `Erc20` / `Erc721` / `Erc1155` / `Other` | +| `resolve_external_contract`, `contract_type_from_decoder_ids` | `src/external_contract.rs` | — | Map a Dojo `contract_name` to `(DecoderIds, RegisteredContractType)` | +| `DojoToriiError`, `DojoToriiResult` | `src/error.rs` | — | Domain errors | + +### Internal Modules + +- `decoder` — `DojoDecoder` + the `Decoder` impl + `DojoEvent` union + helper macros for Cairo deserialisation. +- `event` — lightweight envelope-free wrapper used internally for deserialisation plumbing. +- `table` — `DojoTable` / `DojoTableInfo` + `sort_columns` helper. +- `external_contract` — everything around `ExternalContractRegistered`: types, command, handler, shared registries, `RegisteredContractType` + resolver. +- `error` — `DojoToriiError` (deserialisation failures, schema fetch failures, missing primary key, etc.). +- `store` — `DojoStoreTrait` + no-op SQLite/Postgres impls (retained for callers that need a pass-through store; real persistence happens in `introspect-sql-sink`). + +### Interactions + +- **Upstream (consumers)**: `introspect-sql-sink`, `torii-ecs-sink`, `arcade-sink`, every Dojo-capable binary (`bins/torii-introspect-bin`, `bins/torii-arcade`, `bins/torii-introspect-synth`). +- **Downstream deps**: `torii`, `torii-common`, `torii-introspect`, `torii-sql` (for the optional store impls), `torii-types`, `dojo-introspect`, `introspect-events`, `introspect-rust-macros`, `introspect-types`, `starknet`, `starknet-types-core`, `starknet-types-raw`, `async-trait`, `serde`, `tokio`, `itertools`. +- **Features**: `postgres`, `sqlite` — enable the corresponding sqlx driver in `store/{postgres,sqlite}`. + +### Extension Points + +- New Cairo event → add a variant to `DojoEvent` + a branch in `DojoDecoder::decode`; if it mutates a table, implement `DojoRecordEvent` / `DojoTableEvent` and translate to an `IntrospectMsg` so the existing sinks Just Work. +- New external-contract type → extend `RegisteredContractType` and `resolve_external_contract`. +- Custom store backend → implement `DojoStoreTrait`; wire it into `DojoDecoder::new`. diff --git a/crates/dojo/migrations/001_dojo_store.sql b/crates/dojo/migrations/postgres/001_dojo_store.sql similarity index 100% rename from crates/dojo/migrations/001_dojo_store.sql rename to crates/dojo/migrations/postgres/001_dojo_store.sql diff --git a/crates/dojo/src/decoder.rs b/crates/dojo/src/decoder.rs index 1b95122d..ecf1ed90 100644 --- a/crates/dojo/src/decoder.rs +++ b/crates/dojo/src/decoder.rs @@ -9,6 +9,7 @@ use dojo_introspect::events::{ ModelWithSchemaRegistered, StoreDelRecord, StoreSetRecord, StoreUpdateMember, StoreUpdateRecord, }; +use dojo_introspect::selector::compute_selector_from_dojo_tag; use dojo_introspect::serde::dojo_primary_def; use dojo_introspect::{DojoSchema, DojoSchemaFetcher}; use introspect_types::{ @@ -16,23 +17,46 @@ use introspect_types::{ SliceFeltSource, }; use itertools::Itertools; -use starknet::core::types::EmittedEvent; use starknet_types_core::felt::Felt; -use std::collections::HashMap; +use starknet_types_raw::Felt as RawFelt; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::sync::RwLock; -use torii::etl::event::EmittedEventExt; -use torii::etl::{Decoder, Envelope, EventMsg}; +use torii::etl::{Decoder, Envelope, EventBody, EventMsg, TypeId}; use torii_introspect::events::{IntrospectBody, IntrospectMsg}; -use torii_introspect::schema::{TableMetadata, TableSchema}; -use torii_introspect::EventId; +use torii_introspect::schema::TableSchema; +use torii_introspect::{CreateTable, EventId}; +use torii_types::event::EventContext; pub const DOJO_ID_FIELD_NAME: &str = "entity_id"; +pub const DOJO_TYPE_ID: TypeId = TypeId::new("dojo"); + +#[derive(Debug, Clone)] +pub enum DojoEvent { + Introspect(IntrospectMsg), + ExternalContractRegistered(ExternalContractRegistered), +} + +pub type DojoBody = EventBody; + +impl EventMsg for DojoEvent { + fn event_id(&self) -> String { + match self { + Self::Introspect(msg) => msg.event_id(), + Self::ExternalContractRegistered(msg) => msg.event_id(), + } + } + + fn envelope_type_id(&self) -> TypeId { + DOJO_TYPE_ID + } +} pub struct DojoDecoder { pub tables: RwLock>, pub store: Store, pub fetcher: F, + pub append_only: HashSet<(Felt, Felt)>, } fn deserialize_data<'a, T>(keys: &[Felt], data: &'a [Felt]) -> DojoToriiResult @@ -47,12 +71,16 @@ where } } +fn raw_to_core(raw: RawFelt) -> Felt { + raw.into() +} + #[async_trait] pub trait DojoTableEvent: Sized + CairoEventInfo + Debug { type Msg: EventId; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult; } @@ -65,30 +93,26 @@ pub trait DojoRecordEvent: Sized + CairoEventInfo + Debug { #[async_trait] impl DojoStoreTrait for DojoDecoder where - Store: DojoStoreTrait + Send + Sync, - Store::Error: ToString, - F: Send + Sync + 'static, + Store: DojoStoreTrait + Sync, + F: Sync, { - type Error = DojoToriiError; - + async fn initialize(&self) -> DojoToriiResult<()> { + self.store.initialize().await + } async fn save_table( &self, - owner: &Felt, + owner: Felt, table: &DojoTable, - tx_hash: &Felt, + tx_hash: Felt, block_number: u64, ) -> DojoToriiResult<()> { self.store .save_table(owner, table, tx_hash, block_number) .await - .map_err(DojoToriiError::store_error) } async fn read_tables(&self, owners: &[Felt]) -> DojoToriiResult> { - self.store - .read_tables(owners) - .await - .map_err(DojoToriiError::store_error) + self.store.read_tables(owners).await } } @@ -103,31 +127,36 @@ pub fn primary_field_def() -> PrimaryDef { impl DojoDecoder { pub fn with_table( &self, - id: &Felt, + id: Felt, f: impl FnOnce(&DojoTableInfo) -> DojoToriiResult, ) -> DojoToriiResult { let tables = self.tables.read()?; let table = tables - .get(id) - .ok_or_else(|| DojoToriiError::TableNotFoundById(*id))?; + .get(&id) + .ok_or_else(|| DojoToriiError::TableNotFoundById(id))?; f(table) } } impl DojoDecoder where - Store: DojoStoreTrait + Sync, + Store: DojoStoreTrait + Sync + Send, F: DojoSchemaFetcher + Send + Sync + 'static, { - pub fn new>(store: S, fetcher: F) -> Self { - let store = store.into(); + pub fn new(store: Store, fetcher: F) -> Self { Self { tables: Default::default(), store, fetcher, + append_only: HashSet::new(), + } + } + pub fn append_historical(&mut self, models: HashSet<(Felt, String)>) { + for (address, name) in models { + let table_id = compute_selector_from_dojo_tag(&name).unwrap(); + self.append_only.insert((address, table_id)); } } - pub async fn load_tables(&self, owners: &[Felt]) -> DojoToriiResult<()> { let new = self.read_tables(owners).await?; let mut tables = self.tables.write()?; @@ -155,25 +184,22 @@ where tables: RwLock::new(tables), store, fetcher, + append_only: HashSet::new(), } } pub async fn register_table( &self, - owner: &Felt, namespace: &str, name: &str, schema: DojoSchema, - metadata: &impl TableMetadata, - ) -> DojoToriiResult { + context: EventContext, + ) -> DojoToriiResult { + let owner = raw_to_core(context.from_address); + let tx_hash = raw_to_core(context.transaction_hash); let full_table = DojoTable::from_schema(schema, namespace, name, dojo_primary_def()); - self.save_table( - owner, - &full_table, - metadata.tx_hash(), - metadata.block_number(), - ) - .await?; + self.save_table(owner, &full_table, tx_hash, context.block_number) + .await?; let (id, table) = full_table.clone().into(); { if let Some(existing) = self.tables.read()?.get(&id) { @@ -185,16 +211,20 @@ where } } self.tables.write()?.insert(id, table); - Ok(full_table.into()) + Ok(CreateTable::from_schema( + full_table.into(), + self.append_only.contains(&(owner, id)), + )) } pub async fn update_table( &self, - owner: &Felt, id: Felt, schema: DojoSchema, - meta_data: &impl TableMetadata, + context: EventContext, ) -> DojoToriiResult { + let owner = raw_to_core(context.from_address); + let tx_hash = raw_to_core(context.transaction_hash); let mut info = { let mut tables = self.tables.write()?; match tables.remove(&id) { @@ -207,7 +237,7 @@ where info.key_fields = key_fields; info.value_fields = value_fields; let table = (id, info).into(); - self.save_table(owner, &table, meta_data.tx_hash(), meta_data.block_number()) + self.save_table(owner, &table, tx_hash, context.block_number) .await?; let (_, info) = table.clone().into(); self.tables.write()?.insert(id, info); @@ -216,16 +246,16 @@ where async fn process_table_event<'a, E>( &self, - raw: &EmittedEvent, keys: &'a [Felt], - values: &'a [Felt], + data: &'a [Felt], + context: EventContext, ) -> DojoToriiResult where E: DojoTableEvent + CairoEvent>> + Send, E::Msg: Into, { - deserialize_data::(keys, values)? - .event_to_msg(raw, self) + deserialize_data::(keys, data)? + .event_to_msg(context, self) .await .ok_into() } @@ -233,13 +263,13 @@ where fn process_record_event<'a, E>( &self, keys: &'a [Felt], - values: &'a [Felt], + data: &'a [Felt], ) -> DojoToriiResult where E: DojoRecordEvent + CairoEvent>> + Send, E::Msg: Into, { - deserialize_data::(keys, values)? + deserialize_data::(keys, data)? .event_to_msg(self) .ok_into() } @@ -252,63 +282,82 @@ where deserialize_data::(keys, values).map(Into::into) } - pub async fn decode_event_data( + pub async fn event_with_selector_to_msg( &self, - raw: &EmittedEvent, - selector: &Felt, + selector: Felt, keys: &[Felt], - values: &[Felt], + data: &[Felt], + context: EventContext, ) -> DojoToriiResult { let selector_raw = selector.to_raw(); match selector_raw { ModelRegistered::SELECTOR_RAW => { - self.process_table_event::(raw, keys, values) + self.process_table_event::(keys, data, context) .await } ModelWithSchemaRegistered::SELECTOR_RAW => { - self.process_table_event::(raw, keys, values) + self.process_table_event::(keys, data, context) .await } ModelUpgraded::SELECTOR_RAW => { - self.process_table_event::(raw, keys, values) + self.process_table_event::(keys, data, context) .await } EventRegistered::SELECTOR_RAW => { - self.process_table_event::(raw, keys, values) + self.process_table_event::(keys, data, context) .await } EventUpgraded::SELECTOR_RAW => { - self.process_table_event::(raw, keys, values) + self.process_table_event::(keys, data, context) .await } - StoreSetRecord::SELECTOR_RAW => { - self.process_record_event::(keys, values) - } + StoreSetRecord::SELECTOR_RAW => self.process_record_event::(keys, data), StoreUpdateRecord::SELECTOR_RAW => { - self.process_record_event::(keys, values) + self.process_record_event::(keys, data) } StoreUpdateMember::SELECTOR_RAW => { - self.process_record_event::(keys, values) - } - StoreDelRecord::SELECTOR_RAW => { - self.process_record_event::(keys, values) + self.process_record_event::(keys, data) } - EventEmitted::SELECTOR_RAW => self.process_record_event::(keys, values), - _ => Err(DojoToriiError::UnknownDojoEventSelector(*selector)), + StoreDelRecord::SELECTOR_RAW => self.process_record_event::(keys, data), + EventEmitted::SELECTOR_RAW => self.process_record_event::(keys, data), + _ => Err(DojoToriiError::UnknownDojoEventSelector(selector)), } } - pub async fn decode_raw_event_msg(&self, raw: &EmittedEvent) -> DojoToriiResult { - let (selector, keys) = raw - .split_keys() + pub async fn event_with_selector_to_body( + &self, + selector: Felt, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> DojoToriiResult { + self.event_with_selector_to_msg(selector, keys, data, context) + .await + .map(|msg| msg.to_body(context)) + } + + pub async fn event_to_msg( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> DojoToriiResult { + let (selector, keys) = keys + .split_first() .ok_or(DojoToriiError::MissingEventSelector)?; - self.decode_event_data(raw, selector, keys, &raw.data).await + self.event_with_selector_to_msg(*selector, keys, data, context) + .await } - pub async fn decode_raw_event(&self, raw: &EmittedEvent) -> DojoToriiResult { - self.decode_raw_event_msg(raw) + pub async fn event_to_body( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> DojoToriiResult { + self.event_to_msg(keys, data, context) .await - .map(|msg| msg.to_body(raw)) + .map(|msg| msg.to_body(context)) } } @@ -322,39 +371,46 @@ where "dojo-introspect" } - async fn decode_event(&self, event: &EmittedEvent) -> AnyResult> { - let (selector, keys) = event - .split_keys() + async fn decode( + &self, + keys: &[RawFelt], + data: &[RawFelt], + context: EventContext, + ) -> AnyResult> { + let (&selector, keys) = keys + .split_first() .ok_or(DojoToriiError::MissingEventSelector)?; + let selector = raw_to_core(selector); + let keys: Vec = keys.iter().copied().map(raw_to_core).collect(); + let data: Vec = data.iter().copied().map(raw_to_core).collect(); - if selector.to_raw() == ExternalContractRegisteredEvent::SELECTOR_RAW { + if selector == ExternalContractRegisteredEvent::SELECTOR { return self - .process_external_contract_event(keys, &event.data) - .map(|msg| vec![msg.to_envelope(event)]) + .process_external_contract_event(&keys, &data) + .map(|msg| DojoEvent::ExternalContractRegistered(msg).to_envelopes(context)) .err_into(); } - self.decode_event_data(event, selector, keys, &event.data) + match self + .event_with_selector_to_msg(selector, &keys, &data, context) .await - .map(|msg| vec![msg.to_body(event).into()]) - .err_into() + { + Ok(msg) => DojoEvent::Introspect(msg).to_ok_envelopes(context), + Err(DojoToriiError::UnknownDojoEventSelector(_)) => Ok(Vec::new()), + Err(e) => Err(e.into()), + } } } #[cfg(test)] mod tests { use super::*; - use crate::ExternalContractRegisteredBody; use async_trait::async_trait; use dojo_introspect::DojoIntrospectError; - use introspect_types::{ - utils::string_to_cairo_serialize_byte_array, Attribute, ColumnDef, TypeDef, - }; + use introspect_types::utils::string_to_cairo_serialize_byte_array; + use introspect_types::{Attribute, ColumnDef, TypeDef}; use std::sync::Mutex; - - #[derive(Debug, thiserror::Error)] - #[error("{0}")] - struct FakeStoreError(String); + use torii::etl::StarknetEvent; #[derive(Default)] struct FakeStore { @@ -363,20 +419,21 @@ mod tests { #[async_trait] impl DojoStoreTrait for FakeStore { - type Error = FakeStoreError; - + async fn initialize(&self) -> DojoToriiResult { + Ok(()) + } async fn save_table( &self, - _owner: &Felt, + _owner: Felt, _table: &DojoTable, - _tx_hash: &Felt, + _tx_hash: Felt, block_number: u64, - ) -> Result<(), Self::Error> { + ) -> DojoToriiResult { self.saved_blocks.lock().unwrap().push(block_number); Ok(()) } - async fn read_tables(&self, _owners: &[Felt]) -> Result, Self::Error> { + async fn read_tables(&self, _owners: &[Felt]) -> DojoToriiResult> { Ok(Vec::new()) } } @@ -427,20 +484,23 @@ mod tests { decoder .update_table( - &owner, table_id, schema(&[ (1, "entity_id", true), (2, "health", false), (3, "armor", false), ]), - &(42, Felt::ZERO), + EventContext { + from_address: owner.into(), + transaction_hash: Felt::ZERO.into(), + block_number: 42, + }, ) .await .unwrap(); let parsed = decoder - .with_table(&table_id, |table| Ok(table.columns.len())) + .with_table(table_id, |table| Ok(table.columns.len())) .unwrap(); assert_eq!(parsed, 3); assert_eq!(*decoder.store.saved_blocks.lock().unwrap(), vec![42]); @@ -457,32 +517,36 @@ mod tests { keys.extend(string_to_cairo_serialize_byte_array("eth")); keys.push(Felt::from_hex("0x99").unwrap()); - let event = EmittedEvent { - from_address: Felt::from_hex("0x1").unwrap(), - keys, + let event = StarknetEvent { + from_address: Felt::from_hex("0x1").unwrap().into(), + keys: keys.into_iter().map(Into::into).collect(), data: vec![ - Felt::from_hex("0xabc").unwrap(), - Felt::from_hex("0x1234").unwrap(), - Felt::from(42_u64), + Felt::from_hex("0xabc").unwrap().into(), + Felt::from_hex("0x1234").unwrap().into(), + Felt::from(42_u64).into(), ], - block_hash: None, - block_number: Some(42), - transaction_hash: Felt::from_hex("0xbeef").unwrap(), + block_number: 42, + transaction_hash: Felt::from_hex("0xbeef").unwrap().into(), }; let envelopes = decoder.decode_event(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let body = envelopes[0] - .downcast_ref::() - .unwrap(); + let body = envelopes[0].downcast_ref::().unwrap(); + assert_eq!(envelopes[0].type_id, DOJO_TYPE_ID); + let DojoEvent::ExternalContractRegistered(msg) = &body.msg else { + panic!("expected external contract registered event"); + }; - assert_eq!(body.msg.namespace, "tokens"); - assert_eq!(body.msg.contract_name, "ERC20"); - assert_eq!(body.msg.instance_name, "eth"); - assert_eq!(body.msg.contract_selector, Felt::from_hex("0x99").unwrap()); - assert_eq!(body.msg.class_hash, Felt::from_hex("0xabc").unwrap()); - assert_eq!(body.msg.contract_address, Felt::from_hex("0x1234").unwrap()); - assert_eq!(body.msg.registration_block, 42); - assert_eq!(body.metadata.from_address, Felt::from_hex("0x1").unwrap()); + assert_eq!(msg.namespace, "tokens"); + assert_eq!(msg.contract_name, "ERC20"); + assert_eq!(msg.instance_name, "eth"); + assert_eq!(msg.contract_selector, Felt::from_hex("0x99").unwrap()); + assert_eq!(msg.class_hash, Felt::from_hex("0xabc").unwrap()); + assert_eq!(msg.contract_address, Felt::from_hex("0x1234").unwrap()); + assert_eq!(msg.registration_block, 42); + assert_eq!( + body.context.from_address, + Felt::from_hex("0x1").unwrap().into() + ); } } diff --git a/crates/dojo/src/error.rs b/crates/dojo/src/error.rs index 911f7550..e9d4cc78 100644 --- a/crates/dojo/src/error.rs +++ b/crates/dojo/src/error.rs @@ -1,10 +1,10 @@ -use std::sync::PoisonError; - use dojo_introspect::DojoIntrospectError; use introspect_types::transcode::TranscodeError; use introspect_types::DecodeError; use starknet::core::utils::NonAsciiNameError; use starknet_types_core::felt::Felt; +use std::error::Error as StdError; +use std::sync::PoisonError; #[derive(Debug, thiserror::Error)] pub enum DojoToriiError { @@ -12,8 +12,8 @@ pub enum DojoToriiError { UnknownDojoEventSelector(Felt), #[error("Missing event selector")] MissingEventSelector, - #[error("Column {0:#066x} not found in table {1}")] - ColumnNotFound(Felt, String), + #[error("Column {1:#066x} not found in table {0}")] + ColumnNotFound(String, Felt), #[error("Failed to parse field {0:#066x} in table {1}")] FieldParseError(Felt, String), #[error("Too many values provided for field {0:#066x}")] @@ -26,8 +26,6 @@ pub enum DojoToriiError { TableNotFoundById(Felt), #[error("Failed to acquire lock: {0}")] LockError(String), - #[error("Store error: {0}")] - StoreError(String), #[error("Starknet selector error: {0}")] StarknetSelectorError(#[from] NonAsciiNameError), #[error("Lock poisoned: {0}")] @@ -40,9 +38,13 @@ pub enum DojoToriiError { DojoIntrospectError(#[from] DojoIntrospectError), #[error("Transcode error: {0:?}")] TranscodeError(TranscodeError), + #[error("Store error: {0}")] + StoreError(#[source] Box), + #[error(transparent)] + JsonError(#[from] serde_json::Error), } -pub type DojoToriiResult = std::result::Result; +pub type DojoToriiResult = std::result::Result; impl From> for DojoToriiError { fn from(err: TranscodeError) -> Self { @@ -51,8 +53,8 @@ impl From> for DojoToriiError { } impl DojoToriiError { - pub fn store_error(err: T) -> Self { - Self::StoreError(err.to_string()) + pub fn store_error(err: T) -> Self { + Self::StoreError(Box::new(err)) } } diff --git a/crates/dojo/src/event.rs b/crates/dojo/src/event.rs index 3ae7b61b..cd579b5a 100644 --- a/crates/dojo/src/event.rs +++ b/crates/dojo/src/event.rs @@ -10,9 +10,8 @@ use dojo_introspect::events::{ }; use dojo_introspect::DojoSchemaFetcher; use introspect_types::FeltIds; -use starknet::core::types::EmittedEvent; -use starknet_types_core::felt::Felt; use torii_introspect::events::{CreateTable, DeleteRecords, InsertsFields, UpdateTable}; +use torii_types::event::EventContext; #[async_trait] impl DojoTableEvent for ModelWithSchemaRegistered @@ -23,19 +22,12 @@ where type Msg = CreateTable; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult { decoder - .register_table( - &raw.from_address, - &self.namespace, - &self.name, - self.schema, - raw, - ) + .register_table(&self.namespace, &self.name, self.schema, context) .await - .map(Into::into) } } @@ -48,14 +40,13 @@ where type Msg = CreateTable; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult { let schema = decoder.fetcher.schema(self.address).await?; decoder - .register_table(&raw.from_address, &self.namespace, &self.name, schema, raw) + .register_table(&self.namespace, &self.name, schema, context) .await - .map(Into::into) } } @@ -68,14 +59,13 @@ where type Msg = CreateTable; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult { let schema = decoder.fetcher.schema(self.address).await?; decoder - .register_table(&raw.from_address, &self.namespace, &self.name, schema, raw) + .register_table(&self.namespace, &self.name, schema, context) .await - .map(Into::into) } } @@ -88,12 +78,12 @@ where type Msg = UpdateTable; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult { let schema = decoder.fetcher.schema(self.address).await?; decoder - .update_table(&raw.from_address, self.selector, schema, raw) + .update_table(self.selector, schema, context) .await .map(Into::into) } @@ -108,12 +98,12 @@ where type Msg = UpdateTable; async fn event_to_msg( self, - raw: &EmittedEvent, + context: EventContext, decoder: &DojoDecoder, ) -> DojoToriiResult { let schema = decoder.fetcher.schema(self.address).await?; decoder - .update_table(&raw.from_address, self.selector, schema, raw) + .update_table(self.selector, schema, context) .await .map(Into::into) } @@ -122,7 +112,7 @@ where impl DojoRecordEvent for StoreSetRecord { type Msg = InsertsFields; fn event_to_msg(self, decoder: &DojoDecoder) -> DojoToriiResult { - let (columns, data) = decoder.with_table(&self.selector, |table| { + let (columns, data) = decoder.with_table(self.selector, |table| { table.parse_record(self.keys, self.values) })?; Ok(InsertsFields::new_single( @@ -138,7 +128,7 @@ impl DojoRecordEvent for StoreUpdateRecord { type Msg = InsertsFields; fn event_to_msg(self, decoder: &DojoDecoder) -> DojoToriiResult { let (columns, data) = - decoder.with_table(&self.selector, |table| table.parse_values(self.values))?; + decoder.with_table(self.selector, |table| table.parse_values(self.values))?; Ok(InsertsFields::new_single( self.selector, columns, @@ -151,8 +141,8 @@ impl DojoRecordEvent for StoreUpdateRecord { impl DojoRecordEvent for EventEmitted { type Msg = InsertsFields; fn event_to_msg(self, decoder: &DojoDecoder) -> DojoToriiResult { - let primary = Felt::from_bytes_be(&self.keys.hash().into()); - let (columns, data) = decoder.with_table(&self.selector, |table| { + let primary: [u8; 32] = self.keys.hash().into(); + let (columns, data) = decoder.with_table(self.selector, |table| { table.parse_record(self.keys, self.values) })?; Ok(InsertsFields::new_single( @@ -167,7 +157,7 @@ impl DojoRecordEvent for EventEmitted { impl DojoRecordEvent for StoreUpdateMember { type Msg = InsertsFields; fn event_to_msg(self, decoder: &DojoDecoder) -> DojoToriiResult { - let data = decoder.with_table(&self.selector, |table| { + let data = decoder.with_table(self.selector, |table| { table.parse_field(self.member_selector, self.values) })?; Ok(InsertsFields::new_single( diff --git a/crates/dojo/src/external_contract.rs b/crates/dojo/src/external_contract.rs index 5d2d158f..9b1c6b0f 100644 --- a/crates/dojo/src/external_contract.rs +++ b/crates/dojo/src/external_contract.rs @@ -5,7 +5,7 @@ use introspect_types::{ DecodeResult, FeltSource, }; use serde::{Deserialize, Serialize}; -use starknet::core::types::Felt; +use starknet_types_core::felt::Felt; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use tokio::sync::RwLock; @@ -210,7 +210,7 @@ impl CommandHandler for RegisterExternalContractCommandHandler { let command = *command; self.engine_db - .set_contract_decoders(command.contract_address, &command.decoder_ids) + .set_contract_decoders(command.contract_address.into(), &command.decoder_ids) .await .with_context(|| { format!( @@ -379,7 +379,7 @@ mod tests { ); assert_eq!( engine_db - .get_contract_decoders(contract_address) + .get_contract_decoders(contract_address.into()) .await .unwrap() .unwrap(), diff --git a/crates/dojo/src/lib.rs b/crates/dojo/src/lib.rs index 042aab65..71b3bd2c 100644 --- a/crates/dojo/src/lib.rs +++ b/crates/dojo/src/lib.rs @@ -4,6 +4,7 @@ pub mod event; pub mod external_contract; pub mod store; pub mod table; +pub use decoder::{DojoBody, DojoEvent, DOJO_TYPE_ID}; pub use error::{DojoToriiError, DojoToriiResult}; pub use external_contract::{ contract_type_from_decoder_ids, resolve_external_contract, ExternalContractRegistered, diff --git a/crates/dojo/src/store/json.rs b/crates/dojo/src/store/json.rs index a3349671..1ae0d026 100644 --- a/crates/dojo/src/store/json.rs +++ b/crates/dojo/src/store/json.rs @@ -1,6 +1,7 @@ use super::DojoStoreTrait; -use crate::DojoTable; +use crate::{DojoTable, DojoToriiResult}; use async_trait::async_trait; +use introspect_types::ResultInto; use serde_json::Error as JsonError; use starknet_types_core::felt::Felt; use std::fs; @@ -68,19 +69,24 @@ impl JsonStore { #[async_trait] impl DojoStoreTrait for JsonStore { - type Error = JsonError; - + async fn initialize(&self) -> DojoToriiResult { + // No initialization needed for JSON store, but we can check if the path is accessible. + if !self.path.exists() { + std::fs::create_dir_all(&self.path).map_err(JsonError::io)?; + } + Ok(()) + } async fn save_table( &self, - _owner: &Felt, + _owner: Felt, table: &DojoTable, - _tx_hash: &Felt, + _tx_hash: Felt, _block_number: u64, - ) -> Result<(), Self::Error> { - self.dump_table(table) + ) -> DojoToriiResult { + self.dump_table(table).err_into() } - async fn read_tables(&self, _owners: &[Felt]) -> Result, Self::Error> { - self.load_all_tables() + async fn read_tables(&self, _owners: &[Felt]) -> DojoToriiResult> { + self.load_all_tables().err_into() } } diff --git a/crates/dojo/src/store/mod.rs b/crates/dojo/src/store/mod.rs index 148ad129..057ecfa3 100644 --- a/crates/dojo/src/store/mod.rs +++ b/crates/dojo/src/store/mod.rs @@ -1,31 +1,35 @@ pub mod json; + +#[cfg(feature = "postgres")] pub mod postgres; +#[cfg(feature = "sqlite")] pub mod sqlite; +#[cfg(feature = "postgres")] +#[cfg(feature = "sqlite")] +pub mod sql; + use crate::table::DojoTableInfo; -use crate::DojoTable; +use crate::{DojoTable, DojoToriiResult}; use async_trait::async_trait; use starknet_types_core::felt::Felt; use std::collections::HashMap; #[async_trait] -pub trait DojoStoreTrait -where - Self: Send + Sync + 'static + Sized, -{ - type Error: std::error::Error; +pub trait DojoStoreTrait { + async fn initialize(&self) -> DojoToriiResult; async fn save_table( &self, - owner: &Felt, + owner: Felt, table: &DojoTable, - tx_hash: &Felt, + tx_hash: Felt, block_number: u64, - ) -> Result<(), Self::Error>; - async fn read_tables(&self, owners: &[Felt]) -> Result, Self::Error>; + ) -> DojoToriiResult; + async fn read_tables(&self, owners: &[Felt]) -> DojoToriiResult>; async fn read_table_map( &self, owners: &[Felt], - ) -> Result, Self::Error> { + ) -> DojoToriiResult> { Ok(self .read_tables(owners) .await? diff --git a/crates/dojo/src/store/postgres.rs b/crates/dojo/src/store/postgres.rs index 995aa859..0d5a46d0 100644 --- a/crates/dojo/src/store/postgres.rs +++ b/crates/dojo/src/store/postgres.rs @@ -1,23 +1,20 @@ use super::DojoStoreTrait; use crate::decoder::primary_field_def; use crate::table::DojoTableInfo; -use crate::DojoTable; +use crate::{DojoTable, DojoToriiError, DojoToriiResult}; use async_trait::async_trait; -use introspect_types::{Attribute, ColumnInfo, ResultInto, TypeDef}; +use introspect_types::{Attribute, ColumnInfo, TypeDef}; use itertools::Itertools; use sqlx::migrate::Migrator; -use sqlx::postgres::PgArguments; use sqlx::query::Query; use sqlx::types::Json; -use sqlx::{FromRow, Postgres}; +use sqlx::FromRow; use starknet_types_core::felt::Felt; use std::collections::HashMap; -use std::ops::Deref; -use torii_common::sql::SqlxResult; use torii_introspect::postgres::owned::PgTypeDef; use torii_introspect::postgres::PgFelt; use torii_introspect::schema::ColumnKeyTrait; -use torii_postgres::db::PostgresConnection; +use torii_sql::{PgArguments, PgPool, PoolExt, Postgres, SqlxResult}; pub const FETCH_TABLES_QUERY: &str = r#" SELECT DISTINCT ON (owner, id) @@ -87,7 +84,7 @@ pub const INSERT_COLUMN_QUERY: &str = r#" updated_at = NOW(), updated_tx = EXCLUDED.updated_tx"#; -pub const DOJO_STORE_MIGRATIONS: Migrator = sqlx::migrate!(); +pub const DOJO_STORE_MIGRATIONS: Migrator = sqlx::migrate!("./migrations/postgres"); #[derive(Debug, thiserror::Error)] pub enum DojoPgStoreError { @@ -101,8 +98,6 @@ pub enum DojoPgStoreError { table_id: Felt, column_id: Felt, }, - #[error("Duplicate tables found for owner {owner:?} and table id {table_id}")] - DuplicateTables { owner: Felt, table_id: Felt }, } impl DojoPgStoreError { @@ -191,65 +186,37 @@ where } } -// #[async_trait] -// impl PgTypeDef for DojoTableInfo { -// type Row = DojoTableRow; -// async fn get_rows( -// pool: &PgPool, -// query: &'static str, -// owners: &[Felt], -// ) -> SqlxResult> { -// Self::get_pg_rows(pool, query, owners) -// .await -// .map(|rows| rows.into_iter().map_into().collect_vec()) -// } -// } - -// #[async_trait] -// impl PgTypeDef<()> for DojoTable { -// type Row = DojoTableRow; -// async fn get_rows( -// pool: &PgPool, -// query: &'static str, -// owners: &[Felt], -// ) -> SqlxResult> { -// Self::get_pg_rows(pool, query, owners) -// .await -// .map(|rows| rows.into_iter().map_into().collect_vec()) -// } -// } - pub fn table_insert_query( - owner: &Felt, - id: &Felt, + owner: Felt, + id: Felt, block_number: u64, name: &str, attributes: &[String], keys: &[Felt], values: &[Felt], legacy: bool, - created_tx: &Felt, + created_tx: Felt, ) -> Query<'static, Postgres, PgArguments> { sqlx::query::(INSERT_TABLE_QUERY) - .bind(PgFelt::from(*owner)) - .bind(PgFelt::from(*id)) + .bind(PgFelt::from(owner)) + .bind(PgFelt::from(id)) .bind(block_number.to_string()) .bind(name.to_owned()) .bind(attributes.to_owned()) .bind(keys.iter().copied().map(PgFelt::from).collect_vec()) .bind(values.iter().copied().map(PgFelt::from).collect_vec()) .bind(legacy) - .bind(PgFelt::from(*created_tx)) + .bind(PgFelt::from(created_tx)) } pub fn column_info_insert_query( query: &'static str, - owner: &Felt, - table: &Felt, - id: &Felt, + owner: Felt, + table: Felt, + id: Felt, block_number: u64, info: &ColumnInfo, - created_tx: &Felt, + created_tx: Felt, ) -> Query<'static, Postgres, PgArguments> { column_insert_query( query, @@ -266,36 +233,36 @@ pub fn column_info_insert_query( pub fn column_insert_query( query: &'static str, - owner: &Felt, - table: &Felt, - id: &Felt, + owner: Felt, + table: Felt, + id: Felt, block_number: u64, name: &str, attributes: Vec, type_def: &TypeDef, - created_tx: &Felt, + created_tx: Felt, ) -> Query<'static, Postgres, PgArguments> { sqlx::query::(query) - .bind(PgFelt::from(*owner)) - .bind(PgFelt::from(*table)) - .bind(PgFelt::from(*id)) + .bind(PgFelt::from(owner)) + .bind(PgFelt::from(table)) + .bind(PgFelt::from(id)) .bind(block_number.to_string()) .bind(name.to_owned()) .bind(attributes) .bind(Json(type_def.clone())) - .bind(PgFelt::from(*created_tx)) + .bind(PgFelt::from(created_tx)) } impl DojoTable { pub fn insert_query( &self, - owner: &Felt, - tx_hash: &Felt, + owner: Felt, + tx_hash: Felt, block_number: u64, ) -> Query<'static, Postgres, PgArguments> { table_insert_query( owner, - &self.id, + self.id, block_number, &self.name, &self.attributes, @@ -309,74 +276,82 @@ impl DojoTable { pub struct PgStore(pub T); -impl Deref for PgStore { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.0 +impl> PoolExt for PgStore { + fn pool(&self) -> &PgPool { + self.0.pool() } } -impl PgStore { +impl + Send + Sync> PgStore { pub async fn initialize(&self) -> SqlxResult<()> { self.migrate(Some("dojo"), DOJO_STORE_MIGRATIONS).await } } -impl From for PgStore { +impl> From for PgStore { fn from(pool: T) -> Self { PgStore(pool) } } #[async_trait] -impl DojoStoreTrait for PgStore { - type Error = DojoPgStoreError; - +impl DojoStoreTrait for PgPool { + async fn initialize(&self) -> DojoToriiResult { + self.migrate(Some("dojo"), DOJO_STORE_MIGRATIONS) + .await + .map_err(DojoToriiError::store_error) + } async fn save_table( &self, - owner: &Felt, + owner: Felt, table: &DojoTable, - tx_hash: &Felt, + tx_hash: Felt, block_number: u64, - ) -> Result<(), Self::Error> { - let mut transaction = self.begin().await?; + ) -> DojoToriiResult { + let mut transaction = self.begin().await.map_err(DojoToriiError::store_error)?; table .insert_query(owner, tx_hash, block_number) .execute(&mut *transaction) - .await?; - for (id, info) in &table.columns { + .await + .map_err(DojoToriiError::store_error)?; + for (&id, info) in &table.columns { column_info_insert_query( INSERT_COLUMN_QUERY, owner, - &table.id, + table.id, id, block_number, info, tx_hash, ) .execute(&mut *transaction) - .await?; + .await + .map_err(DojoToriiError::store_error)?; } - transaction.commit().await.err_into() + transaction + .commit() + .await + .map_err(DojoToriiError::store_error) } - async fn read_tables(&self, owners: &[Felt]) -> Result, Self::Error> { + async fn read_tables(&self, owners: &[Felt]) -> DojoToriiResult> { let mut tables = PgTypeDef::get_rows::(self.pool(), FETCH_TABLES_QUERY, owners) - .await? + .await + .map_err(DojoToriiError::store_error)? .into_iter() .map(|row: ((), DojoTable)| row.1) .collect_vec(); let mut columns: HashMap<(Felt, Felt), _> = ColumnInfo::get_hash_map::(self.pool(), FETCH_COLUMNS_QUERY, owners) - .await?; + .await + .map_err(DojoToriiError::store_error)?; for table in &mut tables { for key in table.key_fields.iter().chain(table.value_fields.iter()) { - let column = columns.remove(&(table.id, *key)).ok_or_else(|| { - DojoPgStoreError::column_not_found(table.name.clone(), &(table.id, *key)) - })?; + let column = columns + .remove(&(table.id, *key)) + .ok_or_else(|| DojoToriiError::ColumnNotFound(table.name.clone(), *key))?; table.columns.insert(*key, column); } } diff --git a/crates/dojo/src/store/sql.rs b/crates/dojo/src/store/sql.rs new file mode 100644 index 00000000..e72039e7 --- /dev/null +++ b/crates/dojo/src/store/sql.rs @@ -0,0 +1,35 @@ +use crate::store::DojoStoreTrait; +use crate::{DojoTable, DojoToriiResult}; +use async_trait::async_trait; +use starknet_types_core::felt::Felt; +use torii_sql::DbPool; + +#[async_trait] +impl DojoStoreTrait for DbPool { + async fn initialize(&self) -> DojoToriiResult { + match self { + DbPool::Postgres(pool) => pool.initialize().await, + DbPool::Sqlite(pool) => pool.initialize().await, + } + } + + async fn save_table( + &self, + owner: Felt, + table: &DojoTable, + tx_hash: Felt, + block_number: u64, + ) -> DojoToriiResult { + match self { + DbPool::Postgres(pool) => pool.save_table(owner, table, tx_hash, block_number).await, + DbPool::Sqlite(pool) => pool.save_table(owner, table, tx_hash, block_number).await, + } + } + + async fn read_tables(&self, owners: &[Felt]) -> DojoToriiResult> { + match self { + DbPool::Postgres(pool) => pool.read_tables(owners).await, + DbPool::Sqlite(pool) => pool.read_tables(owners).await, + } + } +} diff --git a/crates/dojo/src/store/sqlite.rs b/crates/dojo/src/store/sqlite.rs index fd348d5e..d5f0bfaf 100644 --- a/crates/dojo/src/store/sqlite.rs +++ b/crates/dojo/src/store/sqlite.rs @@ -1,21 +1,20 @@ use super::DojoStoreTrait; use crate::decoder::primary_field_def; -use crate::DojoTable; +use crate::{DojoTable, DojoToriiError, DojoToriiResult}; use async_trait::async_trait; use introspect_types::ColumnInfo; use serde::{Deserialize, Serialize}; use sqlx::migrate::Migrator; use sqlx::sqlite::SqliteArguments; -use sqlx::Arguments; -use sqlx::{FromRow, Sqlite}; +use sqlx::{Arguments, FromRow, Sqlite, SqlitePool}; use starknet_types_core::felt::Felt; +use starknet_types_raw::{error::OverflowError, Felt as RawFelt}; use std::collections::HashMap; use std::io; use std::ops::Deref; -use torii_common::sql::SqlxResult; -use torii_common::{blob_to_felt, felt_to_blob}; use torii_introspect::schema::ColumnKeyTrait; -use torii_sqlite::SqliteConnection; +use torii_sql::sqlite::SqliteDbConnection; +use torii_sql::{PoolExt, SqlxResult}; pub const DOJO_SQLITE_STORE_MIGRATIONS: Migrator = sqlx::migrate!("./migrations/sqlite"); @@ -31,6 +30,8 @@ pub enum DojoSqliteStoreError { table_id: Felt, column_id: Felt, }, + #[error("Felt overflow error: {0}")] + FeltOverflow(#[from] OverflowError), } impl DojoSqliteStoreError { @@ -88,9 +89,13 @@ fn parse_felt_json_array(value: &str) -> Result, DojoSqliteStoreError> .collect() } +fn felt_from_bytes(value: &[u8]) -> Result { + Ok(RawFelt::from_be_bytes_slice(value)?.into()) +} + fn table_row_into_table(value: TableRow) -> Result { Ok(DojoTable { - id: blob_to_felt(&value.id), + id: felt_from_bytes(&value.id)?, name: value.name, attributes: serde_json::from_str(&value.attributes)?, primary: primary_field_def(), @@ -107,7 +112,10 @@ where { let payload: StoredColumnInfo = serde_json::from_str(&value.payload)?; Ok(( - K::from_parts(blob_to_felt(&value.table_id), blob_to_felt(&value.id)), + K::from_parts( + felt_from_bytes(&value.table_id)?, + felt_from_bytes(&value.id)?, + ), ColumnInfo { name: payload.name, attributes: payload.attributes, @@ -116,7 +124,7 @@ where )) } -fn select_table_query<'a>(owners: &[Felt]) -> (String, SqliteArguments<'a>) { +fn select_table_query(owners: &[Felt]) -> (String, SqliteArguments<'_>) { let mut query = String::from( "SELECT id, name, attributes, keys_json, values_json, legacy FROM dojo_tables", ); @@ -128,14 +136,14 @@ fn select_table_query<'a>(owners: &[Felt]) -> (String, SqliteArguments<'a>) { query.push_str(", "); } query.push('?'); - let _ = args.add(felt_to_blob(*owner)); + let _ = args.add(owner.to_bytes_be().to_vec()); } query.push(')'); } (query, args) } -fn select_column_query<'a>(owners: &[Felt]) -> (String, SqliteArguments<'a>) { +fn select_column_query(owners: &[Felt]) -> (String, SqliteArguments<'_>) { let mut query = String::from("SELECT table_id, id, payload FROM dojo_columns"); let mut args = SqliteArguments::default(); if !owners.is_empty() { @@ -145,7 +153,7 @@ fn select_column_query<'a>(owners: &[Felt]) -> (String, SqliteArguments<'a>) { query.push_str(", "); } query.push('?'); - let _ = args.add(felt_to_blob(*owner)); + let _ = args.add(owner.to_bytes_be().to_vec()); } query.push(')'); } @@ -162,31 +170,34 @@ impl Deref for SqliteStore { } } -impl SqliteStore { +impl SqliteStore { pub async fn initialize(&self) -> SqlxResult<()> { self.migrate(Some("dojo"), DOJO_SQLITE_STORE_MIGRATIONS) .await } } -impl From for SqliteStore { +impl From for SqliteStore { fn from(pool: T) -> Self { Self(pool) } } #[async_trait] -impl DojoStoreTrait for SqliteStore { - type Error = DojoSqliteStoreError; - +impl DojoStoreTrait for SqlitePool { + async fn initialize(&self) -> DojoToriiResult { + self.migrate(Some("dojo"), DOJO_SQLITE_STORE_MIGRATIONS) + .await + .map_err(DojoToriiError::store_error) + } async fn save_table( &self, - owner: &Felt, + owner: Felt, table: &DojoTable, - tx_hash: &Felt, + tx_hash: Felt, block_number: u64, - ) -> Result<(), Self::Error> { - let mut transaction = self.begin().await?; + ) -> DojoToriiResult { + let mut transaction = self.begin().await.map_err(DojoToriiError::store_error)?; sqlx::query( r" @@ -215,8 +226,8 @@ impl DojoStoreTrait for SqliteStore updated_tx = excluded.updated_tx ", ) - .bind(felt_to_blob(*owner)) - .bind(felt_to_blob(table.id)) + .bind(owner.to_bytes_be().to_vec()) + .bind(table.id.to_bytes_be().to_vec()) .bind(&table.name) .bind(serde_json::to_string(&table.attributes)?) .bind(serialize_felt_json_array(&table.key_fields)?) @@ -224,10 +235,11 @@ impl DojoStoreTrait for SqliteStore .bind(table.legacy) .bind(block_number as i64) .bind(block_number as i64) - .bind(felt_to_blob(*tx_hash)) - .bind(felt_to_blob(*tx_hash)) + .bind(tx_hash.to_bytes_be().to_vec()) + .bind(tx_hash.to_bytes_be().to_vec()) .execute(&mut *transaction) - .await?; + .await + .map_err(DojoToriiError::store_error)?; for (id, info) in &table.columns { let payload = StoredColumnInfo { @@ -243,42 +255,50 @@ impl DojoStoreTrait for SqliteStore payload = excluded.payload ", ) - .bind(felt_to_blob(*owner)) - .bind(felt_to_blob(table.id)) - .bind(felt_to_blob(*id)) + .bind(owner.to_bytes_be().to_vec()) + .bind(table.id.to_bytes_be().to_vec()) + .bind(id.to_bytes_be().to_vec()) .bind(serde_json::to_string(&payload)?) .execute(&mut *transaction) - .await?; + .await + .map_err(DojoToriiError::store_error)?; } - transaction.commit().await?; + transaction + .commit() + .await + .map_err(DojoToriiError::store_error)?; Ok(()) } - async fn read_tables(&self, owners: &[Felt]) -> Result, Self::Error> { + async fn read_tables(&self, owners: &[Felt]) -> DojoToriiResult> { let (table_query, table_args) = select_table_query(owners); let rows = sqlx::query_as_with::(&table_query, table_args) .fetch_all(self.pool()) - .await?; + .await + .map_err(DojoToriiError::store_error)?; let mut tables = rows .into_iter() .map(table_row_into_table) - .collect::, _>>()?; + .collect::, _>>() + .map_err(DojoToriiError::store_error)?; let (column_query, column_args) = select_column_query(owners); let mut columns: HashMap<(Felt, Felt), ColumnInfo> = sqlx::query_as_with::(&column_query, column_args) .fetch_all(self.pool()) - .await? + .await + .map_err(DojoToriiError::store_error)? .into_iter() .map(column_row_into_entry::<(Felt, Felt)>) - .collect::, _>>()?; + .collect::, _>>() + .map_err(DojoToriiError::store_error)?; for table in &mut tables { for key in table.key_fields.iter().chain(table.value_fields.iter()) { - let column = columns.remove(&(table.id, *key)).ok_or_else(|| { - DojoSqliteStoreError::column_not_found(table.name.clone(), &(table.id, *key)) - })?; + let column = columns + .remove(&(table.id, *key)) + .ok_or_else(|| DojoToriiError::ColumnNotFound(table.name.clone(), *key))?; table.columns.insert(*key, column); } } diff --git a/crates/dojo/src/table.rs b/crates/dojo/src/table.rs index 05048a88..674f4808 100644 --- a/crates/dojo/src/table.rs +++ b/crates/dojo/src/table.rs @@ -178,7 +178,7 @@ impl DojoTable { pub fn get_column(&self, selector: &Felt) -> DojoToriiResult<&ColumnInfo> { self.columns .get(selector) - .ok_or_else(|| DojoToriiError::ColumnNotFound(*selector, self.name.clone())) + .ok_or_else(|| DojoToriiError::ColumnNotFound(self.name.clone(), *selector)) } pub fn selectors(&self) -> impl Iterator + '_ { @@ -270,7 +270,7 @@ impl DojoTableInfo { pub fn get_column(&self, selector: &Felt) -> DojoToriiResult<&ColumnInfo> { self.columns .get(selector) - .ok_or_else(|| DojoToriiError::ColumnNotFound(*selector, self.name.clone())) + .ok_or_else(|| DojoToriiError::ColumnNotFound(self.name.clone(), *selector)) } pub fn selectors(&self) -> impl Iterator + '_ { diff --git a/crates/introspect-postgres-sink/Cargo.toml b/crates/introspect-postgres-sink/Cargo.toml deleted file mode 100644 index 816f88dd..00000000 --- a/crates/introspect-postgres-sink/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "torii-introspect-postgres-sink" -version = "0.1.0" -edition = "2021" -description = "PostgreSQL sink implementation for Torii runtime" -authors = ["Torii Runtime "] -license = "Apache-2.0" - -[dependencies] -sqlx = { workspace = true, features = [ - "postgres", - "sqlite", - "runtime-tokio-rustls", - "macros", - "migrate", -] } -anyhow.workspace = true -async-trait.workspace = true -metrics.workspace = true -xxhash-rust.workspace = true -hex.workspace = true -serde.workspace = true -serde_json.workspace = true -starknet.workspace = true -tokio.workspace = true -torii.workspace = true -tracing.workspace = true -starknet-types-core.workspace = true -thiserror.workspace = true -introspect-types.workspace = true -itertools.workspace = true - -# Local crates -torii-introspect.workspace = true -torii-postgres.workspace = true -torii-common.workspace = true diff --git a/crates/introspect-postgres-sink/src/error.rs b/crates/introspect-postgres-sink/src/error.rs deleted file mode 100644 index 0dda8017..00000000 --- a/crates/introspect-postgres-sink/src/error.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::sync::PoisonError; - -use introspect_types::{PrimaryTypeDef, TypeDef}; -use sqlx::Error as SqlxError; -use starknet_types_core::felt::Felt; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum PgTypeError { - #[error("Unsupported type for {0}")] - UnsupportedType(String), - #[error("Nested arrays are not supported")] - NestedArrays, -} - -pub type PgTypeResult = std::result::Result; - -#[derive(Debug, Error)] -pub enum PgTableError { - #[error("Column with id: {0} not found in table {1}")] - ColumnNotFound(Felt, String), - #[error(transparent)] - TypeError(#[from] PgTypeError), - #[error("Current type mismatch error")] - TypeMismatch, - #[error("Unsupported upgrade for table {table} column {column}: {reason}")] - UnsupportedUpgrade { - table: String, - column: String, - reason: UpgradeError, - }, -} - -pub type TableResult = std::result::Result; - -#[derive(Debug, thiserror::Error)] -pub enum UpgradeError { - #[error("Failed to upgrade type from {old} to {new}")] - TypeUpgradeError { - old: &'static str, - new: &'static str, - }, - #[error("Failed to upgrade primary from {old} to {new}")] - PrimaryUpgradeError { - old: &'static str, - new: &'static str, - }, - #[error(transparent)] - TypeCreationError(#[from] PgTypeError), - #[error("Array length cannot be decreased from {old} to {new}")] - ArrayLengthDecreaseError { old: u32, new: u32 }, - #[error("Cannot reduce element in tuple")] - TupleReductionError, -} - -pub type UpgradeResult = Result; - -impl UpgradeError { - pub fn type_upgrade_err(old: &TypeDef, new: &TypeDef) -> UpgradeResult { - Err(Self::TypeUpgradeError { - old: old.item_name(), - new: new.item_name(), - }) - } - pub fn type_upgrade_to_err(old: &TypeDef, new: &'static str) -> UpgradeResult { - Err(Self::TypeUpgradeError { - old: old.item_name(), - new, - }) - } - pub fn type_cast_err(old: &TypeDef, new: &'static str) -> UpgradeResult { - Err(Self::TypeUpgradeError { - old: old.item_name(), - new, - }) - } - pub fn array_shorten_err(old: u32, new: u32) -> UpgradeResult { - Err(Self::ArrayLengthDecreaseError { old, new }) - } - pub fn primary_upgrade_err(old: &PrimaryTypeDef, new: &PrimaryTypeDef) -> UpgradeResult { - Err(Self::PrimaryUpgradeError { - old: old.item_name(), - new: new.item_name(), - }) - } -} - -pub trait UpgradeResultExt { - fn to_table_result(self, table: &str, column: &str) -> TableResult; -} - -impl UpgradeResultExt for UpgradeResult { - fn to_table_result(self, table: &str, column: &str) -> TableResult { - self.map_err(|err| PgTableError::UnsupportedUpgrade { - table: table.to_string(), - column: column.to_string(), - reason: err, - }) - } -} - -#[derive(Debug, thiserror::Error)] -pub enum PgDbError { - #[error(transparent)] - DatabaseError(#[from] SqlxError), - #[error("Invalid event format: {0}")] - InvalidEventFormat(String), - #[error(transparent)] - JsonError(#[from] serde_json::Error), - #[error(transparent)] - IoError(#[from] std::io::Error), - #[error(transparent)] - TableError(#[from] PgTableError), - #[error(transparent)] - TypeError(#[from] PgTypeError), - #[error("Table with id: {0} already exists, incoming name: {1}, existing name: {2}")] - TableAlreadyExists(Felt, String, String), - #[error("Table not found with id: {0}")] - TableNotFound(Felt), - #[error("Table not alive - id: {0}, name: {1}")] - TableNotAlive(Felt, String), - #[error("Manager does not support updating")] - UpdateNotSupported, - #[error("Table poison error: {0}")] - PoisonError(String), -} - -pub type PgDbResult = std::result::Result; - -impl From> for PgDbError { - fn from(err: PoisonError) -> Self { - Self::PoisonError(err.to_string()) - } -} diff --git a/crates/introspect-postgres-sink/src/lib.rs b/crates/introspect-postgres-sink/src/lib.rs deleted file mode 100644 index 83d42122..00000000 --- a/crates/introspect-postgres-sink/src/lib.rs +++ /dev/null @@ -1,22 +0,0 @@ -pub mod create; -pub mod error; -pub mod json; -pub mod processor; -pub mod query; -pub mod sink; -pub mod table; -pub mod types; -pub mod upgrade; -pub mod utils; - -pub use error::{ - PgDbError, PgDbResult, PgTableError, PgTypeError, PgTypeResult, TableResult, UpgradeError, - UpgradeResult, UpgradeResultExt, -}; -pub use processor::IntrospectPgDb; -pub use types::{ - PgSchema, PostgresArray, PostgresField, PostgresScalar, PostgresType, PrimaryKey, SchemaName, -}; -pub use utils::{truncate, HasherExt}; - -pub const INTROSPECT_PG_SINK_MIGRATIONS: sqlx::migrate::Migrator = sqlx::migrate!("./migrations"); diff --git a/crates/introspect-postgres-sink/src/processor.rs b/crates/introspect-postgres-sink/src/processor.rs deleted file mode 100644 index 196a7ef1..00000000 --- a/crates/introspect-postgres-sink/src/processor.rs +++ /dev/null @@ -1,334 +0,0 @@ -use crate::json::PostgresJsonSerializer; -use crate::query::{fetch_columns, fetch_dead_fields, fetch_tables, CreatePgTable}; -use crate::table::{DeadField, PgTable}; -use crate::{PgDbError, PgDbResult, PgSchema, INTROSPECT_PG_SINK_MIGRATIONS}; -use introspect_types::ColumnInfo; -use serde_json::Serializer as JsonSerializer; -use sqlx::PgPool; -use starknet_types_core::felt::Felt; -use std::collections::HashMap; -use std::io::Write; -use std::ops::Deref; -use std::rc::Rc; -use std::sync::RwLock; -use torii::etl::envelope::MetaData; -use torii_common::sql::{PgQuery, Queries}; -use torii_introspect::events::{IntrospectBody, IntrospectMsg}; -use torii_introspect::schema::TableSchema; -use torii_introspect::InsertsFields; -use torii_postgres::PostgresConnection; - -pub const COMMIT_CMD: &str = "--COMMIT"; -pub const DEAD_MEMBERS_TABLE: &str = "__introspect_dead_fields"; -pub const TABLES_TABLE: &str = "__introspect_tables"; -pub const COLUMNS_TABLE: &str = "__introspect_columns"; -pub const METADATA_CONFLICTS: &str = "__updated_at = NOW(), __updated_block = EXCLUDED.__updated_block, __updated_tx = EXCLUDED.__updated_tx"; - -#[derive(Debug, Default)] -pub struct PostgresTables(pub RwLock>); - -#[derive(Debug, Default)] -pub struct DeadFields(pub RwLock>>); - -impl Deref for PostgresTables { - type Target = RwLock>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Deref for DeadFields { - type Target = RwLock>>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl From> for PgSchema { - fn from(value: Option) -> Self { - match value { - Some(s) => Self::Custom(s), - None => PgSchema::Public, - } - } -} - -impl From<()> for PgSchema { - fn from(_: ()) -> Self { - PgSchema::Public - } -} - -impl From for PgSchema { - fn from(value: String) -> Self { - Self::Custom(value) - } -} - -impl From<&str> for PgSchema { - fn from(value: &str) -> Self { - Self::Custom(value.to_string()) - } -} - -impl From> for PgSchema { - fn from(value: Option<&str>) -> Self { - match value { - Some(s) => Self::Custom(s.to_string()), - None => PgSchema::Public, - } - } -} - -impl PostgresTables { - pub fn create_table( - &self, - schema: &Rc, - to_table: impl Into, - metadata: &MetaData, - queries: &mut Vec, - ) -> PgDbResult<()> { - let (id, table) = Into::::into(to_table).into(); - self.assert_table_not_exists(&id, &table.name)?; - CreatePgTable::new(schema, &id, &table)?.make_queries(queries); - let table = PgTable::new(schema, table, None); - table.insert_queries( - &id, - None, - metadata.block_number.unwrap_or_default(), - metadata.transaction_hash, - queries, - )?; - let mut tables: std::sync::RwLockWriteGuard<'_, HashMap> = self.write()?; - tables.insert(id, table); - Ok(()) - } - - pub fn update_table( - &self, - to_table: impl Into, - metadata: &MetaData, - queries: &mut Vec, - ) -> PgDbResult<()> { - let tx_hash = &metadata.transaction_hash; - let block_number = metadata.block_number.unwrap_or_default(); - let (id, table) = Into::::into(to_table).into(); - let mut tables = self.write()?; - let existing = tables - .get_mut(&id) - .ok_or_else(|| PgDbError::TableNotFound(id))?; - let upgrades = existing.update_from_info(&id, &table)?; - upgrades.to_queries(&id, block_number, tx_hash, queries)?; - existing.insert_queries( - &id, - Some(&upgrades.columns_upgraded), - block_number, - metadata.transaction_hash, - queries, - ) - } - - pub fn assert_table_not_exists(&self, id: &Felt, name: &str) -> PgDbResult<()> { - match self.read()?.get(id) { - Some(existing) => Err(PgDbError::TableAlreadyExists( - *id, - name.to_string(), - existing.name.to_string(), - )), - None => Ok(()), - } - } - - pub fn set_table_dead(&self, id: &Felt) -> PgDbResult<()> { - let mut tables = self.write()?; - match tables.get_mut(id) { - Some(table) => { - table.alive = false; - Ok(()) - } - None => Err(PgDbError::TableNotFound(*id)), - } - } - - pub fn insert_fields( - &self, - event: &InsertsFields, - context: &MetaData, - queries: &mut Vec, - ) -> PgDbResult<()> { - let tables = self.read().unwrap(); - let table = match tables.get(&event.table) { - Some(table) => Ok(table), - None => Err(PgDbError::TableNotFound(event.table)), - }?; - if !table.alive { - return Ok(()); - } - let record = table.get_record_schema(&event.columns)?; - let table_name = &table.name; - let mut writer = Vec::new(); - let schema = &table.schema; - write!( - writer, - r#"INSERT INTO "{schema}"."{table_name}" SELECT * FROM jsonb_populate_recordset(NULL::"{schema}"."{table_name}", $$"# - ) - .unwrap(); - record.parse_records_with_metadata( - &event.records, - context, - &mut JsonSerializer::new(&mut writer), - &PostgresJsonSerializer, - )?; - write!( - writer, - r#"$$) ON CONFLICT ("{}") DO UPDATE SET {METADATA_CONFLICTS}"#, - record.primary().name - ) - .unwrap(); - for ColumnInfo { name, .. } in record.columns() { - write!( - writer, - r#", "{name}" = COALESCE(EXCLUDED."{name}", "{table_name}"."{name}")"#, - name = name - ) - .unwrap(); - } - let string = unsafe { String::from_utf8_unchecked(writer) }; - queries.add(string); - Ok(()) - } - - pub fn handle_message( - &self, - schema: &Rc, - msg: &IntrospectMsg, - metadata: &MetaData, - queries: &mut Vec, - ) -> PgDbResult<()> { - match msg { - IntrospectMsg::CreateTable(event) => { - self.create_table(schema, event.clone(), metadata, queries) - } - IntrospectMsg::UpdateTable(event) => { - self.update_table(event.clone(), metadata, queries) - } - IntrospectMsg::AddColumns(event) => self.set_table_dead(&event.table), - IntrospectMsg::DropColumns(event) => self.set_table_dead(&event.table), - IntrospectMsg::RetypeColumns(event) => self.set_table_dead(&event.table), - IntrospectMsg::RetypePrimary(event) => self.set_table_dead(&event.table), - IntrospectMsg::RenameTable(_) - | IntrospectMsg::DropTable(_) - | IntrospectMsg::RenameColumns(_) - | IntrospectMsg::RenamePrimary(_) => Ok(()), - IntrospectMsg::InsertsFields(event) => self.insert_fields(event, metadata, queries), - IntrospectMsg::DeleteRecords(_) | IntrospectMsg::DeletesFields(_) => Ok(()), - } - } -} - -fn make_schema_query(schema: &PgSchema) -> String { - format!(r#"CREATE SCHEMA IF NOT EXISTS "{schema}""#) -} - -pub struct IntrospectPgDb { - tables: PostgresTables, - schema: PgSchema, - pool: T, -} - -impl PostgresConnection for IntrospectPgDb { - fn pool(&self) -> &PgPool { - self.pool.pool() - } -} - -impl IntrospectPgDb { - pub fn new(pool: T, schema: impl Into) -> Self { - Self { - tables: PostgresTables::default(), - schema: schema.into(), - pool, - } - } - - pub async fn load_store_data(&self) -> PgDbResult<()> { - let mut tables = fetch_tables(self.pool(), &self.schema) - .await? - .into_iter() - .map(|t| t.to_table(&self.schema)) - .collect::>(); - for (table_id, id, column_info) in fetch_columns(self.pool(), &self.schema).await? { - if let Some(table) = tables.get_mut(&table_id) { - table.columns.insert(id, column_info); - } - } - for (table_id, id, field) in fetch_dead_fields(self.pool(), &self.schema).await? { - if let Some(table) = tables.get_mut(&table_id) { - table.dead.insert(id, field); - } - } - let mut tables_map = self.tables.write()?; - tables_map.extend(tables); - Ok(()) - } - - pub async fn initialize_introspect_pg_sink(&self) -> PgDbResult<()> { - self.migrate(Some("introspect"), INTROSPECT_PG_SINK_MIGRATIONS) - .await?; - self.execute_queries(make_schema_query(&self.schema)) - .await?; - self.load_store_data().await - } - - pub async fn process_message( - &self, - msg: &IntrospectMsg, - metadata: &MetaData, - ) -> PgDbResult<()> { - let mut queries = Vec::new(); - { - let schema = Rc::new(self.schema.clone()); - self.tables - .handle_message(&schema, msg, metadata, &mut queries)?; - } - self.execute_queries(queries).await?; - Ok(()) - } - - pub async fn process_messages( - &self, - msgs: Vec<&IntrospectBody>, - ) -> PgDbResult>> { - let mut queries = Vec::new(); - let mut results = Vec::with_capacity(msgs.len()); - { - let schema = Rc::new(self.schema.clone()); - for body in msgs { - let (msg, metadata) = body.into(); - results.push( - self.tables - .handle_message(&schema, msg, metadata, &mut queries), - ); - } - } - let mut batch = Vec::new(); - for query in queries { - if query == *COMMIT_CMD { - self.execute_queries(std::mem::take(&mut batch)).await?; - } else { - batch.push(query); - } - } - if !batch.is_empty() { - self.execute_queries(batch).await?; - } - Ok(results) - } -} - -pub struct MessageWithContext<'a, M> { - pub msg: &'a M, - pub context: &'a MetaData, -} diff --git a/crates/introspect-postgres-sink/src/sink.rs b/crates/introspect-postgres-sink/src/sink.rs deleted file mode 100644 index d33b260b..00000000 --- a/crates/introspect-postgres-sink/src/sink.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::processor::IntrospectPgDb; -use anyhow::Result; -use async_trait::async_trait; -use std::sync::Arc; -use torii::axum::Router; -use torii::etl::{ - envelope::{Envelope, TypeId}, - extractor::ExtractionBatch, - sink::{EventBus, Sink, SinkContext, TopicInfo}, -}; -use torii_introspect::events::{IntrospectBody, IntrospectMsg}; -use torii_postgres::PostgresConnection; - -pub const LOGGING_TARGET: &str = "torii::sinks::introspect::postgres"; -const INTROSPECT_TYPE: TypeId = TypeId::new("introspect"); - -#[async_trait] -impl Sink for IntrospectPgDb { - fn name(&self) -> &'static str { - "introspect-postgres" - } - - fn interested_types(&self) -> Vec { - vec![INTROSPECT_TYPE] - } - - async fn process(&self, envelopes: &[Envelope], _batch: &ExtractionBatch) -> Result<()> { - let mut processed = 0usize; - let mut create_tables: usize = 0usize; - let mut update_tables = 0usize; - let mut inserts_fields = 0usize; - let mut inserted_records = 0usize; - let mut delete_records = 0usize; - let mut msgs = Vec::with_capacity(envelopes.len()); - for envelope in envelopes { - if envelope.type_id == INTROSPECT_TYPE { - if let Some(body) = envelope.downcast_ref::() { - match &body.msg { - IntrospectMsg::CreateTable(_) => create_tables += 1, - IntrospectMsg::UpdateTable(_) => update_tables += 1, - IntrospectMsg::InsertsFields(event) => { - inserts_fields += 1; - inserted_records += event.records.len(); - } - IntrospectMsg::DeleteRecords(event) => { - delete_records += event.rows.len(); - } - _ => {} - } - processed += 1; - msgs.push(body); - } - } - } - self.process_messages(msgs).await?; - if processed > 0 { - tracing::info!( - target: LOGGING_TARGET, - processed, - create_tables, - update_tables, - inserts_fields, - inserted_records, - delete_records, - "Processed introspect envelopes" - ); - ::metrics::counter!("torii_introspect_sink_messages_total", "message" => "create_table") - .increment(create_tables as u64); - ::metrics::counter!("torii_introspect_sink_messages_total", "message" => "update_table") - .increment(update_tables as u64); - ::metrics::counter!("torii_introspect_sink_messages_total", "message" => "inserts_fields") - .increment(inserts_fields as u64); - ::metrics::counter!("torii_introspect_sink_records_total", "message" => "inserts_fields") - .increment(inserted_records as u64); - ::metrics::counter!("torii_introspect_sink_records_total", "message" => "delete_records") - .increment(delete_records as u64); - } - - Ok(()) - } - - fn topics(&self) -> Vec { - Vec::new() - } - - fn build_routes(&self) -> Router { - Router::new() - } - - async fn initialize( - &mut self, - _event_bus: Arc, - _context: &SinkContext, - ) -> Result<()> { - self.initialize_introspect_pg_sink().await?; - tracing::info!( - target: LOGGING_TARGET, - "Initialized introspect Postgres sink" - ); - Ok(()) - } -} diff --git a/crates/introspect-postgres-sink/src/table.rs b/crates/introspect-postgres-sink/src/table.rs deleted file mode 100644 index eeef1a9c..00000000 --- a/crates/introspect-postgres-sink/src/table.rs +++ /dev/null @@ -1,104 +0,0 @@ -use introspect_types::{ColumnInfo, MemberDef, PrimaryDef, TypeDef}; -use itertools::Itertools; -use sqlx::Error::Encode as EncodeError; -use starknet_types_core::felt::Felt; -use std::collections::HashMap; -use torii_common::sql::PgQuery; -use torii_introspect::{schema::TableInfo, tables::RecordSchema}; - -use crate::{ - query::{insert_columns_query, insert_table_query}, - PgDbResult, PgSchema, PgTableError, TableResult, -}; - -#[derive(Debug)] -pub struct PgTable { - pub schema: PgSchema, - pub name: String, - pub primary: PrimaryDef, - pub columns: HashMap, - pub alive: bool, - pub dead: HashMap, -} - -#[derive(Debug)] -pub struct DeadField { - pub name: String, - pub type_def: TypeDef, -} - -impl From for DeadField { - fn from(value: MemberDef) -> Self { - DeadField { - name: value.name, - type_def: value.type_def, - } - } -} - -impl From for MemberDef { - fn from(value: DeadField) -> Self { - MemberDef { - name: value.name, - attributes: Vec::new(), - type_def: value.type_def, - } - } -} - -impl PgTable { - pub fn column(&self, id: &Felt) -> TableResult<&ColumnInfo> { - self.columns - .get(id) - .ok_or_else(|| PgTableError::ColumnNotFound(*id, self.name.clone())) - } - - pub fn columns(&self, ids: &[Felt]) -> TableResult> { - ids.iter() - .map(|id| self.column(id)) - .collect::>>() - } - - pub fn new(schema: &PgSchema, info: TableInfo, dead: Option>) -> Self { - PgTable { - schema: schema.clone(), - name: info.name, - primary: info.primary, - columns: info.columns.into_iter().map_into().collect(), - alive: true, - dead: dead.unwrap_or_default().into_iter().collect(), - } - } - pub fn get_record_schema(&self, columns: &[Felt]) -> TableResult> { - Ok(RecordSchema::new(&self.primary, self.columns(columns)?)) - } - pub fn insert_queries( - &self, - id: &Felt, - column_ids: Option<&[Felt]>, - block_number: u64, - transaction_hash: Felt, - queries: &mut Vec, - ) -> PgDbResult<()> { - queries.push( - insert_table_query( - &self.schema, - id, - &self.name, - &self.primary, - block_number, - &transaction_hash, - ) - .map_err(EncodeError)?, - ); - let columns = match column_ids { - Some(ids) => ids.iter().zip(self.columns(ids)?).collect_vec(), - None => self.columns.iter().collect_vec(), - }; - queries.push( - insert_columns_query(&self.schema, id, columns, block_number, &transaction_hash) - .map_err(EncodeError)?, - ); - Ok(()) - } -} diff --git a/crates/introspect-sqlite-sink/Cargo.toml b/crates/introspect-sql-sink/Cargo.toml similarity index 53% rename from crates/introspect-sqlite-sink/Cargo.toml rename to crates/introspect-sql-sink/Cargo.toml index 6113d553..317a535f 100644 --- a/crates/introspect-sqlite-sink/Cargo.toml +++ b/crates/introspect-sql-sink/Cargo.toml @@ -1,36 +1,38 @@ [package] -name = "torii-introspect-sqlite-sink" +name = "torii-introspect-sql-sink" version = "0.1.0" edition = "2021" -description = "SQLite sink implementation for Torii runtime" +description = "Database sink implementation for Torii runtime" authors = ["Torii Runtime "] license = "Apache-2.0" [dependencies] -sqlx = { workspace = true, features = [ - "sqlite", - "runtime-tokio-rustls", - "macros", - "migrate", -] } +sqlx = { workspace = true, features = ["runtime-tokio-rustls", "macros"] } anyhow.workspace = true async-trait.workspace = true metrics.workspace = true hex.workspace = true serde.workspace = true serde_json.workspace = true -starknet.workspace = true +starknet-types-core.workspace = true +starknet-types-raw.workspace = true tokio.workspace = true torii.workspace = true tracing.workspace = true -starknet-types-core.workspace = true thiserror.workspace = true introspect-types.workspace = true itertools.workspace = true primitive-types.workspace = true +# Local crates +torii-dojo.workspace = true torii-introspect.workspace = true -torii-sqlite.workspace = true +torii-sql.workspace = true + +# Postgres features +xxhash-rust = { workspace = true, optional = true } + -[lints] -workspace = true +[features] +postgres = ["sqlx/postgres", "dep:xxhash-rust", "torii-sql/postgres"] +sqlite = ["sqlx/sqlite", "torii-sql/sqlite"] diff --git a/crates/introspect-sql-sink/README.md b/crates/introspect-sql-sink/README.md new file mode 100644 index 00000000..3019a127 --- /dev/null +++ b/crates/introspect-sql-sink/README.md @@ -0,0 +1,130 @@ +# torii-introspect-sql-sink + +Materialises **Dojo / Introspect events** into a live SQL schema. This is +the sink that turns `IntrospectMsg::CreateTable` into a `CREATE TABLE`, +`InsertsFields` into an `INSERT`, `DropTable` into a `DROP TABLE`, and so +on. Generic over a backend (`IntrospectPgDb`, `IntrospectSqliteDb`) via +the `IntrospectProcessor` / `IntrospectQueryMaker` traits. + +## Role in Torii + +This is the first sink in a Dojo pipeline. It owns the SQL schema. Other +Dojo-aware sinks (`torii-ecs-sink`, `arcade-sink`) read from the tables +this sink writes. Every binary that indexes a Dojo world includes it — see +`bins/torii-introspect-bin`, `bins/torii-arcade`. + +## Architecture + +```text +Envelope with DOJO_TYPE_ID (from torii-dojo::DojoDecoder) + | + v ++-----------------------------------------------------+ +| IntrospectDb | +| | +| impl Sink: | +| process(envelopes): | +| filter DojoEvent::Introspect(IntrospectMsg) | +| collect into Vec | +| self.process_messages(...) | +| (IntrospectProcessor trait) | +| | +| state: | +| - tables cache (Vec) | +| - namespace mode (NamespaceMode) | +| - column/field metadata (DbColumn, DbDeadField) | ++------+----------------------------------------------+ + | ^ + | | + v | ++-----------------------------+ | +| IntrospectQueryMaker (SQL) | | +| CREATE/ALTER/DROP TABLE | | +| INSERT/DELETE rows | | +| type mapping | | ++--------+--------------------+ | + | | + v | ++-----------------------------+ +----------------+ +| Postgres backend | | SQLite backend | +| IntrospectPgDb | | IntrospectSqliteDb +| native DDL | | JSON append- | +| per-namespace schemas | | only log per | +| typed columns | | namespace | ++-----------------------------+ +----------------+ + +(feature gated: postgres, sqlite; require at least one) +runtime.rs (both features): DbPool::Postgres | DbPool::Sqlite +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `IntrospectDb` | `src/processor.rs` | — | Generic sink; `Sink` impl lives in `sink.rs` | +| `IntrospectSqlSink` (marker trait) | `src/sink.rs` | 14 | Provides `const NAME: &'static str` returned from `Sink::name` | +| `IntrospectProcessor` | `src/backend.rs` | — | Async backend contract: `process_messages(&[&IntrospectBody])` | +| `IntrospectQueryMaker` | `src/backend.rs` | — | SQL generation contract (DDL + record writes) | +| `IntrospectInitialize` | `src/backend.rs` | — | One-shot schema-setup contract | +| `DbTable`, `DbColumn`, `DbDeadField` | `src/processor.rs` | — | In-memory schema snapshot | +| `NamespaceMode` | `src/namespace.rs` | 11 | `None` / `Single(Arc)` / `Address` / `Named(map)` / `Addresses(set)` | +| `TableKey`, `NamespaceKey` | `src/namespace.rs` | 20 / 32 | Routes a table to its schema bucket | +| `Table`, `DeadField`, `DeadFieldDef` | `src/table.rs` | — | Schema evolution helpers (dropped columns, retyped fields) | +| `IntrospectPgDb` | `src/postgres/mod.rs` (feature `postgres`) | — | Postgres backend (native DDL) | +| `IntrospectSqliteDb` | `src/sqlite/mod.rs` (feature `sqlite`) | — | SQLite backend (JSON append-only log) | +| Errors | `src/error.rs` | — | `DbError`, `TableError`, `TypeError`, `UpgradeError`, etc. | + +### Internal Modules + +- `sink` — `impl Sink for IntrospectDb`; the `process` branch filtering `DojoEvent::Introspect` and forwarding to `process_messages`. +- `processor` — `IntrospectDb` struct + the generic `IntrospectProcessor` impl over `Pool`. Holds the cached `DbTable` list and the `NamespaceMode`. +- `backend` — three traits (`IntrospectQueryMaker`, `IntrospectProcessor`, `IntrospectInitialize`) — the backend interface. +- `namespace` — `NamespaceMode` + `TableKey` / `NamespaceKey`; routes a Dojo table to its physical schema slot. +- `table` / `tables` — `Table`, `DeadField`, cached list of alive tables; used to resolve incremental `UpdateTable` messages against prior state. +- `error` — typed domain errors (most carry a `TableKey` or `ColumnKey` for diagnostics). +- `postgres/` — Postgres backend: DDL via native `CREATE TABLE`, `ALTER TABLE`, JSON serialisation for unknown types, typed columns where possible. +- `sqlite/` — SQLite backend: append-only JSON log per (namespace, table) with type coercion on read. +- `runtime` (feature `postgres + sqlite`) — `IntrospectDb` runtime enum so one binary can open either backend. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `Backend::NAME` (e.g. `"introspect-pg"`, `"introspect-sqlite"`) | +| `interested_types` | `[DOJO_TYPE_ID]` | +| `process` | Extracts `IntrospectMsg` from each envelope, counts categories (create/update/inserts/deletes) for metrics, calls `self.process_messages(...)`, and logs per-message failures | +| `topics` | `Vec::new()` — no EventBus topic | +| `build_routes` | `Router::new()` — no HTTP | +| `initialize` | Calls `self.initialize_introspect_sql_sink()` — creates catalog tables, idempotent | + +### Namespace modes (NamespaceMode) + +- `None` — single namespace, no routing. +- `Single(Arc)` — all tables in one named namespace. +- `Address` — one namespace per owner Felt address. +- `Named(HashMap>)` — owner → explicit namespace map. +- `Addresses(HashSet)` — allow-list; unknown owners are dropped. + +Pick the mode when the binary constructs the backend; it determines how +tables are grouped in Postgres schemas or SQLite filenames. + +### Metrics + +| Metric | Labels | +|---|---| +| `torii_introspect_sink_messages_total` | `message=create_table|update_table|inserts_fields` | +| `torii_introspect_sink_records_total` | `message=inserts_fields|delete_records` | + +### Interactions + +- **Upstream (consumers)**: `bins/torii-introspect-bin`, `bins/torii-arcade`, `bins/torii-introspect-synth`. +- **Downstream deps**: `torii`, `torii-dojo`, `torii-introspect`, `torii-sql`, `sqlx` (feature-gated), `introspect-types`, `starknet-types-core`, `starknet-types-raw`, `primitive-types`, `xxhash-rust` (feature `postgres`), `tokio`, `async-trait`, `itertools`, `hex`, `metrics`, `tracing`. +- **Features**: `postgres`, `sqlite` (at least one required). The `runtime` module needs both. + +### Extension Points + +- New backend → impl `IntrospectQueryMaker` + `IntrospectProcessor` + `IntrospectInitialize` + provide `IntrospectSqlSink::NAME`. The `Sink` impl is generic; no changes there. +- New namespace strategy → add a variant to `NamespaceMode` and extend every `resolve_*` helper that pattern-matches it. +- Change the SQLite row encoding → swap the JSON writer in `sqlite/` and bump the schema version; catalog tables detect the mismatch on startup. diff --git a/crates/introspect-postgres-sink/migrations/001_domains.sql b/crates/introspect-sql-sink/migrations/postgres/001_domains.sql similarity index 100% rename from crates/introspect-postgres-sink/migrations/001_domains.sql rename to crates/introspect-sql-sink/migrations/postgres/001_domains.sql diff --git a/crates/introspect-postgres-sink/migrations/002_metadata_function.sql b/crates/introspect-sql-sink/migrations/postgres/002_metadata_function.sql similarity index 100% rename from crates/introspect-postgres-sink/migrations/002_metadata_function.sql rename to crates/introspect-sql-sink/migrations/postgres/002_metadata_function.sql diff --git a/crates/introspect-postgres-sink/migrations/003_store.sql b/crates/introspect-sql-sink/migrations/postgres/003_store.sql similarity index 94% rename from crates/introspect-postgres-sink/migrations/003_store.sql rename to crates/introspect-sql-sink/migrations/postgres/003_store.sql index d58e2145..461fd11f 100644 --- a/crates/introspect-postgres-sink/migrations/003_store.sql +++ b/crates/introspect-sql-sink/migrations/postgres/003_store.sql @@ -22,7 +22,10 @@ CREATE TABLE IF NOT EXISTS introspect.db_tables ( "schema" TEXT NOT NULL, id felt252 NOT NULL, name TEXT NOT NULL, + "owner" felt252 NOT NULL, primary_def introspect.primary_def NOT NULL, + append_only BOOLEAN NOT NULL DEFAULT FALSE, + alive BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_block uint64 NOT NULL, diff --git a/crates/introspect-sqlite-sink/migrations/001_init.sql b/crates/introspect-sql-sink/migrations/sqlite/001_init.sql similarity index 100% rename from crates/introspect-sqlite-sink/migrations/001_init.sql rename to crates/introspect-sql-sink/migrations/sqlite/001_init.sql diff --git a/crates/introspect-sql-sink/migrations/sqlite/002_schema_state.sql b/crates/introspect-sql-sink/migrations/sqlite/002_schema_state.sql new file mode 100644 index 00000000..1c345fb2 --- /dev/null +++ b/crates/introspect-sql-sink/migrations/sqlite/002_schema_state.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS introspect_db_tables ( + namespace TEXT NOT NULL, + id TEXT NOT NULL, + owner TEXT NOT NULL, + name TEXT NOT NULL, + "primary" TEXT NOT NULL, + columns TEXT NOT NULL, + append_only INTEGER NOT NULL DEFAULT 0, + alive INTEGER NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY (namespace, id) +); diff --git a/crates/introspect-sql-sink/src/backend.rs b/crates/introspect-sql-sink/src/backend.rs new file mode 100644 index 00000000..563e2e5e --- /dev/null +++ b/crates/introspect-sql-sink/src/backend.rs @@ -0,0 +1,114 @@ +use crate::processor::{messages_to_queries, DbColumn, DbDeadField, DbTable, COMMIT_CMD}; +use crate::table::Table; +use crate::tables::Tables; +use crate::{DbResult, NamespaceMode, RecordResult, TableResult}; +use async_trait::async_trait; +use introspect_types::{ColumnDef, PrimaryDef}; +use sqlx::{Database, Pool}; +use starknet_types_core::felt::Felt; +use std::fmt::Debug; +use torii_introspect::events::IntrospectBody; +use torii_introspect::Record; +use torii_sql::{Executable, FlexQuery, PoolExt}; + +#[async_trait] +pub trait IntrospectProcessor { + async fn process_msgs( + &self, + namespaces: &NamespaceMode, + tables: &Tables, + msgs: Vec<&IntrospectBody>, + ) -> DbResult>>; +} + +#[async_trait] +pub trait IntrospectInitialize { + async fn initialize(&self) -> DbResult<()>; + async fn load_tables(&self, namespaces: &Option>) -> DbResult>; + async fn load_columns(&self, namespaces: &Option>) -> DbResult>; + async fn load_dead_fields( + &self, + namespaces: &Option>, + ) -> DbResult>; +} + +#[allow(clippy::too_many_arguments)] +pub trait IntrospectQueryMaker: Database { + fn create_table_queries( + namespace: &str, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> TableResult<()>; + fn update_table_queries( + table: &mut Table, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> TableResult<()>; + fn insert_record_queries( + table: &Table, + columns: &[Felt], + records: &[Record], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> RecordResult<()>; +} + +#[async_trait] +pub trait IntrospectPool { + async fn commit_queries(&self, queries: Vec>) -> DbResult<()>; + async fn execute_msgs( + &self, + namespaces: &NamespaceMode, + tables: &Tables, + msgs: Vec<&IntrospectBody>, + ) -> DbResult>> { + let mut queries = Vec::new(); + let results = messages_to_queries::(namespaces, tables, msgs, &mut queries)?; + self.commit_queries(queries).await?; + Ok(results) + } +} + +#[async_trait] +impl IntrospectPool for Pool +where + Vec>: Executable, + FlexQuery: Debug + Clone, +{ + async fn commit_queries(&self, queries: Vec>) -> DbResult<()> { + let mut batch = Vec::new(); + for query in queries { + if query == *COMMIT_CMD { + self.execute_queries(std::mem::take(&mut batch)).await?; + } else { + batch.push(query); + } + } + if !batch.is_empty() { + match self.execute_queries(batch.clone()).await { + Ok(_) => (), + Err(e) => { + for query in batch { + eprintln!("Failed query: {query:?}"); + } + return Err(e.into()); + } + } + } + Ok(()) + } +} diff --git a/crates/introspect-sql-sink/src/error.rs b/crates/introspect-sql-sink/src/error.rs new file mode 100644 index 00000000..b3b201d7 --- /dev/null +++ b/crates/introspect-sql-sink/src/error.rs @@ -0,0 +1,248 @@ +use crate::TableKey; +use introspect_types::{DecodeError, PrimaryTypeDef, TypeDef}; +use sqlx::error::BoxDynError; +use sqlx::Error as SqlxError; +use starknet_types_core::felt::Felt; +use starknet_types_core::felt::FromStrError; +use std::fmt::Display; +use std::sync::PoisonError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum TypeError { + #[error("Unsupported type for {0}")] + UnsupportedType(String), + #[error("Nested arrays are not supported")] + NestedArrays, +} + +pub type TypeResult = std::result::Result; + +#[derive(Debug, Error)] +pub enum TableError { + #[error("Table {0} has not got columns: {1:?}")] + ColumnsNotFound(String, ColumnsNotFoundError), + #[error(transparent)] + TypeError(#[from] TypeError), + #[error("Current type mismatch error")] + TypeMismatch, + #[error("Unsupported upgrade for table {table} column {column}: {reason}")] + UnsupportedUpgrade { + table: String, + column: String, + reason: UpgradeError, + }, + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error("error occurred while encoding a value: {0}")] + Encode(#[from] BoxDynError), +} + +#[derive(Debug)] +pub struct ColumnNotFoundError(pub Felt); + +#[derive(Debug, Default, Error)] +pub struct ColumnsNotFoundError(pub Vec); + +impl ColumnsNotFoundError { + pub fn to_table_error(self, table: &str) -> TableError { + TableError::ColumnsNotFound(table.to_string(), self) + } +} + +impl Display for ColumnsNotFoundError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some((first, rest)) = self.0.split_first() { + write!(f, "0x{first:#063x}")?; + for col in rest { + write!(f, ", 0x{col:#063x}")?; + } + } + Ok(()) + } +} + +pub trait CollectColumnResults { + fn collect_columns(self) -> Result, ColumnsNotFoundError>; +} + +impl CollectColumnResults for I +where + I: Iterator>, +{ + fn collect_columns(self) -> Result, ColumnsNotFoundError> { + let mut columns = Vec::new(); + let mut not_found = Vec::new(); + for result in self { + match result { + Ok(col) => columns.push(col), + Err(e) => not_found.push(e.0), + } + } + if not_found.is_empty() { + Ok(columns) + } else { + Err(ColumnsNotFoundError(not_found)) + } + } +} + +pub type TableResult = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum UpgradeError { + #[error("Failed to upgrade type from {old} to {new}")] + TypeUpgradeError { + old: &'static str, + new: &'static str, + }, + #[error("Failed to upgrade primary from {old} to {new}")] + PrimaryUpgradeError { + old: &'static str, + new: &'static str, + }, + #[error(transparent)] + TypeCreationError(#[from] TypeError), + #[error("Array length cannot be decreased from {old} to {new}")] + ArrayLengthDecreaseError { old: u32, new: u32 }, + #[error("Cannot reduce element in tuple")] + TupleReductionError, +} + +pub type UpgradeResult = Result; + +impl UpgradeError { + pub fn type_upgrade_err(old: &TypeDef, new: &TypeDef) -> UpgradeResult { + Err(Self::TypeUpgradeError { + old: old.item_name(), + new: new.item_name(), + }) + } + pub fn type_upgrade_to_err(old: &TypeDef, new: &'static str) -> UpgradeResult { + Err(Self::TypeUpgradeError { + old: old.item_name(), + new, + }) + } + pub fn type_cast_err(old: &TypeDef, new: &'static str) -> UpgradeResult { + Err(Self::TypeUpgradeError { + old: old.item_name(), + new, + }) + } + pub fn array_shorten_err(old: u32, new: u32) -> UpgradeResult { + Err(Self::ArrayLengthDecreaseError { old, new }) + } + pub fn primary_upgrade_err(old: &TypeDef, new: &PrimaryTypeDef) -> UpgradeResult { + Err(Self::PrimaryUpgradeError { + old: old.item_name(), + new: new.item_name(), + }) + } +} + +pub trait UpgradeResultExt { + fn to_table_result(self, table: &str, column: &str) -> TableResult; +} + +impl UpgradeResultExt for UpgradeResult { + fn to_table_result(self, table: &str, column: &str) -> TableResult { + self.map_err(|err| TableError::UnsupportedUpgrade { + table: table.to_string(), + column: column.to_string(), + reason: err, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RecordError { + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error(transparent)] + TypeError(#[from] TypeError), + #[error("Record does not match table schema")] + SchemaMismatch, + #[error(transparent)] + DecodeError(#[from] DecodeError), + #[error(transparent)] + SqlxError(#[from] SqlxError), + #[error("Columns with ids: {0} not found")] + ColumnsNotFound(#[from] ColumnsNotFoundError), +} + +pub type RecordResult = std::result::Result; + +pub trait RecordResultExt { + fn to_db_result(self, table: &str) -> DbResult; +} + +impl RecordResultExt for RecordResult { + fn to_db_result(self, table: &str) -> DbResult { + self.map_err(|err| DbError::RecordError(table.to_string(), err)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DbError { + #[error(transparent)] + DatabaseError(#[from] SqlxError), + #[error("Invalid event format: {0}")] + InvalidEventFormat(String), + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + TableError(#[from] TableError), + #[error(transparent)] + TypeError(#[from] TypeError), + #[error("Table with id: {0} already exists, incoming name: {1}, existing name: {2}")] + TableAlreadyExists(TableKey, String, String), + #[error("Table not found with id: {0}")] + TableNotFound(TableKey), + #[error("Table not alive - id: {0}, name: {1}")] + TableNotAlive(Felt, String), + #[error("Manager does not support updating")] + UpdateNotSupported, + #[error("Table poison error: {0}")] + PoisonError(String), + #[error("Namespace not found for address: {0:#063x}")] + NamespaceNotFound(Felt), + #[error("Could not parse record for table {0}: {1}")] + RecordError(String, RecordError), + #[error("Failed to pass string to felt")] + FeltFromStrError(#[from] FromStrError), +} + +pub type DbResult = std::result::Result; + +impl From> for DbError { + fn from(err: PoisonError) -> Self { + Self::PoisonError(err.to_string()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum NamespaceError { + #[error("Invalid address length for address: {0} should be 63 characters long")] + InvalidAddressLength(String), + #[error(transparent)] + AddressFromStrError(#[from] FromStrError), + #[error("Namespace {0} does not match expected namespace {1}")] + NamespaceMismatch(String, String), + #[error("Namespace {1} not found for address: {0:#063x}")] + AddressNotFound(Felt, String), +} + +pub type NamespaceResult = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum TableLoadError { + #[error(transparent)] + NamespaceError(#[from] NamespaceError), + #[error("Table {0} {1:#063x} not found for column {2} with id: {3:#063x}")] + ColumnTableNotFound(String, Felt, String, Felt), + #[error("Table {0} {1:#063x} not found for dead field {2} with id: {3}")] + TableDeadNotFound(String, Felt, String, u128), +} diff --git a/crates/introspect-sql-sink/src/lib.rs b/crates/introspect-sql-sink/src/lib.rs new file mode 100644 index 00000000..249898a0 --- /dev/null +++ b/crates/introspect-sql-sink/src/lib.rs @@ -0,0 +1,31 @@ +pub mod backend; +pub mod error; +pub mod namespace; +pub mod processor; +pub mod sink; +pub mod table; +pub mod tables; + +pub use backend::{IntrospectInitialize, IntrospectProcessor, IntrospectQueryMaker}; +pub use error::{ + DbError, DbResult, RecordError, RecordResult, TableError, TableResult, TypeError, TypeResult, + UpgradeError, UpgradeResult, UpgradeResultExt, +}; +pub use namespace::{NamespaceKey, NamespaceMode, TableKey}; +pub use processor::{DbColumn, DbDeadField, DbTable, IntrospectDb}; +pub use sink::IntrospectSqlSink; +pub use table::{DeadField, DeadFieldDef, Table}; + +#[cfg(feature = "postgres")] +pub mod postgres; +#[cfg(feature = "postgres")] +pub use postgres::IntrospectPgDb; + +#[cfg(feature = "sqlite")] +pub mod sqlite; +#[cfg(feature = "sqlite")] +pub use sqlite::IntrospectSqliteDb; + +#[cfg(feature = "postgres")] +#[cfg(feature = "sqlite")] +pub mod runtime; diff --git a/crates/introspect-sql-sink/src/namespace.rs b/crates/introspect-sql-sink/src/namespace.rs new file mode 100644 index 00000000..695a3049 --- /dev/null +++ b/crates/introspect-sql-sink/src/namespace.rs @@ -0,0 +1,206 @@ +use crate::error::{NamespaceError, NamespaceResult}; +use crate::{DbError, DbResult}; +use introspect_types::ResultInto; +use itertools::Itertools; +use starknet_types_core::felt::Felt; +use std::collections::{HashMap, HashSet}; +use std::fmt::Display; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +pub enum NamespaceMode { + None, + Single(Arc), + Address, + Named(HashMap>), + Addresses(HashSet), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableKey { + namespace: NamespaceKey, + id: Felt, +} + +impl TableKey { + pub fn new(namespace: NamespaceKey, id: Felt) -> Self { + Self { namespace, id } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NamespaceKey { + None, + Single(Arc), + Address(Felt), + Named(Arc), +} + +pub fn felt_to_namespace(address: &Felt) -> String { + format!("{address:063x}") +} + +impl Hash for TableKey { + fn hash(&self, state: &mut H) { + self.namespace.hash(state); + self.id.hash(state); + } +} + +impl Hash for NamespaceKey { + fn hash(&self, state: &mut H) { + match self { + NamespaceKey::Address(addr) => addr.hash(state), + NamespaceKey::Named(name) => name.hash(state), + NamespaceKey::Single(_) => {} + NamespaceKey::None => {} + } + } +} + +impl Display for NamespaceKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NamespaceKey::Address(addr) => write!(f, "{addr:063x}"), + NamespaceKey::Named(name) | NamespaceKey::Single(name) => name.fmt(f), + NamespaceKey::None => Ok(()), + } + } +} + +impl Display for TableKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !matches!(self.namespace, NamespaceKey::Single(_) | NamespaceKey::None) { + write!(f, "{} ", self.namespace)?; + } + write!(f, "{:#063x}", self.id) + } +} + +impl From for NamespaceMode { + fn from(value: String) -> Self { + NamespaceMode::Single(value.into()) + } +} + +impl From<&str> for NamespaceMode { + fn from(value: &str) -> Self { + NamespaceMode::Single(value.into()) + } +} + +impl From> for NamespaceMode { + fn from(value: HashMap) -> Self { + NamespaceMode::Named( + value + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect::>(), + ) + } +} + +impl From<[(Felt, &str); N]> for NamespaceMode { + fn from(value: [(Felt, &str); N]) -> Self { + NamespaceMode::Named( + value + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect::>(), + ) + } +} + +impl From<[(Felt, String); N]> for NamespaceMode { + fn from(value: [(Felt, String); N]) -> Self { + NamespaceMode::Named( + value + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect::>(), + ) + } +} + +impl From<[Felt; N]> for NamespaceMode { + fn from(value: [Felt; N]) -> Self { + NamespaceMode::Addresses(value.into_iter().collect()) + } +} + +impl From> for NamespaceMode { + fn from(value: Vec) -> Self { + NamespaceMode::Addresses(value.into_iter().collect()) + } +} + +fn felt_try_from_namespace(namespace: &str) -> NamespaceResult { + match namespace.len() == 63 { + true => Felt::from_hex(namespace).err_into(), + false => Err(NamespaceError::InvalidAddressLength(namespace.to_string())), + } +} + +impl NamespaceMode { + pub fn namespaces(&self) -> Option> { + match self { + NamespaceMode::None => Some(vec!["".to_string()]), + NamespaceMode::Single(name) => Some(vec![name.to_string()]), + NamespaceMode::Address => None, + NamespaceMode::Named(map) => { + Some(map.values().unique().map(ToString::to_string).collect()) + } + NamespaceMode::Addresses(set) => Some(set.iter().map(felt_to_namespace).collect()), + } + } + + pub fn get_namespace_key( + &self, + namespace: String, + owner: &Felt, + ) -> NamespaceResult { + match self { + NamespaceMode::None => Ok(NamespaceKey::None), + NamespaceMode::Single(s) => match **s == *namespace { + true => Ok(NamespaceKey::Single(s.clone())), + false => Err(NamespaceError::NamespaceMismatch(namespace, s.to_string())), + }, + NamespaceMode::Address => { + felt_try_from_namespace(&namespace).map(NamespaceKey::Address) + } + NamespaceMode::Named(map) => match map.get(owner) { + Some(s) if **s == *namespace => Ok(NamespaceKey::Named(s.clone())), + Some(s) => Err(NamespaceError::NamespaceMismatch(namespace, s.to_string())), + None => Err(NamespaceError::AddressNotFound(*owner, namespace)), + }, + NamespaceMode::Addresses(set) => { + let address = felt_try_from_namespace(&namespace)?; + match set.contains(&address) { + true => Ok(NamespaceKey::Address(address)), + false => Err(NamespaceError::AddressNotFound(address, namespace)), + } + } + } + } + + pub fn get_key(&self, namespace: String, id: Felt, owner: &Felt) -> NamespaceResult { + self.get_namespace_key(namespace, owner) + .map(|k| TableKey::new(k, id)) + } + + pub fn to_namespace(&self, from_address: &Felt) -> DbResult { + match self { + NamespaceMode::None => Ok(NamespaceKey::None), + NamespaceMode::Single(name) => Ok(NamespaceKey::Single(name.clone())), + NamespaceMode::Address => Ok(NamespaceKey::Address(*from_address)), + NamespaceMode::Named(map) => match map.get(from_address) { + Some(namespace) => Ok(NamespaceKey::Named(namespace.clone())), + None => Err(DbError::NamespaceNotFound(*from_address)), + }, + NamespaceMode::Addresses(set) => match set.contains(from_address) { + true => Ok(NamespaceKey::Address(*from_address)), + false => Err(DbError::NamespaceNotFound(*from_address)), + }, + } + } +} diff --git a/crates/introspect-sql-sink/src/postgres/append_only.rs b/crates/introspect-sql-sink/src/postgres/append_only.rs new file mode 100644 index 00000000..9d505c64 --- /dev/null +++ b/crates/introspect-sql-sink/src/postgres/append_only.rs @@ -0,0 +1,106 @@ +use super::json::PostgresJsonSerializer; +use crate::postgres::insert::pg_json_felt252; +use crate::{RecordResult, Table}; +use introspect_types::ColumnInfo; +use serde::ser::SerializeMap; +use serde_json::Serializer as JsonSerializer; +use starknet_types_core::felt::Felt; +use std::io::Write; +use torii_introspect::tables::SerializeEntries; +use torii_introspect::Record; +use torii_sql::postgres::PgQuery; +use torii_sql::Queries; + +struct MetaData<'a> { + pub block_number: u64, + pub transaction_hash: &'a Felt, +} + +impl<'a> MetaData<'a> { + pub fn new(block_number: u64, transaction_hash: &'a Felt) -> Self { + Self { + block_number, + transaction_hash, + } + } +} + +impl SerializeEntries for MetaData<'_> { + fn entry_count(&self) -> usize { + 2 + } + fn serialize_entries( + &self, + map: &mut ::SerializeMap, + ) -> Result<(), S::Error> { + let tx_hash = pg_json_felt252(self.transaction_hash); + map.serialize_entry("__created_block", &self.block_number)?; + map.serialize_entry("__created_tx", &tx_hash) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn append_only_record_queries( + table: &Table, + column_ids: &[Felt], + records: &[Record], + _from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, +) -> RecordResult<()> { + let schema = table.get_record_schema(column_ids)?; + let namespace = &table.namespace; + let table_name = &table.name; + let mut writer = Vec::new(); + let metadata = MetaData::new(block_number, transaction_hash); + let primary = schema.primary_name(); + let columns: Vec<(&Felt, &ColumnInfo)> = table.columns.iter().collect(); + + write!( + writer, + r#"WITH latest AS (SELECT DISTINCT ON ("{primary}") * FROM "{namespace}"."{table_name}" ORDER BY "{primary}", "__revision" DESC), + input AS ( SELECT * FROM jsonb_populate_recordset(NULL::"{namespace}"."{table_name}", $$"# + ) + .unwrap(); + + schema.parse_records_with_metadata( + records, + &metadata, + &mut JsonSerializer::new(&mut writer), + &PostgresJsonSerializer, + )?; + + write!( + writer, + r#"$$)) INSERT INTO "{namespace}"."{table_name}" ("{primary}", "__revision", "__created_block", "__created_tx""# + ) + .unwrap(); + + for (_, ColumnInfo { name, .. }) in &columns { + write!(writer, r#", "{name}""#).unwrap(); + } + + write!( + writer, + r#") SELECT i."{primary}", (SELECT COALESCE(MAX("__revision"), 0) + 1 FROM "{namespace}"."{table_name}" WHERE "{primary}" = i."{primary}") AS "__revision", i."__created_block", i."__created_tx""#, + ).unwrap(); + + for (id, ColumnInfo { name, .. }) in &columns { + if column_ids.contains(id) { + write!(writer, r#", i."{name}""#).unwrap(); + } else { + write!(writer, r#", COALESCE(i."{name}", l."{name}")"#).unwrap(); + } + } + + write!( + writer, + r#" FROM input i LEFT JOIN latest l USING ("{primary}")"# + ) + .unwrap(); + + let string = unsafe { String::from_utf8_unchecked(writer) }; + queries.add(string); + Ok(()) +} diff --git a/crates/introspect-sql-sink/src/postgres/backend.rs b/crates/introspect-sql-sink/src/postgres/backend.rs new file mode 100644 index 00000000..31a5c5dd --- /dev/null +++ b/crates/introspect-sql-sink/src/postgres/backend.rs @@ -0,0 +1,147 @@ +use super::insert::insert_record_queries; +use super::query::{insert_columns_query, insert_table_query, CreatePgTable}; +use super::upgrade::PgTableUpgrade; +use crate::postgres::append_only::append_only_record_queries; +use crate::processor::IntrospectDb; +use crate::{ + IntrospectQueryMaker, IntrospectSqlSink, RecordResult, Table, TableError, TableResult, +}; +use async_trait::async_trait; +use introspect_types::schema::{Names, TypeDefs}; +use introspect_types::{ColumnDef, FeltIds, PrimaryDef}; +use starknet_types_core::felt::Felt; +use torii_introspect::Record; +use torii_sql::{PgPool, PgQuery, Postgres}; + +pub type IntrospectPgDb = IntrospectDb; + +#[async_trait] +impl IntrospectQueryMaker for Postgres { + fn create_table_queries( + namespace: &str, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, + ) -> TableResult<()> { + let ns = namespace.into(); + CreatePgTable::new(&ns, id, name, primary, columns, append_only)?.make_queries(queries); + store_table_queries( + namespace, + id, + name, + primary, + append_only, + columns, + from_address, + block_number, + transaction_hash, + queries, + ) + } + fn update_table_queries( + table: &mut Table, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, + ) -> TableResult<()> { + let upgrades = table.upgrade_table(name, primary, columns)?; + upgrades.to_queries(block_number, transaction_hash, queries)?; + let columns: Vec<(&Felt, &introspect_types::ColumnInfo)> = table + .columns_with_ids(&upgrades.columns_upgraded) + .map_err(|e| e.to_table_error(&table.name))?; + store_table_queries( + &table.namespace, + &table.id, + name, + primary, + table.append_only, + columns, + from_address, + block_number, + transaction_hash, + queries, + ) + } + fn insert_record_queries( + table: &Table, + columns: &[Felt], + records: &[Record], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, + ) -> RecordResult<()> { + if table.append_only { + append_only_record_queries( + table, + columns, + records, + from_address, + block_number, + transaction_hash, + queries, + ) + } else { + insert_record_queries( + table, + columns, + records, + from_address, + block_number, + transaction_hash, + queries, + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn store_table_queries( + schema: &str, + id: &Felt, + name: &str, + primary: &PrimaryDef, + append_only: bool, + columns: CS, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, +) -> TableResult<()> +where + CS: Names + FeltIds + TypeDefs, +{ + queries.push( + insert_table_query( + schema, + id, + name, + primary, + from_address, + append_only, + block_number, + transaction_hash, + ) + .map_err(TableError::Encode)?, + ); + + queries.push( + insert_columns_query(schema, id, columns, block_number, transaction_hash) + .map_err(TableError::Encode)?, + ); + Ok(()) +} + +impl IntrospectSqlSink for PgPool { + const NAME: &'static str = "Introspect Postgres"; +} diff --git a/crates/introspect-postgres-sink/src/create.rs b/crates/introspect-sql-sink/src/postgres/create.rs similarity index 84% rename from crates/introspect-postgres-sink/src/create.rs rename to crates/introspect-sql-sink/src/postgres/create.rs index 41bf4d85..34df03c8 100644 --- a/crates/introspect-postgres-sink/src/create.rs +++ b/crates/introspect-sql-sink/src/postgres/create.rs @@ -1,9 +1,7 @@ -use crate::{ - query::{CreatePgTable, CreatesType}, - utils::{AsBytes, HasherExt}, - PgSchema, PgTypeError, PgTypeResult, PostgresField, PostgresScalar, PostgresType, PrimaryKey, - SchemaName, -}; +use super::query::{make_schema_query, CreatePgTable, CreatesType}; +use super::utils::{AsBytes, HasherExt}; +use super::{PostgresField, PostgresScalar, PostgresType, PrimaryKey, SchemaName}; +use crate::{TypeError, TypeResult}; use introspect_types::{ ArrayDef, ColumnDef, EnumDef, FixedArrayDef, MemberDef, OptionDef, PrimaryDef, PrimaryTypeDef, StructDef, TupleDef, TypeDef, VariantDef, @@ -11,17 +9,17 @@ use introspect_types::{ use itertools::Itertools; use starknet_types_core::felt::Felt; use std::rc::Rc; -use torii_common::sql::{PgQuery, Queries}; -use torii_introspect::schema::TableInfo; +use torii_sql::postgres::PgQuery; +use torii_sql::Queries; use xxhash_rust::xxh3::Xxh3; pub trait PostgresTypeExtractor { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult; + ) -> TypeResult; } pub trait PostgresFieldExtractor { @@ -34,10 +32,10 @@ pub trait PostgresFieldExtractor { } fn extract_field( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { Ok(PostgresField::new( self.name().to_string(), self.type_def() @@ -54,7 +52,7 @@ impl PostgresField { } } - pub fn new_composite(name: S, schema: &Rc, type_name: T) -> Self + pub fn new_composite(name: S, schema: &Rc, type_name: T) -> Self where S: Into, T: Into, @@ -111,10 +109,10 @@ impl PostgresFieldExtractor for (&Felt, &VariantDef) { impl PostgresTypeExtractor for TypeDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { match self { TypeDef::None => Ok(PostgresScalar::None.into()), TypeDef::Bool => Ok(PostgresScalar::Boolean.into()), @@ -149,7 +147,7 @@ impl PostgresTypeExtractor for TypeDef { TypeDef::Option(def) => def.type_def.extract_type(schema, branch, creates), TypeDef::Nullable(def) => def.type_def.extract_type(schema, branch, creates), TypeDef::Felt252Dict(_) | TypeDef::Result(_) | TypeDef::Ref(_) | TypeDef::Custom(_) => { - Err(PgTypeError::UnsupportedType(format!("{self:?}"))) + Err(TypeError::UnsupportedType(format!("{self:?}"))) } } } @@ -187,10 +185,10 @@ impl From<&PrimaryDef> for PrimaryKey { impl PostgresTypeExtractor for ArrayDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { self.type_def .extract_type(schema, branch, creates)? .to_array(None) @@ -200,10 +198,10 @@ impl PostgresTypeExtractor for ArrayDef { impl PostgresTypeExtractor for FixedArrayDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { self.type_def .extract_type(schema, branch, creates)? .to_array(Some(self.size)) @@ -213,15 +211,15 @@ impl PostgresTypeExtractor for FixedArrayDef { impl PostgresTypeExtractor for StructDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { let members = self .members .iter() .map(|f| f.extract_field(schema, branch, creates)) - .collect::>>()?; + .collect::>>()?; let name = branch.type_name(&self.name); creates.push(CreatesType::new_struct(schema, &name, members).into()); Ok(PostgresType::composite(schema, name)) @@ -231,10 +229,10 @@ impl PostgresTypeExtractor for StructDef { impl PostgresTypeExtractor for EnumDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { let name = branch.type_name(&self.name); let variants_type = branch.type_name(&format!("v_{}", self.name)); let variant_names = self.variants.values().map(|v| v.name.clone()).collect_vec(); @@ -259,10 +257,10 @@ impl PostgresTypeExtractor for EnumDef { impl PostgresTypeExtractor for TupleDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { let mut variants = Vec::with_capacity(self.elements.len()); for (i, element) in self.elements.iter().enumerate() { variants.push( @@ -280,44 +278,51 @@ impl PostgresTypeExtractor for TupleDef { impl PostgresTypeExtractor for OptionDef { fn extract_type( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, creates: &mut Vec>, - ) -> PgTypeResult { + ) -> TypeResult { self.type_def.extract_type(schema, branch, creates) } } impl CreatePgTable { - pub fn new(schema: &Rc, id: &Felt, table: &TableInfo) -> PgTypeResult { - let TableInfo { - name, - attributes: _, - primary, - columns, - } = table; - let mut creates = Vec::new(); + pub fn new( + schema: &Rc, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, + ) -> TypeResult { + let mut creates: Vec = Vec::new(); let branch = Xxh3::new_based(id); let primary = primary.into(); let columns = columns .iter() .map(|col| col.extract_field(schema, &branch, &mut creates)) - .collect::>>()?; + .collect::>>()?; Ok(Self { name: SchemaName::new(schema, name), primary, columns, pg_types: creates, + append_only, }) } pub fn make_queries(&self, queries: &mut Vec) { + if !self.name.1.is_empty() { + queries.add(make_schema_query(&self.name.0)); + } for pg_type in &self.pg_types { queries.add(pg_type.to_string()); } queries.add(self.to_string()); - queries.add(format!( - r#"CREATE TRIGGER set_timestamps BEFORE INSERT ON {} FOR EACH ROW EXECUTE FUNCTION introspect.set_default_timestamps();"#, - self.name - )); + if !self.append_only { + queries.add(format!( + r#"CREATE TRIGGER set_timestamps BEFORE INSERT ON {} FOR EACH ROW EXECUTE FUNCTION introspect.set_default_timestamps();"#, + self.name + )); + } } } diff --git a/crates/introspect-sql-sink/src/postgres/handler.rs b/crates/introspect-sql-sink/src/postgres/handler.rs new file mode 100644 index 00000000..c445a50c --- /dev/null +++ b/crates/introspect-sql-sink/src/postgres/handler.rs @@ -0,0 +1,28 @@ +use super::query::{fetch_columns, fetch_dead_fields, fetch_tables}; +use crate::backend::IntrospectInitialize; +use crate::{DbColumn, DbDeadField, DbResult, DbTable}; +use async_trait::async_trait; +use introspect_types::ResultInto; +use sqlx::PgPool; +use torii_sql::PoolExt; + +pub const INTROSPECT_PG_SINK_MIGRATIONS: sqlx::migrate::Migrator = + sqlx::migrate!("./migrations/postgres"); + +#[async_trait] +impl IntrospectInitialize for PgPool { + async fn load_tables(&self, schemas: &Option>) -> DbResult> { + fetch_tables(self.pool(), schemas).await.err_into() + } + async fn load_columns(&self, schemas: &Option>) -> DbResult> { + fetch_columns(self.pool(), schemas).await.err_into() + } + async fn load_dead_fields(&self, schemas: &Option>) -> DbResult> { + fetch_dead_fields(self.pool(), schemas).await.err_into() + } + async fn initialize(&self) -> DbResult<()> { + self.migrate(Some("introspect"), INTROSPECT_PG_SINK_MIGRATIONS) + .await + .err_into() + } +} diff --git a/crates/introspect-sql-sink/src/postgres/insert.rs b/crates/introspect-sql-sink/src/postgres/insert.rs new file mode 100644 index 00000000..a49fe6f4 --- /dev/null +++ b/crates/introspect-sql-sink/src/postgres/insert.rs @@ -0,0 +1,92 @@ +use super::json::PostgresJsonSerializer; +use crate::{RecordResult, Table}; +use introspect_types::ColumnInfo; +use serde::ser::SerializeMap; +use serde_json::Serializer as JsonSerializer; +use starknet_types_core::felt::Felt; +use std::io::Write; +use torii_introspect::tables::SerializeEntries; +use torii_introspect::Record; +use torii_sql::postgres::PgQuery; +use torii_sql::Queries; + +pub const METADATA_CONFLICTS: &str = "__updated_at = NOW(), __updated_block = EXCLUDED.__updated_block, __updated_tx = EXCLUDED.__updated_tx"; + +struct MetaData<'a> { + pub block_number: u64, + pub transaction_hash: &'a Felt, +} + +impl<'a> MetaData<'a> { + pub fn new(block_number: u64, transaction_hash: &'a Felt) -> Self { + Self { + block_number, + transaction_hash, + } + } +} + +pub fn pg_json_felt252(value: &Felt) -> String { + format!("\\x{}", hex::encode(value.to_bytes_be())) +} + +impl SerializeEntries for MetaData<'_> { + fn entry_count(&self) -> usize { + 4 + } + fn serialize_entries( + &self, + map: &mut ::SerializeMap, + ) -> Result<(), S::Error> { + let tx_hash = pg_json_felt252(self.transaction_hash); + map.serialize_entry("__created_block", &self.block_number)?; + map.serialize_entry("__updated_block", &self.block_number)?; + map.serialize_entry("__created_tx", &tx_hash)?; + map.serialize_entry("__updated_tx", &tx_hash) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn insert_record_queries( + table: &Table, + columns: &[Felt], + records: &[Record], + _from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, +) -> RecordResult<()> { + let schema = table.get_record_schema(columns)?; + let namespace = &table.namespace; + let table_name = &table.name; + let mut writer = Vec::new(); + let metadata = MetaData::new(block_number, transaction_hash); + write!( + writer, + r#"INSERT INTO "{namespace}"."{table_name}" SELECT * FROM jsonb_populate_recordset(NULL::"{namespace}"."{table_name}", $$"# + ) + .unwrap(); + schema.parse_records_with_metadata( + records, + &metadata, + &mut JsonSerializer::new(&mut writer), + &PostgresJsonSerializer, + )?; + write!( + writer, + r#"$$) ON CONFLICT ("{}") DO UPDATE SET {METADATA_CONFLICTS}"#, + schema.primary().name + ) + .unwrap(); + for ColumnInfo { name, .. } in schema.columns() { + write!( + writer, + r#", "{name}" = COALESCE(EXCLUDED."{name}", "{table_name}"."{name}")"#, + name = name + ) + .unwrap(); + } + let string = unsafe { String::from_utf8_unchecked(writer) }; + queries.add(string); + Ok(()) +} diff --git a/crates/introspect-postgres-sink/src/json.rs b/crates/introspect-sql-sink/src/postgres/json.rs similarity index 64% rename from crates/introspect-postgres-sink/src/json.rs rename to crates/introspect-sql-sink/src/postgres/json.rs index 7e8cc911..02d78188 100644 --- a/crates/introspect-postgres-sink/src/json.rs +++ b/crates/introspect-sql-sink/src/postgres/json.rs @@ -1,8 +1,9 @@ use introspect_types::serialize::ToCairoDeSeFrom; use introspect_types::serialize_def::CairoTypeSerialization; -use introspect_types::{CairoDeserializer, ResultDef, TupleDef, TypeDef}; -use serde::ser::SerializeMap; +use introspect_types::{CairoDeserializer, EnumDef, ResultDef, TupleDef, TypeDef, VariantDef}; +use serde::ser::{Error as SerError, SerializeMap}; use serde::Serializer; +use starknet_types_core::felt::Felt; pub struct PostgresJsonSerializer; @@ -48,27 +49,40 @@ impl CairoTypeSerialization for PostgresJsonSerializer { seq.end() } - fn serialize_variant<'a, S: Serializer>( + fn serialize_enum<'a, S: Serializer>( &'a self, data: &mut impl CairoDeserializer, serializer: S, - name: &str, - type_def: &'a TypeDef, + enum_def: &'a EnumDef, + variant: Felt, ) -> Result { - match type_def { - TypeDef::None => { - let mut map = serializer.serialize_map(Some(1))?; - map.serialize_entry("_variant", name)?; - map - } - _ => { - let mut map = serializer.serialize_map(Some(2))?; - map.serialize_entry("_variant", name)?; - map.serialize_entry(name, &type_def.to_de_se(data, self))?; - map + let VariantDef { name, type_def, .. } = + enum_def.get_variant(&variant).map_err(S::Error::custom)?; + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("_variant", name)?; + if type_def != &TypeDef::None { + map.serialize_entry(name, &type_def.to_de_se(data, self))?; + } + for v in &enum_def.order { + if v != &variant { + let VariantDef { name, type_def, .. } = + enum_def.get_variant(v).map_err(S::Error::custom)?; + if type_def != &TypeDef::None { + map.serialize_entry(name, &())?; + } } } - .end() + map.end() + } + + fn serialize_variant<'a, S: Serializer>( + &'a self, + _data: &mut impl CairoDeserializer, + _serializer: S, + _name: &str, + _type_def: &'a TypeDef, + ) -> Result { + unimplemented!("variant serialization is only supported within enums, and should not be called directly") } fn serialize_result<'a, S: Serializer>( diff --git a/crates/introspect-sql-sink/src/postgres/mod.rs b/crates/introspect-sql-sink/src/postgres/mod.rs new file mode 100644 index 00000000..5bdfab78 --- /dev/null +++ b/crates/introspect-sql-sink/src/postgres/mod.rs @@ -0,0 +1,16 @@ +pub mod append_only; +pub mod backend; +pub mod create; +pub mod handler; +pub mod insert; +pub mod json; +pub mod query; +pub mod types; +pub mod upgrade; +pub mod utils; + +pub use backend::IntrospectPgDb; +pub use types::{ + PostgresArray, PostgresField, PostgresScalar, PostgresType, PrimaryKey, SchemaName, +}; +pub use utils::{truncate, HasherExt}; diff --git a/crates/introspect-postgres-sink/src/query.rs b/crates/introspect-sql-sink/src/postgres/query.rs similarity index 74% rename from crates/introspect-postgres-sink/src/query.rs rename to crates/introspect-sql-sink/src/postgres/query.rs index 63b96983..9a1adc1b 100644 --- a/crates/introspect-postgres-sink/src/query.rs +++ b/crates/introspect-sql-sink/src/postgres/query.rs @@ -1,59 +1,62 @@ -use introspect_types::{ColumnDef, ColumnInfo, MemberDef, PrimaryDef, TypeDef}; +use super::{PostgresField, PostgresType, PrimaryKey, SchemaName}; +use crate::{DbColumn, DbDeadField, DbTable, DeadFieldDef, TableError, TableResult}; +use introspect_types::schema::{Names, TypeDefs}; +use introspect_types::{ColumnDef, FeltIds, MemberDef, PrimaryDef, TypeDef}; use itertools::Itertools; use sqlx::error::BoxDynError; +use sqlx::postgres::{PgArguments, PgRow}; use sqlx::prelude::FromRow; -use sqlx::Error::Encode as EncodeError; -use sqlx::{postgres::PgArguments, types::Json}; +use sqlx::query::QueryAs; +use sqlx::types::Json; use sqlx::{Arguments, Executor, Postgres}; use starknet_types_core::felt::Felt; -use torii_common::sql::{PgQuery, Queries, SqlxResult}; +use std::collections::HashMap; +use std::fmt::{Display, Formatter, Result as FmtResult, Write}; +use std::rc::Rc; use torii_introspect::postgres::types::{PgPrimary, Uint128}; use torii_introspect::postgres::PgFelt; +use torii_sql::postgres::PgQuery; +use torii_sql::{Queries, SqlxResult}; -use crate::table::PgTable; -use crate::{ - processor::COMMIT_CMD, table::DeadField, PgSchema, PostgresField, PostgresType, PrimaryKey, - SchemaName, -}; -use std::collections::HashMap; -use std::{ - fmt::{Display, Formatter, Result as FmtResult, Write}, - rc::Rc, -}; +pub const COMMIT_CMD: &str = "--COMMIT"; -const CREATE_METADATA_COLUMNS: &str = "__created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), __updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), __created_block public.uint64 NOT NULL, __updated_block public.uint64 NOT NULL, __created_tx public.felt252 NOT NULL, __updated_tx public.felt252 NOT NULL);"; +const CREATE_METADATA_COLUMNS: &str = "__created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), __updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), __created_block public.uint64 NOT NULL, __updated_block public.uint64 NOT NULL, __created_tx public.felt252 NOT NULL, __updated_tx public.felt252 NOT NULL"; +const CREATE_APPEND_ONLY_METADATA_COLUMNS: &str = "__created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), __created_block public.uint64 NOT NULL, __created_tx public.felt252 NOT NULL"; +const APPEND_ONLY_REVISION_COLUMN: &str = r#""__revision" bigint NOT NULL, "#; +const INSERT_TABLE_QUERY: &str = r#"INSERT INTO introspect.db_tables + ("schema", id, owner, name, primary_def, append_only, updated_at, created_block, updated_block, created_tx, updated_tx) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7::uint64, $7::uint64, $8, $8) + ON CONFLICT ("schema", id) DO UPDATE SET + name = EXCLUDED.name, append_only = EXCLUDED.append_only, primary_def = EXCLUDED.primary_def, updated_at = NOW(), updated_block = EXCLUDED.updated_block, updated_tx = EXCLUDED.updated_tx"#; const INSERT_DEAD_MEMBER_QUERY: &str = r#"INSERT INTO introspect.db_dead_fields ("schema", "table", id, name, type_def, updated_at, created_block, updated_block, created_tx, updated_tx) SELECT $1, $2, unnest($3::bigint[]), unnest($4::text[]), unnest($5::jsonb[]), NOW(), $6::uint64, $6::uint64, $7, $7 ON CONFLICT ("schema", "table", id) DO UPDATE SET name = EXCLUDED.name, type_def = EXCLUDED.type_def, updated_at = NOW(), updated_block = EXCLUDED.updated_block, updated_tx = EXCLUDED.updated_tx"#; -const INSERT_TABLE_QUERY: &str = r#"INSERT INTO introspect.db_tables - ("schema", id, name, primary_def, updated_at, created_block, updated_block, created_tx, updated_tx) - VALUES ($1, $2, $3, $4, NOW(), $5::uint64, $5::uint64, $6, $6) - ON CONFLICT ("schema", id) DO UPDATE SET - name = EXCLUDED.name, primary_def = EXCLUDED.primary_def, updated_at = NOW(), updated_block = EXCLUDED.updated_block, updated_tx = EXCLUDED.updated_tx"#; + const INSERT_COLUMN_QUERY: &str = r#"INSERT INTO introspect.db_columns ("schema", "table", id, name, type_def, updated_at, created_block, updated_block, created_tx, updated_tx) SELECT $1, $2, unnest($3::felt252[]), unnest($4::text[]), unnest($5::jsonb[]), NOW(), $6::uint64, $6::uint64, $7, $7 ON CONFLICT ("schema", "table", id) DO UPDATE SET name = EXCLUDED.name, type_def = EXCLUDED.type_def, updated_at = NOW(), updated_block = EXCLUDED.updated_block, updated_tx = EXCLUDED.updated_tx"#; -const FETCH_TABLES_QUERY: &str = - r#"SELECT id, name, primary_def FROM introspect.db_tables WHERE "schema" = $1"#; -const FETCH_COLUMNS_QUERY: &str = - r#"SELECT "table", id, name, type_def FROM introspect.db_columns WHERE "schema" = $1"#; -const FETCH_DEAD_FIELDS_QUERY: &str = - r#"SELECT "table", id, name, type_def FROM introspect.db_dead_fields WHERE "schema" = $1"#; +const FETCH_TABLES_QUERY: &str = r#"SELECT "schema", id, name, primary_def, owner, append_only FROM introspect.db_tables WHERE $1::text[] = '{}'::text[] OR "schema" = ANY($1::text[])"#; +const FETCH_COLUMNS_QUERY: &str = r#"SELECT "schema", "table", id, name, type_def FROM introspect.db_columns WHERE $1::text[] = '{}'::text[] OR "schema" = ANY($1::text[])"#; +const FETCH_DEAD_FIELDS_QUERY: &str = r#"SELECT "schema", "table", id, name, type_def FROM introspect.db_dead_fields WHERE $1::text[] = '{}'::text[] OR "schema" = ANY($1::text[])"#; #[derive(FromRow)] pub struct TableRow { + pub schema: String, pub id: PgFelt, pub name: String, pub primary_def: PgPrimary, + pub owner: PgFelt, + pub append_only: bool, } #[derive(FromRow)] pub struct ColumnRow { + pub schema: String, pub table: PgFelt, pub id: PgFelt, pub name: String, @@ -62,36 +65,33 @@ pub struct ColumnRow { #[derive(FromRow)] pub struct DeadFieldRow { + pub schema: String, pub table: PgFelt, pub id: Uint128, pub name: String, pub type_def: Json, } -pub struct PgTableHead { - id: Felt, - name: String, - primary: PrimaryDef, -} - #[derive(Debug)] pub struct CreatePgTable { pub name: SchemaName, pub primary: PrimaryKey, pub columns: Vec, pub pg_types: Vec, + pub append_only: bool, } #[derive(Debug)] pub struct TableUpgrade { - pub schema: Rc, + pub schema: Rc, + pub id: Felt, pub name: String, pub old_name: Option, pub atomic: Vec, pub alters: Vec, pub columns: Vec, pub columns_upgraded: Vec, - pub dead: Vec, + pub dead: Vec, pub col_alters: Vec, } @@ -99,7 +99,7 @@ pub struct TableUpgrade { pub struct ColumnUpgrade { pub atomic: Vec, pub alters: Vec, - pub dead: Vec, + pub dead: Vec, pub altered: bool, pub upgraded: bool, } @@ -156,35 +156,6 @@ pub struct EnumUpgrade { add: Vec, } -#[derive(Debug)] -pub struct DeadFieldWithId { - pub id: u128, - pub name: String, - pub type_def: TypeDef, -} - -impl From for (u128, DeadField) { - fn from(value: DeadFieldWithId) -> Self { - ( - value.id, - DeadField { - name: value.name, - type_def: value.type_def, - }, - ) - } -} - -impl From<(u128, DeadField)> for DeadFieldWithId { - fn from(value: (u128, DeadField)) -> Self { - DeadFieldWithId { - id: value.0, - name: value.1.name, - type_def: value.1.type_def, - } - } -} - #[derive(Debug)] pub enum CreatesType { Struct(CreateStruct), @@ -198,10 +169,25 @@ impl Display for CreatePgTable { r#"CREATE TABLE IF NOT EXISTS {} ({}, "#, self.name, self.primary )?; + if self.append_only { + APPEND_ONLY_REVISION_COLUMN.fmt(f)?; + } for column in &self.columns { write!(f, "{column}, ")?; } - CREATE_METADATA_COLUMNS.fmt(f) + if self.append_only { + write!( + f, + r#"{CREATE_APPEND_ONLY_METADATA_COLUMNS}, PRIMARY KEY ("{}", "__revision"));"#, + self.primary.name + ) + } else { + write!( + f, + r#"{CREATE_METADATA_COLUMNS}, PRIMARY KEY ("{}"));"#, + self.primary.name + ) + } } } @@ -242,7 +228,7 @@ impl Display for CreatesType { impl CreatesType { pub fn new_struct>( - schema: &Rc, + schema: &Rc, name: S, fields: Vec, ) -> Self { @@ -252,11 +238,7 @@ impl CreatesType { }) } - pub fn new_enum>( - schema: &Rc, - name: S, - variants: Vec, - ) -> Self { + pub fn new_enum>(schema: &Rc, name: S, variants: Vec) -> Self { Self::Enum(CreateEnum { name: SchemaName::new(schema, name), variants, @@ -265,9 +247,10 @@ impl CreatesType { } impl TableUpgrade { - pub fn new>(schema: &Rc, name: S) -> Self { + pub fn new>(schema: &Rc, id: Felt, name: S) -> Self { Self { schema: schema.clone(), + id, name: name.into(), old_name: None, columns: Vec::new(), @@ -338,31 +321,31 @@ impl TableUpgrade { pub fn to_queries( &self, - table_id: &Felt, block_number: u64, transaction_hash: &Felt, queries: &mut Vec, - ) -> SqlxResult<()> { - let schema = &self.schema; - let name = &self.name; + ) -> TableResult<()> { queries.add( insert_dead_member_query( &self.schema, - table_id, + &self.id, &self.dead, block_number, transaction_hash, ) - .map_err(EncodeError)?, + .map_err(TableError::Encode)?, ); if let Some(old_name) = &self.old_name { queries.add(format!( - r#"ALTER TABLE "{schema}"."{old_name}" RENAME TO "{name}";"# + r#"ALTER TABLE {} RENAME TO "{}";"#, + SchemaName::new(&self.schema, old_name), + self.name )); } self.atomic.iter().for_each(|m| m.to_queries(queries)); + let name = SchemaName::new(&self.schema, &self.name); if let Some((last, columns)) = self.columns.split_last() { - let mut alterations = format!(r#"ALTER TABLE "{schema}"."{name}" "#); + let mut alterations = format!(r#"ALTER TABLE {name} "#); columns .iter() .for_each(|m| write!(alterations, "{m}, ").unwrap()); @@ -375,8 +358,8 @@ impl TableUpgrade { fn alter_queries(&self, queries: &mut Vec) { if let Some((last, others)) = self.col_alters.split_last() { - let (schema, name) = (&self.schema, &self.name); - let mut forward = format!(r#"ALTER TABLE "{schema}"."{name}" "#); + let table_name = SchemaName::new(&self.schema, &self.name); + let mut forward = format!(r#"ALTER TABLE {table_name} "#); let mut reverse = forward.clone(); for PostgresField { name: col, pg_type } in others { write!( @@ -496,7 +479,7 @@ impl TypeMods for Vec { impl ColumnUpgrade { pub fn maybe_alter( &mut self, - schema: &Rc, + schema: &Rc, name: &str, field: &str, pg_type: Option, @@ -513,7 +496,7 @@ impl ColumnUpgrade { pub fn add_struct_mod>( &mut self, - schema: &Rc, + schema: &Rc, name: S, mods: Vec, ) { @@ -527,7 +510,7 @@ impl ColumnUpgrade { } pub fn add_enum_mod>( &mut self, - schema: &Rc, + schema: &Rc, name: S, rename: Vec<(String, String)>, add: Vec, @@ -544,7 +527,7 @@ impl ColumnUpgrade { } pub fn add_dead_member(&mut self, id: u128, member: &MemberDef) { self.upgraded = true; - self.dead.push(DeadFieldWithId { + self.dead.push(DeadFieldDef { id, name: member.name.clone(), type_def: member.type_def.clone(), @@ -577,64 +560,50 @@ impl From for TypeMod { } } -impl From for (Felt, Felt, ColumnInfo) { +impl From for DbColumn { fn from(value: ColumnRow) -> Self { - ( - value.table.into(), - value.id.into(), - ColumnInfo { - name: value.name, - attributes: Vec::new(), - type_def: value.type_def.0, - }, - ) + DbColumn { + namespace: value.schema, + table: value.table.into(), + id: value.id.into(), + name: value.name, + type_def: value.type_def.0, + } } } -impl From for (Felt, u128, DeadField) { +impl From for DbDeadField { fn from(value: DeadFieldRow) -> Self { - ( - value.table.into(), - value.id.into(), - DeadField { - name: value.name, - type_def: value.type_def.0, - }, - ) + DbDeadField { + namespace: value.schema, + table: value.table.into(), + id: value.id.into(), + name: value.name, + type_def: value.type_def.0, + } } } -impl From for PgTableHead { +impl From for DbTable { fn from(value: TableRow) -> Self { - let row = value; - PgTableHead { - id: row.id.into(), - name: row.name, - primary: row.primary_def.into(), + DbTable { + namespace: value.schema, + id: value.id.into(), + owner: value.owner.into(), + name: value.name, + primary: value.primary_def.into(), + columns: HashMap::new(), + dead: HashMap::new(), + append_only: value.append_only, + alive: true, } } } -impl PgTableHead { - pub fn to_table(self, schema: &PgSchema) -> (Felt, PgTable) { - ( - self.id, - PgTable { - schema: schema.clone(), - name: self.name, - primary: self.primary, - columns: HashMap::new(), - alive: true, - dead: HashMap::new(), - }, - ) - } -} - fn insert_dead_member_query( - schema: &PgSchema, + schema: &str, table: &Felt, - fields: &[DeadFieldWithId], + fields: &[DeadFieldDef], block_number: u64, transaction_hash: &Felt, ) -> Result { @@ -650,55 +619,71 @@ fn insert_dead_member_query( Ok(PgQuery::new(INSERT_DEAD_MEMBER_QUERY, args)) } -pub fn insert_columns_query( - schema: &PgSchema, +pub fn insert_columns_query( + schema: &str, table: &Felt, - columns: Vec<(&Felt, &ColumnInfo)>, + columns: CS, block_number: u64, transaction_hash: &Felt, -) -> Result { +) -> Result +where + CS: Names + FeltIds + TypeDefs, +{ let mut args = PgArguments::default(); args.add(schema.to_string())?; args.add(PgFelt::from(*table))?; - args.add( - columns - .iter() - .map(|(id, _)| PgFelt::from(*id)) - .collect_vec(), - )?; - args.add(columns.iter().map(|(_, c)| c.name.clone()).collect_vec())?; - args.add(columns.iter().map(|(_, c)| Json(&c.type_def)).collect_vec())?; + args.add(columns.ids().iter().map_into::().collect_vec())?; + args.add(columns.names())?; + args.add(columns.type_defs().into_iter().map(Json).collect_vec())?; args.add(block_number.to_string())?; args.add(PgFelt::from(*transaction_hash))?; Ok(PgQuery::new(INSERT_COLUMN_QUERY, args)) } +#[allow(clippy::too_many_arguments)] pub fn insert_table_query( - schema: &PgSchema, + schema: &str, id: &Felt, name: &str, primary_def: &PrimaryDef, + from_address: &Felt, + append_only: bool, block_number: u64, transaction_hash: &Felt, ) -> Result { let mut args = PgArguments::default(); args.add(schema.to_string())?; args.add(PgFelt::from(*id))?; + args.add(PgFelt::from(*from_address))?; args.add(name.to_owned())?; args.add(PgPrimary::from(primary_def))?; + args.add(append_only)?; args.add(block_number.to_string())?; args.add(PgFelt::from(*transaction_hash))?; Ok(PgQuery::new(INSERT_TABLE_QUERY, args)) } +pub fn schema_query<'a, R>( + query: &'a str, + schemas: &'a Option>, +) -> QueryAs<'a, Postgres, R, PgArguments> +where + R: for<'r> FromRow<'r, PgRow>, +{ + let query = sqlx::query_as::<_, R>(query); + match schemas { + Some(schemas) => query.bind(schemas), + None => query.bind("{}".to_string()), + } +} + pub async fn fetch_tables<'e, 'c, E: 'e + Executor<'c, Database = Postgres>>( conn: E, - schema: &PgSchema, -) -> SqlxResult> { - sqlx::query_as::<_, TableRow>(FETCH_TABLES_QUERY) - .bind(schema.to_string()) + schemas: &Option>, +) -> SqlxResult> { + schema_query::(FETCH_TABLES_QUERY, schemas) .fetch_all(conn) .await .map(|rows| rows.into_iter().map_into().collect()) @@ -706,10 +691,9 @@ pub async fn fetch_tables<'e, 'c, E: 'e + Executor<'c, Database = Postgres>>( pub async fn fetch_columns<'e, 'c, E: 'e + Executor<'c, Database = Postgres>>( conn: E, - schema: &PgSchema, -) -> SqlxResult> { - sqlx::query_as::<_, ColumnRow>(FETCH_COLUMNS_QUERY) - .bind(schema.to_string()) + schemas: &Option>, +) -> SqlxResult> { + schema_query::(FETCH_COLUMNS_QUERY, schemas) .fetch_all(conn) .await .map(|rows| rows.into_iter().map_into().collect()) @@ -717,11 +701,14 @@ pub async fn fetch_columns<'e, 'c, E: 'e + Executor<'c, Database = Postgres>>( pub async fn fetch_dead_fields<'e, 'c, E: 'e + Executor<'c, Database = Postgres>>( conn: E, - schema: &PgSchema, -) -> SqlxResult> { - sqlx::query_as::<_, DeadFieldRow>(FETCH_DEAD_FIELDS_QUERY) - .bind(schema.to_string()) + schemas: &Option>, +) -> SqlxResult> { + schema_query::(FETCH_DEAD_FIELDS_QUERY, schemas) .fetch_all(conn) .await .map(|rows| rows.into_iter().map_into().collect()) } + +pub fn make_schema_query(schema: &str) -> String { + format!(r#"CREATE SCHEMA IF NOT EXISTS "{schema}""#) +} diff --git a/crates/introspect-postgres-sink/src/types.rs b/crates/introspect-sql-sink/src/postgres/types.rs similarity index 83% rename from crates/introspect-postgres-sink/src/types.rs rename to crates/introspect-sql-sink/src/postgres/types.rs index 7354b8b4..57f97de6 100644 --- a/crates/introspect-postgres-sink/src/types.rs +++ b/crates/introspect-sql-sink/src/postgres/types.rs @@ -1,20 +1,11 @@ +use crate::{TypeError, TypeResult}; use serde::{Deserialize, Serialize}; -use std::{ - collections::VecDeque, - fmt::{Display, Formatter, Result as FmtResult}, - rc::Rc, -}; - -use crate::{PgTypeError, PgTypeResult}; - -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] -pub enum PgSchema { - Public, - Custom(String), -} +use std::collections::VecDeque; +use std::fmt::{Display, Formatter, Result as FmtResult}; +use std::rc::Rc; #[derive(Clone, Deserialize, Serialize, PartialEq, Debug)] -pub struct SchemaName(pub Rc, pub String); +pub struct SchemaName(pub Rc, pub String); #[derive(Clone, Deserialize, Serialize, PartialEq, Debug)] pub enum PostgresScalar { @@ -75,18 +66,12 @@ impl From for PostgresType { } } -impl Display for PgSchema { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - PgSchema::Custom(namespace) => write!(f, "{namespace}",), - PgSchema::Public => write!(f, "public"), - } - } -} - impl Display for SchemaName { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, r#""{}"."{}""#, self.0, self.1) + match self.0.is_empty() { + true => write!(f, r#""{}""#, self.1), + false => write!(f, r#""{}"."{}""#, self.0, self.1), + } } } @@ -136,7 +121,7 @@ impl Display for PostgresType { impl Display for PrimaryKey { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, r#""{}" {} PRIMARY KEY"#, self.name, self.pg_type) + write!(f, r#""{}" {}"#, self.name, self.pg_type) } } @@ -156,7 +141,7 @@ impl From for PostgresType { } impl SchemaName { - pub fn new>(schema: &Rc, name: T) -> Self { + pub fn new>(schema: &Rc, name: T) -> Self { Self(schema.clone(), name.into()) } pub fn replace>(&mut self, name: S) -> String { @@ -168,7 +153,7 @@ impl PostgresType { pub fn is_composite(&self) -> bool { matches!(self.scalar, PostgresScalar::Composite(_)) } - pub fn to_array(self, size: Option) -> PgTypeResult { + pub fn to_array(self, size: Option) -> TypeResult { let arr = match (self.array, size) { (PostgresArray::None, None) => PostgresArray::Dynamic, (PostgresArray::None, Some(size)) => PostgresArray::Fixed(VecDeque::from([size])), @@ -176,7 +161,7 @@ impl PostgresType { sizes.push_back(size); PostgresArray::Fixed(sizes) } - _ => return Err(PgTypeError::NestedArrays), + _ => return Err(TypeError::NestedArrays), }; Ok(Self { scalar: self.scalar, @@ -184,7 +169,7 @@ impl PostgresType { }) } - pub fn composite>(schema: &Rc, name: S) -> Self { + pub fn composite>(schema: &Rc, name: S) -> Self { Self { scalar: PostgresScalar::Composite(SchemaName::new(schema, name)), array: PostgresArray::None, diff --git a/crates/introspect-postgres-sink/src/upgrade.rs b/crates/introspect-sql-sink/src/postgres/upgrade.rs similarity index 83% rename from crates/introspect-postgres-sink/src/upgrade.rs rename to crates/introspect-sql-sink/src/postgres/upgrade.rs index f565d01d..ac6681ca 100644 --- a/crates/introspect-postgres-sink/src/upgrade.rs +++ b/crates/introspect-sql-sink/src/postgres/upgrade.rs @@ -1,33 +1,38 @@ +use super::create::PostgresTypeExtractor; +use super::query::{ColumnUpgrade, StructMod, StructMods, TableUpgrade}; +use super::{HasherExt, PostgresScalar, PostgresType}; use crate::{ - create::PostgresTypeExtractor, - query::{ColumnUpgrade, StructMod, StructMods, TableUpgrade}, - table::{DeadField, PgTable}, - HasherExt, PgSchema, PgTypeError, PgTypeResult, PostgresScalar, PostgresType, TableResult, - UpgradeError, UpgradeResult, UpgradeResultExt, + DeadField, Table, TableResult, TypeError, TypeResult, UpgradeError, UpgradeResult, + UpgradeResultExt, }; use introspect_types::{ ArrayDef, ColumnDef, EnumDef, FixedArrayDef, MemberDef, OptionDef, PrimaryDef, PrimaryTypeDef, ResultInto, StructDef, TupleDef, TypeDef, VariantDef, }; -use starknet_types_core::felt::Felt; -use std::{collections::HashMap, rc::Rc}; -use torii_introspect::schema::TableInfo; +use std::collections::HashMap; +use std::rc::Rc; use xxhash_rust::xxh3::Xxh3; -impl PgTable { - pub fn update_from_info(&mut self, id: &Felt, info: &TableInfo) -> TableResult { - self.update(id, &info.name, &info.primary, &info.columns) - } - pub fn update( +pub trait PgTableUpgrade { + fn upgrade_table( + &mut self, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + ) -> TableResult; + fn retype_primary(&mut self, new: &PrimaryTypeDef) -> UpgradeResult>; +} + +impl PgTableUpgrade for Table { + fn upgrade_table( &mut self, - id: &Felt, name: &str, primary: &PrimaryDef, columns: &[ColumnDef], ) -> TableResult { - let branch = Xxh3::new_based(id); - let schema = Rc::new(self.schema.clone()); - let mut table_mod = TableUpgrade::new(&schema, self.name.clone()); + let branch = Xxh3::new_based(&self.id); + let schema: Rc = self.namespace(); + let mut table_mod = TableUpgrade::new(&schema, self.id, self.name.clone()); table_mod.rename_table(name); table_mod.rename_column(&mut self.primary.name, &primary.name); let pg_type = self @@ -64,58 +69,66 @@ impl PgTable { Ok(table_mod) } fn retype_primary(&mut self, new: &PrimaryTypeDef) -> UpgradeResult> { - use crate::PostgresScalar::{ + use super::PostgresScalar::{ BigInt, Felt252 as PgFelt252, Int, Int128, SmallInt, Uint128, Uint16, Uint32, Uint64, Uint8, }; use introspect_types::PrimaryTypeDef::{ + Bool as PBool, Bytes31 as PBytes31, Bytes31Encoded as PBytes31Encoded, + ClassHash as PClassHash, ContractAddress as PContractAddress, + EthAddress as PEthAddress, Felt252 as PFelt252, ShortUtf8 as PShortUtf8, + StorageAddress as PStorageAddress, StorageBaseAddress as PStorageBaseAddress, + I128 as PI128, I16 as PI16, I32 as PI32, I64 as PI64, I8 as PI8, U128 as PU128, + U16 as PU16, U32 as PU32, U64 as PU64, U8 as PU8, + }; + use introspect_types::TypeDef::{ Bool, Bytes31, Bytes31Encoded, ClassHash, ContractAddress, EthAddress, Felt252, ShortUtf8, StorageAddress, StorageBaseAddress, I128, I16, I32, I64, I8, U128, U16, U32, U64, U8, }; match (&self.primary.type_def, new) { - (Bool, Bool) - | (U8, U8) - | (U16, U16) - | (U32, U32) - | (U64, U64) - | (U128, U128) - | (I8, I8) - | (I16, I16) - | (I32, I32) - | (I64, I64) - | (I128, I128) - | (ShortUtf8, ShortUtf8) - | (EthAddress, EthAddress) - | (ClassHash, ClassHash) - | (ContractAddress, ContractAddress) - | (StorageAddress, StorageAddress) - | (StorageBaseAddress, StorageBaseAddress) - | (Bytes31, Bytes31) - | (Bytes31Encoded(_), Bytes31Encoded(_)) - | (Felt252, Felt252) => Ok(None), - (Bool, U8) => self.primary.type_def.update_as(U8, Uint8), - (Bool | U8, U16) => self.primary.type_def.update_as(U16, Uint16), - (Bool | U8 | U16, U32) => self.primary.type_def.update_as(U32, Uint32), - (Bool | U8 | U16 | U32, U64) => self.primary.type_def.update_as(U64, Uint64), - (Bool | U8 | U16 | U32 | U64, U128) => self.primary.type_def.update_as(U128, Uint128), - (Bool | U8, I8) => self.primary.type_def.update_as(I8, SmallInt), - (Bool | U8 | I8, I16) => self.primary.type_def.update_as(I16, SmallInt), - (Bool | U8 | U16 | I8 | I16, I32) => self.primary.type_def.update_as(I32, Int), - (Bool | U8 | U16 | U32 | I8 | I16 | I32, I64) => { + (Bool, PBool) + | (U8, PU8) + | (U16, PU16) + | (U32, PU32) + | (U64, PU64) + | (U128, PU128) + | (I8, PI8) + | (I16, PI16) + | (I32, PI32) + | (I64, PI64) + | (I128, PI128) + | (ShortUtf8, PShortUtf8) + | (EthAddress, PEthAddress) + | (ClassHash, PClassHash) + | (ContractAddress, PContractAddress) + | (StorageAddress, PStorageAddress) + | (StorageBaseAddress, PStorageBaseAddress) + | (Bytes31, PBytes31) + | (Bytes31Encoded(_), PBytes31Encoded(_)) + | (Felt252, PFelt252) => Ok(None), + (Bool, PU8) => self.primary.type_def.update_as(U8, Uint8), + (Bool | U8, PU16) => self.primary.type_def.update_as(U16, Uint16), + (Bool | U8 | U16, PU32) => self.primary.type_def.update_as(U32, Uint32), + (Bool | U8 | U16 | U32, PU64) => self.primary.type_def.update_as(U64, Uint64), + (Bool | U8 | U16 | U32 | U64, PU128) => self.primary.type_def.update_as(U128, Uint128), + (Bool | U8, PI8) => self.primary.type_def.update_as(I8, SmallInt), + (Bool | U8 | I8, PI16) => self.primary.type_def.update_as(I16, SmallInt), + (Bool | U8 | U16 | I8 | I16, PI32) => self.primary.type_def.update_as(I32, Int), + (Bool | U8 | U16 | U32 | I8 | I16 | I32, PI64) => { self.primary.type_def.update_as(I64, BigInt) } - (Bool | U8 | U16 | U32 | U64 | I8 | I16 | I32 | I64, I128) => { + (Bool | U8 | U16 | U32 | U64 | I8 | I16 | I32 | I64, PI128) => { self.primary.type_def.update_as(I128, Int128) } ( EthAddress, - ClassHash | ContractAddress | StorageAddress | StorageBaseAddress | Felt252, - ) => self.primary.type_def.update_to(new, PgFelt252), + PClassHash | PContractAddress | PStorageAddress | PStorageBaseAddress | PFelt252, + ) => self.primary.type_def.update_to(&new.into(), PgFelt252), ( ClassHash | ContractAddress | StorageAddress | StorageBaseAddress | Felt252, - ClassHash | ContractAddress | StorageAddress | StorageBaseAddress | Felt252, - ) => self.primary.type_def.update_no_change(new), + PClassHash | PContractAddress | PStorageAddress | PStorageBaseAddress | PFelt252, + ) => self.primary.type_def.update_no_change(&new.into()), _ => UpgradeError::primary_upgrade_err(&self.primary.type_def, new), } } @@ -129,7 +142,7 @@ pub trait ExtractItem { fn as_tuple(&mut self) -> UpgradeResult<&mut TupleDef>; fn update_as_array( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &ArrayDef, dead: &mut HashMap, @@ -137,13 +150,13 @@ pub trait ExtractItem { ) -> UpgradeResult>; fn update_as_option( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &TypeDef, dead: &mut HashMap, queries: &mut ColumnUpgrade, ) -> UpgradeResult>; - fn get_pg_type(&self, schema: &Rc, branch: &Xxh3) -> PgTypeResult; + fn get_pg_type(&self, schema: &Rc, branch: &Xxh3) -> TypeResult; } impl ExtractItem for TypeDef { @@ -181,7 +194,7 @@ impl ExtractItem for TypeDef { fn update_as_array( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &ArrayDef, dead: &mut HashMap, @@ -195,7 +208,7 @@ impl ExtractItem for TypeDef { } fn update_as_option( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &TypeDef, dead: &mut HashMap, @@ -210,7 +223,7 @@ impl ExtractItem for TypeDef { } } } - fn get_pg_type(&self, schema: &Rc, branch: &Xxh3) -> PgTypeResult { + fn get_pg_type(&self, schema: &Rc, branch: &Xxh3) -> TypeResult { match self { TypeDef::None => Ok(PostgresScalar::None.into()), TypeDef::Bool => Ok(PostgresScalar::Boolean.into()), @@ -248,7 +261,7 @@ impl ExtractItem for TypeDef { TypeDef::Option(def) => def.get_pg_type(schema, branch), TypeDef::Nullable(def) => def.get_pg_type(schema, branch), TypeDef::Felt252Dict(_) | TypeDef::Result(_) | TypeDef::Ref(_) | TypeDef::Custom(_) => { - Err(PgTypeError::UnsupportedType(format!("{self:?}"))) + Err(TypeError::UnsupportedType(format!("{self:?}"))) } } } @@ -257,7 +270,7 @@ impl ExtractItem for TypeDef { pub trait CompareType { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &Self, dead: &mut HashMap, @@ -297,7 +310,7 @@ pub trait UpgradeField { fn name(&self) -> &str; fn upgrade_field( &mut self, - schema: &Rc, + schema: &Rc, name: &str, branch: &Xxh3, new: &Self, @@ -312,7 +325,7 @@ pub trait UpgradeField { } fn add_field( &self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, queries: &mut ColumnUpgrade, ) -> UpgradeResult { @@ -350,7 +363,7 @@ impl UpgradeField for VariantDef { impl CompareType for TypeDef { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &TypeDef, dead: &mut HashMap, @@ -439,7 +452,7 @@ impl CompareType for TypeDef { impl CompareType for StructDef { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &StructDef, dead: &mut HashMap, @@ -477,7 +490,7 @@ impl CompareType for StructDef { impl CompareType for EnumDef { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &EnumDef, dead: &mut HashMap, @@ -517,7 +530,7 @@ impl CompareType for EnumDef { impl CompareType for FixedArrayDef { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &Self, dead: &mut HashMap, @@ -540,7 +553,7 @@ impl CompareType for FixedArrayDef { impl CompareType for TupleDef { fn compare_type( &mut self, - schema: &Rc, + schema: &Rc, branch: &Xxh3, new: &Self, dead: &mut HashMap, diff --git a/crates/introspect-postgres-sink/src/utils.rs b/crates/introspect-sql-sink/src/postgres/utils.rs similarity index 100% rename from crates/introspect-postgres-sink/src/utils.rs rename to crates/introspect-sql-sink/src/postgres/utils.rs diff --git a/crates/introspect-sql-sink/src/processor.rs b/crates/introspect-sql-sink/src/processor.rs new file mode 100644 index 00000000..77dabbca --- /dev/null +++ b/crates/introspect-sql-sink/src/processor.rs @@ -0,0 +1,277 @@ +use crate::backend::{IntrospectInitialize, IntrospectPool, IntrospectProcessor}; +use crate::error::TableLoadError; +use crate::table::{DeadField, Table}; +use crate::tables::Tables; +use crate::{DbResult, IntrospectQueryMaker, NamespaceKey, NamespaceMode}; +use async_trait::async_trait; +use introspect_types::{ColumnInfo, PrimaryDef, TypeDef}; +use itertools::Itertools; +use sqlx::{Database, Pool}; +use starknet_types_core::felt::Felt; +use std::collections::HashMap; +use std::fmt::Debug; +use torii_introspect::events::{IntrospectBody, IntrospectMsg}; +use torii_sql::{Executable, FlexQuery, PoolExt}; + +pub const COMMIT_CMD: &str = "--COMMIT"; + +pub struct IntrospectDb { + tables: Tables, + namespaces: NamespaceMode, + db: Backend, +} + +pub struct DbTable { + pub namespace: String, + pub id: Felt, + pub owner: Felt, + pub name: String, + pub primary: PrimaryDef, + pub columns: HashMap, + pub dead: HashMap, + pub append_only: bool, + pub alive: bool, +} + +pub struct DbColumn { + pub namespace: String, + pub table: Felt, + pub id: Felt, + pub name: String, + pub type_def: TypeDef, +} + +pub struct DbDeadField { + pub namespace: String, + pub table: Felt, + pub id: u128, + pub name: String, + pub type_def: TypeDef, +} + +impl PoolExt for IntrospectDb> { + fn pool(&self) -> &Pool { + &self.db + } +} + +pub trait IntoHashMap { + fn into_hash_map(self) -> HashMap; +} + +impl IntoHashMap for Vec +where + T: Into<(K, V)>, + K: std::hash::Hash + Eq, +{ + fn into_hash_map(self) -> HashMap { + self.into_iter().map_into().collect() + } +} + +#[async_trait] +impl IntrospectProcessor for Pool +where + Vec>: Executable, + FlexQuery: Debug + Clone, +{ + async fn process_msgs( + &self, + namespaces: &NamespaceMode, + tables: &Tables, + msgs: Vec<&IntrospectBody>, + ) -> DbResult>> { + self.execute_msgs(namespaces, tables, msgs).await + } +} + +impl IntrospectDb { + pub fn new(pool: Backend, namespaces: impl Into) -> Self { + Self { + tables: Tables::default(), + namespaces: namespaces.into(), + db: pool, + } + } +} +impl IntrospectDb { + pub async fn initialize_introspect_sql_sink(&self) -> DbResult> { + self.db.initialize().await?; + self.load_store_data().await + } + + pub async fn process_messages( + &self, + msgs: Vec<&IntrospectBody>, + ) -> DbResult>> { + self.db + .process_msgs(&self.namespaces, &self.tables, msgs) + .await + } + + pub async fn load_store_data(&self) -> DbResult> { + let mut errors = Vec::new(); + let namespaces = self.namespaces.namespaces(); + let mut tables: HashMap<(String, Felt), Table> = + self.db.load_tables(&namespaces).await?.into_hash_map(); + for column in self.db.load_columns(&namespaces).await? { + let (namespace, table_id, id, column_info) = column.into(); + if let Some(table) = tables.get_mut(&(namespace.clone(), table_id)) { + table.columns.insert(id, column_info); + } else { + errors.push(TableLoadError::ColumnTableNotFound( + namespace, + table_id, + column_info.name, + id, + )); + } + } + for dead_field in self.db.load_dead_fields(&namespaces).await? { + let (namespace, table_id, id, field) = dead_field.into(); + if let Some(table) = tables.get_mut(&(namespace.clone(), table_id)) { + table.dead.insert(id, field); + } else { + errors.push(TableLoadError::TableDeadNotFound( + namespace, table_id, field.name, id, + )); + } + } + let mut map = self.tables.write()?; + for ((namespace, id), table) in tables { + match self.namespaces.get_key(namespace, id, &table.owner) { + Ok(key) => { + map.insert(key, table); + } + Err(err) => errors.push(TableLoadError::NamespaceError(err)), + } + } + Ok(errors) + } +} + +impl From for ((String, Felt), Table) { + fn from(value: DbTable) -> Self { + ( + (value.namespace.clone(), value.id), + Table { + id: value.id, + namespace: value.namespace, + name: value.name, + owner: value.owner, + primary: value.primary.into(), + columns: value.columns, + dead: value.dead, + append_only: value.append_only, + alive: value.alive, + }, + ) + } +} + +impl From for (String, Felt, Felt, ColumnInfo) { + fn from(value: DbColumn) -> Self { + ( + value.namespace, + value.table, + value.id, + ColumnInfo { + name: value.name, + attributes: Vec::new(), + type_def: value.type_def, + }, + ) + } +} + +impl From for (String, Felt, u128, DeadField) { + fn from(value: DbDeadField) -> Self { + ( + value.namespace, + value.table, + value.id, + DeadField { + name: value.name, + type_def: value.type_def, + }, + ) + } +} + +pub fn messages_to_queries( + namespaces: &NamespaceMode, + tables: &Tables, + msgs: Vec<&IntrospectBody>, + queries: &mut Vec>, +) -> DbResult>> { + let mut results = Vec::with_capacity(msgs.len()); + for body in msgs { + let (msg, metadata) = body.into(); + let from_address: Felt = metadata.from_address.into(); + let transaction_hash: Felt = metadata.transaction_hash.into(); + let namespace = namespaces.to_namespace(&from_address)?; + results.push(handle_message::( + namespace, + tables, + msg, + &from_address, + metadata.block_number, + &transaction_hash, + queries, + )); + } + Ok(results) +} + +pub fn handle_message( + namespace: NamespaceKey, + tables: &Tables, + msg: &IntrospectMsg, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, +) -> DbResult<()> { + match msg { + IntrospectMsg::CreateTable(event) => tables.create_table::( + namespace, + &event.id, + &event.name, + &event.primary, + &event.columns, + event.append_only, + from_address, + block_number, + transaction_hash, + queries, + ), + IntrospectMsg::UpdateTable(event) => tables.update_table::( + namespace, + &event.id, + &event.name, + &event.primary, + &event.columns, + from_address, + block_number, + transaction_hash, + queries, + ), + IntrospectMsg::AddColumns(event) => tables.set_table_dead(namespace, event.table), + IntrospectMsg::DropColumns(event) => tables.set_table_dead(namespace, event.table), + IntrospectMsg::RetypeColumns(event) => tables.set_table_dead(namespace, event.table), + IntrospectMsg::RetypePrimary(event) => tables.set_table_dead(namespace, event.table), + IntrospectMsg::RenameTable(_) + | IntrospectMsg::DropTable(_) + | IntrospectMsg::RenameColumns(_) + | IntrospectMsg::RenamePrimary(_) => Ok(()), + IntrospectMsg::InsertsFields(event) => tables.insert_fields::( + namespace, + event, + from_address, + block_number, + transaction_hash, + queries, + ), + IntrospectMsg::DeleteRecords(_) | IntrospectMsg::DeletesFields(_) => Ok(()), + } +} diff --git a/crates/introspect-sql-sink/src/runtime.rs b/crates/introspect-sql-sink/src/runtime.rs new file mode 100644 index 00000000..1c3667eb --- /dev/null +++ b/crates/introspect-sql-sink/src/runtime.rs @@ -0,0 +1,58 @@ +use crate::tables::Tables; +use crate::{ + DbColumn, DbDeadField, DbResult, DbTable, IntrospectInitialize, IntrospectProcessor, + IntrospectSqlSink, NamespaceMode, +}; +use async_trait::async_trait; +use torii_introspect::events::IntrospectBody; +use torii_sql::DbPool; + +impl IntrospectSqlSink for DbPool { + const NAME: &'static str = "introspect-sql"; +} + +#[async_trait] +impl IntrospectInitialize for DbPool { + async fn initialize(&self) -> DbResult { + match self { + DbPool::Postgres(pg) => pg.initialize().await, + DbPool::Sqlite(site) => site.initialize().await, + } + } + async fn load_tables(&self, namespaces: &Option>) -> DbResult> { + match self { + DbPool::Postgres(pg) => pg.load_tables(namespaces).await, + DbPool::Sqlite(site) => site.load_tables(namespaces).await, + } + } + async fn load_columns(&self, namespaces: &Option>) -> DbResult> { + match self { + DbPool::Postgres(pg) => pg.load_columns(namespaces).await, + DbPool::Sqlite(site) => site.load_columns(namespaces).await, + } + } + async fn load_dead_fields( + &self, + namespaces: &Option>, + ) -> DbResult> { + match self { + DbPool::Postgres(pg) => pg.load_dead_fields(namespaces).await, + DbPool::Sqlite(site) => site.load_dead_fields(namespaces).await, + } + } +} + +#[async_trait] +impl IntrospectProcessor for DbPool { + async fn process_msgs( + &self, + namespaces: &NamespaceMode, + tables: &Tables, + msgs: Vec<&IntrospectBody>, + ) -> DbResult>> { + match self { + DbPool::Postgres(pg) => pg.process_msgs(namespaces, tables, msgs).await, + DbPool::Sqlite(site) => site.process_msgs(namespaces, tables, msgs).await, + } + } +} diff --git a/crates/introspect-sqlite-sink/src/sink.rs b/crates/introspect-sql-sink/src/sink.rs similarity index 57% rename from crates/introspect-sqlite-sink/src/sink.rs rename to crates/introspect-sql-sink/src/sink.rs index 86706aed..d3ffec80 100644 --- a/crates/introspect-sqlite-sink/src/sink.rs +++ b/crates/introspect-sql-sink/src/sink.rs @@ -1,58 +1,67 @@ -use crate::processor::IntrospectSqliteDb; use anyhow::Result; use async_trait::async_trait; use std::sync::Arc; use torii::axum::Router; -use torii::etl::{ - envelope::{Envelope, TypeId}, - extractor::ExtractionBatch, - sink::{EventBus, Sink, SinkContext, TopicInfo}, -}; +use torii::etl::envelope::{Envelope, TypeId}; +use torii::etl::extractor::ExtractionBatch; +use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; +use torii_dojo::{DojoBody, DojoEvent, DOJO_TYPE_ID}; use torii_introspect::events::{IntrospectBody, IntrospectMsg}; -use torii_sqlite::SqliteConnection; -pub const LOGGING_TARGET: &str = "torii::sinks::introspect::sqlite"; -const INTROSPECT_TYPE: TypeId = TypeId::new("introspect"); +use crate::{IntrospectDb, IntrospectInitialize, IntrospectProcessor}; + +pub const LOGGING_TARGET: &str = "torii::sinks::introspect-sql"; +pub trait IntrospectSqlSink { + const NAME: &'static str; +} #[async_trait] -impl Sink for IntrospectSqliteDb { +impl Sink + for IntrospectDb +{ fn name(&self) -> &'static str { - "introspect-sqlite" + Backend::NAME } fn interested_types(&self) -> Vec { - vec![TypeId::new("introspect")] + vec![DOJO_TYPE_ID] } async fn process(&self, envelopes: &[Envelope], _batch: &ExtractionBatch) -> Result<()> { let mut processed = 0usize; - let mut create_tables = 0usize; + let mut create_tables: usize = 0usize; let mut update_tables = 0usize; let mut inserts_fields = 0usize; let mut inserted_records = 0usize; let mut delete_records = 0usize; let mut msgs = Vec::with_capacity(envelopes.len()); for envelope in envelopes { - if envelope.type_id == INTROSPECT_TYPE { - if let Some(body) = envelope.downcast_ref::() { - match &body.msg { - IntrospectMsg::CreateTable(_) => create_tables += 1, - IntrospectMsg::UpdateTable(_) => update_tables += 1, - IntrospectMsg::InsertsFields(event) => { - inserts_fields += 1; - inserted_records += event.records.len(); - } - IntrospectMsg::DeleteRecords(event) => { - delete_records += event.rows.len(); + if envelope.type_id == DOJO_TYPE_ID { + if let Some(body) = envelope.downcast_ref::() { + if let DojoEvent::Introspect(msg) = &body.msg { + match msg { + IntrospectMsg::CreateTable(_) => create_tables += 1, + IntrospectMsg::UpdateTable(_) => update_tables += 1, + IntrospectMsg::InsertsFields(event) => { + inserts_fields += 1; + inserted_records += event.records.len(); + } + IntrospectMsg::DeleteRecords(event) => { + delete_records += event.rows.len(); + } + _ => {} } - _ => {} + processed += 1; + msgs.push(IntrospectBody { + context: body.context, + msg: msg.clone(), + }); } - processed += 1; - msgs.push(body); } } } - let results = self.process_messages(msgs).await?; + let msg_refs = msgs.iter().collect::>(); + let results = self.process_messages(msg_refs).await?; let failed = results.iter().filter(|r| r.is_err()).count(); if failed > 0 { tracing::error!( @@ -102,10 +111,11 @@ impl Sink for IntrospectSqliteDb { _event_bus: Arc, _context: &SinkContext, ) -> Result<()> { - self.initialize_introspect_sqlite_sink().await?; + self.initialize_introspect_sql_sink().await?; tracing::info!( target: LOGGING_TARGET, - "Initialized introspect SQLite sink" + "Connected to introspect SQL sink with database: {}", + Backend::NAME ); Ok(()) } diff --git a/crates/introspect-sql-sink/src/sqlite/append_only.rs b/crates/introspect-sql-sink/src/sqlite/append_only.rs new file mode 100644 index 00000000..5607c0a2 --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/append_only.rs @@ -0,0 +1,71 @@ +use crate::sqlite::record::SqliteDeserializer; +use crate::sqlite::table::qualified_table_name; +use crate::sqlite::types::{SqliteType, TypeDefSqliteExt}; +use crate::{RecordResult, Table}; +use introspect_types::bytes::IntoByteSource; +use introspect_types::schema::{Names, TypeDefs}; +use introspect_types::ColumnInfo; +use itertools::Itertools; +use sqlx::Arguments; +use sqlx::Error::Encode as EncodeError; +use starknet_types_core::felt::Felt; +use std::fmt::Write as FmtWrite; +use std::sync::Arc; +use torii_introspect::Record; +use torii_sql::{Queries, SqliteArguments, SqliteQuery}; + +pub fn append_only_record_queries( + table: &Table, + column_ids: &[Felt], + records: &[Record], + _from_address: &Felt, + _block_number: u64, + _transaction_hash: &Felt, + queries: &mut Vec, +) -> RecordResult<()> { + let table_name = qualified_table_name(&table.namespace, &table.name); + let primary = &table.primary.name; + let columns = table.columns.iter().collect_vec(); + let mut sql = format!(r#"INSERT INTO "{table_name}" ("{primary}", "__revision""#,); + for name in columns.names() { + write!(sql, r#", "{name}""#).unwrap(); + } + write!(sql, r#") VALUES (?, (SELECT COALESCE(MAX("__revision"), 0) + 1 FROM "{table_name}" WHERE "{primary}" = ?1)"#).unwrap(); + for (id, ColumnInfo { name, type_def, .. }) in &columns { + match column_ids.iter().position(|c| &c == id) { + Some(index) => write!(sql, r#", {}"#, type_def.index_placeholder(index + 2)?).unwrap(), + None => match type_def.try_into()? { + SqliteType::Json => write!( + sql, + r#", (SELECT jsonb("{name}") FROM "{table_name}" WHERE "{primary}" = ?1 ORDER BY "__revision" DESC LIMIT 1)"# + ).unwrap(), + _ => write!( + sql, + r#", (SELECT "{name}" FROM "{table_name}" WHERE "{primary}" = ?1 ORDER BY "__revision" DESC LIMIT 1)"# + ).unwrap(), + } + } + } + let schema = table.get_record_schema(column_ids)?; + sql.push(')'); + let sql: Arc = sql.into(); + for record in records { + let mut arguments: SqliteArguments<'static> = SqliteArguments::default(); + let mut primary_data = record.id.as_slice().into_source(); + let mut data = record.values.as_slice().into_source(); + arguments + .add( + schema + .primary_type_def() + .deserialize_column(&mut primary_data)?, + ) + .map_err(EncodeError)?; + for type_def in schema.columns().type_defs() { + arguments + .add(type_def.deserialize_column(&mut data)?) + .map_err(EncodeError)?; + } + queries.add((sql.clone(), arguments)); + } + Ok(()) +} diff --git a/crates/introspect-sql-sink/src/sqlite/backend.rs b/crates/introspect-sql-sink/src/sqlite/backend.rs new file mode 100644 index 00000000..6f0d4999 --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/backend.rs @@ -0,0 +1,190 @@ +use crate::sqlite::append_only::append_only_record_queries; +use crate::sqlite::record::insert_record_queries; +use crate::sqlite::table::{ + create_table_query, persist_table_state_query, qualified_table_name, update_column, + update_columns, FETCH_TABLES_QUERY, +}; +use crate::{ + DbColumn, DbDeadField, DbResult, DbTable, IntrospectDb, IntrospectInitialize, + IntrospectQueryMaker, IntrospectSqlSink, RecordResult, Table, TableResult, UpgradeResultExt, +}; +use async_trait::async_trait; +use introspect_types::{ColumnDef, ColumnInfo, PrimaryDef, ResultInto}; +use itertools::Itertools; +use sqlx::prelude::FromRow; +use sqlx::types::Json; +use starknet_types_core::felt::{Felt, FromStrError}; +use std::collections::HashMap; +use torii_introspect::Record; +use torii_sql::{PoolExt, Queries, Sqlite, SqlitePool, SqliteQuery}; + +pub const INTROSPECT_SQLITE_SINK_MIGRATIONS: sqlx::migrate::Migrator = + sqlx::migrate!("./migrations/sqlite"); + +pub type IntrospectSqliteDb = IntrospectDb; + +#[derive(FromRow)] +pub struct SqliteTableRow { + namespace: String, + id: String, + owner: String, + name: String, + primary: Json, + columns: Json>, + append_only: bool, + alive: bool, +} + +impl TryFrom for DbTable { + type Error = FromStrError; + fn try_from(value: SqliteTableRow) -> Result { + Ok(DbTable { + namespace: value.namespace, + id: Felt::from_hex(&value.id)?, + owner: Felt::from_hex(&value.owner)?, + name: value.name, + primary: value.primary.0, + columns: value.columns.0.into_iter().map_into().collect(), + dead: HashMap::new(), + append_only: value.append_only, + alive: value.alive, + }) + } +} + +#[async_trait] +impl IntrospectQueryMaker for Sqlite { + fn create_table_queries( + namespace: &str, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, + ) -> TableResult<()> { + queries.add(create_table_query( + namespace, + name, + primary, + columns, + append_only, + )?); + persist_table_state_query( + namespace, + id, + name, + primary, + columns, + append_only, + from_address, + block_number, + transaction_hash, + queries, + ) + } + fn update_table_queries( + table: &mut Table, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec, + ) -> TableResult<()> { + let table_name = qualified_table_name(&table.namespace, name); + if table.name != name { + queries.add(format!( + r#"ALTER TABLE "{}" RENAME TO "{table_name}""#, + qualified_table_name(&table.namespace, &table.name), + )); + table.name = name.to_string(); + } + update_column( + &table_name, + &mut table.primary, + &primary.name, + &((&primary.type_def).into()), + queries, + ) + .to_table_result(&table_name, "primary")?; + update_columns(&mut table.columns, &table_name, columns, queries)?; + persist_table_state_query( + &table.namespace, + &table.id, + &table.name, + primary, + &table.columns.iter().collect_vec(), + table.append_only, + from_address, + block_number, + transaction_hash, + queries, + ) + } + fn insert_record_queries( + table: &Table, + columns: &[Felt], + records: &[Record], + _from_address: &Felt, + _block_number: u64, + _transaction_hash: &Felt, + queries: &mut Vec, + ) -> RecordResult<()> { + if table.append_only { + append_only_record_queries( + table, + columns, + records, + _from_address, + _block_number, + _transaction_hash, + queries, + ) + } else { + insert_record_queries( + table, + columns, + records, + _from_address, + _block_number, + _transaction_hash, + queries, + ) + } + } +} + +impl IntrospectSqlSink for SqlitePool { + const NAME: &'static str = "Introspect Sqlite"; +} + +#[async_trait] +impl IntrospectInitialize for SqlitePool { + async fn load_tables(&self, _schemas: &Option>) -> DbResult> { + let rows: Vec = sqlx::query_as(FETCH_TABLES_QUERY) + .fetch_all(self.pool()) + .await?; + + let tables: Vec = rows + .into_iter() + .map(|row| row.try_into()) + .collect::>()?; + Ok(tables) + } + async fn load_columns(&self, _schemas: &Option>) -> DbResult> { + Ok(Vec::new()) + } + async fn load_dead_fields(&self, _schemas: &Option>) -> DbResult> { + Ok(Vec::new()) + } + async fn initialize(&self) -> DbResult<()> { + self.migrate(Some("introspect"), INTROSPECT_SQLITE_SINK_MIGRATIONS) + .await + .err_into() + } +} diff --git a/crates/introspect-sqlite-sink/src/json.rs b/crates/introspect-sql-sink/src/sqlite/json.rs similarity index 92% rename from crates/introspect-sqlite-sink/src/json.rs rename to crates/introspect-sql-sink/src/sqlite/json.rs index 3b7c28d8..7cf2f75b 100644 --- a/crates/introspect-sqlite-sink/src/json.rs +++ b/crates/introspect-sql-sink/src/sqlite/json.rs @@ -2,7 +2,7 @@ use introspect_types::serialize::ToCairoDeSeFrom; use introspect_types::serialize_def::CairoTypeSerialization; use introspect_types::{CairoDeserializer, ResultDef, TupleDef, TypeDef}; use primitive_types::{U256, U512}; -use serde::ser::SerializeMap; +use serde::ser::{SerializeMap, SerializeTuple}; use serde::Serializer; pub struct SqliteJsonSerializer; @@ -54,9 +54,9 @@ impl CairoTypeSerialization for SqliteJsonSerializer { serializer: S, tuple: &'a TupleDef, ) -> Result { - let mut seq = serializer.serialize_map(Some(tuple.elements.len()))?; - for (index, element) in tuple.elements.iter().enumerate() { - seq.serialize_entry(&format!("_{index}"), &element.to_de_se(data, self))?; + let mut seq = serializer.serialize_tuple(tuple.elements.len())?; + for element in tuple.elements.iter() { + seq.serialize_element(&element.to_de_se(data, self))?; } seq.end() } diff --git a/crates/introspect-sql-sink/src/sqlite/mod.rs b/crates/introspect-sql-sink/src/sqlite/mod.rs new file mode 100644 index 00000000..3f8c4ef1 --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/mod.rs @@ -0,0 +1,12 @@ +pub mod append_only; +pub mod backend; +pub mod json; +pub mod record; +pub mod table; +pub mod types; + +use sqlx::migrate::Migrator; + +pub use backend::IntrospectSqliteDb; + +pub const INTROSPECT_SQLITE_SINK_MIGRATIONS: Migrator = sqlx::migrate!("./migrations/sqlite"); diff --git a/crates/introspect-sql-sink/src/sqlite/record.rs b/crates/introspect-sql-sink/src/sqlite/record.rs new file mode 100644 index 00000000..ab7d5d48 --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/record.rs @@ -0,0 +1,230 @@ +use crate::sqlite::json::SqliteJsonSerializer; +use crate::sqlite::table::qualified_table_name; +use crate::sqlite::types::{SqliteColumn, SqliteType}; +use crate::{RecordResult, Table, TypeResult}; +use introspect_types::bytes::IntoByteSource; +use introspect_types::schema::{Names, TypeDefs}; +use introspect_types::serialize::CairoSeFrom; +use introspect_types::{CairoDeserializer, DecodeError, EthAddress, ResultInto, TypeDef}; +use itertools::Itertools; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::sqlite::SqliteArgumentValue; +use sqlx::Error::Encode as EncodeError; +use sqlx::{Arguments, Encode, Sqlite, Type}; +use starknet_types_core::felt::Felt; +use std::sync::Arc; +use torii_introspect::Record; +use torii_sql::{Queries, SqliteArguments, SqliteQuery}; + +pub fn coalesce_sql<'a>(table_name: &str, column: &SqliteColumn<'a>) -> String { + let column_name = column.name; + match column.sql_type { + SqliteType::Json => { + format!( + r#""{column_name}" = COALESCE(jsonb(excluded."{column_name}"), "{table_name}"."{column_name}")"# + ) + } + _ => format!( + r#""{column_name}" = COALESCE(excluded."{column_name}", "{table_name}"."{column_name}")"# + ), + } +} + +pub enum SqliteValue { + Null, + Integer(i64), + Text(String), + Blob(Vec), +} + +impl SqliteValue { + fn integer(n: impl Into) -> Self { + SqliteValue::Integer(n.into()) + } + fn text(s: impl ToString) -> Self { + SqliteValue::Text(s.to_string()) + } +} + +impl From for SqliteValue { + fn from(value: String) -> Self { + SqliteValue::Text(value) + } +} + +impl From> for SqliteValue { + fn from(value: Vec) -> Self { + SqliteValue::Blob(value) + } +} + +impl From<[u8; N]> for SqliteValue { + fn from(value: [u8; N]) -> Self { + SqliteValue::Blob(value.to_vec()) + } +} + +impl From for SqliteValue { + fn from(value: Felt) -> Self { + SqliteValue::Text(format!("{value:#064x}")) + } +} + +impl From for SqliteValue { + fn from(value: EthAddress) -> Self { + SqliteValue::Text(format!("0x{}", hex::encode(value.0))) + } +} + +impl From for SqliteValue { + fn from(value: bool) -> Self { + SqliteValue::Integer(if value { 1 } else { 0 }) + } +} + +pub trait SqliteDeserializer { + fn deserialize_column(&self, data: &mut impl CairoDeserializer) -> RecordResult; + fn deserialize_json(&self, data: &mut impl CairoDeserializer) -> RecordResult; +} + +impl SqliteDeserializer for TypeDef { + fn deserialize_column(&self, data: &mut impl CairoDeserializer) -> RecordResult { + match self { + TypeDef::None => Ok(SqliteValue::Null), + TypeDef::Felt252 + | TypeDef::ClassHash + | TypeDef::ContractAddress + | TypeDef::StorageAddress + | TypeDef::StorageBaseAddress => data.next_felt().result_into(), + TypeDef::ShortUtf8 => data.next_short_string().result_into(), + TypeDef::Bytes31 | TypeDef::Bytes31Encoded(_) => data.next_bytes::<31>().result_into(), + TypeDef::Bool => data.next_bool().result_into(), + TypeDef::U8 => data.next_u8().map(SqliteValue::integer).err_into(), + TypeDef::U16 => data.next_u16().map(SqliteValue::integer).err_into(), + TypeDef::U32 => data.next_u32().map(SqliteValue::integer).err_into(), + TypeDef::U64 => data.next_u64().map(SqliteValue::text).err_into(), + TypeDef::U128 => data.next_u128().map(SqliteValue::text).err_into(), + TypeDef::U256 => data.next_u256().map(SqliteValue::text).err_into(), + TypeDef::U512 => data.next_u512().map(SqliteValue::text).err_into(), + TypeDef::I8 => data.next_i8().map(SqliteValue::integer).err_into(), + TypeDef::I16 => data.next_i16().map(SqliteValue::integer).err_into(), + TypeDef::I32 => data.next_i32().map(SqliteValue::integer).err_into(), + TypeDef::I64 => data.next_i64().map(SqliteValue::integer).err_into(), + TypeDef::I128 => data.next_i128().map(SqliteValue::text).err_into(), + TypeDef::EthAddress => data.next_eth_address().result_into(), + TypeDef::Utf8String => data.next_string().result_into(), + TypeDef::ByteArray | TypeDef::ByteArrayEncoded(_) | TypeDef::Custom(_) => { + data.next_byte_array_bytes().result_into() + } + TypeDef::Tuple(_) + | TypeDef::Array(_) + | TypeDef::FixedArray(_) + | TypeDef::Felt252Dict(_) + | TypeDef::Struct(_) + | TypeDef::Enum(_) + | TypeDef::Option(_) + | TypeDef::Result(_) + | TypeDef::Nullable(_) => self.deserialize_json(data), + TypeDef::Ref(_) => Err(DecodeError::message( + "TypeDef Ref needs to be expanded before transoding", + )) + .err_into(), + } + } + fn deserialize_json(&self, data: &mut impl CairoDeserializer) -> RecordResult { + let se = CairoSeFrom::new(self, data, &SqliteJsonSerializer); + serde_json::to_string(&se).result_into() + } +} + +impl From for SqliteArgumentValue<'_> { + fn from(value: SqliteValue) -> Self { + match value { + SqliteValue::Null => SqliteArgumentValue::Null, + SqliteValue::Integer(n) => SqliteArgumentValue::Int64(n), + SqliteValue::Text(s) => SqliteArgumentValue::Text(s.into()), + SqliteValue::Blob(b) => SqliteArgumentValue::Blob(b.into()), + } + } +} + +impl Type for SqliteValue { + fn type_info() -> ::TypeInfo { + // SqliteValue is dynamically typed; report as Text since SQLite is flexible with types. + >::type_info() + } + + fn compatible(ty: &::TypeInfo) -> bool { + >::compatible(ty) + || >::compatible(ty) + || as Type>::compatible(ty) + } +} + +impl<'q> Encode<'q, Sqlite> for SqliteValue { + fn encode_by_ref(&self, buf: &mut Vec>) -> Result { + match self { + SqliteValue::Null => Ok(IsNull::Yes), + SqliteValue::Integer(n) => >::encode_by_ref(n, buf), + SqliteValue::Text(s) => >::encode_by_ref(s, buf), + SqliteValue::Blob(b) => as Encode>::encode_by_ref(b, buf), + } + } +} + +pub fn insert_record_queries( + table: &Table, + columns: &[Felt], + records: &[Record], + _from_address: &Felt, + _block_number: u64, + _transaction_hash: &Felt, + queries: &mut Vec, +) -> RecordResult<()> { + let schema = table.get_record_schema(columns)?; + let table_name = qualified_table_name(&table.namespace, &table.name); + let all_columns = schema.all_columns(); + let sql_columns = all_columns + .iter() + .map(|c| (*c).try_into()) + .collect::>>()?; + let column_names = all_columns.names(); + let placeholders = sql_columns.iter().map(SqliteColumn::placeholder).join(", "); + let coalesce = sql_columns[1..] + .iter() + .map(|col| coalesce_sql(&table_name, col)) + .join(", "); + let sql: Arc = format!( + r#"INSERT INTO "{table_name}" ({}) VALUES ({}) ON CONFLICT("{}") DO UPDATE SET {}"#, + column_names + .iter() + .map(|name| format!(r#""{name}""#)) + .collect::>() + .join(", "), + placeholders, + schema.primary_name(), + coalesce + ) + .into(); + + for record in records { + let mut arguments: SqliteArguments<'static> = SqliteArguments::default(); + let mut primary_data = record.id.as_slice().into_source(); + let mut data = record.values.as_slice().into_source(); + arguments + .add( + schema + .primary_type_def() + .deserialize_column(&mut primary_data)?, + ) + .map_err(EncodeError)?; + for type_def in schema.columns().type_defs() { + arguments + .add(type_def.deserialize_column(&mut data)?) + .map_err(EncodeError)?; + } + queries.add((sql.clone(), arguments)); + } + Ok(()) +} diff --git a/crates/introspect-sql-sink/src/sqlite/table.rs b/crates/introspect-sql-sink/src/sqlite/table.rs new file mode 100644 index 00000000..317f837f --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/table.rs @@ -0,0 +1,217 @@ +use crate::sqlite::types::SqliteType; +use crate::{TableResult, UpgradeError, UpgradeResult, UpgradeResultExt}; +use introspect_types::schema::AsColumnRef; +use introspect_types::{ColumnDef, ColumnInfo, PrimaryDef, TypeDef}; +use serde::ser::SerializeMap; +use serde::Serializer; +use serde_json::{Result as JsonResult, Serializer as JsonSerializer}; +use sqlx::Arguments; +use starknet_types_core::felt::Felt; +use starknet_types_raw::Felt as RawFelt; +use std::collections::HashMap; +use std::fmt::{Display, Write}; +use torii_sql::types::SqlFelt; +use torii_sql::{Queries, SqliteArguments, SqliteQuery}; + +pub const FETCH_TABLES_QUERY: &str = r#" + SELECT namespace, id, owner, name, "primary", columns, append_only, alive + FROM introspect_db_tables + ORDER BY updated_at ASC +"#; + +const INSERT_TABLE_QUERY: &str = r#" INSERT INTO introspect_db_tables + (namespace, id, owner, name, "primary", columns, append_only, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, unixepoch()) + ON CONFLICT (namespace, id) DO UPDATE SET + owner = excluded.owner, name = excluded.name, "primary" = excluded."primary", columns = excluded.columns, append_only = excluded.append_only, updated_at = unixepoch() +"#; + +struct TableName<'a>(&'a str, &'a str); + +impl Display for TableName<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.0.is_empty() { + write!(f, "{}__", self.0)?; + } + self.1.fmt(f) + } +} + +pub fn qualified_table_name(namespace: &str, table_name: &str) -> String { + if namespace.is_empty() { + table_name.to_string() + } else { + format!("{}__{}", namespace, table_name) + } +} + +pub fn serialize_columns<'a>(columns: &'a [impl AsColumnRef<'a>]) -> JsonResult { + let mut data = Vec::new(); + let mut serializer = JsonSerializer::new(&mut data); + let mut array = serializer.serialize_map(Some(columns.len()))?; + for column in columns { + let (id, info) = column.as_entry(); + array.serialize_entry(id, &info)?; + } + array.end()?; + Ok(unsafe { String::from_utf8_unchecked(data) }) +} + +#[allow(clippy::too_many_arguments)] +pub fn persist_table_state_query<'a>( + namespace: &str, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &'a [impl AsColumnRef<'a>], + append_only: bool, + from_address: &Felt, + _block_number: u64, + _transaction_hash: &Felt, + queries: &mut Vec, +) -> TableResult<()> { + let mut args = SqliteArguments::default(); + args.add(namespace.to_string())?; + args.add(SqlFelt::from(RawFelt::from(*id)))?; + args.add(SqlFelt::from(RawFelt::from(*from_address)))?; + args.add(name.to_string())?; + args.add(serde_json::to_string(primary)?)?; + args.add(serialize_columns(columns)?)?; + args.add(append_only)?; + queries.add((INSERT_TABLE_QUERY, args)); + Ok(()) +} + +pub fn create_table_query( + namespace: &str, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, +) -> TableResult { + let table_name = TableName(namespace, name); + let mut query = format!( + r#"CREATE TABLE IF NOT EXISTS "{table_name}" ("{}" {}"#, + primary.name, + TryInto::::try_into(primary)? + ); + if append_only { + query.push_str(r#", "__revision" INTEGER NOT NULL"#); + } + for column in columns { + let sql_type: SqliteType = column.try_into()?; + write!(query, r#", "{}" {sql_type}"#, column.name).unwrap(); + } + if append_only { + write!( + query, + r#", PRIMARY KEY ("{}", "__revision"));"#, + primary.name + ) + .unwrap(); + } else { + write!(query, r#", PRIMARY KEY ("{}"));"#, primary.name).unwrap(); + } + Ok(query) +} + +pub fn update_columns( + columns: &mut HashMap, + table_name: &str, + new: &[ColumnDef], + queries: &mut Vec, +) -> TableResult<()> { + for column in new { + let result = match columns.get_mut(&column.id) { + Some(existing) => update_column( + table_name, + existing, + &column.name, + &column.type_def, + queries, + ), + None => { + columns.insert( + column.id, + ColumnInfo { + name: column.name.clone(), + type_def: column.type_def.clone(), + attributes: column.attributes.clone(), + }, + ); + create_column_query(table_name, column).map(|query| queries.add(query)) + } + }; + result.to_table_result(table_name, &column.name)?; + } + Ok(()) +} + +pub fn update_column( + table_name: &str, + column: &mut ColumnInfo, + new_name: &str, + new_type: &TypeDef, + queries: &mut Vec, +) -> UpgradeResult { + use introspect_types::TypeDef::{ + Array, Bool, ByteArray, ByteArrayEncoded, Bytes31, Bytes31Encoded, ClassHash, + ContractAddress, Custom, Enum, EthAddress, Felt252, FixedArray, Nullable, + Option as TDOption, Result as TDResult, ShortUtf8, StorageAddress, StorageBaseAddress, + Struct, Tuple, Utf8String, I128, I16, I32, I64, I8, U128, U16, U256, U32, U512, U64, U8, + }; + if column.name != new_name { + queries.add(format!( + r#"ALTER TABLE "{table_name}" RENAME COLUMN "{}" TO "{new_name}";"#, + column.name + )); + column.name = new_name.to_string(); + } + let cast = match (&column.type_def, &new_type) { + (Bool | U8 | U16 | U32, Bool | U8 | U16 | U32 | I8 | I16 | I32 | I64) + | (I8 | I16 | I32 | I64, I8 | I16 | I32 | I64) + | (U64 | U128 | U256 | U512, U64 | U128 | U256 | U512 | I128) + | (I128, I128) + | ( + Felt252 | ClassHash | ContractAddress | EthAddress | StorageAddress + | StorageBaseAddress, + Felt252 | ClassHash | ContractAddress | EthAddress | StorageAddress + | StorageBaseAddress, + ) + | (ShortUtf8 | Utf8String, ShortUtf8 | Utf8String) + | ( + Bytes31 | Bytes31Encoded(_) | ByteArray | ByteArrayEncoded(_) | Custom(_), + Bytes31 | Bytes31Encoded(_) | ByteArray | ByteArrayEncoded(_) | Custom(_), + ) + | ( + Tuple(_) | Array(_) | FixedArray(_) | Struct(_) | Enum(_) | TDOption(_) | TDResult(_) + | Nullable(_), + Tuple(_) | Array(_) | FixedArray(_) | Struct(_) | Enum(_) | TDOption(_) | TDResult(_) + | Nullable(_), + ) => None, + (Bool | U8 | U16 | U32, U64 | U128 | U256 | U512 | I128) | (I8 | I16 | I32 | I64, I128) => { + Some(format!(r#"CAST("{new_name}" AS TEXT)"#)) + } + ( + Bool | U8 | U16 | U32, + Felt252 | ClassHash | ContractAddress | EthAddress | StorageAddress + | StorageBaseAddress, + ) => Some(format!(r#"printf('0x%064x', "{new_name}")"#)), + _ => return UpgradeError::type_upgrade_err(&column.type_def, new_type), + }; + column.type_def = new_type.clone(); + if let Some(cast) = cast { + queries.add(format!( + r#"UPDATE "{table_name}" SET "{new_name}" = {cast};"# + )); + } + Ok(()) +} + +pub fn create_column_query(table_name: &str, column: &ColumnDef) -> UpgradeResult { + let sql_type: SqliteType = column.try_into()?; + Ok(format!( + r#"ALTER TABLE "{table_name}" ADD COLUMN "{}" {sql_type};"#, + column.name + )) +} diff --git a/crates/introspect-sql-sink/src/sqlite/types.rs b/crates/introspect-sql-sink/src/sqlite/types.rs new file mode 100644 index 00000000..749e69eb --- /dev/null +++ b/crates/introspect-sql-sink/src/sqlite/types.rs @@ -0,0 +1,161 @@ +use std::fmt::Display; + +use introspect_types::{ColumnDef, ColumnInfo, PrimaryDef, PrimaryTypeDef, TypeDef}; + +use crate::{TypeError, TypeResult}; + +pub enum SqliteType { + Null, + Text, + Integer, + Real, + Blob, + Json, +} + +impl Display for SqliteType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SqliteType::Null => write!(f, "NULL"), + SqliteType::Text => write!(f, "TEXT"), + SqliteType::Integer => write!(f, "INTEGER"), + SqliteType::Real => write!(f, "REAL"), + SqliteType::Blob => write!(f, "BLOB"), + SqliteType::Json => write!(f, "TEXT"), + } + } +} + +pub struct SqliteColumn<'a> { + pub name: &'a str, + pub sql_type: SqliteType, +} + +impl TryFrom<&TypeDef> for SqliteType { + type Error = TypeError; + + fn try_from(value: &TypeDef) -> Result { + match value { + TypeDef::None => Ok(SqliteType::Null), + TypeDef::Bool + | TypeDef::U8 + | TypeDef::U16 + | TypeDef::U32 + | TypeDef::I8 + | TypeDef::I16 + | TypeDef::I32 + | TypeDef::I64 => Ok(SqliteType::Integer), + TypeDef::U64 | TypeDef::U128 | TypeDef::U256 | TypeDef::U512 | TypeDef::I128 => { + Ok(SqliteType::Text) + } + TypeDef::Felt252 + | TypeDef::ClassHash + | TypeDef::ContractAddress + | TypeDef::EthAddress + | TypeDef::StorageAddress + | TypeDef::StorageBaseAddress => Ok(SqliteType::Text), + TypeDef::ShortUtf8 | TypeDef::Utf8String => Ok(SqliteType::Text), + TypeDef::Bytes31 + | TypeDef::Bytes31Encoded(_) + | TypeDef::ByteArray + | TypeDef::ByteArrayEncoded(_) => Ok(SqliteType::Blob), + TypeDef::Struct(_) + | TypeDef::Enum(_) + | TypeDef::Tuple(_) + | TypeDef::Array(_) + | TypeDef::FixedArray(_) + | TypeDef::Option(_) + | TypeDef::Nullable(_) + | TypeDef::Result(_) => Ok(SqliteType::Json), + TypeDef::Custom(_) => Ok(SqliteType::Blob), + _ => Err(TypeError::UnsupportedType(value.item_name().to_string())), + } + } +} + +impl TryFrom<&ColumnDef> for SqliteType { + type Error = TypeError; + + fn try_from(value: &ColumnDef) -> Result { + (&value.type_def).try_into() + } +} + +impl TryFrom<&ColumnInfo> for SqliteType { + type Error = TypeError; + + fn try_from(value: &ColumnInfo) -> Result { + (&value.type_def).try_into() + } +} + +impl TryFrom<&PrimaryTypeDef> for SqliteType { + type Error = TypeError; + + fn try_from(value: &PrimaryTypeDef) -> Result { + (&Into::::into(value)).try_into() + } +} + +impl TryFrom<&PrimaryDef> for SqliteType { + type Error = TypeError; + + fn try_from(value: &PrimaryDef) -> Result { + (&value.type_def).try_into() + } +} + +impl<'a> TryFrom<&'a ColumnInfo> for SqliteColumn<'a> { + type Error = TypeError; + + fn try_from(value: &'a ColumnInfo) -> Result { + let sql_type = (&value.type_def).try_into()?; + Ok(SqliteColumn { + name: &value.name, + sql_type, + }) + } +} + +impl SqliteType { + pub fn placeholder(&self) -> &'static str { + match self { + SqliteType::Null => "NULL", + SqliteType::Text | SqliteType::Integer | SqliteType::Real | SqliteType::Blob => "?", + SqliteType::Json => "jsonb(?)", + } + } + pub fn index_placeholder(&self, index: usize) -> String { + match self { + SqliteType::Null => "NULL".to_string(), + SqliteType::Text | SqliteType::Integer | SqliteType::Real | SqliteType::Blob => { + format!("?{index}") + } + SqliteType::Json => format!("jsonb(?{index})"), + } + } +} + +impl<'a> SqliteColumn<'a> { + pub fn placeholder(&self) -> &'static str { + self.sql_type.placeholder() + } + pub fn index_placeholder(&self, index: usize) -> String { + self.sql_type.index_placeholder(index) + } +} + +pub trait TypeDefSqliteExt { + fn placeholder(&self) -> TypeResult<&'static str>; + fn index_placeholder(&self, index: usize) -> TypeResult; +} + +impl TypeDefSqliteExt for TypeDef { + fn placeholder(&self) -> TypeResult<&'static str> { + self.try_into().map(|t: SqliteType| t.placeholder()) + } + fn index_placeholder(&self, index: usize) -> TypeResult { + self.try_into() + .map(|t: SqliteType| t.index_placeholder(index)) + } +} diff --git a/crates/introspect-sql-sink/src/table.rs b/crates/introspect-sql-sink/src/table.rs new file mode 100644 index 00000000..7d9242ee --- /dev/null +++ b/crates/introspect-sql-sink/src/table.rs @@ -0,0 +1,137 @@ +use crate::error::{CollectColumnResults, ColumnNotFoundError, ColumnsNotFoundError}; +use introspect_types::{ColumnDef, ColumnInfo, MemberDef, PrimaryDef, TypeDef}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use starknet_types_core::felt::Felt; +use std::collections::HashMap; +use std::rc::Rc; +use torii_introspect::tables::RecordSchema; + +#[derive(Debug)] +pub struct Table { + pub id: Felt, + pub namespace: String, + pub name: String, + pub owner: Felt, + pub primary: ColumnInfo, + pub columns: HashMap, + pub dead: HashMap, + pub append_only: bool, + pub alive: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeadField { + pub name: String, + pub type_def: TypeDef, +} + +#[derive(Debug)] +pub struct DeadFieldDef { + pub id: u128, + pub name: String, + pub type_def: TypeDef, +} + +impl From for DeadField { + fn from(value: MemberDef) -> Self { + DeadField { + name: value.name, + type_def: value.type_def, + } + } +} + +impl From for MemberDef { + fn from(value: DeadField) -> Self { + MemberDef { + name: value.name, + attributes: Vec::new(), + type_def: value.type_def, + } + } +} + +impl From for (u128, DeadField) { + fn from(value: DeadFieldDef) -> Self { + ( + value.id, + DeadField { + name: value.name, + type_def: value.type_def, + }, + ) + } +} + +impl From<(u128, DeadField)> for DeadFieldDef { + fn from(value: (u128, DeadField)) -> Self { + DeadFieldDef { + id: value.0, + name: value.1.name, + type_def: value.1.type_def, + } + } +} + +impl Table { + pub fn column(&self, id: &Felt) -> Result<&ColumnInfo, ColumnNotFoundError> { + self.columns.get(id).ok_or(ColumnNotFoundError(*id)) + } + + pub fn namespace(&self) -> Rc { + self.namespace.as_str().into() + } + + pub fn columns(&self, ids: &[Felt]) -> Result, ColumnsNotFoundError> { + ids.iter().map(|id| self.column(id)).collect_columns() + } + + pub fn all_columns(&self) -> Vec<&ColumnInfo> { + self.columns.values().collect() + } + + pub fn columns_with_ids<'a>( + &'a self, + ids: &'a [Felt], + ) -> Result, ColumnsNotFoundError> { + ids.iter() + .map(|id| self.column(id).map(|col| (id, col))) + .collect_columns() + } + + pub fn all_columns_with_ids(&self) -> Vec<(&Felt, &ColumnInfo)> { + self.columns.iter().collect() + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + namespace: String, + id: Felt, + owner: Felt, + name: String, + primary: PrimaryDef, + columns: &[ColumnDef], + dead: Option>, + append_only: bool, + ) -> Self { + Table { + id, + namespace, + owner, + name, + primary: primary.into(), + columns: columns.iter().cloned().map_into().collect(), + dead: dead.unwrap_or_default().into_iter().collect(), + append_only, + alive: true, + } + } + + pub fn get_record_schema( + &self, + columns: &[Felt], + ) -> Result, ColumnsNotFoundError> { + Ok(RecordSchema::new(&self.primary, self.columns(columns)?)) + } +} diff --git a/crates/introspect-sql-sink/src/tables.rs b/crates/introspect-sql-sink/src/tables.rs new file mode 100644 index 00000000..95693cdb --- /dev/null +++ b/crates/introspect-sql-sink/src/tables.rs @@ -0,0 +1,155 @@ +use crate::backend::IntrospectQueryMaker; +use crate::error::RecordResultExt; +use crate::namespace::{NamespaceKey, TableKey}; +use crate::table::Table; +use crate::{DbError, DbResult}; +use introspect_types::{ColumnDef, PrimaryDef, ResultInto}; +use starknet_types_core::felt::Felt; +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::RwLock; +use torii_introspect::InsertsFields; +use torii_sql::FlexQuery; + +#[derive(Debug, Default)] +pub struct Tables(pub RwLock>); + +impl Deref for Tables { + type Target = RwLock>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[allow(clippy::too_many_arguments)] +impl Tables { + pub fn create_table( + &self, + namespace_key: NamespaceKey, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + append_only: bool, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> DbResult<()> { + let namespace = namespace_key.to_string(); + + let key = TableKey::new(namespace_key, *id); + self.assert_table_not_exists(&key, name)?; + DB::create_table_queries( + &namespace, + id, + name, + primary, + columns, + append_only, + from_address, + block_number, + transaction_hash, + queries, + )?; + let mut tables = self.write()?; + tables.insert( + key, + Table::new( + namespace, + *id, + *from_address, + name.to_string(), + primary.clone(), + columns, + None, + append_only, + ), + ); + Ok(()) + } + + pub fn update_table( + &self, + namespace_key: NamespaceKey, + id: &Felt, + name: &str, + primary: &PrimaryDef, + columns: &[ColumnDef], + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> DbResult<()> { + let mut tables = self.write()?; + let key = TableKey::new(namespace_key, *id); + let table = tables + .get_mut(&key) + .ok_or_else(|| DbError::TableNotFound(key.clone()))?; + DB::update_table_queries( + table, + name, + primary, + columns, + from_address, + block_number, + transaction_hash, + queries, + ) + .err_into() + } + + pub fn assert_table_not_exists(&self, id: &TableKey, name: &str) -> DbResult<()> { + match self.read()?.get(id) { + Some(existing) => Err(DbError::TableAlreadyExists( + id.clone(), + name.to_string(), + existing.name.to_string(), + )), + None => Ok(()), + } + } + + pub fn set_table_dead(&self, namespace: NamespaceKey, id: Felt) -> DbResult<()> { + let mut tables = self.write()?; + let key = TableKey::new(namespace, id); + match tables.get_mut(&key) { + Some(table) => { + table.alive = false; + Ok(()) + } + None => Err(DbError::TableNotFound(key)), + } + } + + pub fn insert_fields( + &self, + namespace: NamespaceKey, + event: &InsertsFields, + from_address: &Felt, + block_number: u64, + transaction_hash: &Felt, + queries: &mut Vec>, + ) -> DbResult<()> { + let tables = self.read().unwrap(); + let key = TableKey::new(namespace, event.table); + let table = match tables.get(&key) { + Some(table) => Ok(table), + None => Err(DbError::TableNotFound(key)), + }?; + if !table.alive { + return Ok(()); + } + DB::insert_record_queries( + table, + &event.columns, + &event.records, + from_address, + block_number, + transaction_hash, + queries, + ) + .to_db_result(&table.name) + } +} diff --git a/crates/introspect-sqlite-sink/migrations/002_schema_state.sql b/crates/introspect-sqlite-sink/migrations/002_schema_state.sql deleted file mode 100644 index caa564d4..00000000 --- a/crates/introspect-sqlite-sink/migrations/002_schema_state.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS introspect_sink_schema_state ( - table_id TEXT PRIMARY KEY, - table_schema_json TEXT NOT NULL, - alive INTEGER NOT NULL DEFAULT 1, - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) -); diff --git a/crates/introspect-sqlite-sink/src/lib.rs b/crates/introspect-sqlite-sink/src/lib.rs deleted file mode 100644 index ac41c665..00000000 --- a/crates/introspect-sqlite-sink/src/lib.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod json; -pub mod processor; -pub mod sink; -pub mod table; - -use sqlx::migrate::Migrator; - -pub const INTROSPECT_SQLITE_SINK_MIGRATIONS: Migrator = sqlx::migrate!("./migrations"); diff --git a/crates/introspect-sqlite-sink/src/processor.rs b/crates/introspect-sqlite-sink/src/processor.rs deleted file mode 100644 index d3a00a93..00000000 --- a/crates/introspect-sqlite-sink/src/processor.rs +++ /dev/null @@ -1,650 +0,0 @@ -use crate::json::SqliteJsonSerializer; -use crate::table::{SqliteTable, SqliteTableError}; -use crate::INTROSPECT_SQLITE_SINK_MIGRATIONS; -use introspect_types::{PrimaryTypeDef, TypeDef}; -use serde_json::{Serializer as JsonSerializer, Value}; -use sqlx::Error as SqlxError; -use sqlx::Row; -use starknet_types_core::felt::Felt; -use std::collections::HashMap; -use std::fmt::Display; -use std::ops::Deref; -use std::sync::{PoisonError, RwLock}; -use torii::etl::envelope::MetaData; -use torii::etl::EventMsg; -use torii_introspect::events::{IntrospectBody, IntrospectMsg}; -use torii_introspect::schema::TableSchema; -use torii_introspect::InsertsFields; -use torii_sqlite::SqliteConnection; - -#[derive(Debug, thiserror::Error)] -pub enum SqliteDbError { - #[error(transparent)] - DatabaseError(#[from] SqlxError), - #[error(transparent)] - JsonError(#[from] serde_json::Error), - #[error(transparent)] - TableError(#[from] SqliteTableError), - #[error("record frame must serialize to an object")] - InvalidRecordFrame, - #[error("Table with id: {0} already exists, incoming name: {1}, existing name: {2}")] - TableAlreadyExists(Felt, String, String), - #[error("Table not found with id: {0}")] - TableNotFound(Felt), - #[error("Table poison error: {0}")] - PoisonError(String), -} - -type SqliteDbResult = std::result::Result; - -impl From> for SqliteDbError { - fn from(err: PoisonError) -> Self { - Self::PoisonError(err.to_string()) - } -} - -#[derive(Debug, Default)] -pub struct SqliteTables(pub RwLock>); - -impl Deref for SqliteTables { - type Target = RwLock>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -#[derive(Debug, Clone, Default)] -pub enum SqliteNamespace { - #[default] - None, - Custom(String), -} - -impl SqliteNamespace { - pub fn prefix(&self) -> &str { - match self { - Self::None => "", - Self::Custom(prefix) => prefix, - } - } -} - -impl From<()> for SqliteNamespace { - fn from((): ()) -> Self { - Self::None - } -} - -impl From for SqliteNamespace { - fn from(value: String) -> Self { - if value.is_empty() { - Self::None - } else { - Self::Custom(value) - } - } -} - -impl From<&str> for SqliteNamespace { - fn from(value: &str) -> Self { - if value.is_empty() { - Self::None - } else { - Self::Custom(value.to_string()) - } - } -} - -impl Display for SqliteNamespace { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => f.write_str("main"), - Self::Custom(prefix) => f.write_str(prefix), - } - } -} - -impl SqliteTables { - pub fn assert_table_not_exists(&self, id: &Felt, name: &str) -> SqliteDbResult<()> { - match self.read()?.get(id) { - Some(existing) => Err(SqliteDbError::TableAlreadyExists( - *id, - name.to_string(), - existing.name.clone(), - )), - None => Ok(()), - } - } - - pub fn create_table( - &self, - namespace: &SqliteNamespace, - to_table: impl Into, - ) -> SqliteDbResult<(Felt, String)> { - let table = to_table.into(); - self.assert_table_not_exists(&table.id, &table.name)?; - let (id, sqlite_table) = SqliteTable::new_from_table(namespace.prefix(), table); - let create_query = create_table_query(&sqlite_table); - self.write()?.insert(id, sqlite_table); - Ok((id, create_query)) - } - - pub fn set_table_dead(&self, id: &Felt) -> SqliteDbResult<()> { - if let Some(table) = self.write()?.get_mut(id) { - table.alive = false; - return Ok(()); - } - Err(SqliteDbError::TableNotFound(*id)) - } -} - -fn sqlite_column_type(type_def: &TypeDef) -> &'static str { - if is_json_type(type_def) { - "JSONB" - } else if matches!( - type_def, - TypeDef::Bool - | TypeDef::I8 - | TypeDef::I16 - | TypeDef::I32 - | TypeDef::U8 - | TypeDef::U16 - | TypeDef::U32 - ) { - "INTEGER" - } else { - "TEXT" - } -} - -fn sqlite_primary_type(type_def: &PrimaryTypeDef) -> &'static str { - if matches!( - type_def, - PrimaryTypeDef::Bool - | PrimaryTypeDef::I8 - | PrimaryTypeDef::I16 - | PrimaryTypeDef::I32 - | PrimaryTypeDef::U8 - | PrimaryTypeDef::U16 - | PrimaryTypeDef::U32 - ) { - "INTEGER" - } else { - "TEXT" - } -} - -fn is_json_type(type_def: &TypeDef) -> bool { - matches!( - type_def, - TypeDef::Struct(_) - | TypeDef::Enum(_) - | TypeDef::Tuple(_) - | TypeDef::Array(_) - | TypeDef::FixedArray(_) - | TypeDef::Option(_) - | TypeDef::Nullable(_) - | TypeDef::Result(_) - ) -} - -fn create_table_query(table: &SqliteTable) -> String { - let primary_type = sqlite_primary_type(&table.primary.type_def); - let mut columns = Vec::with_capacity(table.columns.len() + 1); - columns.push(format!( - r#""{}" {primary_type} PRIMARY KEY"#, - table.primary.name - )); - for column_id in &table.order { - let column = &table.columns[column_id]; - let col_type = sqlite_column_type(&column.type_def); - columns.push(format!(r#""{}" {col_type}"#, column.name)); - } - format!( - r#"CREATE TABLE IF NOT EXISTS "{}" ({});"#, - table.storage_name, - columns.join(", ") - ) -} - -enum SqliteBindValue { - Null, - Integer(i64), - Text(String), -} - -fn to_bind_value(value: &Value, type_def: &TypeDef) -> SqliteBindValue { - if value.is_null() { - return SqliteBindValue::Null; - } - - match type_def { - TypeDef::Bool => match value.as_bool() { - Some(b) => SqliteBindValue::Integer(i64::from(b)), - None => SqliteBindValue::Null, - }, - TypeDef::I8 | TypeDef::I16 | TypeDef::I32 | TypeDef::U8 | TypeDef::U16 | TypeDef::U32 => { - match value.as_i64() { - Some(n) => SqliteBindValue::Integer(n), - None => SqliteBindValue::Null, - } - } - TypeDef::U64 => match value.as_u64() { - Some(n) => SqliteBindValue::Text(format!("0x{n:x}")), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - TypeDef::I64 => match value.as_i64() { - Some(n) => SqliteBindValue::Text(format!("0x{n:x}")), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - - TypeDef::Felt252 - | TypeDef::ClassHash - | TypeDef::ContractAddress - | TypeDef::StorageAddress - | TypeDef::StorageBaseAddress - | TypeDef::EthAddress - | TypeDef::U256 - | TypeDef::U512 => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - - TypeDef::U128 => match value.as_u64() { - Some(n) => SqliteBindValue::Text(format!("0x{:032x}", n as u128)), - None => match value.as_str() { - Some(s) => match s.parse::() { - Ok(n) => SqliteBindValue::Text(format!("0x{n:032x}")), - Err(_) => SqliteBindValue::Text(s.to_string()), - }, - None => SqliteBindValue::Null, - }, - }, - TypeDef::I128 => match value.as_i64() { - Some(n) => SqliteBindValue::Text(format!("{}", n as i128)), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - - TypeDef::Struct(_) - | TypeDef::Enum(_) - | TypeDef::Tuple(_) - | TypeDef::Array(_) - | TypeDef::FixedArray(_) - | TypeDef::Option(_) - | TypeDef::Nullable(_) - | TypeDef::Result(_) => SqliteBindValue::Text(value.to_string()), - - _ => match value { - Value::String(s) => SqliteBindValue::Text(s.clone()), - _ => SqliteBindValue::Text(value.to_string()), - }, - } -} - -fn primary_to_bind_value(value: &Value, type_def: &PrimaryTypeDef) -> SqliteBindValue { - if value.is_null() { - return SqliteBindValue::Null; - } - - match type_def { - PrimaryTypeDef::Bool => match value.as_bool() { - Some(b) => SqliteBindValue::Integer(i64::from(b)), - None => SqliteBindValue::Null, - }, - PrimaryTypeDef::I8 - | PrimaryTypeDef::I16 - | PrimaryTypeDef::I32 - | PrimaryTypeDef::U8 - | PrimaryTypeDef::U16 - | PrimaryTypeDef::U32 => match value.as_i64() { - Some(n) => SqliteBindValue::Integer(n), - None => SqliteBindValue::Null, - }, - PrimaryTypeDef::U64 => match value.as_u64() { - Some(n) => SqliteBindValue::Text(format!("0x{n:x}")), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - PrimaryTypeDef::I64 => match value.as_i64() { - Some(n) => SqliteBindValue::Text(format!("0x{n:x}")), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - - PrimaryTypeDef::Felt252 - | PrimaryTypeDef::ClassHash - | PrimaryTypeDef::ContractAddress - | PrimaryTypeDef::StorageAddress - | PrimaryTypeDef::StorageBaseAddress - | PrimaryTypeDef::EthAddress => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - - PrimaryTypeDef::U128 => match value.as_u64() { - Some(n) => SqliteBindValue::Text(format!("0x{:032x}", n as u128)), - None => match value.as_str() { - Some(s) => match s.parse::() { - Ok(n) => SqliteBindValue::Text(format!("0x{n:032x}")), - Err(_) => SqliteBindValue::Text(s.to_string()), - }, - None => SqliteBindValue::Null, - }, - }, - PrimaryTypeDef::I128 => match value.as_i64() { - Some(n) => SqliteBindValue::Text(format!("{}", n as i128)), - None => match value.as_str() { - Some(s) => SqliteBindValue::Text(s.to_string()), - None => SqliteBindValue::Null, - }, - }, - - _ => match value { - Value::String(s) => SqliteBindValue::Text(s.clone()), - _ => SqliteBindValue::Text(value.to_string()), - }, - } -} - -pub struct IntrospectSqliteDb { - tables: SqliteTables, - namespace: SqliteNamespace, - pool: T, -} - -impl SqliteConnection for IntrospectSqliteDb { - fn pool(&self) -> &sqlx::SqlitePool { - self.pool.pool() - } -} - -impl IntrospectSqliteDb { - pub fn new(pool: T, namespace: impl Into) -> Self { - Self { - tables: SqliteTables::default(), - namespace: namespace.into(), - pool, - } - } - - pub async fn initialize_introspect_sqlite_sink(&self) -> SqliteDbResult<()> { - self.migrate(Some("introspect"), INTROSPECT_SQLITE_SINK_MIGRATIONS) - .await?; - self.load_persisted_state().await?; - Ok(()) - } - - async fn load_persisted_state(&self) -> SqliteDbResult<()> { - let rows = sqlx::query( - r" - SELECT table_schema_json, alive - FROM introspect_sink_schema_state - ORDER BY updated_at ASC - ", - ) - .fetch_all(self.pool()) - .await?; - - let mut tables = self.tables.write()?; - for row in rows { - let schema_json: String = row.try_get("table_schema_json")?; - let alive: i64 = row.try_get("alive")?; - let table_schema: TableSchema = serde_json::from_str(&schema_json)?; - let (id, mut table) = - SqliteTable::new_from_table(self.namespace.prefix(), table_schema); - table.alive = alive != 0; - tables.insert(id, table); - } - - Ok(()) - } - - async fn persist_table_state(&self, table: &TableSchema, alive: bool) -> SqliteDbResult<()> { - let schema_json = serde_json::to_string(table)?; - let alive = i64::from(alive); - sqlx::query( - r" - INSERT INTO introspect_sink_schema_state (table_id, table_schema_json, alive, updated_at) - VALUES (?1, ?2, ?3, unixepoch()) - ON CONFLICT (table_id) - DO UPDATE SET - table_schema_json = excluded.table_schema_json, - alive = excluded.alive, - updated_at = unixepoch() - ", - ) - .bind(format!("{:#x}", table.id)) - .bind(schema_json) - .bind(alive) - .execute(self.pool()) - .await?; - Ok(()) - } - - async fn update_table(&self, event: impl Into) -> SqliteDbResult<()> { - let table_schema: TableSchema = event.into(); - let id = table_schema.id; - let exists_in_memory = self.tables.read()?.contains_key(&id); - - if !exists_in_memory { - let (_, query) = self - .tables - .create_table(&self.namespace, table_schema.clone())?; - self.execute_queries(&[query]).await?; - self.persist_table_state(&table_schema, true).await?; - return Ok(()); - } - - let (old_columns, storage_name) = { - let tables = self.tables.read()?; - let old = tables.get(&id).unwrap(); - (old.columns.clone(), old.storage_name.clone()) - }; - - let (_, new_table) = - SqliteTable::new_from_table(self.namespace.prefix(), table_schema.clone()); - - let mut alter_queries = Vec::new(); - for (col_id, col_info) in &new_table.columns { - if !old_columns.contains_key(col_id) { - let col_type = sqlite_column_type(&col_info.type_def); - alter_queries.push(format!( - r#"ALTER TABLE "{storage_name}" ADD COLUMN "{}" {col_type}"#, - col_info.name - )); - } - } - - if !alter_queries.is_empty() { - self.execute_queries(&alter_queries).await?; - } - - self.tables.write()?.insert(id, new_table); - self.persist_table_state(&table_schema, true).await?; - Ok(()) - } - - pub fn load_tables_no_commit(&self, table_schemas: Vec) -> SqliteDbResult<()> { - let mut tables = self.tables.write()?; - for table in table_schemas { - let (id, sqlite_table) = SqliteTable::new_from_table(self.namespace.prefix(), table); - tables.insert(id, sqlite_table); - } - Ok(()) - } - - pub async fn process_message( - &self, - msg: &IntrospectMsg, - metadata: &MetaData, - ) -> SqliteDbResult<()> { - match msg { - IntrospectMsg::CreateTable(event) => { - let (_, query) = self.tables.create_table(&self.namespace, event.clone())?; - self.execute_queries(&[query]).await?; - self.persist_table_state(&event.clone().into(), true) - .await?; - Ok(()) - } - IntrospectMsg::UpdateTable(event) => self.update_table(event.clone()).await, - IntrospectMsg::AddColumns(event) => { - tracing::warn!( - target: "torii::introspect_sqlite_sink", - table = %event.table, - "AddColumns received — table kept alive, new columns ignored until next UpdateTable" - ); - Ok(()) - } - IntrospectMsg::DropColumns(event) => { - tracing::warn!( - target: "torii::introspect_sqlite_sink", - table = %event.table, - "DropColumns received — table kept alive, columns left in place" - ); - Ok(()) - } - IntrospectMsg::RetypeColumns(event) => { - tracing::warn!( - target: "torii::introspect_sqlite_sink", - table = %event.table, - "RetypeColumns received — table kept alive, types unchanged" - ); - Ok(()) - } - IntrospectMsg::RetypePrimary(event) => { - tracing::warn!( - target: "torii::introspect_sqlite_sink", - table = %event.table, - "RetypePrimary received — table kept alive, primary type unchanged" - ); - Ok(()) - } - IntrospectMsg::RenameTable(_) - | IntrospectMsg::DropTable(_) - | IntrospectMsg::RenameColumns(_) - | IntrospectMsg::RenamePrimary(_) - | IntrospectMsg::DeleteRecords(_) - | IntrospectMsg::DeletesFields(_) => Ok(()), - IntrospectMsg::InsertsFields(event) => self.insert_fields(event, metadata).await, - } - } - - pub async fn process_messages( - &self, - msgs: Vec<&IntrospectBody>, - ) -> SqliteDbResult>> { - let mut results = Vec::with_capacity(msgs.len()); - for body in msgs { - let (msg, metadata) = body.into(); - let result = self.process_message(msg, metadata).await; - if let Err(ref err) = result { - tracing::warn!( - target: "torii::introspect_sqlite_sink", - event_id = msg.event_id(), - error = %err, - "Failed to process introspect message" - ); - } - results.push(result); - } - Ok(results) - } - - async fn insert_fields( - &self, - event: &InsertsFields, - _metadata: &MetaData, - ) -> SqliteDbResult<()> { - let table = self - .tables - .read()? - .get(&event.table) - .ok_or(SqliteDbError::TableNotFound(event.table))? - .clone(); - if !table.alive { - return Ok(()); - } - - let record_schema = table.get_schema(&event.columns)?; - let column_names = std::iter::once(table.primary.name.as_str()) - .chain( - event - .columns - .iter() - .map(|id| table.columns[id].name.as_str()), - ) - .collect::>(); - - let column_type_defs: Vec<&TypeDef> = event - .columns - .iter() - .map(|id| &table.columns[id].type_def) - .collect(); - - let mut bytes = Vec::new(); - let mut serializer = JsonSerializer::new(&mut bytes); - record_schema.parse_records_with_metadata( - &event.records, - &(), - &mut serializer, - &SqliteJsonSerializer, - )?; - let rows = serde_json::from_slice::>(&bytes)?; - - let mut tx = self.begin().await?; - for value in rows { - let object = value.as_object().ok_or(SqliteDbError::InvalidRecordFrame)?; - - let mut query = sqlx::query(&table.upsert_sql); - - let primary_value = object - .get(table.primary.name.as_str()) - .cloned() - .unwrap_or(Value::Null); - match primary_to_bind_value(&primary_value, &table.primary.type_def) { - SqliteBindValue::Null => { - query = query.bind(None::>); - } - SqliteBindValue::Integer(n) => { - query = query.bind(n); - } - SqliteBindValue::Text(s) => { - query = query.bind(s); - } - } - - for (column_name, type_def) in column_names.iter().skip(1).zip(column_type_defs.iter()) - { - let val = object.get(*column_name).cloned().unwrap_or(Value::Null); - match to_bind_value(&val, type_def) { - SqliteBindValue::Null => { - query = query.bind(None::); - } - SqliteBindValue::Integer(n) => { - query = query.bind(n); - } - SqliteBindValue::Text(s) => { - query = query.bind(s); - } - } - } - query.execute(&mut *tx).await?; - } - tx.commit().await?; - Ok(()) - } -} diff --git a/crates/introspect-sqlite-sink/src/table.rs b/crates/introspect-sqlite-sink/src/table.rs deleted file mode 100644 index 8bbb653e..00000000 --- a/crates/introspect-sqlite-sink/src/table.rs +++ /dev/null @@ -1,151 +0,0 @@ -use introspect_types::{ColumnDef, ColumnInfo, FeltIds, PrimaryDef}; -use itertools::Itertools; -use starknet_types_core::felt::Felt; -use std::collections::HashMap; -use thiserror::Error; -use torii_introspect::schema::TableSchema; -use torii_introspect::tables::RecordSchema; - -#[derive(Debug, Error)] -pub enum SqliteTableError { - #[error("Column with id: {0} not found in table {1}")] - ColumnNotFound(Felt, String), -} - -pub type TableResult = std::result::Result; - -#[derive(Debug, Clone)] -pub struct SqliteTable { - pub name: String, - pub storage_name: String, - pub primary: PrimaryDef, - pub columns: HashMap, - pub order: Vec, - pub upsert_sql: String, - pub alive: bool, -} - -impl SqliteTable { - pub fn new( - storage_name: String, - name: String, - primary: PrimaryDef, - columns: Vec, - ) -> Self { - Self { - name, - storage_name, - primary, - order: columns.ids(), - columns: columns.into_iter().map_into().collect(), - upsert_sql: String::new(), - alive: true, - } - .with_upsert_sql() - } - - pub fn new_from_table(namespace: &str, table: impl Into) -> (Felt, Self) { - let table = table.into(); - let storage_name = if namespace.is_empty() { - table.name.clone() - } else { - format!("{namespace}__{}", table.name) - }; - ( - table.id, - Self::new(storage_name, table.name, table.primary, table.columns), - ) - } - - pub fn get_column(&self, selector: &Felt) -> TableResult<&ColumnInfo> { - self.columns - .get(selector) - .ok_or_else(|| SqliteTableError::ColumnNotFound(*selector, self.name.clone())) - } - - pub fn get_schema(&self, column_ids: &[Felt]) -> TableResult> { - let columns = column_ids - .iter() - .map(|selector| self.get_column(selector)) - .collect::, _>>()?; - Ok(RecordSchema::new(&self.primary, columns)) - } - - fn with_upsert_sql(mut self) -> Self { - self.upsert_sql = build_upsert_sql(&self); - self - } -} - -fn sqlite_column_type(type_def: &introspect_types::TypeDef) -> &'static str { - if matches!( - type_def, - introspect_types::TypeDef::Struct(_) - | introspect_types::TypeDef::Enum(_) - | introspect_types::TypeDef::Tuple(_) - | introspect_types::TypeDef::Array(_) - | introspect_types::TypeDef::FixedArray(_) - | introspect_types::TypeDef::Option(_) - | introspect_types::TypeDef::Nullable(_) - | introspect_types::TypeDef::Result(_) - ) { - "JSONB" - } else { - "" - } -} - -fn build_upsert_sql(table: &SqliteTable) -> String { - let column_names = std::iter::once(table.primary.name.as_str()) - .chain(table.order.iter().map(|id| table.columns[id].name.as_str())) - .collect::>(); - let column_type_defs = table - .order - .iter() - .map(|id| &table.columns[id].type_def) - .collect::>(); - - let placeholders = std::iter::once("?".to_string()) - .chain(column_type_defs.iter().map(|td| { - if sqlite_column_type(td) == "JSONB" { - "jsonb(?)".to_string() - } else { - "?".to_string() - } - })) - .collect::>() - .join(", "); - - let update_columns = column_names - .iter() - .skip(1) - .zip(column_type_defs.iter()) - .map(|(name, td)| { - if sqlite_column_type(td) == "JSONB" { - format!( - r#""{name}" = COALESCE(jsonb(excluded."{name}"), "{table_name}"."{name}")"#, - table_name = table.storage_name - ) - } else { - format!( - r#""{name}" = COALESCE(excluded."{name}", "{table_name}"."{name}")"#, - table_name = table.storage_name - ) - } - }) - .collect::>() - .join(", "); - - format!( - r#"INSERT INTO "{}" ({}) VALUES ({}) ON CONFLICT("{}") DO UPDATE SET {}"#, - table.storage_name, - column_names - .iter() - .map(|name| format!(r#""{name}""#)) - .collect::>() - .join(", "), - placeholders, - table.primary.name, - update_columns - ) -} diff --git a/crates/introspect/Cargo.toml b/crates/introspect/Cargo.toml index 4e00c48e..e9fbcd5a 100644 --- a/crates/introspect/Cargo.toml +++ b/crates/introspect/Cargo.toml @@ -10,8 +10,8 @@ introspect-types.workspace = true introspect-rust-macros.workspace = true serde.workspace = true serde_json.workspace = true -starknet.workspace = true starknet-types-core.workspace = true +starknet-types-raw.workspace = true torii.workspace = true blake3.workspace = true thiserror.workspace = true @@ -28,6 +28,7 @@ async-trait.workspace = true bigdecimal.workspace = true torii-common.workspace = true +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } [build-dependencies] tonic-build = "0.12" diff --git a/crates/introspect/README.md b/crates/introspect/README.md new file mode 100644 index 00000000..74a2c592 --- /dev/null +++ b/crates/introspect/README.md @@ -0,0 +1,81 @@ +# torii-introspect + +The **schema event vocabulary** shared between `torii-dojo` and every +introspect-aware sink. No decoding, no persistence — just the data types +that describe a table being created, renamed, retyped, repopulated, or +deleted at runtime. + +## Role in Torii + +`DojoDecoder` (`crates/dojo/src/decoder.rs`) takes Cairo-level Dojo events +and translates them into this crate's `IntrospectMsg` enum. The resulting +envelopes are then consumed by `introspect-sql-sink` (materialises the +tables to SQL), `torii-ecs-sink` (classifies as entity vs event-message), +and `arcade-sink` (projects into Arcade models). Because everybody agrees +on this vocabulary, the same event stream fans out to all three without +sharing any code. + +## Architecture + +```text +Cairo events from the Dojo world contract + | + v + torii-dojo::DojoDecoder + | + v IntrospectMsg (13 variants, see below) + | + +---> introspect-sql-sink (DDL + records) + +---> torii-ecs-sink (entity/event-message classification) + +---> arcade-sink (Arcade projections) + ++---------------------------------------------------------+ +| IntrospectMsg enum | +| | +| Schema lifecycle Column lifecycle Record lifecyc.| +| - CreateTable - AddColumns - InsertsFields| +| - UpdateTable - RenameColumns - DeleteRecords| +| - RenameTable - RetypeColumns - DeletesFields| +| - RenamePrimary - DropColumns | +| - RetypePrimary - DropTable | ++---------------------------------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `IntrospectMsg` (13 variants) | `src/events.rs` | 16 | The union type every Dojo-aware sink pattern-matches | +| `IntrospectBody = EventBody` | `src/events.rs` | 32 | Convenience alias for the envelope body | +| `EventId` (trait) | `src/events.rs` | 34 | Every variant has a stable `event_id()` for dedup | +| `CreateTable` | `src/events.rs` | 69 | `id`, `name`, `attributes`, `primary`, `columns`, `append_only` | +| `UpdateTable` | `src/events.rs` | 79 | Same as `CreateTable` minus `append_only` | +| `RenameTable`, `RenamePrimary`, `RetypePrimary`, `RenameColumns`, `RetypeColumns`, `AddColumns`, `DropColumns`, `DropTable` | `src/events.rs` | 88–128 | Schema mutations | +| `InsertsFields`, `Record` | `src/events.rs` | 144 / 138 | Bulk insert of rows (row id + raw serialized values) | +| `DeleteRecords`, `DeletesFields` | `src/events.rs` | 151 / 157 | Row and field deletions | +| `ColumnKey` | `src/schema.rs` | — | Newtype wrapping column identity | +| `TableSchema` (`From`, `From`) | `src/schema.rs` | — | Non-event-shaped view of a table (id + name + attrs + primary + columns) used internally | + +### Internal Modules + +- `events` — `IntrospectMsg` + 13 variant structs + their `EventId` impls + `EventMsg` impl (`TypeId::new("introspect")`). +- `schema` — `TableSchema` + `ColumnKey` newtype + conversions to/from `CreateTable`/`UpdateTable`. +- `tables` — multi-table cache helpers (used by the SQL sink). +- `types` — `TypeLibrary` trait for cairo type reference expansion. +- `store` — trait stub (no persistence lives here). +- `manager` — reserved for a future schema manager facade; currently inert. +- `postgres/` — PostgreSQL-specific type mappers; consumed by `introspect-sql-sink` when the `postgres` feature is on. + +### Interactions + +- **Upstream (consumers)**: `torii-dojo` (emits `IntrospectMsg` inside `DojoEvent::Introspect`), `introspect-sql-sink` (materialises), `torii-ecs-sink` (classifies), `arcade-sink` (projects). +- **Downstream deps**: `torii`, `torii-common`, `torii-sql`, `introspect-events`, `introspect-rust-macros`, `introspect-types`, `starknet-types-raw`, `starknet-types-core`, `serde`, `sqlx` (only when feature `postgres` is on). +- **Features**: `postgres` — pulls in `sqlx` and compiles the `postgres/` mappers. + +### Extension Points + +- New schema event → add a variant to `IntrospectMsg` + `EventId` impl + bump every matching sink. Because `IntrospectMsg` is an exhaustive enum, the compiler enforces the fan-out. +- New column attribute → extend `introspect-types::Attribute` (external crate); no change needed here. +- The type itself is ABI-stable on the wire — changing variant names or orderings breaks every sink. diff --git a/crates/introspect/src/events.rs b/crates/introspect/src/events.rs index c4797559..591e4de1 100644 --- a/crates/introspect/src/events.rs +++ b/crates/introspect/src/events.rs @@ -12,7 +12,7 @@ use torii::etl::{EventBody, TypeId}; use crate::schema::TableSchema; -#[derive(EnumFrom, Debug)] +#[derive(EnumFrom, Debug, Clone)] pub enum IntrospectMsg { CreateTable(CreateTable), UpdateTable(UpdateTable), @@ -72,6 +72,7 @@ pub struct CreateTable { pub attributes: Vec, pub primary: PrimaryDef, pub columns: Vec, + pub append_only: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -288,6 +289,7 @@ impl From for CreateTable { attributes: schema.attributes, primary: schema.primary, columns: schema.columns, + append_only: false, } } } @@ -351,6 +353,19 @@ impl InsertsFields { } } +impl CreateTable { + pub fn from_schema(schema: TableSchema, append_only: bool) -> Self { + Self { + id: schema.id, + name: schema.name, + attributes: schema.attributes, + primary: schema.primary, + columns: schema.columns, + append_only, + } + } +} + impl DeleteRecords { pub fn new(table: Felt, rows: Vec) -> Self { Self { table, rows } @@ -382,3 +397,9 @@ impl ToKeyBytes for Felt { self.to_bytes_be() } } + +impl ToKeyBytes for [u8; 32] { + fn to_key_bytes(&self) -> [u8; 32] { + *self + } +} diff --git a/crates/introspect/src/postgres/global.rs b/crates/introspect/src/postgres/global.rs index 41f23449..d651ce22 100644 --- a/crates/introspect/src/postgres/global.rs +++ b/crates/introspect/src/postgres/global.rs @@ -6,7 +6,7 @@ use itertools::Itertools; use sqlx::types::Json; use sqlx::{FromRow, PgPool}; use starknet_types_core::felt::Felt; -use torii_common::sql::SqlxResult; +use torii_sql::SqlxResult; use crate::postgres::{attribute_type, felt252_type, string_type, PgAttribute, PgFelt}; diff --git a/crates/introspect/src/postgres/owned.rs b/crates/introspect/src/postgres/owned.rs index c781c520..bc0c2521 100644 --- a/crates/introspect/src/postgres/owned.rs +++ b/crates/introspect/src/postgres/owned.rs @@ -10,7 +10,7 @@ use sqlx::types::Json; use sqlx::{FromRow, PgPool, Postgres}; use starknet_types_core::felt::Felt; use std::collections::HashMap; -use torii_common::sql::SqlxResult; +use torii_sql::SqlxResult; pub const TABLE_INSERT_QUERY: &str = " INSERT INTO introspect.tables (owner, id, name, attributes, primary_def, column_ids, updated_at, created_block, updated_block, created_tx, updated_tx) diff --git a/crates/introspect/src/postgres/types.rs b/crates/introspect/src/postgres/types.rs index b06dced8..70d75b8b 100644 --- a/crates/introspect/src/postgres/types.rs +++ b/crates/introspect/src/postgres/types.rs @@ -2,13 +2,11 @@ use std::fmt::Display; use introspect_types::{Attribute, PrimaryDef, PrimaryTypeDef}; use itertools::Itertools; -use sqlx::{ - encode::IsNull, - error::BoxDynError, - postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef}, - types::{BigDecimal, Json}, - Decode, Encode, Postgres, Type, -}; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef}; +use sqlx::types::{BigDecimal, Json}; +use sqlx::{Decode, Encode, Postgres, Type}; use starknet_types_core::felt::Felt; #[derive(sqlx::Type, Debug)] diff --git a/crates/introspect/src/schema.rs b/crates/introspect/src/schema.rs index 3586e6d7..a521a6ef 100644 --- a/crates/introspect/src/schema.rs +++ b/crates/introspect/src/schema.rs @@ -1,9 +1,6 @@ -use std::collections::HashMap; - use introspect_types::{Attribute, ColumnDef, PrimaryDef}; -use starknet::core::types::EmittedEvent; use starknet_types_core::felt::Felt; -use torii::etl::EventContext; +use std::collections::HashMap; #[derive(PartialEq, Eq, Hash, Clone, Copy)] pub struct ColumnKey { @@ -76,48 +73,6 @@ pub struct Table { pub order: Vec, pub alive: bool, } - -pub trait TableMetadata { - fn block_number(&self) -> u64; - fn tx_hash(&self) -> &Felt; -} - -impl TableMetadata for EmittedEvent { - fn block_number(&self) -> u64 { - self.block_number.unwrap_or_default() - } - fn tx_hash(&self) -> &Felt { - &self.transaction_hash - } -} - -impl TableMetadata for EventContext { - fn block_number(&self) -> u64 { - self.block.number - } - fn tx_hash(&self) -> &Felt { - &self.transaction.hash - } -} - -impl TableMetadata for (u64, Felt) { - fn block_number(&self) -> u64 { - self.0 - } - fn tx_hash(&self) -> &Felt { - &self.1 - } -} - -impl TableMetadata for (u64, &Felt) { - fn block_number(&self) -> u64 { - self.0 - } - fn tx_hash(&self) -> &Felt { - self.1 - } -} - // impl From<(Felt, TableInfo)> for TableSchema { // fn from(value: (Felt, TableInfo)) -> Self { // let (id, info) = value; diff --git a/crates/introspect/src/tables.rs b/crates/introspect/src/tables.rs index 43775d93..be081a9c 100644 --- a/crates/introspect/src/tables.rs +++ b/crates/introspect/src/tables.rs @@ -1,34 +1,40 @@ use crate::Record; use introspect_types::bytes::IntoByteSource; +use introspect_types::schema::Names; use introspect_types::serialize::CairoSeFrom; use introspect_types::serialize_def::CairoTypeSerialization; use introspect_types::{CairoDeserializer, ColumnInfo, PrimaryDef, PrimaryTypeDef, TypeDef}; use serde::ser::{SerializeMap, SerializeSeq}; use serde::{Serialize, Serializer}; -use starknet_types_core::felt::Felt; use std::ops::Deref; -use torii::etl::envelope::MetaData; pub struct RecordSchema<'a> { - primary: &'a PrimaryDef, + primary: &'a ColumnInfo, columns: Vec<&'a ColumnInfo>, } impl<'a> RecordSchema<'a> { - pub fn new(primary: &'a PrimaryDef, columns: Vec<&'a ColumnInfo>) -> Self { + pub fn new(primary: &'a ColumnInfo, columns: Vec<&'a ColumnInfo>) -> Self { Self { primary, columns } } pub fn columns(&self) -> &[&'a ColumnInfo] { &self.columns } - + pub fn all_columns(&self) -> Vec<&'a ColumnInfo> { + std::iter::once(self.primary) + .chain(self.columns.iter().copied()) + .collect() + } pub fn column_names(&self) -> Vec<&str> { - self.columns.iter().map(|col| col.name.as_str()).collect() + self.columns.names() } - pub fn primary(&self) -> &PrimaryDef { + pub fn primary(&self) -> &ColumnInfo { self.primary } + pub fn primary_type_def(&self) -> &TypeDef { + &self.primary.type_def + } pub fn primary_name(&self) -> &str { &self.primary.name @@ -132,7 +138,7 @@ impl<'a, M: SerializeEntries> RecordWithMetadata<'a, M> { } pub struct RecordFrame<'a, C: CairoTypeSerialization, M: SerializeEntries> { - primary: &'a PrimaryDef, + primary: &'a ColumnInfo, columns: &'a [&'a ColumnInfo], id: &'a [u8; 32], values: &'a [u8], @@ -161,7 +167,7 @@ impl<'a, C: CairoTypeSerialization, M: SerializeEntries> RecordFrame<'a, C, M> { &self, map: &mut ::SerializeMap, ) -> Result<(), S::Error> { - let mut id: introspect_types::bytes::DerefBytesSource<&[u8]> = self.id.into_source(); + let mut id = self.id.into_source(); map.serialize_entry( &self.primary.name, &CairoSeFrom::new(&self.primary.type_def, &mut id, self.cairo_se), @@ -215,23 +221,3 @@ impl SerializeEntries for () { Ok(()) } } - -pub fn pg_json_felt252(value: &Felt) -> String { - format!("\\x{}", hex::encode(value.to_bytes_be())) -} - -impl SerializeEntries for MetaData { - fn entry_count(&self) -> usize { - 4 - } - fn serialize_entries( - &self, - map: &mut ::SerializeMap, - ) -> Result<(), S::Error> { - let tx_hash = pg_json_felt252(&self.transaction_hash); - map.serialize_entry("__created_block", &self.block_number)?; - map.serialize_entry("__updated_block", &self.block_number)?; - map.serialize_entry("__created_tx", &tx_hash)?; - map.serialize_entry("__updated_tx", &tx_hash) - } -} diff --git a/crates/pathfinder/Cargo.toml b/crates/pathfinder/Cargo.toml index 35a1306b..ed3778e2 100644 --- a/crates/pathfinder/Cargo.toml +++ b/crates/pathfinder/Cargo.toml @@ -5,22 +5,22 @@ edition = "2021" description = "Pathfinding utilities for Torii" [dependencies] -starknet.workspace = true -rusqlite = { version = "0.32.1", features = ["bundled"] } +rusqlite = { version = "0.32.1" } zstd = { version = "0.13.2", features = ["experimental"] } serde.workspace = true bincode = { workspace = true, features = ["serde"] } thiserror.workspace = true rand = "0.8.5" -torii-starknet.workspace = true +starknet-types-raw = { workspace = true, features = ["serde"] } +torii-types.workspace = true # Etl dependencies -torii = { workspace = true, optional = true } -async-trait = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +torii = { workspace = true, optional = true } [lints] workspace = true [features] -etl = ["torii", "async-trait", "anyhow", "torii-starknet/starknet"] +etl = ["dep:async-trait", "dep:anyhow", "dep:torii"] diff --git a/crates/pathfinder/README.md b/crates/pathfinder/README.md new file mode 100644 index 00000000..33cb2e01 --- /dev/null +++ b/crates/pathfinder/README.md @@ -0,0 +1,90 @@ +# torii-pathfinder + +Reads Starknet events directly from a **Pathfinder SQLite database**. Used +when indexing from an archival Pathfinder node is faster than calling +`starknet_getEvents` over RPC — typically for backfills. Under the `etl` +feature it also exposes an `Extractor` implementation so Torii can plug it +into the normal ETL loop. + +## Role in Torii + +Ordinary binaries read from an RPC endpoint via `BlockRangeExtractor` or +`EventExtractor` (`src/etl/extractor/`). For bulk historical ingests, where +going through RPC is prohibitively slow, this crate lets a binary point at +a local Pathfinder SQLite DB and stream events out directly — decoding the +binary event-column format Pathfinder uses (zstd-compressed bincode over +felt arrays). + +## Architecture + +```text ++------------------------------------------------------+ +| torii-pathfinder | +| | +| utils.rs sqlite.rs | +| +----------+ +-------------------------+ | +| | connect()|----->| SqliteExt trait | | +| | (rusqlite) | - get_block_context_rows| | +| +----------+ | - get_block_events_rows | | +| | - get_block_tx_hash_rows| | +| +-------------------------+ | +| | | +| v | +| decoding.rs | +| +----------------+ zstd-decompress + | +| | BlockEvents | bincode-decode + | +| | BlockTxHashes | felt-array parsing | +| +----------------+ | +| | | +| v | +| fetcher.rs | +| +---------------------------------+ | +| | EventFetcher (trait) | | +| | get_events(from, to) | | +| | get_events_with_context(...) | | +| | impl EventFetcher for Connection| | +| +---------------------------------+ | +| | | +| v (feature = "etl") | +| extractor.rs | +| +---------------------------------+ | +| | PathfinderExtractor | | +| | PathfinderCombinedExtractor |--- impl Extractor +| +---------------------------------+ | ++------------------------------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `connect(path)` | `src/utils.rs` | — | Open a `rusqlite::Connection` with the read-only pragmas Pathfinder expects | +| `EventFetcher` trait | `src/fetcher.rs` | 19 | `get_events(from, to)` / `get_events_with_context(from, to)` — blanket impl for `rusqlite::Connection` | +| `BlockEvents` / `BlockTxHashes` | `src/decoding.rs` | — | Zstd-bincode decoders for Pathfinder's blob columns | +| `BlockContextRow` | `src/sqlite.rs` | — | Row shape for `block_headers` | +| `SqliteExt` trait | `src/sqlite.rs` | — | Raw SQL readers used by `EventFetcher` | +| `PathfinderExtractor` | `src/extractor.rs` (feature `etl`) | 8 | Stateful block-range extractor keeping a `Mutex` | +| `PathfinderCombinedExtractor` | `src/extractor.rs` (feature `etl`) | 17 | Switches from the Pathfinder DB to a live-head extractor `T` once caught up | +| `PFError` / `PFResult` | `src/error.rs` | — | Domain error covering IO, SQLite, bincode, zstd, and invariant failures | + +### Internal Modules + +- `decoding` — zstd + bincode felt-array parsing. +- `error` — `PFError` (with typed constructors) and `PFResult`. +- `fetcher` — `EventFetcher` trait and its blanket impl over `rusqlite::Connection`. +- `sqlite` — raw SQL extensions and row structs. +- `utils` — `connect` (read-only open). +- `extractor` (feature `etl`) — `PathfinderExtractor` + combined variant that swaps to a live extractor once the range is exhausted. + +### Interactions + +- **Upstream (consumers)**: `examples/pathfinder/main.rs` demonstrates usage; binaries that want Pathfinder backfill opt in by enabling the `etl` feature. +- **Downstream deps**: `rusqlite` (bundled), `zstd`, `bincode`, `serde`, `starknet-types-raw`, `torii-types`. With `etl`: `torii`, `async-trait`, `anyhow`. +- **Features**: `etl` — adds the `Extractor` implementation and the `torii` dep. Binaries that use only `EventFetcher` do not need it. + +### Extension Points + +- New snapshot source: add a module beside `sqlite` (e.g. `postgres.rs`) and implement `EventFetcher` for its connection. `PathfinderExtractor` becomes reusable because it consumes `EventFetcher` traits, not concrete types. +- New blob format: add a module under `decoding` and widen `BlockEvents::try_from(row)` to dispatch by column version. diff --git a/crates/pathfinder/src/decoding.rs b/crates/pathfinder/src/decoding.rs index eb7cd0d1..1e8694c3 100644 --- a/crates/pathfinder/src/decoding.rs +++ b/crates/pathfinder/src/decoding.rs @@ -1,12 +1,12 @@ use crate::sqlite::BlockEventsRow; use crate::{PFError, PFResult}; use serde::{Deserialize, Serialize}; +use starknet_types_raw::event::Event; +use starknet_types_raw::Felt; use std::cell::RefCell; use std::fmt::{Formatter, Result as FmtResult}; use std::io::Result as IoResult; use std::sync::LazyLock; -use torii_starknet::event::Event; -use torii_starknet::Felt; use zstd::bulk::Decompressor; // Taken from pathfinder-common but with some optimizations diff --git a/crates/pathfinder/src/extractor.rs b/crates/pathfinder/src/extractor.rs index 46b86456..3dcf5201 100644 --- a/crates/pathfinder/src/extractor.rs +++ b/crates/pathfinder/src/extractor.rs @@ -1,13 +1,9 @@ use crate::{connect, EventFetcher, PFResult}; -use anyhow::Result as AnyResult; -use async_trait::async_trait; use rusqlite::Connection; -use starknet::core::types::EmittedEvent; -use std::collections::HashMap; use std::path::Path; -use std::sync::{Arc, Mutex}; -use torii::etl::{BlockContext, EngineDb, ExtractionBatch, Extractor}; - +use std::sync::Mutex; +use torii_types::block::BlockContext; +use torii_types::event::StarknetEvent; #[derive(Debug)] pub struct PathfinderExtractor { pub conn: Mutex, @@ -18,7 +14,7 @@ pub struct PathfinderExtractor { } #[derive(Debug)] -pub struct PathfinderCombinedExtractor { +pub struct PathfinderCombinedExtractor { pub pathfinder: PathfinderExtractor, pub head: T, pub on_head: bool, @@ -35,7 +31,7 @@ impl PathfinderExtractor { }) } - pub fn next_batch(&mut self) -> PFResult<(Vec, Vec)> { + pub fn next_batch(&mut self) -> PFResult<(Vec, Vec)> { let mut last = self.current + self.batch; if last >= self.end { last = self.end; @@ -44,17 +40,17 @@ impl PathfinderExtractor { let (blocks, events) = self .conn .lock()? - .get_emitted_events_with_context(self.current, last)?; + .get_events_with_context(self.current, last)?; let last_block = blocks.last().map_or(self.current, |b| b.number); if last_block < last { self.finished = true; } self.current = last_block + 1; - Ok((blocks, events.into_iter().map(Into::into).collect())) + Ok((blocks, events)) } } -impl PathfinderCombinedExtractor { +impl PathfinderCombinedExtractor { pub fn new>( path: P, batch: u64, @@ -70,64 +66,74 @@ impl PathfinderCombinedExtractor { } } -#[async_trait] -impl Extractor for PathfinderExtractor { - fn set_start_block(&mut self, start_block: u64) { - self.current = start_block.max(self.current); - } - async fn extract( - &mut self, - _cursor: Option, - _engine_db: &EngineDb, - ) -> AnyResult { - let (blocks, events) = self.next_batch()?; - let blocks = blocks - .into_iter() - .map(|b| (b.number, Arc::new(b))) - .collect(); - Ok(ExtractionBatch { - events, - blocks, - transactions: HashMap::new(), - declared_classes: Vec::new(), - deployed_contracts: Vec::new(), - cursor: None, - chain_head: None, - }) - } - fn is_finished(&self) -> bool { - self.current >= self.end - } - fn as_any(&self) -> &dyn std::any::Any { - self - } -} +#[cfg(feature = "etl")] +mod etl { + use super::{PathfinderCombinedExtractor, PathfinderExtractor}; + use anyhow::Result as AnyResult; + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::Arc; + use torii::etl::{EngineDb, ExtractionBatch, Extractor}; -#[async_trait] -impl Extractor for PathfinderCombinedExtractor { - fn set_start_block(&mut self, start_block: u64) { - self.pathfinder.set_start_block(start_block); - self.head.set_start_block(start_block); - } - async fn extract( - &mut self, - cursor: Option, - engine_db: &EngineDb, - ) -> AnyResult { - if self.on_head { - self.head.extract(cursor, engine_db).await - } else if self.pathfinder.is_finished() { - self.on_head = true; - self.head.set_start_block(self.pathfinder.current); - self.head.extract(cursor, engine_db).await - } else { - self.pathfinder.extract(cursor, engine_db).await + #[async_trait] + impl Extractor for PathfinderExtractor { + fn set_start_block(&mut self, start_block: u64) { + self.current = start_block.max(self.current); + } + async fn extract( + &mut self, + _cursor: Option, + _engine_db: &EngineDb, + ) -> AnyResult { + let (blocks, events) = self.next_batch()?; + let blocks = blocks + .into_iter() + .map(|b| (b.number, Arc::new(b))) + .collect(); + Ok(ExtractionBatch { + events, + blocks, + transactions: HashMap::new(), + declared_classes: Vec::new(), + deployed_contracts: Vec::new(), + cursor: None, + chain_head: None, + }) + } + fn is_finished(&self) -> bool { + self.current >= self.end + } + fn as_any(&self) -> &dyn std::any::Any { + self } } - fn is_finished(&self) -> bool { - self.pathfinder.is_finished() && self.head.is_finished() - } - fn as_any(&self) -> &dyn std::any::Any { - self + + #[async_trait] + impl Extractor for PathfinderCombinedExtractor { + fn set_start_block(&mut self, start_block: u64) { + self.pathfinder.set_start_block(start_block); + self.head.set_start_block(start_block); + } + async fn extract( + &mut self, + cursor: Option, + engine_db: &EngineDb, + ) -> AnyResult { + if self.on_head { + self.head.extract(cursor, engine_db).await + } else if self.pathfinder.is_finished() { + self.on_head = true; + self.head.set_start_block(self.pathfinder.current); + self.head.extract(cursor, engine_db).await + } else { + self.pathfinder.extract(cursor, engine_db).await + } + } + fn is_finished(&self) -> bool { + self.pathfinder.is_finished() && self.head.is_finished() + } + fn as_any(&self) -> &dyn std::any::Any { + self + } } } diff --git a/crates/pathfinder/src/fetcher.rs b/crates/pathfinder/src/fetcher.rs index e3aebabf..f5182cc6 100644 --- a/crates/pathfinder/src/fetcher.rs +++ b/crates/pathfinder/src/fetcher.rs @@ -1,75 +1,37 @@ -use std::collections::HashMap; - +use crate::decoding::BlockEvents; use crate::sqlite::{BlockContextRow, SqliteExt}; -use crate::PFError; -use crate::{decoding::BlockEvents, PFResult}; +use crate::{PFError, PFResult}; use rusqlite::Connection; - -#[cfg(not(feature = "etl"))] -/// Block context information Copied from core to avoid importing the entire crate -#[derive(Debug, Clone, Default)] -pub struct BlockContext { - pub number: u64, - pub hash: Felt, - pub parent_hash: Felt, - pub timestamp: u64, -} - -#[cfg(feature = "etl")] -pub use torii::etl::extractor::BlockContext; -use torii_starknet::event::EmittedEvent; -use torii_starknet::Felt; +use torii_types::block::BlockContext; +use torii_types::event::StarknetEvent; impl From for BlockContext { fn from(value: BlockContextRow) -> Self { BlockContext { number: value.number, - hash: value.hash.into(), - parent_hash: value.parent_hash.into(), + hash: value.hash, + parent_hash: value.parent_hash, timestamp: value.timestamp, } } } -pub trait EmittedEventExt { - fn with_block_hash(self, block_hashes: &HashMap) -> PFResult; -} - -impl EmittedEventExt for EmittedEvent { - fn with_block_hash(mut self, block_hashes: &HashMap) -> PFResult { - let block_number = self.block_number.unwrap(); - match block_hashes.get(&block_number) { - Some(block_hash) => self.block_hash = Some(*block_hash), - None => return Err(PFError::block_hash_missing(block_number)), - } - Ok(self) - } -} - pub trait EventFetcher { - fn get_emitted_events_wo_block_hashes( - &self, - from_block: u64, - to_block: u64, - ) -> PFResult>; - fn get_emitted_events(&self, from_block: u64, to_block: u64) -> PFResult>; - fn get_emitted_events_with_context( + fn get_events(&self, from_block: u64, to_block: u64) -> PFResult>; + fn get_events_with_context( &self, from_block: u64, to_block: u64, - ) -> PFResult<(Vec, Vec)>; + ) -> PFResult<(Vec, Vec)>; } + impl EventFetcher for Connection { - fn get_emitted_events_wo_block_hashes( - &self, - from_block: u64, - to_block: u64, - ) -> PFResult> { + fn get_events(&self, from_block: u64, to_block: u64) -> PFResult> { let total_events = self.get_number_of_events_for_blocks(from_block, to_block)?; let mut tx_hashes = self .get_block_tx_hash_rows(from_block, to_block)? .into_iter(); - let mut emitted_events = Vec::with_capacity(total_events as usize); + let mut events = Vec::with_capacity(total_events as usize); for row in self.get_block_events_rows(from_block, to_block)? { let block: BlockEvents = row.try_into()?; @@ -79,9 +41,8 @@ impl EventFetcher for Connection { .map(|r| r.1) .ok_or_else(|| PFError::tx_hash_mismatch(block.block_number))?; for event in transaction { - emitted_events.push(EmittedEvent { - block_hash: None, - block_number: Some(block.block_number), + events.push(StarknetEvent { + block_number: block.block_number, data: event.data, from_address: event.from_address, keys: event.keys, @@ -90,57 +51,15 @@ impl EventFetcher for Connection { } } } - Ok(emitted_events) - } - fn get_emitted_events(&self, from_block: u64, to_block: u64) -> PFResult> { - let contexts = self.get_block_hash_rows_with_count(from_block, to_block)?; - let total_events: u64 = contexts.iter().map(|r| r.event_count).sum(); - let mut tx_hashes = self - .get_block_tx_hash_rows(from_block, to_block)? - .into_iter(); - let mut hash_rows = contexts.into_iter(); - let mut emitted_events = Vec::with_capacity(total_events as usize); - - for row in self.get_block_events_rows(from_block, to_block)? { - let block: BlockEvents = row.try_into()?; - let block_hash = loop { - match hash_rows.next() { - Some(hash_row) => { - if hash_row.number == block.block_number { - break hash_row.hash; - } else if hash_row.number > block.block_number { - return Err(PFError::block_hash_missing(block.block_number)); - } - } - None => return Err(PFError::block_hash_missing(block.block_number)), - } - }; - for transaction in block.transactions { - let transaction_hash = tx_hashes - .next() - .map(|r| r.1) - .ok_or_else(|| PFError::tx_hash_mismatch(block.block_number))?; - for event in transaction { - emitted_events.push(EmittedEvent { - block_hash: Some(block_hash), - block_number: Some(block.block_number), - data: event.data, - from_address: event.from_address, - keys: event.keys, - transaction_hash, - }); - } - } - } - Ok(emitted_events) + Ok(events) } - fn get_emitted_events_with_context( + fn get_events_with_context( &self, from_block: u64, to_block: u64, - ) -> PFResult<(Vec, Vec)> { + ) -> PFResult<(Vec, Vec)> { let context_rows = self.get_block_context_rows(from_block, to_block)?; let total_events: u64 = context_rows.iter().map(|r| r.event_count).sum(); let mut tx_hashes = self @@ -170,9 +89,8 @@ impl EventFetcher for Connection { .map(|r| r.1) .ok_or_else(|| PFError::tx_hash_mismatch(block.block_number))?; for event in transaction { - emitted_events.push(EmittedEvent { - block_hash: Some(ctx.hash), - block_number: Some(block.block_number), + emitted_events.push(StarknetEvent { + block_number: block.block_number, data: event.data, from_address: event.from_address, keys: event.keys, diff --git a/crates/pathfinder/src/lib.rs b/crates/pathfinder/src/lib.rs index 2286741f..487298d4 100644 --- a/crates/pathfinder/src/lib.rs +++ b/crates/pathfinder/src/lib.rs @@ -11,5 +11,5 @@ pub mod extractor; mod test; pub use error::{PFError, PFResult}; -pub use fetcher::{BlockContext, EventFetcher}; +pub use fetcher::EventFetcher; pub use utils::connect; diff --git a/crates/pathfinder/src/sqlite.rs b/crates/pathfinder/src/sqlite.rs index fb71976c..33757937 100644 --- a/crates/pathfinder/src/sqlite.rs +++ b/crates/pathfinder/src/sqlite.rs @@ -1,9 +1,6 @@ -use rusqlite::params; use rusqlite::types::ValueRef; -use rusqlite::Connection; -use rusqlite::Error as SqliteError; -use rusqlite::Row; -use torii_starknet::Felt; +use rusqlite::{params, Connection, Error as SqliteError, Row}; +use starknet_types_raw::Felt; pub type SqliteResult = Result; @@ -91,7 +88,9 @@ pub trait RowExt { impl RowExt for Row<'_> { fn get_felt(&self, idx: usize) -> SqliteResult { match self.get_ref(idx)? { - ValueRef::Blob(b) => Ok(Felt::from_bytes_be_slice(b)), + ValueRef::Blob(b) => Felt::from_be_bytes_slice(b).map_err(|e| { + SqliteError::FromSqlConversionFailure(idx, rusqlite::types::Type::Blob, Box::new(e)) + }), _ => Err(SqliteError::InvalidColumnType( idx, "felt".into(), @@ -102,7 +101,9 @@ impl RowExt for Row<'_> { fn get_felt_opt(&self, idx: usize) -> SqliteResult> { match self.get_ref(idx)? { - ValueRef::Blob(b) => Ok(Some(Felt::from_bytes_be_slice(b))), + ValueRef::Blob(b) => Ok(Some(Felt::from_be_bytes_slice(b).map_err(|e| { + SqliteError::FromSqlConversionFailure(idx, rusqlite::types::Type::Blob, Box::new(e)) + })?)), ValueRef::Null => Ok(None), _ => Err(SqliteError::InvalidColumnType( idx, diff --git a/crates/pathfinder/src/test.rs b/crates/pathfinder/src/test.rs index 33324343..8451ce6f 100644 --- a/crates/pathfinder/src/test.rs +++ b/crates/pathfinder/src/test.rs @@ -1,8 +1,12 @@ -use crate::{connect, extractor::PathfinderExtractor, fetcher::EventFetcher}; +use crate::connect; +#[cfg(feature = "etl")] +use crate::extractor::PathfinderExtractor; +use crate::fetcher::EventFetcher; const DB_PATH: &str = "/mnt/store/mainnet.sqlite"; #[test] +#[cfg(feature = "etl")] #[ignore = "requires /mnt/store/mainnet.sqlite snapshot"] fn test_emitted_events() { let mut extractor = @@ -25,7 +29,7 @@ fn test_emitted_events() { fn test_get_emitted_events_with_context() { let conn = connect(DB_PATH).unwrap(); let (blocks, events) = conn - .get_emitted_events_with_context(3000000, 3000010) + .get_events_with_context(3000000, 3000010) .expect("failed to fetch events with context"); println!( "Fetched {} blocks and {} events", diff --git a/crates/postgres/Cargo.toml b/crates/postgres/Cargo.toml deleted file mode 100644 index 9624b2f6..00000000 --- a/crates/postgres/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "torii-postgres" -version = "0.1.0" -edition = "2021" -description = "PostgreSQL utils for Torii runtime" -authors = ["Torii Runtime "] -license = "Apache-2.0" - -[dependencies] -sqlx = { workspace = true, features = [ - "postgres", - "runtime-tokio-rustls", - "macros", - "migrate", -] } -anyhow.workspace = true -async-trait.workspace = true -xxhash-rust.workspace = true -hex.workspace = true -serde.workspace = true -serde_json.workspace = true -tokio.workspace = true -torii.workspace = true -tracing.workspace = true -starknet-types-core.workspace = true -thiserror.workspace = true -futures.workspace = true -crc.workspace = true - - -torii-common.workspace = true diff --git a/crates/postgres/src/db.rs b/crates/postgres/src/db.rs deleted file mode 100644 index ef5d608d..00000000 --- a/crates/postgres/src/db.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::migration::SchemaMigrator; -use async_trait::async_trait; -use sqlx::{migrate::Migrator, Postgres}; -pub use sqlx::{PgPool, Transaction}; -use std::ops::Deref; -use torii_common::sql::{Executable, SqlxResult}; - -#[async_trait] -pub trait PostgresConnection { - fn pool(&self) -> &PgPool; - - async fn begin(&self) -> SqlxResult> { - Ok(self.pool().begin().await?) - } - async fn migrate(&self, schema: Option<&'static str>, migrator: Migrator) -> SqlxResult<()> { - let result = match schema { - Some(schema) => SchemaMigrator::new(schema, migrator).run(self.pool()).await, - None => migrator.run(self.pool()).await, - }; - Ok(result?) - } - async fn execute_queries(&self, queries: impl Executable + Send) -> SqlxResult<()> { - let mut transaction = self.begin().await?; - queries.execute(&mut transaction).await?; - transaction.commit().await - } -} - -#[allow(clippy::explicit_auto_deref)] -#[async_trait] -impl + Send + Sync + 'static> PostgresConnection for T { - fn pool(&self) -> &PgPool { - &**self - } -} diff --git a/crates/postgres/src/lib.rs b/crates/postgres/src/lib.rs deleted file mode 100644 index a2da4928..00000000 --- a/crates/postgres/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod db; -pub mod metadata; -pub mod migration; -pub use db::PostgresConnection; diff --git a/crates/postgres/src/metadata.rs b/crates/postgres/src/metadata.rs deleted file mode 100644 index 3368b76c..00000000 --- a/crates/postgres/src/metadata.rs +++ /dev/null @@ -1,72 +0,0 @@ -use sqlx::{postgres::PgArguments, query::Query, Postgres}; -use std::fmt::Write; -use torii::etl::EventContext; - -pub const INSERTS: &str = "__created_block, __updated_block, __created_tx, __updated_tx"; -pub const CONFLICTS: &str = "__updated_at = NOW(), __updated_block = EXCLUDED.__updated_block, __updated_tx = EXCLUDED.__updated_tx"; - -pub trait PgMetadata { - fn insert_string( - &self, - schema: &str, - table: &str, - primary_name: &str, - primary_value: String, - ) -> String; - fn static_insert_query(schema: &str, table: &str, primary_name: &str) -> String { - format!( - r#" - INSERT INTO "{schema}"."{table}" ("{primary_name}", {INSERTS}) - VALUES ($1, $2, $2, $3, $3) - ON CONFLICT ({primary_name}) DO UPDATE SET {CONFLICTS}"# - ) - } - fn insert_values(&self, string: &mut String); - fn bind_query<'a>( - &self, - query: &'a str, - primary_value: String, - ) -> Query<'a, Postgres, PgArguments>; -} - -impl PgMetadata for EventContext { - fn insert_string( - &self, - schema: &str, - table: &str, - primary_name: &str, - primary_value: String, - ) -> String { - let mut string = format!( - r#"INSERT INTO "{schema}"."{table}" ("{primary_name}", {INSERTS}) VALUES ({primary_value}, "# - ); - self.insert_values(&mut string); - write!( - string, - ") ON CONFLICT ({primary_name}) DO UPDATE SET {CONFLICTS}" - ) - .unwrap(); - string - } - fn bind_query<'a>( - &self, - query: &'a str, - primary_value: String, - ) -> Query<'a, Postgres, PgArguments> { - let block_number = self.block.number.to_string(); - let tx_hash = self.transaction.hash.to_bytes_be(); - sqlx::query::(query) - .bind(primary_value.clone()) - .bind(block_number) - .bind(tx_hash) - } - fn insert_values(&self, string: &mut String) { - let block_number = self.block.number; - let tx_hash = hex::encode(self.transaction.hash.to_bytes_be()); - write!( - string, - "{block_number}, {block_number}, '\\x{tx_hash}', '\\x{tx_hash}'" - ) - .unwrap(); - } -} diff --git a/crates/postgres/src/migration.rs b/crates/postgres/src/migration.rs deleted file mode 100644 index 9be9fce7..00000000 --- a/crates/postgres/src/migration.rs +++ /dev/null @@ -1,443 +0,0 @@ -use futures::future::BoxFuture; -use sqlx::{ - migrate::{AppliedMigration, Migrate, MigrateError, Migration, MigrationSource, Migrator}, - query, query_scalar, Acquire, PgConnection, Postgres, -}; -use sqlx::{query_as, Executor}; -use std::{ - collections::{HashMap, HashSet}, - ops::{Deref, DerefMut}, - slice, - time::{Duration, Instant}, -}; - -pub struct SchemaMigrator { - pub migrator: Migrator, - pub schema: &'static str, -} - -pub struct PgAcquiredSchema<'a, A> -where - A: Acquire<'a>, -{ - pub connection: >::Connection, - pub schema: &'static str, -} - -impl<'a, A> Deref for PgAcquiredSchema<'a, A> -where - A: Acquire<'a, Database = Postgres>, -{ - type Target = PgConnection; - fn deref(&self) -> &Self::Target { - &self.connection - } -} - -impl<'a, A> DerefMut for PgAcquiredSchema<'a, A> -where - A: Acquire<'a, Database = Postgres>, -{ - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.connection - } -} - -impl<'a, A> Migrate for PgAcquiredSchema<'a, A> -where - A: Acquire<'a, Database = Postgres>, - A: Executor<'a, Database = Postgres>, -{ - fn ensure_migrations_table(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { - // language=SQL - self.connection - .execute( - format!( - r#" -CREATE SCHEMA IF NOT EXISTS {schema}; -CREATE TABLE IF NOT EXISTS {schema}._sqlx_migrations ( - version BIGINT PRIMARY KEY, - description TEXT NOT NULL, - installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), - success BOOLEAN NOT NULL, - checksum BYTEA NOT NULL, - execution_time BIGINT NOT NULL -); - "#, - schema = self.schema - ) - .as_str(), - ) - .await?; - - Ok(()) - }) - } - - fn dirty_version(&mut self) -> BoxFuture<'_, Result, MigrateError>> { - Box::pin(async move { - // language=SQL - - let row: Option<(i64,)> = query_as( - format!("SELECT version FROM {schema}._sqlx_migrations WHERE success = false ORDER BY version LIMIT 1", schema = self.schema).as_str() - ) - .fetch_optional(&mut *self.connection) - .await?; - - Ok(row.map(|r: (i64,)| r.0)) - }) - } - - fn list_applied_migrations( - &mut self, - ) -> BoxFuture<'_, Result, MigrateError>> { - Box::pin(async move { - // language=SQL - let rows: Vec<(i64, Vec)> = query_as( - format!( - "SELECT version, checksum FROM {schema}._sqlx_migrations ORDER BY version", - schema = self.schema - ) - .as_str(), - ) - .fetch_all(&mut *self.connection) - .await?; - - let migrations = rows - .into_iter() - .map(|(version, checksum)| AppliedMigration { - version, - checksum: checksum.into(), - }) - .collect(); - - Ok(migrations) - }) - } - - fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { - let database_name = current_database(&mut self.connection).await?; - let lock_id = generate_lock_id(&database_name); - - // create an application lock over the database - // this function will not return until the lock is acquired - - // https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS - // https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS-TABLE - - // language=SQL - let _ = query("SELECT pg_advisory_lock($1)") - .bind(lock_id) - .execute(&mut *self.connection) - .await?; - - Ok(()) - }) - } - - fn unlock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { - let database_name = current_database(self).await?; - let lock_id = generate_lock_id(&database_name); - - // language=SQL - let _ = query("SELECT pg_advisory_unlock($1)") - .bind(lock_id) - .execute(&mut *self.connection) - .await?; - - Ok(()) - }) - } - - fn apply<'e: 'm, 'm>( - &'e mut self, - migration: &'m Migration, - ) -> BoxFuture<'m, Result> { - Box::pin(async move { - let start = Instant::now(); - let schema = self.schema; - // execute migration queries - if migration.no_tx { - execute_migration(self, schema, migration).await?; - } else { - // Use a single transaction for the actual migration script and the essential bookeeping so we never - // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. - // The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for - // data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1 - // and update it once the actual transaction completed. - let mut tx = self.begin().await?; - execute_migration(&mut tx, schema, migration).await?; - tx.commit().await?; - } - - // Update `elapsed_time`. - // NOTE: The process may disconnect/die at this point, so the elapsed time value might be lost. We accept - // this small risk since this value is not super important. - let elapsed = start.elapsed(); - - // language=SQL - #[allow(clippy::cast_possible_truncation)] - let _ = query(&format!( - r#" - UPDATE {schema}._sqlx_migrations - SET execution_time = $1 - WHERE version = $2 - "#, - schema = self.schema - )) - .bind(elapsed.as_nanos() as i64) - .bind(migration.version) - .execute(&mut *self.connection) - .await?; - - Ok(elapsed) - }) - } - - fn revert<'e: 'm, 'm>( - &'e mut self, - migration: &'m Migration, - ) -> BoxFuture<'m, Result> { - Box::pin(async move { - let start = Instant::now(); - let schema = self.schema; - // execute migration queries - if migration.no_tx { - revert_migration(&mut self.connection, schema, migration).await?; - } else { - // Use a single transaction for the actual migration script and the essential bookeeping so we never - // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. - let mut tx = self.begin().await?; - revert_migration(&mut tx, schema, migration).await?; - tx.commit().await?; - } - - let elapsed = start.elapsed(); - - Ok(elapsed) - }) - } -} - -async fn current_database(conn: &mut PgConnection) -> Result { - // language=SQL - Ok(query_scalar("SELECT current_database()") - .fetch_one(conn) - .await?) -} - -fn generate_lock_id(database_name: &str) -> i64 { - const CRC_IEEE: crc::Crc = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); - // 0x3d32ad9e chosen by fair dice roll - 0x3d32ad9e * (CRC_IEEE.checksum(database_name.as_bytes()) as i64) -} - -async fn execute_migration( - conn: &mut PgConnection, - schema: &'static str, - migration: &Migration, -) -> Result<(), MigrateError> { - let _ = conn - .execute(&*migration.sql) - .await - .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - - // language=SQL - let _ = query( - format!(r#" - INSERT INTO {schema}._sqlx_migrations ( version, description, success, checksum, execution_time ) - VALUES ( $1, $2, TRUE, $3, -1 ) - "#).as_str() - ) - .bind(migration.version) - .bind(&*migration.description) - .bind(&*migration.checksum) - .execute(conn) - .await?; - - Ok(()) -} - -async fn revert_migration( - conn: &mut PgConnection, - schema: &'static str, - migration: &Migration, -) -> Result<(), MigrateError> { - let _ = conn - .execute(&*migration.sql) - .await - .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - - // language=SQL - let _ = query(format!(r#"DELETE FROM {schema}._sqlx_migrations WHERE version = $1"#).as_str()) - .bind(migration.version) - .execute(conn) - .await?; - - Ok(()) -} - -impl SchemaMigrator { - pub const fn new(schema: &'static str, migrator: Migrator) -> Self { - Self { migrator, schema } - } - pub async fn new_from_source<'s, S>( - schema: &'static str, - source: S, - ) -> Result - where - S: MigrationSource<'s>, - { - Migrator::new(source) - .await - .map(|migrator| Self { migrator, schema }) - } - - /// Specify whether applied migrations that are missing from the resolved migrations should be ignored. - pub fn set_ignore_missing(&mut self, ignore_missing: bool) -> &Self { - self.migrator.ignore_missing = ignore_missing; - self - } - - /// Specify whether or not to lock the database during migration. Defaults to `true`. - /// - /// ### Warning - /// Disabling locking can lead to errors or data loss if multiple clients attempt to apply migrations simultaneously - /// without some sort of mutual exclusion. - /// - /// This should only be used if the database does not support locking, e.g. CockroachDB which talks the Postgres - /// protocol but does not support advisory locks used by SQLx's migrations support for Postgres. - pub fn set_locking(&mut self, locking: bool) -> &Self { - self.migrator.locking = locking; - self - } - - /// Get an iterator over all known migrations. - pub fn iter(&self) -> slice::Iter<'_, Migration> { - self.migrator.migrations.iter() - } - - /// Check if a migration version exists. - pub fn version_exists(&self, version: i64) -> bool { - self.iter().any(|m| m.version == version) - } - - /// Run any pending migrations against the database; and, validate previously applied migrations - /// against the current migration source to detect accidental changes in previously-applied migrations. - /// - /// # Examples - /// - /// ```rust,no_run - /// # use sqlx::migrate::MigrateError; - /// # fn main() -> Result<(), MigrateError> { - /// # sqlx::__rt::test_block_on(async move { - /// use sqlx::migrate::Migrator; - /// use sqlx::sqlite::SqlitePoolOptions; - /// - /// let m = Migrator::new(std::path::Path::new("./migrations")).await?; - /// let pool = SqlitePoolOptions::new().connect("sqlite::memory:").await?; - /// m.run(&pool).await - /// # }) - /// # } - /// ``` - pub async fn run<'a, A>(&self, migrator: A) -> Result<(), MigrateError> - where - A: Acquire<'a, Database = Postgres>, - PgAcquiredSchema<'a, A>: Migrate, - { - let mut conn = PgAcquiredSchema { - connection: migrator.acquire().await?, - schema: self.schema, - }; - self.migrator.run_direct(&mut conn).await - } - - /// Run down migrations against the database until a specific version. - /// - /// # Examples - /// - /// ```rust,no_run - /// # use sqlx::migrate::MigrateError; - /// # fn main() -> Result<(), MigrateError> { - /// # sqlx::__rt::test_block_on(async move { - /// use sqlx::migrate::Migrator; - /// use sqlx::sqlite::SqlitePoolOptions; - /// - /// let m = Migrator::new(std::path::Path::new("./migrations")).await?; - /// let pool = SqlitePoolOptions::new().connect("sqlite::memory:").await?; - /// m.undo(&pool, 4).await - /// # }) - /// # } - /// ``` - pub async fn undo<'a, A>(&self, migrator: A, target: i64) -> Result<(), MigrateError> - where - A: Acquire<'a, Database = Postgres>, - PgAcquiredSchema<'a, A>: Migrate, - { - let mut conn = PgAcquiredSchema { - connection: migrator.acquire().await?, - schema: self.schema, - }; - // lock the database for exclusive access by the migrator - if self.migrator.locking { - conn.lock().await?; - } - - // creates [_migrations] table only if needed - // eventually this will likely migrate previous versions of the table - conn.ensure_migrations_table().await?; - - let version = conn.dirty_version().await?; - if let Some(version) = version { - return Err(MigrateError::Dirty(version)); - } - - let applied_migrations = conn.list_applied_migrations().await?; - validate_applied_migrations(&applied_migrations, &self.migrator)?; - - let applied_migrations: HashMap<_, _> = applied_migrations - .into_iter() - .map(|m| (m.version, m)) - .collect(); - - for migration in self - .iter() - .rev() - .filter(|m| m.migration_type.is_down_migration()) - .filter(|m| applied_migrations.contains_key(&m.version)) - .filter(|m| m.version > target) - { - conn.revert(migration).await?; - } - - // unlock the migrator to allow other migrators to run - // but do nothing as we already migrated - if self.migrator.locking { - conn.unlock().await?; - } - - Ok(()) - } -} - -fn validate_applied_migrations( - applied_migrations: &[AppliedMigration], - migrator: &Migrator, -) -> Result<(), MigrateError> { - if migrator.ignore_missing { - return Ok(()); - } - - let migrations: HashSet<_> = migrator.iter().map(|m| m.version).collect(); - - for applied_migration in applied_migrations { - if !migrations.contains(&applied_migration.version) { - return Err(MigrateError::VersionMissing(applied_migration.version)); - } - } - - Ok(()) -} diff --git a/crates/sql/Cargo.toml b/crates/sql/Cargo.toml new file mode 100644 index 00000000..9743060e --- /dev/null +++ b/crates/sql/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "torii-sql" +version = "0.1.0" +edition = "2021" +description = "Common utilities for Torii token indexers" + +[dependencies] +async-trait = "0.1" +sqlx = { workspace = true, features = ["runtime-tokio-rustls"] } +itertools.workspace = true +futures.workspace = true +crc.workspace = true +starknet-types-raw.workspace = true +log.workspace = true + +[features] +postgres = ["sqlx/postgres"] +sqlite = ["sqlx/sqlite"] +mysql = ["sqlx/mysql"] + +[lints] +workspace = true diff --git a/crates/sql/README.md b/crates/sql/README.md new file mode 100644 index 00000000..7427ebed --- /dev/null +++ b/crates/sql/README.md @@ -0,0 +1,98 @@ +# torii-sql + +The database abstraction layer. Wraps `sqlx` to give every Torii sink a +unified API over **SQLite** and **PostgreSQL** (MySQL has hooks but is +unused), plus a set of helpers for building pools, running migrations, and +composing parametric queries across backends. + +## Role in Torii + +Every sink that persists state uses this crate: `torii-sql-sink`, +`torii-controllers-sink`, `torii-ecs-sink`, `introspect-sql-sink`, and the +three token sinks. `EngineDb` in the root crate does *not* use this layer +(it is pinned to raw `sqlx::Any`) — everything else does. The +`DbBackend` enum defined here is the same one `torii-runtime-common` returns +from `backend_from_url_or_path`, so a single value flows from CLI → runtime +resolver → pool → migrations → queries. + +## Architecture + +```text ++-------------------------------------------------------------------+ +| torii-sql | +| | +| connection.rs | +| +-------------------+ DbBackend::{Sqlite,Postgres} | +| | DbBackend | DbOption (backend-tagged pair) | +| | POSTGRES_URL_... | | +| | SQLITE_URL_... | | +| +-------------------+ | +| | +| pool.rs | +| +---------------------+ PoolExt / PoolConfig / DbPoolOptions | +| | Connection pools | (pool tuning helpers) | +| +---------------------+ | +| | +| migrate.rs | +| +---------------------+ SchemaMigrator (versioned, backend- | +| | Schema migrations | aware), AcquiredSchema (lock guard) | +| +---------------------+ | +| | +| query.rs | +| +---------------------+ Executable trait + FlexQuery + Queries | +| | Query composition | placeholder-normalisation for ?/$n | +| +---------------------+ | +| | +| postgres/ (feature: postgres) sqlite/ (feature: sqlite) | +| +------------------+ +------------------+ | +| | Postgres + Pg* | | Sqlite + Sqlite* | | +| +------------------+ +------------------+ | +| | +| runtime.rs (feature: postgres + sqlite) | +| +----------------------+ | +| | DbPool / DbConnection| runtime-picked backend | +| | Options | | +| +----------------------+ | ++-------------------------------------------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `DbBackend` | `src/connection.rs` | 4 | `Postgres` / `Sqlite`; `FromStr` accepts `postgres://`, `postgresql://`, `sqlite*`, `:memory:` | +| `DbOption` | `src/connection.rs` | 10 | Backend-indexed pair (stores two values, picks one at runtime) | +| `POSTGRES_URL_SCHEMES` / `SQLITE_URL_SCHEMES` | `src/connection.rs` | 15 / 16 | URL prefix constants | +| `PoolExt` / `PoolConfig` / `DbPoolOptions` | `src/pool.rs` | — | Pool tuning + trait-object friendly pool abstraction | +| `SchemaMigrator` / `AcquiredSchema` | `src/migrate.rs` | — | Versioned schema migrations with cross-process locks | +| `Executable` | `src/query.rs` | — | Execute + fetch over any backend | +| `FlexQuery` / `Queries` | `src/query.rs` | — | Normalises `?` / `$n` placeholders and bindings | +| `Postgres`, `PgPool`, `PgQuery`, `PgArguments`, `PgDbConnection` | `src/postgres/mod.rs` | — | feature `postgres` | +| `Sqlite`, `SqlitePool`, `SqliteQuery`, `SqliteArguments`, `SqliteDbConnection` | `src/sqlite/mod.rs` | — | feature `sqlite` | +| `DbPool` / `DbConnectionOptions` | `src/runtime.rs` | — | feature `postgres + sqlite`; runtime enum wrapping either side | +| `SqlxResult` alias | `src/lib.rs` | 13 | `Result` | + +### Internal Modules + +- `connection` — `DbBackend` + URL classification + `DbOption` pair. +- `pool` — pool configuration helpers (size, timeouts) and the `PoolExt` blanket-impl. +- `migrate` — versioned schema migrator backed by a per-backend lock. +- `query` — `FlexQuery` and friends; bridges SQLite `?` vs. Postgres `$n` placeholder conventions. +- `postgres/` — sqlx-postgres wrappers + Postgres-specific migrate helpers + type adapters. +- `sqlite/` — sqlx-sqlite wrappers + SQLite migrate helpers. +- `runtime` — `DbPool::Postgres(pool) | DbPool::Sqlite(pool)` runtime enum (only compiled when both features are on). +- `types` — shared numeric/date conversions. + +### Interactions + +- **Upstream (consumers)**: `torii-runtime-common` (reads `DbBackend`), every SQL-backed sink, `introspect-sql-sink` (both PG + SQLite via its own features). +- **Downstream deps**: `sqlx` (patched fork from `bengineer42/sqlx`, see workspace `Cargo.toml` line 81), `async-trait`, `futures`. +- **Features**: `postgres`, `sqlite`, `mysql` — each pulls the corresponding sqlx driver. The `runtime` module and `DbPool` only exist when `postgres + sqlite` are both enabled. + +### Extension Points + +- New backend: add a feature flag + a sibling module mirroring `postgres/` and `sqlite/`, expose a `Pool`, a `Query`, and a `DbConnection`, and extend `DbBackend`. +- New cross-backend query helper: put it in `query.rs`. Keep backend-specific quirks (JSON operators, upsert syntax) behind `DbOption<&'static str>` lookups. +- New migration surface: add a `SchemaMigrator` subscriber; do not add a second migrator, the backend-aware one is authoritative. diff --git a/crates/sql/src/connection.rs b/crates/sql/src/connection.rs new file mode 100644 index 00000000..9e544687 --- /dev/null +++ b/crates/sql/src/connection.rs @@ -0,0 +1,76 @@ +use crate::SqlxError; +use std::fmt::Display; +use std::str::FromStr; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DbBackend { + Postgres, + Sqlite, +} + +pub struct DbOption { + postgres: T, + sqlite: T, +} + +pub const POSTGRES_URL_SCHEMES: [&str; 2] = ["postgres", "postgresql"]; +pub const SQLITE_URL_SCHEMES: [&str; 1] = ["sqlite"]; + +impl FromStr for DbBackend { + type Err = SqlxError; + + fn from_str(s: &str) -> Result { + if s.starts_with("postgres") || s.starts_with("postgresql") { + Ok(DbBackend::Postgres) + } else if s.starts_with("sqlite") || s == ":memory:" || s == "memory" { + Ok(DbBackend::Sqlite) + } else { + Err(SqlxError::Configuration( + format!("Unsupported database url: {s}").into(), + )) + } + } +} + +impl Display for DbBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +impl DbBackend { + pub fn as_str(&self) -> &'static str { + match self { + DbBackend::Postgres => "postgres", + DbBackend::Sqlite => "sqlite", + } + } +} + +impl TryFrom<&str> for DbBackend { + type Error = SqlxError; + + fn try_from(value: &str) -> Result { + DbBackend::from_str(value) + } +} + +impl TryFrom for DbBackend { + type Error = SqlxError; + + fn try_from(value: String) -> Result { + DbBackend::try_from(value.as_str()) + } +} + +impl DbOption { + pub fn new(postgres: T, sqlite: T) -> Self { + Self { postgres, sqlite } + } + + pub fn value(self, db_type: &DbBackend) -> T { + match db_type { + DbBackend::Postgres => self.postgres, + DbBackend::Sqlite => self.sqlite, + } + } +} diff --git a/crates/sql/src/lib.rs b/crates/sql/src/lib.rs new file mode 100644 index 00000000..13e98d24 --- /dev/null +++ b/crates/sql/src/lib.rs @@ -0,0 +1,31 @@ +pub mod connection; +pub mod migrate; +pub mod pool; +pub mod query; +pub mod types; + +pub use connection::DbBackend; +pub use migrate::{AcquiredSchema, SchemaMigrator}; +pub use pool::{DbPoolOptions, PoolConfig, PoolExt}; +pub use query::{Executable, FlexQuery, Queries}; + +pub use sqlx::Error as SqlxError; +pub type SqlxResult = std::result::Result; + +#[cfg(feature = "postgres")] +pub mod postgres; +#[cfg(feature = "postgres")] +pub use postgres::{PgArguments, PgDbConnection, PgPool, PgQuery, Postgres}; + +#[cfg(feature = "sqlite")] +pub mod sqlite; +#[cfg(feature = "sqlite")] +pub use sqlite::{Sqlite, SqliteArguments, SqliteDbConnection, SqlitePool, SqliteQuery}; + +#[cfg(feature = "postgres")] +#[cfg(feature = "sqlite")] +pub mod runtime; + +#[cfg(feature = "postgres")] +#[cfg(feature = "sqlite")] +pub use runtime::{DbConnectionOptions, DbPool}; diff --git a/crates/sql/src/migrate.rs b/crates/sql/src/migrate.rs new file mode 100644 index 00000000..42093cdc --- /dev/null +++ b/crates/sql/src/migrate.rs @@ -0,0 +1,218 @@ +use sqlx::{ + migrate::{AppliedMigration, Migrate, MigrateError, Migration, MigrationSource, Migrator}, + Acquire, Connection, Database, +}; +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; +use std::{collections::HashSet, slice}; + +pub struct SchemaMigrator { + pub migrator: Migrator, + pub schema: &'static str, +} + +pub struct AcquiredSchema +where + DB: Database, + C: Connection, +{ + pub connection: C, + pub schema: &'static str, +} + +impl Deref for AcquiredSchema +where + DB: Database, + C: Connection, +{ + type Target = C; + fn deref(&self) -> &Self::Target { + &self.connection + } +} + +impl DerefMut for AcquiredSchema +where + DB: Database, + C: Connection, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.connection + } +} + +impl AcquiredSchema +where + DB: Database, + C: Connection, +{ + pub fn new(connection: C, schema: &'static str) -> Self { + Self { connection, schema } + } +} + +impl SchemaMigrator { + pub const fn new(schema: &'static str, migrator: Migrator) -> Self { + Self { migrator, schema } + } + pub async fn new_from_source<'s, S>( + schema: &'static str, + source: S, + ) -> Result + where + S: MigrationSource<'s>, + { + Migrator::new(source) + .await + .map(|migrator| Self { migrator, schema }) + } + + /// Specify whether applied migrations that are missing from the resolved migrations should be ignored. + pub fn set_ignore_missing(&mut self, ignore_missing: bool) -> &Self { + self.migrator.ignore_missing = ignore_missing; + self + } + + /// Specify whether or not to lock the database during migration. Defaults to `true`. + /// + /// ### Warning + /// Disabling locking can lead to errors or data loss if multiple clients attempt to apply migrations simultaneously + /// without some sort of mutual exclusion. + /// + /// This should only be used if the database does not support locking, e.g. CockroachDB which talks the Postgres + /// protocol but does not support advisory locks used by SQLx's migrations support for Postgres. + pub fn set_locking(&mut self, locking: bool) -> &Self { + self.migrator.locking = locking; + self + } + + /// Get an iterator over all known migrations. + pub fn iter(&self) -> slice::Iter<'_, Migration> { + self.migrator.migrations.iter() + } + + /// Check if a migration version exists. + pub fn version_exists(&self, version: i64) -> bool { + self.iter().any(|m| m.version == version) + } + + /// Run any pending migrations against the database; and, validate previously applied migrations + /// against the current migration source to detect accidental changes in previously-applied migrations. + /// + /// # Examples + /// + /// ```rust,no_run + /// # use sqlx::migrate::MigrateError; + /// # fn main() -> Result<(), MigrateError> { + /// # sqlx::__rt::test_block_on(async move { + /// use sqlx::migrate::Migrator; + /// use sqlx::sqlite::SqlitePoolOptions; + /// + /// let m = Migrator::new(std::path::Path::new("./migrations")).await?; + /// let pool = SqlitePoolOptions::new().connect("sqlite::memory:").await?; + /// m.run(&pool).await + /// # }) + /// # } + /// ``` + pub async fn run<'a, A>(&self, migrator: A) -> Result<(), MigrateError> + where + A: Acquire<'a>, + >::Connection: Connection, + AcquiredSchema: Migrate, + { + let mut conn = AcquiredSchema { + connection: migrator.acquire().await?, + schema: self.schema, + }; + self.migrator.run_direct(&mut conn).await + } + + /// Run down migrations against the database until a specific version. + /// + /// # Examples + /// + /// ```rust,no_run + /// # use sqlx::migrate::MigrateError; + /// # fn main() -> Result<(), MigrateError> { + /// # sqlx::__rt::test_block_on(async move { + /// use sqlx::migrate::Migrator; + /// use sqlx::sqlite::SqlitePoolOptions; + /// + /// let m = Migrator::new(std::path::Path::new("./migrations")).await?; + /// let pool = SqlitePoolOptions::new().connect("sqlite::memory:").await?; + /// m.undo(&pool, 4).await + /// # }) + /// # } + /// ``` + pub async fn undo<'a, A>(&self, migrator: A, target: i64) -> Result<(), MigrateError> + where + A: Acquire<'a>, + >::Connection: Connection, + AcquiredSchema: Migrate, + { + let mut conn = AcquiredSchema:: { + connection: migrator.acquire().await?, + schema: self.schema, + }; + // lock the database for exclusive access by the migrator + if self.migrator.locking { + conn.lock().await?; + } + + // creates [_migrations] table only if needed + // eventually this will likely migrate previous versions of the table + conn.ensure_migrations_table().await?; + + let version = conn.dirty_version().await?; + if let Some(version) = version { + return Err(MigrateError::Dirty(version)); + } + + let applied_migrations = conn.list_applied_migrations().await?; + validate_applied_migrations(&applied_migrations, &self.migrator)?; + + let applied_migrations: HashMap<_, _> = applied_migrations + .into_iter() + .map(|m| (m.version, m)) + .collect(); + + for migration in self + .iter() + .rev() + .filter(|m| m.migration_type.is_down_migration()) + .filter(|m| applied_migrations.contains_key(&m.version)) + .filter(|m| m.version > target) + { + conn.revert(migration).await?; + } + + // unlock the migrator to allow other migrators to run + // but do nothing as we already migrated + if self.migrator.locking { + conn.unlock().await?; + } + + Ok(()) + } +} + +fn validate_applied_migrations( + applied_migrations: &[AppliedMigration], + migrator: &Migrator, +) -> Result<(), MigrateError> { + if migrator.ignore_missing { + return Ok(()); + } + + let migrations: HashSet<_> = migrator.iter().map(|m| m.version).collect(); + + for applied_migration in applied_migrations { + if !migrations.contains(&applied_migration.version) { + return Err(MigrateError::VersionMissing(applied_migration.version)); + } + } + + Ok(()) +} diff --git a/crates/sql/src/pool.rs b/crates/sql/src/pool.rs new file mode 100644 index 00000000..73387198 --- /dev/null +++ b/crates/sql/src/pool.rs @@ -0,0 +1,302 @@ +use std::time::Duration; + +use crate::query::Executable; +use crate::{AcquiredSchema, SqlxResult}; +use async_trait::async_trait; +use log::LevelFilter; +use sqlx::migrate::{Migrate, Migrator}; +use sqlx::pool::PoolOptions; +use sqlx::{Connection, Database, Pool, Transaction}; + +#[async_trait] +pub trait PoolExt { + fn pool(&self) -> &Pool; + async fn begin(&self) -> SqlxResult> { + Ok(self.pool().begin().await?) + } + async fn migrate(&self, schema: Option<&'static str>, migrator: Migrator) -> SqlxResult<()> + where + ::Connection: Migrate, + AcquiredSchema::Connection>: Migrate, + { + let result = match schema { + Some(schema) => { + let mut conn: AcquiredSchema::Connection> = AcquiredSchema { + connection: self.pool().acquire().await?.detach(), + schema, + }; + migrator.run_direct(&mut conn).await + } + None => migrator.run(self.pool()).await, + }; + Ok(result?) + } + async fn execute_queries + Send>(&self, queries: E) -> SqlxResult<()> { + let mut transaction: Transaction<'_, DB> = self.begin().await?; + queries.execute(&mut transaction).await?; + transaction.commit().await + } +} + +impl PoolExt for Pool { + fn pool(&self) -> &Pool { + self + } +} + +const DEFAULT_TEST_BEFORE_ACQUIRE: bool = true; +const DEFAULT_MAX_CONNECTIONS: u32 = 10; +const DEFAULT_MIN_CONNECTIONS: u32 = 0; +const DEFAULT_ACQUIRE_TIME_LEVEL: LevelFilter = LevelFilter::Off; +const DEFAULT_ACQUIRE_SLOW_LEVEL: LevelFilter = LevelFilter::Warn; +const DEFAULT_ACQUIRE_SLOW_THRESHOLD: Duration = Duration::from_secs(2); +const DEFAULT_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_IDLE_TIMEOUT: Option = Some(Duration::from_secs(10 * 60)); +const DEFAULT_MAX_LIFETIME: Option = Some(Duration::from_secs(30 * 60)); +const DEFAULT_FAIR: bool = true; + +#[derive(Debug, Clone, Copy)] +pub struct DbPoolOptions { + pub test_before_acquire: bool, + pub max_connections: u32, + pub acquire_time_level: LevelFilter, + pub acquire_slow_level: LevelFilter, + pub acquire_slow_threshold: Duration, + pub acquire_timeout: Duration, + pub min_connections: u32, + pub max_lifetime: Option, + pub idle_timeout: Option, + pub fair: bool, +} + +#[derive(Debug, Clone)] +pub struct PoolConfig { + pub url: String, + pub options: DbPoolOptions, +} + +impl PoolConfig { + pub fn new(url: String) -> Self { + Self { + url, + options: DbPoolOptions::new(), + } + } + pub async fn connect(&self) -> SqlxResult> { + self.options.connect(&self.url).await + } + pub fn options(&self) -> PoolOptions { + self.options.options() + } + pub fn max_connections(mut self, max: u32) -> Self { + self.options.max_connections = max; + self + } + + pub fn get_max_connections(&self) -> u32 { + self.options.max_connections + } + + pub fn min_connections(mut self, min: u32) -> Self { + self.options.min_connections = min; + self + } + + pub fn get_min_connections(&self) -> u32 { + self.options.min_connections + } + + pub fn acquire_time_level(mut self, level: LevelFilter) -> Self { + self.options.acquire_time_level = level; + self + } + + pub fn acquire_slow_level(mut self, level: LevelFilter) -> Self { + self.options.acquire_slow_level = level; + self + } + + pub fn acquire_slow_threshold(mut self, threshold: Duration) -> Self { + self.options.acquire_slow_threshold = threshold; + self + } + + pub fn get_acquire_slow_threshold(&self) -> Duration { + self.options.acquire_slow_threshold + } + + pub fn acquire_timeout(mut self, timeout: Duration) -> Self { + self.options.acquire_timeout = timeout; + self + } + + pub fn get_acquire_timeout(&self) -> Duration { + self.options.acquire_timeout + } + + pub fn max_lifetime(mut self, lifetime: impl Into>) -> Self { + self.options.max_lifetime = lifetime.into(); + self + } + + pub fn get_max_lifetime(&self) -> Option { + self.options.max_lifetime + } + + pub fn idle_timeout(mut self, timeout: impl Into>) -> Self { + self.options.idle_timeout = timeout.into(); + self + } + + pub fn get_idle_timeout(&self) -> Option { + self.options.idle_timeout + } + + pub fn test_before_acquire(mut self, test: bool) -> Self { + self.options.test_before_acquire = test; + self + } + + pub fn get_test_before_acquire(&self) -> bool { + self.options.test_before_acquire + } + + pub fn fair(mut self, fair: bool) -> Self { + self.options.fair = fair; + self + } + + pub fn get_fair(&self) -> bool { + self.options.fair + } +} + +impl Default for DbPoolOptions { + fn default() -> Self { + DbPoolOptions::new() + } +} + +impl DbPoolOptions { + pub fn new() -> Self { + Self { + test_before_acquire: DEFAULT_TEST_BEFORE_ACQUIRE, + max_connections: DEFAULT_MAX_CONNECTIONS, + acquire_time_level: DEFAULT_ACQUIRE_TIME_LEVEL, + acquire_slow_level: DEFAULT_ACQUIRE_SLOW_LEVEL, + acquire_slow_threshold: DEFAULT_ACQUIRE_SLOW_THRESHOLD, + acquire_timeout: DEFAULT_ACQUIRE_TIMEOUT, + min_connections: DEFAULT_MIN_CONNECTIONS, + max_lifetime: DEFAULT_MAX_LIFETIME, + idle_timeout: DEFAULT_IDLE_TIMEOUT, + fair: DEFAULT_FAIR, + } + } + + pub async fn connect(&self, url: &str) -> SqlxResult> { + self.options::().connect(url).await + } + + pub async fn connect_with( + &self, + options: ::Options, + ) -> SqlxResult> { + self.options::().connect_with(options).await + } + + pub fn options(&self) -> PoolOptions { + PoolOptions::::new() + .test_before_acquire(self.test_before_acquire) + .max_connections(self.max_connections) + .acquire_time_level(self.acquire_time_level) + .acquire_slow_level(self.acquire_slow_level) + .acquire_slow_threshold(self.acquire_slow_threshold) + .acquire_timeout(self.acquire_timeout) + .min_connections(self.min_connections) + .max_lifetime(self.max_lifetime) + .idle_timeout(self.idle_timeout) + .__fair(self.fair) + } + + pub fn max_connections(mut self, max: u32) -> Self { + self.max_connections = max; + self + } + + pub fn get_max_connections(&self) -> u32 { + self.max_connections + } + + pub fn min_connections(mut self, min: u32) -> Self { + self.min_connections = min; + self + } + + pub fn get_min_connections(&self) -> u32 { + self.min_connections + } + + pub fn acquire_time_level(mut self, level: LevelFilter) -> Self { + self.acquire_time_level = level; + self + } + + pub fn acquire_slow_level(mut self, level: LevelFilter) -> Self { + self.acquire_slow_level = level; + self + } + + pub fn acquire_slow_threshold(mut self, threshold: Duration) -> Self { + self.acquire_slow_threshold = threshold; + self + } + + pub fn get_acquire_slow_threshold(&self) -> Duration { + self.acquire_slow_threshold + } + + pub fn acquire_timeout(mut self, timeout: Duration) -> Self { + self.acquire_timeout = timeout; + self + } + + pub fn get_acquire_timeout(&self) -> Duration { + self.acquire_timeout + } + + pub fn max_lifetime(mut self, lifetime: impl Into>) -> Self { + self.max_lifetime = lifetime.into(); + self + } + + pub fn get_max_lifetime(&self) -> Option { + self.max_lifetime + } + + pub fn idle_timeout(mut self, timeout: impl Into>) -> Self { + self.idle_timeout = timeout.into(); + self + } + + pub fn get_idle_timeout(&self) -> Option { + self.idle_timeout + } + + pub fn test_before_acquire(mut self, test: bool) -> Self { + self.test_before_acquire = test; + self + } + + pub fn get_test_before_acquire(&self) -> bool { + self.test_before_acquire + } + + pub fn fair(mut self, fair: bool) -> Self { + self.fair = fair; + self + } + + pub fn get_fair(&self) -> bool { + self.fair + } +} diff --git a/crates/sql/src/postgres/migrate.rs b/crates/sql/src/postgres/migrate.rs new file mode 100644 index 00000000..02982cc6 --- /dev/null +++ b/crates/sql/src/postgres/migrate.rs @@ -0,0 +1,227 @@ +use crate::AcquiredSchema; +use futures::future::BoxFuture; +use sqlx::migrate::{AppliedMigration, Migrate, MigrateError, Migration}; +use sqlx::{query, query_as, query_scalar, Connection, Executor, PgConnection, Postgres}; +use std::time::{Duration, Instant}; + +impl Migrate for AcquiredSchema { + fn ensure_migrations_table(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { + // language=SQL + self.connection + .execute( + format!( + "CREATE SCHEMA IF NOT EXISTS {schema}; + CREATE TABLE IF NOT EXISTS {schema}._sqlx_migrations ( + version BIGINT PRIMARY KEY, + description TEXT NOT NULL, + installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), + success BOOLEAN NOT NULL, + checksum BYTEA NOT NULL, + execution_time BIGINT NOT NULL + );", + schema = self.schema + ) + .as_str(), + ) + .await?; + + Ok(()) + }) + } + + fn dirty_version(&mut self) -> BoxFuture<'_, Result, MigrateError>> { + Box::pin(async move { + // language=SQL + + let row: Option<(i64,)> = query_as( + format!("SELECT version FROM {schema}._sqlx_migrations WHERE success = false ORDER BY version LIMIT 1", schema = self.schema).as_str() + ) + .fetch_optional(&mut self.connection) + .await?; + + Ok(row.map(|r: (i64,)| r.0)) + }) + } + + fn list_applied_migrations( + &mut self, + ) -> BoxFuture<'_, Result, MigrateError>> { + Box::pin(async move { + // language=SQL + let rows: Vec<(i64, Vec)> = query_as( + format!( + "SELECT version, checksum FROM {schema}._sqlx_migrations ORDER BY version", + schema = self.schema + ) + .as_str(), + ) + .fetch_all(&mut self.connection) + .await?; + + let migrations = rows + .into_iter() + .map(|(version, checksum)| AppliedMigration { + version, + checksum: checksum.into(), + }) + .collect(); + + Ok(migrations) + }) + } + + fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { + let database_name = current_database(&mut self.connection).await?; + let lock_id = generate_lock_id(&database_name); + + // create an application lock over the database + // this function will not return until the lock is acquired + + // https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS + // https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS-TABLE + + // language=SQL + let _ = query("SELECT pg_advisory_lock($1)") + .bind(lock_id) + .execute(&mut self.connection) + .await?; + + Ok(()) + }) + } + + fn unlock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { + let database_name = current_database(self).await?; + let lock_id = generate_lock_id(&database_name); + + // language=SQL + let _ = query("SELECT pg_advisory_unlock($1)") + .bind(lock_id) + .execute(&mut self.connection) + .await?; + + Ok(()) + }) + } + + fn apply<'e: 'm, 'm>( + &'e mut self, + migration: &'m Migration, + ) -> BoxFuture<'m, Result> { + Box::pin(async move { + let start = Instant::now(); + let schema = self.schema; + // execute migration queries + if migration.no_tx { + execute_migration(self, schema, migration).await?; + } else { + // Use a single transaction for the actual migration script and the essential bookeeping so we never + // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. + // The `execution_time` however can only be measured for the whole transaction. This value _only_ exists for + // data lineage and debugging reasons, so it is not super important if it is lost. So we initialize it to -1 + // and update it once the actual transaction completed. + let mut tx = Connection::begin(&mut self.connection).await?; + execute_migration(&mut tx, schema, migration).await?; + tx.commit().await?; + } + + // Update `elapsed_time`. + // NOTE: The process may disconnect/die at this point, so the elapsed time value might be lost. We accept + // this small risk since this value is not super important. + let elapsed = start.elapsed(); + + // language=SQL + #[allow(clippy::cast_possible_truncation)] + let _ = query(&format!( + "UPDATE {schema}._sqlx_migrations SET execution_time = $1 WHERE version = $2", + schema = self.schema + )) + .bind(elapsed.as_nanos() as i64) + .bind(migration.version) + .execute(&mut self.connection) + .await?; + + Ok(elapsed) + }) + } + + fn revert<'e: 'm, 'm>( + &'e mut self, + migration: &'m Migration, + ) -> BoxFuture<'m, Result> { + Box::pin(async move { + let start = Instant::now(); + let schema = self.schema; + // execute migration queries + if migration.no_tx { + revert_migration(&mut self.connection, schema, migration).await?; + } else { + // Use a single transaction for the actual migration script and the essential bookeeping so we never + // execute migrations twice. See https://github.com/launchbadge/sqlx/issues/1966. + let mut tx = Connection::begin(&mut self.connection).await?; + revert_migration(&mut tx, schema, migration).await?; + tx.commit().await?; + } + + let elapsed = start.elapsed(); + + Ok(elapsed) + }) + } +} + +async fn current_database(conn: &mut PgConnection) -> Result { + // language=SQL + Ok(query_scalar("SELECT current_database()") + .fetch_one(conn) + .await?) +} + +fn generate_lock_id(database_name: &str) -> i64 { + const CRC_IEEE: crc::Crc = crc::Crc::::new(&crc::CRC_32_ISO_HDLC); + // 0x3d32ad9e chosen by fair dice roll + 0x3d32ad9e * (CRC_IEEE.checksum(database_name.as_bytes()) as i64) +} + +async fn execute_migration( + conn: &mut PgConnection, + schema: &'static str, + migration: &Migration, +) -> Result<(), MigrateError> { + let _ = conn + .execute(&*migration.sql) + .await + .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; + + // language=SQL + query( + format!(r#"INSERT INTO "{schema}"._sqlx_migrations ( version, description, success, checksum, execution_time ) VALUES ( $1, $2, TRUE, $3, -1 )"#).as_str() + ) + .bind(migration.version) + .bind(&*migration.description) + .bind(&*migration.checksum) + .execute(conn) + .await?; + Ok(()) +} + +async fn revert_migration( + conn: &mut PgConnection, + schema: &'static str, + migration: &Migration, +) -> Result<(), MigrateError> { + let _ = conn + .execute(&*migration.sql) + .await + .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; + + query(format!(r#"DELETE FROM "{schema}"._sqlx_migrations WHERE version = $1"#).as_str()) + .bind(migration.version) + .execute(conn) + .await?; + + Ok(()) +} diff --git a/crates/sql/src/postgres/mod.rs b/crates/sql/src/postgres/mod.rs new file mode 100644 index 00000000..379307b3 --- /dev/null +++ b/crates/sql/src/postgres/mod.rs @@ -0,0 +1,35 @@ +pub mod migrate; +pub mod types; + +pub use sqlx::postgres::PgArguments; +pub use sqlx::{PgPool, Postgres}; + +use crate::{Executable, FlexQuery, SqlxResult}; +use futures::future::BoxFuture; +use sqlx::{Executor, PgTransaction}; + +pub type PgQuery = crate::FlexQuery; + +pub trait PgDbConnection: crate::PoolExt {} +impl crate::PgDbConnection for T {} + +impl Executable for FlexQuery { + fn execute<'t>(self, transaction: &'t mut PgTransaction) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + match self.args { + Some(args) => { + transaction + .execute(sqlx::query_with(self.sql.as_ref(), args)) + .await?; + } + None => { + transaction.execute(self.sql.as_ref()).await?; + } + } + Ok(()) + }) + } +} diff --git a/crates/sql/src/postgres/types.rs b/crates/sql/src/postgres/types.rs new file mode 100644 index 00000000..3ed6d57e --- /dev/null +++ b/crates/sql/src/postgres/types.rs @@ -0,0 +1,38 @@ +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::postgres::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueRef}; +use sqlx::{Decode, Encode, Postgres, Type}; + +use crate::types::SqlFelt; + +impl Type for SqlFelt { + fn type_info() -> PgTypeInfo { + PgTypeInfo::with_name("felt252") + } + + fn compatible(ty: &PgTypeInfo) -> bool { + *ty == PgTypeInfo::with_name("felt252") || <[u8] as Type>::compatible(ty) + } +} + +impl PgHasArrayType for SqlFelt { + fn array_type_info() -> PgTypeInfo { + PgTypeInfo::with_name("_felt252") + } +} + +impl Encode<'_, Postgres> for SqlFelt { + fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result { + <&[u8] as Encode>::encode(self.0.as_slice(), buf) + } +} + +impl Decode<'_, Postgres> for SqlFelt { + fn decode(value: PgValueRef<'_>) -> Result { + let bytes = <&[u8] as Decode>::decode(value)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| format!("expected 32 bytes for felt252, got {}", bytes.len()))?; + Ok(SqlFelt(arr)) + } +} diff --git a/crates/sql/src/query.rs b/crates/sql/src/query.rs new file mode 100644 index 00000000..fe916a37 --- /dev/null +++ b/crates/sql/src/query.rs @@ -0,0 +1,315 @@ +use crate::SqlxResult; +use futures::future::BoxFuture; +use itertools::Itertools; +use sqlx::{Database, Executor, Transaction}; +use std::fmt::Display; +use std::sync::Arc; + +pub trait Executable { + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't; +} + +impl Executable for &str +where + for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + transaction.execute(self).await?; + Ok(()) + }) + } +} + +impl Executable for &String +where + for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + transaction.execute(self.as_str()).await?; + Ok(()) + }) + } +} + +impl Executable for String +where + for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + transaction.execute(self.as_str()).await?; + Ok(()) + }) + } +} + +impl Executable for FlexStr +where + for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + transaction.execute(self.as_ref()).await?; + Ok(()) + }) + } +} + +#[derive(Debug, Clone)] +pub enum FlexStr { + Owned(String), + Static(&'static str), + Shared(Arc), +} + +impl Display for FlexStr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_ref().fmt(f) + } +} + +impl FlexStr { + pub fn as_str(&self) -> &str { + match self { + FlexStr::Owned(s) => s.as_str(), + FlexStr::Shared(s) => s, + FlexStr::Static(s) => s, + } + } +} + +impl AsRef for FlexStr { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl PartialEq for FlexStr { + fn eq(&self, other: &str) -> bool { + self.as_ref() == other + } +} + +/// A query with optional bind arguments. +/// +/// SQL can be any `FlexStr` (String, Arc, &'static str). +/// The `Bound` variant carries SQL + pre-built arguments. +/// The per-database `Executable` impls handle the lifetime requirements: +/// Postgres needs no special treatment; SQLite uses an unsafe lifetime extension +/// that is sound because the `FlexStr` outlives the `.await` point. +pub struct FlexQuery { + pub(crate) sql: FlexStr, + pub(crate) args: Option<::Arguments<'static>>, +} + +impl Clone for FlexQuery +where + DB::Arguments<'static>: Clone, +{ + fn clone(&self) -> Self { + Self { + sql: self.sql.clone(), + args: self.args.clone(), + } + } +} + +impl std::fmt::Debug for FlexQuery +where + DB::Arguments<'static>: std::fmt::Debug, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlexQuery") + .field("sql", &self.sql) + .field("args", &self.args) + .finish() + } +} + +impl FlexQuery { + pub fn new(sql: impl Into, args: ::Arguments<'static>) -> Self { + FlexQuery { + sql: sql.into(), + args: Some(args), + } + } + + pub fn from_sql(sql: impl Into) -> Self { + FlexQuery { + sql: sql.into(), + args: None, + } + } +} + +impl PartialEq for FlexQuery { + fn eq(&self, other: &str) -> bool { + self.sql.as_ref() == other + } +} + +impl From for FlexStr { + fn from(s: String) -> Self { + FlexStr::Owned(s) + } +} + +impl From<&'static str> for FlexStr { + fn from(s: &'static str) -> Self { + FlexStr::Static(s) + } +} + +impl From> for FlexStr { + fn from(s: Arc) -> Self { + FlexStr::Shared(s) + } +} + +impl From for FlexQuery { + fn from(sql: FlexStr) -> Self { + FlexQuery::from_sql(sql) + } +} + +impl From<&'static str> for FlexQuery { + fn from(sql: &'static str) -> Self { + FlexQuery::from_sql(sql) + } +} + +impl From for FlexQuery { + fn from(sql: String) -> Self { + FlexQuery::from_sql(sql) + } +} + +impl From> for FlexQuery { + fn from(sql: Arc) -> Self { + FlexQuery::from_sql(sql) + } +} + +impl From<(S, A)> for FlexQuery +where + S: Into, + A: Into<::Arguments<'static>>, +{ + fn from((sql, args): (S, A)) -> Self { + FlexQuery::new(sql, args.into()) + } +} + +pub trait Queries { + fn add(&mut self, query: impl Into>); + fn adds(&mut self, queries: impl IntoIterator>>); +} + +impl Queries for Vec> { + fn add(&mut self, query: impl Into>) { + self.push(query.into()); + } + fn adds(&mut self, queries: impl IntoIterator>>) { + self.extend(queries.into_iter().map_into()); + } +} + +impl Executable for &[String; N] +where + for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + for query in self { + transaction.execute(query.as_str()).await?; + } + Ok(()) + }) + } +} + +impl + Send> Executable for Vec { + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + for item in self { + item.execute(transaction).await?; + } + Ok(()) + }) + } +} + +impl<'a, DB: Database, T> Executable for &'a Vec +where + &'a T: Executable + Send, + T: Send + Sync, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + for item in self { + item.execute(transaction).await?; + } + Ok(()) + }) + } +} + +impl<'a, DB: Database, T> Executable for &'a [T] +where + &'a T: Executable + Send, + T: Send + Sync, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + for item in self { + item.execute(transaction).await?; + } + Ok(()) + }) + } +} + +impl Executable for [T; N] +where + T: Executable + Send, +{ + fn execute<'t>(self, transaction: &'t mut Transaction<'_, DB>) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + for item in self { + item.execute(transaction).await?; + } + Ok(()) + }) + } +} diff --git a/crates/sql/src/runtime.rs b/crates/sql/src/runtime.rs new file mode 100644 index 00000000..e9c16bec --- /dev/null +++ b/crates/sql/src/runtime.rs @@ -0,0 +1,58 @@ +use std::str::FromStr; + +use crate::connection::DbBackend; +use crate::{DbPoolOptions, PoolConfig, SqlxError, SqlxResult}; +use sqlx::postgres::PgConnectOptions; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::{Pool, Postgres, Sqlite}; + +#[derive(Clone)] +pub enum DbPool { + Postgres(Pool), + Sqlite(Pool), +} + +#[derive(Debug, Clone)] +pub enum DbConnectionOptions { + Postgres(PgConnectOptions), + Sqlite(SqliteConnectOptions), +} + +impl FromStr for DbConnectionOptions { + type Err = SqlxError; + fn from_str(s: &str) -> Result { + match DbBackend::from_str(s)? { + DbBackend::Postgres => PgConnectOptions::from_str(s).map(DbConnectionOptions::Postgres), + DbBackend::Sqlite => SqliteConnectOptions::from_str(s).map(DbConnectionOptions::Sqlite), + } + } +} + +impl PoolConfig { + pub async fn connect_any(&self) -> SqlxResult { + match DbBackend::try_from(self.url.as_str()) { + Ok(DbBackend::Postgres) => self.connect::().await.map(DbPool::Postgres), + Ok(DbBackend::Sqlite) => self.connect::().await.map(DbPool::Sqlite), + Err(err) => Err(SqlxError::Configuration(err.into())), + } + } +} + +impl DbPoolOptions { + pub async fn connect_any(&self, url: &str) -> SqlxResult { + match DbBackend::try_from(url) { + Ok(DbBackend::Postgres) => self.connect::(url).await.map(DbPool::Postgres), + Ok(DbBackend::Sqlite) => self.connect::(url).await.map(DbPool::Sqlite), + Err(err) => Err(SqlxError::Configuration(err.into())), + } + } + + pub async fn connect_any_with(&self, options: DbConnectionOptions) -> SqlxResult { + match options { + DbConnectionOptions::Postgres(opts) => { + self.connect_with(opts).await.map(DbPool::Postgres) + } + DbConnectionOptions::Sqlite(opts) => self.connect_with(opts).await.map(DbPool::Sqlite), + } + } +} diff --git a/crates/sql/src/sqlite/migrate.rs b/crates/sql/src/sqlite/migrate.rs new file mode 100644 index 00000000..11c1ca22 --- /dev/null +++ b/crates/sql/src/sqlite/migrate.rs @@ -0,0 +1,160 @@ +use crate::AcquiredSchema; +use futures::future::BoxFuture; +use sqlx::migrate::{AppliedMigration, Migrate, MigrateError, Migration}; +use sqlx::{query, query_as, Acquire, Executor, Sqlite, SqliteConnection}; +use std::borrow::Cow; +use std::time::{Duration, Instant}; + +impl AcquiredSchema { + fn table_name(&self) -> Cow<'static, str> { + Cow::Owned(format!("_sqlx_migrations_{}", self.schema)) + } +} + +impl Migrate for AcquiredSchema { + fn ensure_migrations_table(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { + let table_name = self.table_name(); + self.connection + .execute( + format!( + r#" +CREATE TABLE IF NOT EXISTS "{table_name}" ( + version BIGINT PRIMARY KEY, + description TEXT NOT NULL, + installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN NOT NULL, + checksum BLOB NOT NULL, + execution_time BIGINT NOT NULL +); + "# + ) + .as_str(), + ) + .await?; + + Ok(()) + }) + } + + fn dirty_version(&mut self) -> BoxFuture<'_, Result, MigrateError>> { + Box::pin(async move { + let table_name = self.table_name(); + let row: Option<(i64,)> = query_as( + format!( + r#"SELECT version FROM "{table_name}" WHERE success = false ORDER BY version LIMIT 1"# + ) + .as_str(), + ) + .fetch_optional(&mut self.connection) + .await?; + + Ok(row.map(|row| row.0)) + }) + } + + fn list_applied_migrations( + &mut self, + ) -> BoxFuture<'_, Result, MigrateError>> { + Box::pin(async move { + let table_name = self.table_name(); + let rows: Vec<(i64, Vec)> = query_as( + format!(r#"SELECT version, checksum FROM "{table_name}" ORDER BY version"#) + .as_str(), + ) + .fetch_all(&mut self.connection) + .await?; + + Ok(rows + .into_iter() + .map(|(version, checksum)| AppliedMigration { + version, + checksum: checksum.into(), + }) + .collect()) + }) + } + + fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { Ok(()) }) + } + + fn unlock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { + Box::pin(async move { Ok(()) }) + } + + fn apply<'e: 'm, 'm>( + &'e mut self, + migration: &'m Migration, + ) -> BoxFuture<'m, Result> { + Box::pin(async move { + let table_name = self.table_name(); + let mut tx = self.begin().await?; + let start = Instant::now(); + + let _ = tx + .execute(&*migration.sql) + .await + .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; + + let _ = query( + format!( + r#" +INSERT INTO "{table_name}" (version, description, success, checksum, execution_time) +VALUES (?1, ?2, TRUE, ?3, -1) + "# + ) + .as_str(), + ) + .bind(migration.version) + .bind(&*migration.description) + .bind(&*migration.checksum) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + let elapsed = start.elapsed(); + + #[allow(clippy::cast_possible_truncation)] + let _ = query( + format!( + r#" +UPDATE "{table_name}" +SET execution_time = ?1 +WHERE version = ?2 + "# + ) + .as_str(), + ) + .bind(elapsed.as_nanos() as i64) + .bind(migration.version) + .execute(&mut self.connection) + .await?; + + Ok(elapsed) + }) + } + + fn revert<'e: 'm, 'm>( + &'e mut self, + migration: &'m Migration, + ) -> BoxFuture<'m, Result> { + Box::pin(async move { + let table_name = self.table_name(); + let mut tx = self.begin().await?; + let start = Instant::now(); + + let _ = tx.execute(&*migration.sql).await?; + + let _ = query(format!(r#"DELETE FROM "{table_name}" WHERE version = ?1"#).as_str()) + .bind(migration.version) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(start.elapsed()) + }) + } +} diff --git a/crates/sql/src/sqlite/mod.rs b/crates/sql/src/sqlite/mod.rs new file mode 100644 index 00000000..d797fb48 --- /dev/null +++ b/crates/sql/src/sqlite/mod.rs @@ -0,0 +1,68 @@ +pub mod migrate; +pub mod types; + +use futures::future::BoxFuture; +pub use sqlx::sqlite::SqliteArguments; +use sqlx::{Executor, SqliteTransaction}; +pub use sqlx::{Sqlite, SqlitePool}; + +use sqlx::sqlite::SqliteConnectOptions; +use std::str::FromStr; + +use crate::{Executable, SqlxResult}; + +pub type SqliteQuery = super::FlexQuery; + +pub trait SqliteDbConnection: super::PoolExt {} +impl> SqliteDbConnection for T {} + +pub fn is_sqlite_memory_path(path: &str) -> bool { + path == ":memory:" + || path == "sqlite::memory:" + || path == "sqlite://:memory:" + || path.contains("mode=memory") +} + +pub fn sqlite_connect_options(path: &str) -> Result { + if path == ":memory:" || path == "sqlite::memory:" { + return SqliteConnectOptions::from_str("sqlite::memory:"); + } + + let options = if path.starts_with("sqlite:") { + SqliteConnectOptions::from_str(path)? + } else { + SqliteConnectOptions::new().filename(path) + }; + + if path.starts_with("sqlite:") && path.contains("mode=") { + Ok(options) + } else { + Ok(options.create_if_missing(true)) + } +} + +#[allow(unsafe_code)] +impl Executable for SqliteQuery { + fn execute<'t>(self, transaction: &'t mut SqliteTransaction) -> BoxFuture<'t, SqlxResult<()>> + where + Self: 't, + { + Box::pin(async move { + match self.args { + Some(args) => { + // SAFETY: `self.sql` is moved into this async block and lives for + // its entire duration. The extended reference is only used by + // `query_with` which is immediately awaited, so it cannot outlive + // the backing data in `FlexStr`. + let sql_ref: &'static str = + unsafe { std::mem::transmute::<&str, &'static str>(self.sql.as_ref()) }; + transaction.execute(sqlx::query_with(sql_ref, args)).await?; + } + None => { + transaction.execute(self.sql.as_ref()).await?; + } + } + Ok(()) + }) + } +} diff --git a/crates/sql/src/sqlite/types.rs b/crates/sql/src/sqlite/types.rs new file mode 100644 index 00000000..8d3ad9ab --- /dev/null +++ b/crates/sql/src/sqlite/types.rs @@ -0,0 +1,51 @@ +use crate::types::SqlFelt; +use sqlx::encode::IsNull; +use sqlx::error::BoxDynError; +use sqlx::{Decode, Encode, Sqlite, Type}; + +impl Type for SqlFelt { + fn type_info() -> ::TypeInfo { + >::type_info() + } +} + +impl<'q> Encode<'q, Sqlite> for SqlFelt { + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let mut hex = String::with_capacity(66); + hex.push_str("0x"); + for byte in &self.0 { + use std::fmt::Write; + write!(hex, "{byte:02x}").unwrap(); + } + Encode::::encode(hex, buf) + } +} + +impl Decode<'_, Sqlite> for SqlFelt { + fn decode(value: ::ValueRef<'_>) -> Result { + let s = >::decode(value)?; + let s = s.strip_prefix("0x").unwrap_or(&s); + if s.len() != 64 { + return Err(format!("expected 64 hex chars for felt252, got {}", s.len()).into()); + } + let mut arr = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = hex_nibble(chunk[0])?; + let lo = hex_nibble(chunk[1])?; + arr[i] = (hi << 4) | lo; + } + Ok(SqlFelt(arr)) + } +} + +fn hex_nibble(c: u8) -> Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + _ => Err(format!("invalid hex char: {}", c as char).into()), + } +} diff --git a/crates/sql/src/types.rs b/crates/sql/src/types.rs new file mode 100644 index 00000000..347e1aef --- /dev/null +++ b/crates/sql/src/types.rs @@ -0,0 +1,22 @@ +use starknet_types_raw::Felt; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SqlFelt(pub [u8; 32]); + +impl From for Felt { + fn from(value: SqlFelt) -> Self { + value.0.into() + } +} + +impl From for SqlFelt { + fn from(value: Felt) -> Self { + SqlFelt(value.to_be_bytes()) + } +} + +impl From<&Felt> for SqlFelt { + fn from(value: &Felt) -> Self { + SqlFelt(value.to_be_bytes()) + } +} diff --git a/crates/sqlite/Cargo.toml b/crates/sqlite/Cargo.toml deleted file mode 100644 index 08f62825..00000000 --- a/crates/sqlite/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "torii-sqlite" -version = "0.1.0" -edition = "2021" -description = "SQLite connection helpers for Torii storage crates" - -[dependencies] -async-trait.workspace = true -futures.workspace = true -sqlx = { workspace = true, features = [ - "sqlite", - "runtime-tokio-rustls", - "migrate", -] } - -torii-common.workspace = true - -[lints] -workspace = true diff --git a/crates/sqlite/src/db.rs b/crates/sqlite/src/db.rs deleted file mode 100644 index c9f4a200..00000000 --- a/crates/sqlite/src/db.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::ops::Deref; -use std::str::FromStr; - -pub use async_trait::async_trait; -use sqlx::migrate::Migrator; -use sqlx::sqlite::SqliteConnectOptions; -use sqlx::Sqlite; -pub use sqlx::{SqlitePool, Transaction}; -use torii_common::sql::SqlxResult; - -use crate::migration::NamespaceMigrator; - -#[async_trait] -pub trait SqliteConnection { - fn pool(&self) -> &SqlitePool; - - async fn begin(&self) -> SqlxResult> { - Ok(self.pool().begin().await?) - } - - async fn migrate(&self, namespace: Option<&'static str>, migrator: Migrator) -> SqlxResult<()> { - let result = match namespace { - Some(namespace) => { - NamespaceMigrator::new(namespace, migrator) - .run(self.pool()) - .await - } - None => migrator.run(self.pool()).await, - }; - Ok(result?) - } - - async fn execute_queries(&self, queries: &[String]) -> SqlxResult<()> { - let mut transaction = self.begin().await?; - for query in queries { - sqlx::query(query).execute(&mut *transaction).await?; - } - transaction.commit().await - } -} - -#[allow(clippy::explicit_auto_deref)] -#[async_trait] -impl + Send + Sync + 'static> SqliteConnection for T { - fn pool(&self) -> &SqlitePool { - &**self - } -} - -pub fn is_sqlite_memory_path(path: &str) -> bool { - path == ":memory:" - || path == "sqlite::memory:" - || path == "sqlite://:memory:" - || path.contains("mode=memory") -} - -pub fn sqlite_connect_options(path: &str) -> Result { - if path == ":memory:" || path == "sqlite::memory:" { - return SqliteConnectOptions::from_str("sqlite::memory:"); - } - - let options = if path.starts_with("sqlite:") { - SqliteConnectOptions::from_str(path)? - } else { - SqliteConnectOptions::new().filename(path) - }; - - if path.starts_with("sqlite:") && path.contains("mode=") { - Ok(options) - } else { - Ok(options.create_if_missing(true)) - } -} diff --git a/crates/sqlite/src/lib.rs b/crates/sqlite/src/lib.rs deleted file mode 100644 index e0e1dc65..00000000 --- a/crates/sqlite/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod db; -pub mod migration; - -pub use db::{is_sqlite_memory_path, sqlite_connect_options, SqliteConnection}; diff --git a/crates/sqlite/src/migration.rs b/crates/sqlite/src/migration.rs deleted file mode 100644 index 8b9e3eef..00000000 --- a/crates/sqlite/src/migration.rs +++ /dev/null @@ -1,320 +0,0 @@ -use futures::future::BoxFuture; -use sqlx::migrate::{ - AppliedMigration, Migrate, MigrateError, Migration, MigrationSource, Migrator, -}; -use sqlx::sqlite::SqliteConnection; -use sqlx::{query, query_as, Acquire, Executor, Sqlite}; -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; -use std::ops::{Deref, DerefMut}; -use std::slice; -use std::time::{Duration, Instant}; - -pub struct NamespaceMigrator { - pub migrator: Migrator, - pub namespace: &'static str, -} - -pub struct SqliteAcquiredNamespace<'a, A> -where - A: Acquire<'a>, -{ - pub connection: >::Connection, - pub namespace: &'static str, -} - -impl<'a, A> Deref for SqliteAcquiredNamespace<'a, A> -where - A: Acquire<'a, Database = Sqlite>, -{ - type Target = SqliteConnection; - - fn deref(&self) -> &Self::Target { - &self.connection - } -} - -impl<'a, A> DerefMut for SqliteAcquiredNamespace<'a, A> -where - A: Acquire<'a, Database = Sqlite>, -{ - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.connection - } -} - -impl<'a, A> SqliteAcquiredNamespace<'a, A> -where - A: Acquire<'a, Database = Sqlite>, -{ - fn table_name(&self) -> Cow<'static, str> { - Cow::Owned(format!("_sqlx_migrations_{}", self.namespace)) - } -} - -impl<'a, A> Migrate for SqliteAcquiredNamespace<'a, A> -where - A: Acquire<'a, Database = Sqlite> + Executor<'a, Database = Sqlite>, -{ - fn ensure_migrations_table(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { - let table_name = self.table_name(); - self.connection - .execute( - format!( - r#" -CREATE TABLE IF NOT EXISTS "{table_name}" ( - version BIGINT PRIMARY KEY, - description TEXT NOT NULL, - installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - success BOOLEAN NOT NULL, - checksum BLOB NOT NULL, - execution_time BIGINT NOT NULL -); - "# - ) - .as_str(), - ) - .await?; - - Ok(()) - }) - } - - fn dirty_version(&mut self) -> BoxFuture<'_, Result, MigrateError>> { - Box::pin(async move { - let table_name = self.table_name(); - let row: Option<(i64,)> = query_as( - format!( - r#"SELECT version FROM "{table_name}" WHERE success = false ORDER BY version LIMIT 1"# - ) - .as_str(), - ) - .fetch_optional(&mut *self.connection) - .await?; - - Ok(row.map(|row| row.0)) - }) - } - - fn list_applied_migrations( - &mut self, - ) -> BoxFuture<'_, Result, MigrateError>> { - Box::pin(async move { - let table_name = self.table_name(); - let rows: Vec<(i64, Vec)> = query_as( - format!(r#"SELECT version, checksum FROM "{table_name}" ORDER BY version"#) - .as_str(), - ) - .fetch_all(&mut *self.connection) - .await?; - - Ok(rows - .into_iter() - .map(|(version, checksum)| AppliedMigration { - version, - checksum: checksum.into(), - }) - .collect()) - }) - } - - fn lock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { Ok(()) }) - } - - fn unlock(&mut self) -> BoxFuture<'_, Result<(), MigrateError>> { - Box::pin(async move { Ok(()) }) - } - - fn apply<'e: 'm, 'm>( - &'e mut self, - migration: &'m Migration, - ) -> BoxFuture<'m, Result> { - Box::pin(async move { - let table_name = self.table_name(); - let mut tx = self.begin().await?; - let start = Instant::now(); - - let _ = tx - .execute(&*migration.sql) - .await - .map_err(|e| MigrateError::ExecuteMigration(e, migration.version))?; - - let _ = query( - format!( - r#" -INSERT INTO "{table_name}" (version, description, success, checksum, execution_time) -VALUES (?1, ?2, TRUE, ?3, -1) - "# - ) - .as_str(), - ) - .bind(migration.version) - .bind(&*migration.description) - .bind(&*migration.checksum) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - let elapsed = start.elapsed(); - - #[allow(clippy::cast_possible_truncation)] - let _ = query( - format!( - r#" -UPDATE "{table_name}" -SET execution_time = ?1 -WHERE version = ?2 - "# - ) - .as_str(), - ) - .bind(elapsed.as_nanos() as i64) - .bind(migration.version) - .execute(&mut *self.connection) - .await?; - - Ok(elapsed) - }) - } - - fn revert<'e: 'm, 'm>( - &'e mut self, - migration: &'m Migration, - ) -> BoxFuture<'m, Result> { - Box::pin(async move { - let table_name = self.table_name(); - let mut tx = self.begin().await?; - let start = Instant::now(); - - let _ = tx.execute(&*migration.sql).await?; - - let _ = query(format!(r#"DELETE FROM "{table_name}" WHERE version = ?1"#).as_str()) - .bind(migration.version) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(start.elapsed()) - }) - } -} - -impl NamespaceMigrator { - pub const fn new(namespace: &'static str, migrator: Migrator) -> Self { - Self { - migrator, - namespace, - } - } - - pub async fn new_from_source<'s, S>( - namespace: &'static str, - source: S, - ) -> Result - where - S: MigrationSource<'s>, - { - Migrator::new(source).await.map(|migrator| Self { - migrator, - namespace, - }) - } - - pub fn set_ignore_missing(&mut self, ignore_missing: bool) -> &Self { - self.migrator.ignore_missing = ignore_missing; - self - } - - pub fn set_locking(&mut self, locking: bool) -> &Self { - self.migrator.locking = locking; - self - } - - pub fn iter(&self) -> slice::Iter<'_, Migration> { - self.migrator.migrations.iter() - } - - pub fn version_exists(&self, version: i64) -> bool { - self.iter().any(|migration| migration.version == version) - } - - pub async fn run<'a, A>(&self, migrator: A) -> Result<(), MigrateError> - where - A: Acquire<'a, Database = Sqlite>, - SqliteAcquiredNamespace<'a, A>: Migrate, - { - let mut conn = SqliteAcquiredNamespace { - connection: migrator.acquire().await?, - namespace: self.namespace, - }; - self.migrator.run_direct(&mut conn).await - } - - pub async fn undo<'a, A>(&self, migrator: A, target: i64) -> Result<(), MigrateError> - where - A: Acquire<'a, Database = Sqlite>, - SqliteAcquiredNamespace<'a, A>: Migrate, - { - let mut conn = SqliteAcquiredNamespace { - connection: migrator.acquire().await?, - namespace: self.namespace, - }; - - if self.migrator.locking { - conn.lock().await?; - } - - conn.ensure_migrations_table().await?; - - if let Some(version) = conn.dirty_version().await? { - return Err(MigrateError::Dirty(version)); - } - - let applied_migrations = conn.list_applied_migrations().await?; - validate_applied_migrations(&applied_migrations, &self.migrator)?; - - let applied_migrations: HashMap<_, _> = applied_migrations - .into_iter() - .map(|migration| (migration.version, migration)) - .collect(); - - for migration in self - .iter() - .rev() - .filter(|migration| migration.migration_type.is_down_migration()) - .filter(|migration| applied_migrations.contains_key(&migration.version)) - .filter(|migration| migration.version > target) - { - conn.revert(migration).await?; - } - - if self.migrator.locking { - conn.unlock().await?; - } - - Ok(()) - } -} - -fn validate_applied_migrations( - applied_migrations: &[AppliedMigration], - migrator: &Migrator, -) -> Result<(), MigrateError> { - if migrator.ignore_missing { - return Ok(()); - } - - let migrations: HashSet<_> = migrator.iter().map(|migration| migration.version).collect(); - - for applied_migration in applied_migrations { - if !migrations.contains(&applied_migration.version) { - return Err(MigrateError::VersionMissing(applied_migration.version)); - } - } - - Ok(()) -} diff --git a/crates/starknet/Cargo.toml b/crates/starknet/Cargo.toml deleted file mode 100644 index 9517b699..00000000 --- a/crates/starknet/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "torii-starknet" -version = "0.1.0" -edition = "2021" -description = "StarkNet utilities for Torii" - -[dependencies] -starknet = { workspace = true, optional = true } -serde = { workspace = true, optional = true } - -[dev-dependencies] -pretty_assertions_sorted = "1.2.3" -assert_matches = "1.5.0" -serde_json.workspace = true -bincode = { workspace = true, features = ["serde"] } - -[features] -default = ["serde"] -starknet = ["dep:starknet"] -serde = ["dep:serde"] - -[lints] -workspace = true diff --git a/crates/starknet/src/event.rs b/crates/starknet/src/event.rs deleted file mode 100644 index d04e86d6..00000000 --- a/crates/starknet/src/event.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::Felt; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -pub struct EmittedEvent { - pub from_address: Felt, - pub keys: Vec, - pub data: Vec, - #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - pub block_hash: Option, - #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] - pub block_number: Option, - pub transaction_hash: Felt, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] -pub struct Event { - pub from_address: Felt, - pub keys: Vec, - pub data: Vec, -} diff --git a/crates/starknet/src/felt.rs b/crates/starknet/src/felt.rs deleted file mode 100644 index 90dbb411..00000000 --- a/crates/starknet/src/felt.rs +++ /dev/null @@ -1,714 +0,0 @@ -use std::borrow::Cow; -use std::error::Error; - -/// Starknet Field Element. -/// -/// A field element is a number 0..p-1 with p=2^{251}+17*2^{192}+1, and it forms -/// the basic building block of most Starknet interactions. -#[repr(transparent)] -#[derive(Clone, Copy, PartialEq, Hash, Eq, PartialOrd, Ord)] -pub struct Felt(pub(crate) [u8; 32]); - -const MODULUS_U64: [u64; 4] = [576460752303423505u64, 0, 0, 1]; - -impl std::fmt::Debug for Felt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "0x{self:x}") - } -} - -impl std::fmt::Display for Felt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{self:x}") - } -} - -impl std::fmt::LowerHex for Felt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let bytes = &self.0; - let first = bytes.iter().position(|&b| b != 0).unwrap_or(31); - write!(f, "{:x}", bytes[first])?; - bytes[first + 1..] - .iter() - .try_for_each(|&b| write!(f, "{b:02x}")) - } -} - -impl std::fmt::UpperHex for Felt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let bytes = &self.0; - let first = bytes.iter().position(|&b| b != 0).unwrap_or(31); - write!(f, "{:X}", bytes[first])?; - bytes[first + 1..] - .iter() - .try_for_each(|&b| write!(f, "{b:02X}")) - } -} -impl Default for Felt { - fn default() -> Self { - Felt::ZERO - } -} - -/// Error returned by [Felt::from_be_bytes] indicating the maximum field value -/// was exceeded. -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -pub struct OverflowError; - -impl Error for OverflowError {} - -const OVERFLOW_MSG: &str = "The maximum field value was exceeded."; - -impl std::fmt::Display for OverflowError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(OVERFLOW_MSG) - } -} - -impl Felt { - pub const ZERO: Felt = Felt([0u8; 32]); - pub const ONE: Felt = Self::from_u64(1); - - /// Return true if the element is zero. - pub fn is_zero(&self) -> bool { - self == &Felt::ZERO - } - - /// Returns the big-endian representation of this [Felt]. - pub const fn to_be_bytes(self) -> [u8; 32] { - self.0 - } - - /// Returns the little-endian representation of this [Felt]. - pub fn to_le_bytes(self) -> [u8; 32] { - let mut tmp = self.0; - tmp.reverse(); - tmp - } - - /// Big-endian representation of this [Felt]. - pub const fn as_be_bytes(&self) -> &[u8; 32] { - &self.0 - } - - /// Big-endian mutable representation of this [Felt]. - pub fn as_mut_be_bytes(&mut self) -> &mut [u8; 32] { - &mut self.0 - } - - /// Convenience function which extends [Felt::from_be_bytes] to work with - /// slices. - pub const fn from_be_slice(bytes: &[u8]) -> Result { - if bytes.len() > 32 { - return Err(OverflowError); - } - - let mut buf = [0u8; 32]; - let mut index = 0; - loop { - if index == bytes.len() { - break; - } - buf[32 - bytes.len() + index] = bytes[index]; - index += 1; - } - - Felt::from_be_bytes(buf) - } - - /// Creates a [Felt] from big-endian bytes. - /// - /// Returns [OverflowError] if not less than the field modulus. - pub const fn from_be_bytes(bytes: [u8; 32]) -> Result { - #[rustfmt::skip] - let limbs = [ - u64::from_be_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], - ]), - u64::from_be_bytes([ - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15], - ]), - u64::from_be_bytes([ - bytes[16], bytes[17], bytes[18], bytes[19], - bytes[20], bytes[21], bytes[22], bytes[23], - ]), - u64::from_be_bytes([ - bytes[24], bytes[25], bytes[26], bytes[27], - bytes[28], bytes[29], bytes[30], bytes[31], - ]), - ]; - - // Loop over each word, if all previous are equal and current is less, we are - // good. - let mut maybe_overflow = true; - let mut i = 0; - while i < 4 && maybe_overflow { - if limbs[i] < MODULUS_U64[i] { - maybe_overflow = false; - } else if limbs[i] > MODULUS_U64[i] { - return Err(OverflowError); - } - i += 1; - } - if maybe_overflow { - Err(OverflowError) - } else { - Ok(Felt(bytes)) - } - } - - /// Creates a Felt from a big-endian byte slice of up to 32 bytes. - /// Panics if slice is longer than 32 bytes. - pub fn from_bytes_be_slice(bytes: &[u8]) -> Self { - assert!(bytes.len() <= 32, "slice too long"); - let mut buf = [0u8; 32]; - buf[32 - bytes.len()..].copy_from_slice(bytes); - Self(buf) - } - - /// Creates a Felt from a little-endian byte slice of up to 32 bytes. - pub fn from_bytes_le_slice(bytes: &[u8]) -> Self { - assert!(bytes.len() <= 32, "slice too long"); - let mut buf = [0u8; 32]; - for (i, &b) in bytes.iter().enumerate() { - buf[31 - i] = b; - } - Self(buf) - } - - /// Returns `true` if the value of [`Felt`] is larger than `2^251 - 1`. - /// - /// Every [`Felt`] that is used to traverse a Merkle-Patricia Tree - /// must not exceed 251 bits, since 251 is the height of the tree. - pub const fn has_more_than_251_bits(&self) -> bool { - self.0[0] & 0b1111_1000 > 0 - } - - pub const fn from_u64(u: u64) -> Self { - const_expect!( - Self::from_be_slice(&u.to_be_bytes()), - "64 bits is less than 251 bits" - ) - } - - pub const fn from_u128(u: u128) -> Self { - const_expect!( - Self::from_be_slice(&u.to_be_bytes()), - "128 bits is less than 251 bits" - ) - } -} - -macro_rules! const_expect { - ($e:expr, $why:expr) => {{ - match $e { - Ok(x) => x, - Err(_) => panic!(concat!("Expectation failed: ", $why)), - } - }}; -} - -use const_expect; - -impl From for Felt { - fn from(value: u64) -> Self { - Self::from_u64(value) - } -} - -impl From for Felt { - fn from(value: usize) -> Self { - Self::from_u64(value.try_into().expect("ptr size is 64 bits")) - } -} - -impl From for Felt { - fn from(value: u128) -> Self { - Self::from_u128(value) - } -} - -impl From<[u8; 32]> for Felt { - fn from(bytes: [u8; 32]) -> Self { - Self(bytes) - } -} - -impl From<[u8; 31]> for Felt { - fn from(bytes: [u8; 31]) -> Self { - let mut buf = [0u8; 32]; - buf[1..].copy_from_slice(&bytes); - Self(buf) - } -} - -impl TryInto for Felt { - type Error = OverflowError; - - fn try_into(self) -> Result { - let initial_zeroes = self.0.iter().take_while(|b| **b == 0).count(); - const EXPECTED_ZEROES: usize = (32 - u128::BITS / u8::BITS) as usize; - - if initial_zeroes < EXPECTED_ZEROES { - return Err(OverflowError); - } - - let bytes = self.0[EXPECTED_ZEROES..] - .try_into() - .expect("Should match u128 size"); - Ok(u128::from_be_bytes(bytes)) - } -} - -impl TryInto for Felt { - type Error = OverflowError; - - fn try_into(self) -> Result { - let initial_zeroes = self.0.iter().take_while(|b| **b == 0).count(); - const EXPECTED_ZEROES: usize = (32 - u64::BITS / u8::BITS) as usize; - - if initial_zeroes < EXPECTED_ZEROES { - return Err(OverflowError); - } - let bytes = self.0[EXPECTED_ZEROES..] - .try_into() - .expect("Should match u64 size"); - Ok(u64::from_be_bytes(bytes)) - } -} - -impl Felt { - /// A convenience function which parses a hex string into a [Felt]. - /// - /// Supports both upper and lower case hex strings, as well as an optional - /// "0x" prefix. - pub const fn from_hex_str(hex_str: &str) -> Result { - const fn parse_hex_digit(digit: u8) -> Result { - match digit { - b'0'..=b'9' => Ok(digit - b'0'), - b'A'..=b'F' => Ok(digit - b'A' + 10), - b'a'..=b'f' => Ok(digit - b'a' + 10), - other => Err(HexParseError::InvalidNibble(other)), - } - } - - let bytes = hex_str.as_bytes(); - let start = if bytes.len() >= 2 && bytes[0] == b'0' && bytes[1] == b'x' { - 2 - } else { - 0 - }; - let len = bytes.len() - start; - - if len > 64 { - return Err(HexParseError::InvalidLength { - max: 64, - actual: bytes.len(), - }); - } - - let mut buf = [0u8; 32]; - - // We want the result in big-endian so reverse iterate over each pair of - // nibbles. let chunks = hex_str.as_bytes().rchunks_exact(2); - - // Handle a possible odd nibble remaining nibble. - if len % 2 == 1 { - let idx = len / 2; - buf[31 - idx] = match parse_hex_digit(bytes[start]) { - Ok(b) => b, - Err(e) => return Err(e), - }; - } - - let chunks = len / 2; - let mut chunk = 0; - - while chunk < chunks { - let lower = match parse_hex_digit(bytes[bytes.len() - chunk * 2 - 1]) { - Ok(b) => b, - Err(e) => return Err(e), - }; - let upper = match parse_hex_digit(bytes[bytes.len() - chunk * 2 - 2]) { - Ok(b) => b, - Err(e) => return Err(e), - }; - buf[31 - chunk] = (upper << 4) | lower; - chunk += 1; - } - - let felt = match Felt::from_be_bytes(buf) { - Ok(felt) => felt, - Err(OverflowError) => return Err(HexParseError::Overflow), - }; - Ok(felt) - } - - /// The first stage of conversion - skip leading zeros - fn skip_zeros(&self) -> (impl Iterator, usize, usize) { - // Skip all leading zero bytes - let it = self.0.iter().skip_while(|&&b| b == 0); - let num_bytes = it.clone().count(); - let skipped = self.0.len() - num_bytes; - // The first high nibble can be 0 - let start = if self.0[skipped] < 0x10 { 1 } else { 2 }; - // Number of characters to display - let len = start + num_bytes * 2; - (it, start, len) - } - - /// The second stage of conversion - map bytes to hex str - fn it_to_hex_str<'a>( - it: impl Iterator, - start: usize, - len: usize, - buf: &'a mut [u8], - ) -> &'a [u8] { - const LUT: [u8; 16] = *b"0123456789abcdef"; - buf[0] = b'0'; - // Same small lookup table is ~25% faster than hex::encode_from_slice 🤷 - it.enumerate().for_each(|(i, &b)| { - let idx = b as usize; - let pos = start + i * 2; - let x = [LUT[(idx & 0xf0) >> 4], LUT[idx & 0x0f]]; - buf[pos..pos + 2].copy_from_slice(&x); - }); - buf[1] = b'x'; - &buf[..len] - } - - /// A convenience function which produces a "0x" prefixed hex str slice in a - /// given buffer `buf` from a [Felt]. - /// Panics if `self.0.len() * 2 + 2 > buf.len()` - pub fn as_hex_str<'a>(&'a self, buf: &'a mut [u8]) -> &'a str { - let expected_buf_len = self.0.len() * 2 + 2; - assert!( - buf.len() >= expected_buf_len, - "buffer size is {}, expected at least {}", - buf.len(), - expected_buf_len - ); - - if !self.0.iter().any(|b| *b != 0) { - return "0x0"; - } - - let (it, start, len) = self.skip_zeros(); - let res = Self::it_to_hex_str(it, start, len, buf); - // Unwrap is safe because `buf` holds valid UTF8 characters. - std::str::from_utf8(res).unwrap() - } - - /// A convenience function which produces a "0x" prefixed hex string from a - /// [Felt]. - pub fn to_hex_str(&self) -> Cow<'static, str> { - if !self.0.iter().any(|b| *b != 0) { - return Cow::from("0x0"); - } - let (it, start, len) = self.skip_zeros(); - let mut buf = vec![0u8; len]; - Self::it_to_hex_str(it, start, len, &mut buf); - // Unwrap is safe as the buffer contains valid utf8 - String::from_utf8(buf).unwrap().into() - } -} - -/// Error returned by [Felt::from_hex_str] indicating an invalid hex string. -#[derive(Debug, PartialEq, Eq)] -pub enum HexParseError { - InvalidNibble(u8), - InvalidLength { max: usize, actual: usize }, - Overflow, -} - -impl Error for HexParseError {} - -impl From for HexParseError { - fn from(_: OverflowError) -> Self { - Self::Overflow - } -} - -impl std::fmt::Display for HexParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidNibble(n) => f.write_fmt(format_args!("Invalid nibble found: 0x{:x}", *n)), - Self::InvalidLength { max, actual } => { - f.write_fmt(format_args!("More than {} digits found: {}", *max, *actual)) - } - Self::Overflow => f.write_str(OVERFLOW_MSG), - } - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions_sorted::assert_eq; - - use super::*; - - const MODULUS_U8: [u8; 32] = [ - 8, 0, 0, 0, 0, 0, 0, 17, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 1, - ]; - - #[test] - fn bytes_round_trip() { - let original = [ - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, - 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, - 0x1C, 0x1D, 0x1E, 0x1F, - ]; - let hash = Felt::from_be_bytes(original).unwrap(); - let bytes = hash.to_be_bytes(); - assert_eq!(bytes, original); - } - - #[test] - fn from_bytes_overflow() { - // Field modulus - assert_eq!(Felt::from_be_bytes(MODULUS_U8), Err(OverflowError)); - // Field modulus - 1 - let mut max_val = MODULUS_U8; - max_val[31] -= 1; - Felt::from_be_bytes(max_val).unwrap(); - } - - mod from_be_slice { - use pretty_assertions_sorted::assert_eq; - - use super::*; - - #[test] - fn round_trip() { - let original = Felt::from_hex_str("abcdef0123456789").unwrap(); - let bytes = original.to_be_bytes(); - let result = Felt::from_be_slice(&bytes[..]).unwrap(); - - assert_eq!(result, original); - } - - #[test] - fn too_long() { - let original = Felt::from_hex_str("abcdef0123456789").unwrap(); - let mut bytes = original.to_be_bytes().to_vec(); - bytes.push(0); - Felt::from_be_slice(&bytes[..]).unwrap_err(); - } - - #[test] - fn short_slice() { - let original = Felt::from_hex_str("abcdef0123456789").unwrap(); - let bytes = original.to_be_bytes(); - let result = Felt::from_be_slice(&bytes[24..]); - - assert_eq!(result, Ok(original)); - } - - #[test] - fn max() { - let mut max_val = MODULUS_U8; - max_val[31] -= 1; - Felt::from_be_slice(&max_val[..]).unwrap(); - } - - #[test] - fn overflow() { - assert_eq!(Felt::from_be_slice(&MODULUS_U8[..]), Err(OverflowError)); - } - } - - mod fmt { - use pretty_assertions_sorted::assert_eq; - - use super::Felt; - - #[test] - fn debug() { - let hex_str = "1234567890abcdef000edcba0987654321"; - let felt = Felt::from_hex_str(hex_str).unwrap(); - let result = format!("{felt:?}"); - - let expected = format!("0x{felt}"); - - assert_eq!(result, expected); - } - - #[test] - fn fmt() { - let hex_str = "1234567890abcdef000edcba0987654321"; - let starkhash = Felt::from_hex_str(hex_str).unwrap(); - let result = format!("{starkhash:x}"); - - // We don't really care which casing is used by fmt. - assert_eq!(result.to_lowercase(), hex_str.to_lowercase()); - } - - #[test] - fn lower_hex() { - let hex_str = "1234567890abcdef000edcba0987654321"; - let starkhash = Felt::from_hex_str(hex_str).unwrap(); - let result = format!("{starkhash:x}"); - - assert_eq!(result, hex_str.to_lowercase()); - } - - #[test] - fn upper_hex() { - let hex_str = "1234567890abcdef000edcba0987654321"; - let starkhash = Felt::from_hex_str(hex_str).unwrap(); - let result = format!("{starkhash:X}"); - - assert_eq!(result, hex_str.to_uppercase()); - } - } - - mod from_hex_str { - use assert_matches::assert_matches; - use pretty_assertions_sorted::assert_eq; - - use super::*; - - /// Test hex string with its expected [Felt]. - fn test_data() -> (&'static str, Felt) { - let mut expected = [0; 32]; - expected[31] = 0xEF; - expected[30] = 0xCD; - expected[29] = 0xAB; - expected[28] = 0xef; - expected[27] = 0xcd; - expected[26] = 0xab; - expected[25] = 0x89; - expected[24] = 0x67; - expected[23] = 0x45; - expected[22] = 0x23; - expected[21] = 0x01; - let expected = Felt::from_be_bytes(expected).unwrap(); - - ("0123456789abcdefABCDEF", expected) - } - - #[test] - fn simple() { - let (test_str, expected) = test_data(); - let uut = Felt::from_hex_str(test_str).unwrap(); - assert_eq!(uut, expected); - } - - #[test] - fn prefix() { - let (test_str, expected) = test_data(); - let uut = Felt::from_hex_str(&format!("0x{test_str}")).unwrap(); - assert_eq!(uut, expected); - } - - #[test] - fn leading_zeros() { - let (test_str, expected) = test_data(); - let uut = Felt::from_hex_str(&format!("000000000{test_str}")).unwrap(); - assert_eq!(uut, expected); - } - - #[test] - fn prefix_and_leading_zeros() { - let (test_str, expected) = test_data(); - let uut = Felt::from_hex_str(&format!("0x000000000{test_str}")).unwrap(); - assert_eq!(uut, expected); - } - - #[test] - fn invalid_nibble() { - assert_matches!(Felt::from_hex_str("0x123z").unwrap_err(), HexParseError::InvalidNibble(n) => assert_eq!(n, b'z')); - } - - #[test] - fn invalid_len() { - assert_matches!(Felt::from_hex_str(&"1".repeat(65)).unwrap_err(), HexParseError::InvalidLength{max: 64, actual: n} => assert_eq!(n, 65)); - } - - #[test] - fn overflow() { - // Field modulus - let mut modulus = - "0x800000000000011000000000000000000000000000000000000000000000001".to_string(); - assert_eq!( - Felt::from_hex_str(&modulus).unwrap_err(), - HexParseError::Overflow - ); - // Field modulus - 1 - modulus.pop(); - modulus.push('0'); - Felt::from_hex_str(&modulus).unwrap(); - } - } - - mod to_hex_str { - use pretty_assertions_sorted::assert_eq; - - use super::*; - - const ODD: &str = "0x1234567890abcde"; - const EVEN: &str = "0x1234567890abcdef"; - const MAX: &str = "0x800000000000011000000000000000000000000000000000000000000000000"; - - #[test] - fn zero() { - assert_eq!(Felt::ZERO.to_hex_str(), "0x0"); - let mut buf = [0u8; 66]; - assert_eq!(Felt::ZERO.as_hex_str(&mut buf), "0x0"); - } - - #[test] - fn odd() { - let hash = Felt::from_hex_str(ODD).unwrap(); - assert_eq!(hash.to_hex_str(), ODD); - let mut buf = [0u8; 66]; - assert_eq!(hash.as_hex_str(&mut buf), ODD); - } - - #[test] - fn even() { - let hash = Felt::from_hex_str(EVEN).unwrap(); - assert_eq!(hash.to_hex_str(), EVEN); - let mut buf = [0u8; 66]; - assert_eq!(hash.as_hex_str(&mut buf), EVEN); - } - - #[test] - fn max() { - let hash = Felt::from_hex_str(MAX).unwrap(); - assert_eq!(hash.to_hex_str(), MAX); - let mut buf = [0u8; 66]; - assert_eq!(hash.as_hex_str(&mut buf), MAX); - } - - #[test] - #[should_panic(expected = "buffer size is 65, expected at least 66")] - fn buffer_too_small() { - let mut buf = [0u8; 65]; - Felt::ZERO.as_hex_str(&mut buf); - } - } - - mod has_more_than_251_bits { - use super::*; - - #[test] - fn has_251_bits() { - let mut bytes = [0xFFu8; 32]; - bytes[0] = 0x07; - let h = Felt::from_be_bytes(bytes).unwrap(); - assert!(!h.has_more_than_251_bits()); - } - - #[test] - fn has_252_bits() { - let mut bytes = [0u8; 32]; - bytes[0] = 0x08; - let h = Felt::from_be_bytes(bytes).unwrap(); - assert!(h.has_more_than_251_bits()); - } - } -} diff --git a/crates/starknet/src/lib.rs b/crates/starknet/src/lib.rs deleted file mode 100644 index 54ae3603..00000000 --- a/crates/starknet/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod event; -pub mod felt; - -#[cfg(feature = "serde")] -pub mod serde; - -#[cfg(feature = "starknet")] -pub mod starknet; - -pub use felt::Felt; diff --git a/crates/starknet/src/serde.rs b/crates/starknet/src/serde.rs deleted file mode 100644 index 1aece7f2..00000000 --- a/crates/starknet/src/serde.rs +++ /dev/null @@ -1,134 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::Felt; - -impl Serialize for Felt { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - if serializer.is_human_readable() { - serializer.serialize_str(&self.to_hex_str()) - } else { - let bytes = self.as_be_bytes(); - let first = bytes.iter().position(|&b| b != 0).unwrap_or(31); - serializer.serialize_bytes(&bytes[first..]) - } - } -} - -impl<'de> Deserialize<'de> for Felt { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - if deserializer.is_human_readable() { - deserializer.deserialize_str(FeltVisitor) - } else { - deserializer.deserialize_bytes(FeltVisitor) - } - } -} - -struct FeltVisitor; - -impl serde::de::Visitor<'_> for FeltVisitor { - type Value = Felt; - - fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "a hex string prefixed with 0x or up to 32 bytes") - } - - fn visit_str(self, value: &str) -> Result { - Felt::from_hex_str(value).map_err(|e| E::custom(e.to_string())) - } - - fn visit_bytes(self, value: &[u8]) -> Result { - if value.len() > 32 { - return Err(E::invalid_length(value.len(), &self)); - } - let mut buf = [0u8; 32]; - buf[32 - value.len()..].copy_from_slice(value); - Ok(Felt(buf)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json; - - #[test] - fn serialize_zero_human_readable() { - let felt = Felt::ZERO; - assert_eq!(serde_json::to_string(&felt).unwrap(), "\"0x0\""); - } - - #[test] - fn serialize_one_human_readable() { - let felt = Felt::ONE; - assert_eq!(serde_json::to_string(&felt).unwrap(), "\"0x1\""); - } - - #[test] - fn serialize_human_readable_round_trip() { - let original = Felt::from_hex_str("0xdeadbeef").unwrap(); - let serialized = serde_json::to_string(&original).unwrap(); - let deserialized: Felt = serde_json::from_str(&serialized).unwrap(); - assert_eq!(original, deserialized); - } - - #[test] - fn serialize_max_human_readable() { - let max = - Felt::from_hex_str("0x800000000000011000000000000000000000000000000000000000000000000") - .unwrap(); - let serialized = serde_json::to_string(&max).unwrap(); - let deserialized: Felt = serde_json::from_str(&serialized).unwrap(); - assert_eq!(max, deserialized); - } - - #[test] - fn deserialize_overflow_fails() { - // Field modulus - should fail - assert!(serde_json::from_str::( - "\"0x800000000000011000000000000000000000000000000000000000000000001\"" - ) - .is_err()); - } - - #[test] - fn serialize_binary_round_trip() { - let original = Felt::from_hex_str("0xdeadbeef").unwrap(); - let serialized = - bincode::serde::encode_to_vec(original, bincode::config::standard()).unwrap(); - let deserialized: Felt = - bincode::serde::decode_from_slice(&serialized, bincode::config::standard()) - .unwrap() - .0; - assert_eq!(original, deserialized); - } - - #[test] - fn serialize_binary_zero() { - let felt = Felt::ZERO; - let serialized = bincode::serde::encode_to_vec(felt, bincode::config::standard()).unwrap(); - let deserialized: Felt = - bincode::serde::decode_from_slice(&serialized, bincode::config::standard()) - .unwrap() - .0; - assert_eq!(felt, deserialized); - } - - #[test] - fn serialize_binary_compact() { - let felt = Felt::from_hex_str("0xbabe").unwrap(); - let serialized = bincode::serde::encode_to_vec(felt, bincode::config::standard()).unwrap(); - assert!(serialized.len() < 32); - let deserialized: Felt = - bincode::serde::decode_from_slice(&serialized, bincode::config::standard()) - .unwrap() - .0; - assert_eq!(felt, deserialized); - } -} diff --git a/crates/starknet/src/starknet.rs b/crates/starknet/src/starknet.rs deleted file mode 100644 index d3ac96c7..00000000 --- a/crates/starknet/src/starknet.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::event::{EmittedEvent, Event}; -use crate::Felt; -use starknet::core::types::Felt as SnFelt; -use starknet::core::types::{EmittedEvent as SnEmittedEvent, Event as SnEvent}; - -impl From for Felt { - fn from(value: SnFelt) -> Self { - Self(value.to_bytes_be()) - } -} - -impl From<&SnFelt> for Felt { - fn from(value: &SnFelt) -> Self { - Self(value.to_bytes_be()) - } -} - -impl From for SnFelt { - fn from(value: Felt) -> Self { - Self::from_bytes_be(&value.0) - } -} -impl From<&Felt> for SnFelt { - fn from(value: &Felt) -> Self { - Self::from_bytes_be(&value.0) - } -} - -impl From for Event { - fn from(value: SnEvent) -> Self { - Self { - from_address: value.from_address.into(), - keys: value.keys.into_iter().map(Into::into).collect(), - data: value.data.into_iter().map(Into::into).collect(), - } - } -} - -impl From for EmittedEvent { - fn from(value: SnEmittedEvent) -> Self { - Self { - from_address: value.from_address.into(), - keys: value.keys.into_iter().map(Into::into).collect(), - data: value.data.into_iter().map(Into::into).collect(), - block_hash: value.block_hash.map(Into::into), - block_number: value.block_number, - transaction_hash: value.transaction_hash.into(), - } - } -} - -impl From for SnEvent { - fn from(value: Event) -> Self { - Self { - from_address: value.from_address.into(), - keys: value.keys.into_iter().map(Into::into).collect(), - data: value.data.into_iter().map(Into::into).collect(), - } - } -} - -impl From for SnEmittedEvent { - fn from(value: EmittedEvent) -> Self { - Self { - from_address: value.from_address.into(), - keys: value.keys.into_iter().map(Into::into).collect(), - data: value.data.into_iter().map(Into::into).collect(), - block_hash: value.block_hash.map(Into::into), - block_number: value.block_number, - transaction_hash: value.transaction_hash.into(), - } - } -} diff --git a/crates/testing/Cargo.toml b/crates/testing/Cargo.toml index 37f3c534..fd74cc53 100644 --- a/crates/testing/Cargo.toml +++ b/crates/testing/Cargo.toml @@ -12,7 +12,7 @@ async-trait.workspace = true resolve-path.workspace = true serde.workspace = true serde_json.workspace = true -starknet.workspace = true dojo-introspect.workspace = true introspect-types.workspace = true starknet-types-core.workspace = true +starknet-types-raw.workspace = true diff --git a/crates/testing/README.md b/crates/testing/README.md new file mode 100644 index 00000000..97be69af --- /dev/null +++ b/crates/testing/README.md @@ -0,0 +1,73 @@ +# torii-test-utils + +Test-only helpers for working with captured Starknet event fixtures and +Dojo schemas. Kept out of `torii-common` because it pulls in `resolve-path`, +`alphanumeric-sort`, and `dojo-introspect` — weight no production binary +should carry. + +## Role in Torii + +Unit and integration tests across the workspace replay on-chain events from +JSON fixtures instead of hitting a real RPC. `EventIterator` turns a +directory of `events_*.json` batch files into an `Iterator`; `MultiContractEventIterator` round-robins between several +such directories to mimic multi-contract workloads. `FakeProvider` gives +`torii-dojo` tests a `DojoSchemaFetcher` that reads schemas from disk instead +of performing a `starknet_getClass` RPC call. + +## Architecture + +```text ++---------------------------------------------------------+ +| torii-test-utils | +| | +| event_reader.rs | +| +---------------------+ Event (serde-compatible JSON)| +| | EventBatch |---+ | +| | EventIterator | | | +| | MultiContractEvent- | v | +| | Iterator | starknet_types_raw:: | +| +---------------------+ EmittedEvent | +| | +| dojo.rs | +| +---------------------+ reads {contract:#066x}.json | +| | FakeProvider |---> impls DojoSchemaFetcher | +| +---------------------+ | +| | +| utils.rs | +| +---------------------+ | +| | read_json_file | `~/` expansion via resolve_ | +| | resolve_path_like | path | +| +---------------------+ | ++---------------------------------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `EventIterator` | `src/event_reader.rs` | 39 | Streams `EmittedEvent`s from a directory of batch JSON files (sorted alphanumerically) | +| `MultiContractEventIterator` | `src/event_reader.rs` | 48 | Round-robin merge of several `EventIterator`s | +| `EventBatch` / `Event` | `src/event_reader.rs` | 9 / 32 | Serde-compatible wire shapes used by fixture files | +| `FakeProvider` | `src/dojo.rs` | 11 | `DojoSchemaFetcher` impl that loads `{contract:#066x}.json` from disk | +| `read_json_file(path)` | `src/utils.rs` | — | Typed JSON loader used by both modules | +| `resolve_path_like` | `src/utils.rs` | — | Expands `~`-relative paths via `resolve-path` | + +### Internal Modules + +- `event_reader` — two iterators and their batch JSON schemas. +- `dojo` — a single `DojoSchemaFetcher` impl for test-time introspection. +- `utils` — JSON loader + `~/` path expander (the module is `pub` but the functions are re-exported at the crate root). + +### Interactions + +- **Upstream (consumers)**: tests and benches across the workspace; the root crate lists this in its `[dev-dependencies]` (`Cargo.toml:244`). +- **Downstream deps**: `dojo-introspect`, `introspect-types`, `starknet-types-core`, `starknet-types-raw`, `serde`, `serde_json`, `resolve-path`, `alphanumeric-sort`, `async-trait`. +- **Workspace deps**: none at runtime — it is only pulled in as a dev-dep. + +### Extension Points + +- New fixture shape: add a module, re-export from `lib.rs`, keep fixtures beside their tests rather than under this crate. +- Do **not** put production code here — anything in this crate's deps (e.g. `resolve-path`) will leak into binaries if they accidentally take a runtime dep. diff --git a/crates/testing/src/event_reader.rs b/crates/testing/src/event_reader.rs index 9b79a376..be9c4509 100644 --- a/crates/testing/src/event_reader.rs +++ b/crates/testing/src/event_reader.rs @@ -1,7 +1,7 @@ use crate::{read_json_file, resolve_path_like}; use serde::Deserialize; -use starknet::core::types::EmittedEvent; -use starknet_types_core::felt::Felt; +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt; use std::collections::VecDeque; use std::fs::read_dir; use std::path::PathBuf; @@ -45,6 +45,10 @@ pub struct EventIterator { pub event: usize, } +pub struct MultiContractEventIterator { + iterators: VecDeque, +} + impl EventIterator { pub fn new>(path: P) -> Self { let path = resolve_path_like(path); @@ -63,6 +67,16 @@ impl EventIterator { } } +impl MultiContractEventIterator { + pub fn new>(paths: Vec

) -> Self { + let iterators = paths + .into_iter() + .map(|p| EventIterator::new(p)) + .collect::>(); + Self { iterators } + } +} + impl Iterator for EventIterator { type Item = EmittedEvent; @@ -81,3 +95,18 @@ impl Iterator for EventIterator { } } } + +impl Iterator for MultiContractEventIterator { + type Item = EmittedEvent; + + fn next(&mut self) -> Option { + while let Some(iterator) = self.iterators.front_mut() { + if let Some(event) = iterator.next() { + self.iterators.rotate_left(1); + return Some(event); + } + self.iterators.pop_front(); + } + None + } +} diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs index 1635a8e8..70afaa2d 100644 --- a/crates/testing/src/lib.rs +++ b/crates/testing/src/lib.rs @@ -2,5 +2,5 @@ mod dojo; mod event_reader; pub mod utils; pub use dojo::FakeProvider; -pub use event_reader::EventIterator; +pub use event_reader::{EventIterator, MultiContractEventIterator}; pub use utils::{read_json_file, resolve_path_like}; diff --git a/crates/torii-common/Cargo.toml b/crates/torii-common/Cargo.toml index 9112e39f..572afe1d 100644 --- a/crates/torii-common/Cargo.toml +++ b/crates/torii-common/Cargo.toml @@ -5,7 +5,6 @@ edition = "2021" description = "Common utilities for Torii token indexers" [dependencies] -starknet = "0.17" anyhow = "1.0" tracing = "0.1" tokio = { version = "1", features = ["full"] } @@ -15,13 +14,9 @@ urlencoding = "2" async-trait = "0.1" serde.workspace = true serde_json.workspace = true -sqlx = { workspace = true, features = [ - "postgres", - "runtime-tokio-rustls", - "mysql", - "sqlite", -] } -itertools.workspace = true +starknet.workspace = true +starknet-types-raw.workspace = true +primitive-types.workspace = true [lints] workspace = true diff --git a/crates/torii-common/README.md b/crates/torii-common/README.md new file mode 100644 index 00000000..b93036d4 --- /dev/null +++ b/crates/torii-common/README.md @@ -0,0 +1,70 @@ +# torii-common + +Shared low-level utilities used by every Torii sink and decoder: compact U256 +and Felt BLOB encodings, JSON helpers, token metadata fetching, and a +pluggable token-URI resolver. This crate has no knowledge of the ETL loop — +it is a pure toolbox. + +## Role in Torii + +`torii-common` is consumed everywhere amounts, addresses, or token metadata +need to move between the chain and the database. The token sinks +(`torii-erc20`, `torii-erc721`, `torii-erc1155`) store amounts and token IDs +as variable-length BLOBs via `U256Blob`, and fetch name/symbol/decimals from +on-chain views or off-chain metadata URIs via `MetadataFetcher` + +`TokenUriService`. + +## Architecture + +```text ++-----------------------------------------------------------+ +| torii-common | +| | +| src/lib.rs src/metadata.rs src/token_uri.rs| +| +-----------+ +----------------+ +--------------+| +| | U256Blob | | MetadataFetcher| |TokenUriService| +| | FeltBlob | | TokenMetadata | |TokenStandard | +| +-----------+ +----------------+ |TokenUriRequest| +| ^ ^ |TokenUriStore | +| | | +--------------+| +| | | ^ | +| | | | | +| Sinks / Decoders <---------+--------------------+ | +| (torii-erc20, torii-erc721, torii-erc1155) | ++-----------------------------------------------------------+ +``` + +## Deep Dive + +### Public API + +| Type / Fn | File | Line | Purpose | +|---|---|---|---| +| `U256Blob` | `src/lib.rs` | 22 | Compact big-endian BLOB codec for U256 (leading zeros stripped, zero → `[0]`) | +| `FeltBlob` | `src/lib.rs` | 103 | BLOB codec for `RawFelt` / `CoreFelt` | +| `u256_to_blob` / `blob_to_u256` | `src/lib.rs` | 72 / 80 | Generic helpers over `U256Blob` | +| `felt_to_blob` / `blob_to_felt` | `src/lib.rs` | 134 / 148 | Generic helpers over `FeltBlob` | +| `MetadataFetcher` | `src/metadata.rs` | — | Async HTTP + on-chain metadata enrichment | +| `TokenMetadata` | `src/metadata.rs` | — | Name / symbol / decimals / URI | +| `TokenUriService` | `src/token_uri.rs` | — | Resolves on-chain `token_uri` / `uri` to structured `TokenUriResult`s | +| `TokenStandard` | `src/token_uri.rs` | — | ERC20 / ERC721 / ERC1155 tag | +| `process_token_uri_request` | `src/token_uri.rs` | — | One-shot resolver used by metadata command handlers | + +### Internal Modules + +- `json` — small serde helpers (hex parsing, optional fields). +- `metadata` — async fetcher, retry policy, error types, unit-tested around HTTP mocking. +- `token_uri` — token-standard-aware URI resolution (`ipfs://`, `data:` URIs, on-chain strings). +- `utils` — miscellaneous helpers (hex printing, string normalization). + +### Interactions + +- **Upstream (consumers)**: `torii-erc20`, `torii-erc721`, `torii-erc1155`, `torii-dojo`, `arcade-sink`, `introspect-sql-sink`. The root crate re-exports `StarknetEvent` from `torii-types`, not from here. +- **Downstream deps**: `reqwest` (HTTP), `serde` / `serde_json`, `primitive-types`, `starknet` / `starknet-types-raw`, `tokio`. +- **Workspace deps**: none (pure library). + +### Extension Points + +- Adding a new token standard: extend `TokenStandard` in `src/token_uri.rs` and the corresponding branches of `process_token_uri_request`. +- New wire encoding: impl `U256Blob` or `FeltBlob` for a custom numeric type; the existing helpers become generic over it automatically. +- New metadata source: wrap `MetadataFetcher` or register a new `CommandHandler` in the binary (see `torii-erc20::FetchErc20MetadataCommand`). diff --git a/crates/torii-common/src/lib.rs b/crates/torii-common/src/lib.rs index 5a96b8dc..36dcfb3d 100644 --- a/crates/torii-common/src/lib.rs +++ b/crates/torii-common/src/lib.rs @@ -5,104 +5,149 @@ pub mod json; pub mod metadata; -pub mod sql; pub mod token_uri; pub mod utils; -use starknet::core::types::{Felt, U256}; - pub use metadata::{MetadataFetcher, TokenMetadata}; +use primitive_types::U256 as PrimitiveU256; +use starknet::core::types::{Felt as CoreFelt, U256 as CoreU256}; +use starknet_types_raw::Felt as RawFelt; pub use token_uri::{ process_token_uri_request, TokenStandard, TokenUriRequest, TokenUriResult, TokenUriSender, TokenUriService, TokenUriStore, }; -// ===== Felt conversions ===== +// ===== U256 conversions ===== -/// Convert Felt to 32-byte BLOB for storage (big-endian) -pub fn felt_to_blob(felt: Felt) -> Vec { - felt.to_bytes_be().to_vec() +pub trait U256Blob: Sized { + fn to_blob_bytes(self) -> Vec; + fn from_blob_bytes(bytes: &[u8]) -> Self; } -/// Convert BLOB back to Felt (big-endian) -pub fn blob_to_felt(bytes: &[u8]) -> Felt { - let mut arr = [0u8; 32]; - let len = bytes.len().min(32); - // Right-align for big-endian (pad zeros on the left) - arr[32 - len..].copy_from_slice(&bytes[..len]); - Felt::from_bytes_be(&arr) -} +impl U256Blob for PrimitiveU256 { + fn to_blob_bytes(self) -> Vec { + if self.is_zero() { + return vec![0]; + } -/// Parse bytes to Felt (returns None if > 32 bytes) -pub fn bytes_to_felt(bytes: &[u8]) -> Option { - if bytes.len() > 32 { - return None; + let bytes = self.to_big_endian(); + let start = bytes.iter().position(|&b| b != 0).unwrap_or(31); + bytes[start..].to_vec() } - Some(blob_to_felt(bytes)) -} -// ===== U256 conversions ===== - -/// Convert U256 to variable-length BLOB for storage (big-endian, compact) -/// -/// Compression strategy: -/// - Zero value: 1 byte (0x00) -/// - Values < 2^128: 1-16 bytes (minimal encoding of low word) -/// - Values >= 2^128: Full encoding (17-32 bytes) -pub fn u256_to_blob(value: U256) -> Vec { - let high = value.high(); - let low = value.low(); - - if high == 0 { - if low == 0 { - return vec![0u8]; - } - let bytes = low.to_be_bytes(); - let start = bytes.iter().position(|&b| b != 0).unwrap_or(15); - return bytes[start..].to_vec(); + fn from_blob_bytes(bytes: &[u8]) -> Self { + Self::from_big_endian(bytes) } - - let mut result = Vec::with_capacity(32); - let high_bytes = high.to_be_bytes(); - let high_start = high_bytes.iter().position(|&b| b != 0).unwrap_or(15); - result.extend_from_slice(&high_bytes[high_start..]); - result.extend_from_slice(&low.to_be_bytes()); - result } -/// Convert BLOB back to U256 (big-endian) -pub fn blob_to_u256(bytes: &[u8]) -> U256 { - let len = bytes.len(); +impl U256Blob for CoreU256 { + fn to_blob_bytes(self) -> Vec { + if self == Self::from(0u8) { + return vec![0]; + } - if len == 0 { - return U256::from(0u64); + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(&self.high().to_be_bytes()); + bytes[16..].copy_from_slice(&self.low().to_be_bytes()); + let start = bytes.iter().position(|&b| b != 0).unwrap_or(31); + bytes[start..].to_vec() } - if len <= 16 { - let mut low_bytes = [0u8; 16]; - low_bytes[16 - len..].copy_from_slice(bytes); - let low = u128::from_be_bytes(low_bytes); - U256::from_words(low, 0) - } else { - let high_len = len - 16; - let mut high_bytes = [0u8; 16]; - high_bytes[16 - high_len..].copy_from_slice(&bytes[..high_len]); - let high = u128::from_be_bytes(high_bytes); - - let mut low_bytes = [0u8; 16]; - low_bytes.copy_from_slice(&bytes[high_len..]); - let low = u128::from_be_bytes(low_bytes); - - U256::from_words(low, high) + fn from_blob_bytes(bytes: &[u8]) -> Self { + let mut padded = [0u8; 32]; + let source = if bytes.len() > 32 { + &bytes[bytes.len() - 32..] + } else { + bytes + }; + let offset = 32 - source.len(); + padded[offset..].copy_from_slice(source); + let high = u128::from_be_bytes(padded[..16].try_into().unwrap()); + let low = u128::from_be_bytes(padded[16..].try_into().unwrap()); + Self::from_words(low, high) } } +/// Convert U256 to variable-length BLOB for storage (big-endian, compact) +pub fn u256_to_blob(value: U) -> Vec +where + U: U256Blob, +{ + value.to_blob_bytes() +} + +/// Convert BLOB back to U256 (big-endian) +pub fn blob_to_u256(bytes: &[u8]) -> U +where + U: U256Blob, +{ + U::from_blob_bytes(bytes) +} + /// Alias for u256_to_blob (same format, different context name) -pub fn u256_to_bytes(value: U256) -> Vec { +pub fn u256_to_bytes(value: U) -> Vec +where + U: U256Blob, +{ u256_to_blob(value) } /// Alias for blob_to_u256 (same format, different context name) -pub fn bytes_to_u256(bytes: &[u8]) -> U256 { +pub fn bytes_to_u256(bytes: &[u8]) -> U +where + U: U256Blob, +{ blob_to_u256(bytes) } + +pub trait FeltBlob: Sized { + fn to_blob_bytes(self) -> Vec; + fn from_blob_bytes(bytes: &[u8]) -> Option; +} + +impl FeltBlob for RawFelt { + fn to_blob_bytes(self) -> Vec { + self.to_be_bytes_vec() + } + + fn from_blob_bytes(bytes: &[u8]) -> Option { + Self::from_be_bytes_slice(bytes).ok() + } +} + +impl FeltBlob for CoreFelt { + fn to_blob_bytes(self) -> Vec { + self.to_bytes_be().to_vec() + } + + fn from_blob_bytes(bytes: &[u8]) -> Option { + if bytes.len() > 32 { + return None; + } + + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(bytes); + Some(Self::from_bytes_be(&padded)) + } +} + +pub fn felt_to_blob(value: F) -> Vec +where + F: FeltBlob, +{ + value.to_blob_bytes() +} + +pub fn bytes_to_felt(bytes: &[u8]) -> Option +where + F: FeltBlob, +{ + F::from_blob_bytes(bytes) +} + +pub fn blob_to_felt(bytes: &[u8]) -> F +where + F: FeltBlob, +{ + bytes_to_felt(bytes).expect("database stores canonical felt blobs") +} diff --git a/crates/torii-common/src/metadata.rs b/crates/torii-common/src/metadata.rs index 8c8bd24c..8259a340 100644 --- a/crates/torii-common/src/metadata.rs +++ b/crates/torii-common/src/metadata.rs @@ -4,16 +4,19 @@ //! making `starknet_call` requests. Handles both snake_case and camelCase //! selectors, felt-encoded strings and ByteArray returns. +use primitive_types::U256; use starknet::core::codec::Decode; -use starknet::core::types::{ - requests::CallRequest, BlockId, BlockTag, ByteArray, Felt, FunctionCall, U256, -}; +use starknet::core::types::requests::CallRequest; +use starknet::core::types::{BlockId, BlockTag, ByteArray, Felt as SnFelt, FunctionCall}; use starknet::core::utils::parse_cairo_short_string; use starknet::macros::selector; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::sync::Arc; +use crate::utils::parse_u256_result; + /// Token metadata (common fields for all ERC standards) #[derive(Debug, Clone, Default)] pub struct TokenMetadata { @@ -85,11 +88,13 @@ impl MetadataFetcher { /// Returns None if the call fails or returns empty data. pub async fn fetch_token_uri(&self, contract: Felt, token_id: Felt) -> Option { // Try snake_case first, then camelCase + let contract = contract.into(); + let token_id = token_id.into(); for sel in [selector!("token_uri"), selector!("tokenURI")] { let call = FunctionCall { contract_address: contract, entry_point_selector: sel, - calldata: vec![token_id, Felt::ZERO], // u256: (low, high) + calldata: vec![token_id, SnFelt::ZERO], // u256: (low, high) }; if let Ok(result) = self @@ -132,10 +137,13 @@ impl MetadataFetcher { /// Fetch `uri(token_id)` for ERC1155 tokens. pub async fn fetch_uri(&self, contract: Felt, token_id: Felt) -> Option { // ERC1155 uses `uri(token_id)` — u256 arg + let contract = contract.into(); + let token_id = token_id.into(); + let call = FunctionCall { contract_address: contract, entry_point_selector: selector!("uri"), - calldata: vec![token_id, Felt::ZERO], + calldata: vec![token_id, SnFelt::ZERO], }; if let Ok(result) = self @@ -206,7 +214,7 @@ impl MetadataFetcher { }; let call = FunctionCall { - contract_address: contract, + contract_address: contract.into(), entry_point_selector: sel, calldata: vec![], }; @@ -233,7 +241,7 @@ impl MetadataFetcher { /// Fetch `decimals()` from an ERC20 contract. async fn fetch_decimals(&self, contract: Felt) -> Option { let call = FunctionCall { - contract_address: contract, + contract_address: contract.into(), entry_point_selector: selector!("decimals"), calldata: vec![], }; @@ -276,7 +284,7 @@ impl MetadataFetcher { async fn fetch_total_supply(&self, contract: Felt) -> Option { for sel in [selector!("total_supply"), selector!("totalSupply")] { let call = FunctionCall { - contract_address: contract, + contract_address: contract.into(), entry_point_selector: sel, calldata: vec![], }; @@ -290,13 +298,9 @@ impl MetadataFetcher { continue; } // U256 return: [low, high] or single felt - let low: u128 = result[0].try_into().unwrap_or(0); - return Some(if result.len() == 1 { - U256::from(low) - } else { - let high: u128 = result[1].try_into().unwrap_or(0); - U256::from_words(low, high) - }); + return Some(parse_u256_result( + result.into_iter().map(Into::into).collect(), + )); } } @@ -309,7 +313,7 @@ impl MetadataFetcher { /// 1. **Single short string** (felt): via `parse_cairo_short_string` /// 2. **Cairo ByteArray**: via `ByteArray::decode` (the standard Cairo string type) /// 3. **Legacy array**: `[len, felt1, felt2, ...]` where each felt is a short string segment - fn decode_string_result(result: &[Felt]) -> Option { + fn decode_string_result(result: &[SnFelt]) -> Option { if result.is_empty() { return None; } @@ -355,7 +359,7 @@ impl MetadataFetcher { async fn fetch_string_calls_batch( &self, requests: &[(Felt, Felt)], - attempts: &[(Felt, bool)], + attempts: &[(SnFelt, bool)], ) -> Vec> { if requests.is_empty() { return Vec::new(); @@ -374,13 +378,13 @@ impl MetadataFetcher { .map(|&idx| { let (contract, token_id) = requests[idx]; let calldata = if use_u256 { - vec![token_id, Felt::ZERO] + vec![token_id.into(), SnFelt::ZERO] } else { - vec![token_id] + vec![token_id.into()] }; ProviderRequestData::Call(CallRequest { request: FunctionCall { - contract_address: contract, + contract_address: contract.into(), entry_point_selector: selector, calldata, }, @@ -435,13 +439,13 @@ mod tests { #[test] fn test_parse_short_string() { // "ETH" = 0x455448 - let felt = Felt::from(0x455448u64); + let felt = SnFelt::from(0x455448u64); assert_eq!(parse_cairo_short_string(&felt).unwrap(), "ETH".to_string()); } #[test] fn test_decode_single_felt_string() { - let result = vec![Felt::from(0x455448u64)]; // "ETH" + let result = vec![SnFelt::from(0x455448u64)]; // "ETH" assert_eq!( MetadataFetcher::decode_string_result(&result), Some("ETH".to_string()) @@ -452,9 +456,9 @@ mod tests { fn test_decode_byte_array() { // ByteArray: [data_len=0, pending_word="ETH", pending_word_len=3] let result = vec![ - Felt::from(0u64), // data_len = 0 chunks - Felt::from(0x455448u64), // pending_word = "ETH" - Felt::from(3u64), // pending_word_len = 3 + SnFelt::from(0u64), // data_len = 0 chunks + SnFelt::from(0x455448u64), // pending_word = "ETH" + SnFelt::from(3u64), // pending_word_len = 3 ]; assert_eq!( MetadataFetcher::decode_string_result(&result), diff --git a/crates/torii-common/src/sql.rs b/crates/torii-common/src/sql.rs deleted file mode 100644 index 531adaf0..00000000 --- a/crates/torii-common/src/sql.rs +++ /dev/null @@ -1,241 +0,0 @@ -use async_trait::async_trait; -use itertools::Itertools; -use sqlx::{Database, Executor, Postgres}; -pub use sqlx::{PgPool, Transaction}; -use std::borrow::Cow; -use std::sync::Arc; - -pub use sqlx::Error as SqlxError; - -pub type SqlxResult = std::result::Result; - -#[async_trait] -pub trait Executable { - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()>; -} - -#[async_trait] -impl Executable for &str -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - transaction.execute(self).await?; - Ok(()) - } -} - -#[async_trait] -impl Executable for &String -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - transaction.execute(self.as_str()).await?; - Ok(()) - } -} - -#[async_trait] -impl Executable for String -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - transaction.execute(self.as_str()).await?; - Ok(()) - } -} - -#[async_trait] -impl Executable for Cow<'_, str> -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - transaction.execute(self.as_ref()).await?; - Ok(()) - } -} - -pub struct QueryLike, DB: Database> { - sql: S, - args: Option<::Arguments<'static>>, -} - -impl, DB: Database> QueryLike { - pub fn new(sql: impl Into, args: ::Arguments<'static>) -> Self { - Self { - sql: sql.into(), - args: Some(args), - } - } - - pub fn from_sql(sql: S) -> Self { - Self { sql, args: None } - } -} - -#[async_trait] -impl Executable for FlexStr -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - transaction.execute(self.as_ref()).await?; - Ok(()) - } -} - -pub enum FlexStr { - Owned(String), - Static(&'static str), - Shared(Arc), -} - -impl AsRef for FlexStr { - fn as_ref(&self) -> &str { - match self { - FlexStr::Owned(s) => s.as_str(), - FlexStr::Static(s) => s, - FlexStr::Shared(s) => s, - } - } -} - -impl PartialEq for FlexStr { - fn eq(&self, other: &str) -> bool { - self.as_ref() == other - } -} - -impl, DB: Database> PartialEq for QueryLike { - fn eq(&self, other: &str) -> bool { - self.sql.as_ref() == other - } -} - -impl From for FlexStr { - fn from(s: String) -> Self { - FlexStr::Owned(s) - } -} - -impl From<&'static str> for FlexStr { - fn from(s: &'static str) -> Self { - FlexStr::Static(s) - } -} - -impl From> for FlexStr { - fn from(s: Arc) -> Self { - FlexStr::Shared(s) - } -} - -impl From for QueryLike { - fn from(sql: FlexStr) -> Self { - QueryLike::from_sql(sql) - } -} - -impl From<&'static str> for QueryLike { - fn from(sql: &'static str) -> Self { - QueryLike::from_sql(sql.into()) - } -} - -impl From for QueryLike { - fn from(sql: String) -> Self { - QueryLike::from_sql(sql.into()) - } -} - -impl From> for QueryLike { - fn from(sql: Arc) -> Self { - QueryLike::from_sql(sql.into()) - } -} - -pub trait Queries { - fn add(&mut self, query: impl Into>); - fn adds(&mut self, queries: impl IntoIterator>>); -} - -impl Queries for Vec> { - fn add(&mut self, query: impl Into>) { - self.push(query.into()); - } - fn adds(&mut self, queries: impl IntoIterator>>) { - self.extend(queries.into_iter().map_into()); - } -} - -#[async_trait] -impl Executable for &[String; N] -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - for query in self { - transaction.execute(query.as_str()).await?; - } - Ok(()) - } -} - -#[async_trait] -impl + Send> Executable for Vec { - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - for item in self { - item.execute(transaction).await?; - } - Ok(()) - } -} - -#[async_trait] -impl<'a, DB: sqlx::Database, T> Executable for &'a Vec -where - &'a T: Executable + Send, - T: Send + Sync, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - for item in self { - item.execute(transaction).await?; - } - Ok(()) - } -} - -#[async_trait] -impl<'a, DB: sqlx::Database, T> Executable for &'a [T] -where - &'a T: Executable + Send, - T: Send + Sync, -{ - async fn execute(self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - for item in self { - item.execute(transaction).await?; - } - Ok(()) - } -} - -#[async_trait] -impl + Send, DB: sqlx::Database> Executable for QueryLike -where - for<'c> &'c mut ::Connection: Executor<'c, Database = DB>, - for<'q> ::Arguments<'static>: sqlx::IntoArguments<'q, DB>, -{ - async fn execute(mut self, transaction: &mut Transaction<'_, DB>) -> SqlxResult<()> { - let sql = self.sql.as_ref(); - let args = self.args.take().unwrap_or_default(); - transaction.execute(sqlx::query_with(sql, args)).await?; - Ok(()) - } -} - -pub type PgQuery = QueryLike; -pub type MySqlQuery = QueryLike; -pub type SqliteQuery = QueryLike; diff --git a/crates/torii-common/src/token_uri.rs b/crates/torii-common/src/token_uri.rs index 71cdd249..43d831e0 100644 --- a/crates/torii-common/src/token_uri.rs +++ b/crates/torii-common/src/token_uri.rs @@ -17,7 +17,8 @@ //! - JSON sanitization for broken metadata (control chars, unescaped quotes) //! - Raw JSON fallback for inline metadata -use starknet::core::types::{Felt, U256}; +use primitive_types::U256; +use starknet_types_raw::Felt; use std::collections::{HashMap, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -470,7 +471,8 @@ impl TokenUriService { let mut erc1155_requests = Vec::new(); for (idx, request) in requests.iter().enumerate() { - let token_id = Felt::from(request.token_id.low()); + // let token_id = Felt::from(request.token_id.low()); + let token_id = Felt::from_le_words(request.token_id.0); // CHECK: same logic match request.standard { TokenStandard::Erc721 => { erc721_positions.push(idx); @@ -804,7 +806,7 @@ async fn fetch_token_uri_with_retry( standard: TokenStandard, ) -> Option { // Use the MetadataFetcher which already tries multiple selectors - let token_id_felt = Felt::from(token_id.low()); + let token_id_felt = Felt::from_le_words(token_id.0); // CHECK: same logic as before match standard { TokenStandard::Erc721 => fetcher.fetch_token_uri(contract, token_id_felt).await, diff --git a/crates/torii-common/src/utils.rs b/crates/torii-common/src/utils.rs index f442b9ca..d13adcf8 100644 --- a/crates/torii-common/src/utils.rs +++ b/crates/torii-common/src/utils.rs @@ -1,3 +1,6 @@ +use primitive_types::U256; +use starknet_types_raw::Felt; + pub trait ElementsInto { fn elements_into(self) -> Vec; } @@ -23,3 +26,70 @@ where vec.into_iter().map(U::from).collect() } } + +/// Parse a U256 result from balance_of return value +/// +/// ERC20 balance_of typically returns: +/// - Cairo 0: A single felt (fits in 252 bits, usually enough for balances) +/// - Cairo 1 with u256: Two felts [low, high] representing a 256-bit value +pub fn parse_u256_result(result: Vec) -> U256 { + match result.len() { + 0 => U256::from(0u64), + 1 => { + // Single felt - convert to U256 + // Felt is 252 bits max, so it fits in the low part + // Take the lower 16 bytes for u128 (fits any felt value) + felt_to_u256(result.into_iter().next().unwrap()) + } + _ => { + // Two felts: [low, high] for u256 + let mut iter = result.into_iter(); + felt_pair_to_u256(iter.next().unwrap(), iter.next().unwrap()) + } + } +} + +pub fn felt_pair_to_u256(low: Felt, high: Felt) -> U256 { + let [l0, l1, _, _] = low.to_le_words(); + let [h0, h1, _, _] = high.to_le_words(); + U256([l0, l1, h0, h1]) +} + +pub fn felt_to_u256(value: Felt) -> U256 { + U256(value.to_le_words()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_u256_empty() { + let result = parse_u256_result(vec![]); + assert_eq!(result, U256::from(0u64)); + } + + #[test] + fn test_parse_u256_single_felt() { + let felt = Felt::from(1000u64); + let result = parse_u256_result(vec![felt]); + assert_eq!(result, U256::from(1000u64)); + } + + #[test] + fn test_parse_u256_two_felts() { + // low = 100, high = 0 + let low = Felt::from(100u64); + let high = Felt::from(0u64); + let result = parse_u256_result(vec![low, high]); + assert_eq!(result, U256::from(100u64)); + + // Test with high value + let low = Felt::from(0u64); + let high = Felt::from(1u64); + let result = parse_u256_result(vec![low, high]); + // high = 1 means value = 1 * 2^128 + let expected = U256::from(1u64) << 128; + assert_eq!(result, expected); + } +} diff --git a/crates/torii-config-common/README.md b/crates/torii-config-common/README.md index b27cea7f..7bc86268 100644 --- a/crates/torii-config-common/README.md +++ b/crates/torii-config-common/README.md @@ -1,19 +1,60 @@ # torii-config-common -Shared CLI configuration helpers for Torii binaries. +Shared **CLI configuration** helpers for Torii binaries. A tiny crate whose +only purpose is to keep clap parsers honest: same flags → same behaviour +across every binary under `bins/`. -## What it provides +## Role in Torii -- `apply_observability_env(enabled: bool)` - - Sets `TORII_METRICS_ENABLED` from CLI flags so runtime behavior is explicit. -- `require_postgres_url(url, arg_name)` - - Validates that a URL is PostgreSQL and returns a user-facing error if not. +Every binary uses `clap` to build its own `Config` struct, then calls into +this crate to (a) turn observability flags into environment variables the +core library reads (`TORII_METRICS_ENABLED`) and (b) validate that strings +purporting to be PostgreSQL URLs actually are. Everything that needs a real +database URL lives in `torii-runtime-common`; this crate is pure validation +and env plumbing. -## Example +## Architecture -```rust -use torii_config_common::{apply_observability_env, require_postgres_url}; - -apply_observability_env(config.observability); -let storage_url = require_postgres_url(&config.storage_database_url, "--storage-database-url")?; +```text + clap parser (in each bin) + | + v + +-----------------------------+ + | torii-config-common | + | | + | apply_observability_env() |---> sets TORII_METRICS_ENABLED + | is_postgres_url() | + | require_postgres_url() |---> anyhow::Error on bad URL + +-----------------------------+ + | + v + torii-runtime-common::resolve_*_db_setup + | + v + torii::ToriiConfigBuilder ``` + +## Deep Dive + +### Public API + +| Fn | File | Purpose | +|---|---|---| +| `apply_observability_env(enabled: bool)` | `src/lib.rs` | Sets `TORII_METRICS_ENABLED=true/false` so the metrics subsystem in `src/metrics.rs::init_from_env` picks it up | +| `is_postgres_url(url: &str) -> bool` | `src/lib.rs` | Prefix check for `postgres://` / `postgresql://` | +| `require_postgres_url(url: &str, arg_name: &str)` | `src/lib.rs` | Returns `anyhow::Error` with a user-facing message if the URL is not PostgreSQL; used by binaries that only support PG (synthetic profilers) | + +### Internal Modules + +- `lib.rs` — single file, < 100 LoC, no sub-modules. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-arcade`, `bins/torii-introspect-bin`, `bins/torii-tokens`, the three `-synth` profilers. +- **Downstream deps**: `anyhow` only. +- **Workspace deps**: none. + +### Extension Points + +- New cross-binary CLI rules (e.g., conflicting-flag validation) belong here so every binary inherits them. +- Resist adding DB-setup logic — that belongs in `torii-runtime-common`. diff --git a/crates/torii-controllers-sink/Cargo.toml b/crates/torii-controllers-sink/Cargo.toml index 4f5c07a6..6396be7c 100644 --- a/crates/torii-controllers-sink/Cargo.toml +++ b/crates/torii-controllers-sink/Cargo.toml @@ -4,18 +4,25 @@ version = "0.1.0" edition = "2021" [dependencies] -torii = { path = "../.." } +torii.workspace = true torii-runtime-common.workspace = true +torii-sql.workspace = true anyhow.workspace = true async-trait.workspace = true chrono.workspace = true +hex.workspace = true metrics.workspace = true reqwest = { version = "0.12", features = ["json", "rustls-tls"] } serde.workspace = true serde_json.workspace = true -sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "postgres", "any"] } -starknet.workspace = true +sqlx = { workspace = true, features = [ + "runtime-tokio", + "sqlite", + "postgres", + "any", +] } +starknet-types-raw.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/torii-controllers-sink/README.md b/crates/torii-controllers-sink/README.md new file mode 100644 index 00000000..3b7b5703 --- /dev/null +++ b/crates/torii-controllers-sink/README.md @@ -0,0 +1,111 @@ +# torii-controllers-sink + +Syncs **Cartridge controller accounts** (username + address + deployment +timestamp) from the Cartridge GraphQL API into a local SQL table. Unlike +most sinks, this one is **API-driven** — it does not consume `Envelope`s; +it watches the block timestamps moving in the `ExtractionBatch` and uses +that as a tick to fetch fresh controllers from +`https://api.cartridge.gg/query`. + +## Role in Torii + +Binaries that serve the Cartridge ecosystem (`bins/torii-arcade`, +`bins/torii-introspect-bin`) plug this sink in alongside the token / dojo +sinks. It provides the `controllers` table other sinks JOIN against to +enrich queries with a human-readable `username` for a given on-chain +address. + +## Architecture + +```text + +------------------------------+ + | ControllersSink | + | | + | process(batch): | + | time_window := min/max | + | block.timestamp | + | if window > synced_until: | + | fetch_controllers(GQL) |---> https://api.cartridge.gg/query + | upsert batch(store) | + +------------------------------+ + | + v + +------------------------------+ + | ControllersStore | + | Arc> | + | backend = Sqlite | Postgres | + | | + | controllers (id PK, addr, | + | username, deployed_at, | + | updated_at) | + | torii_controller_sync_state | + | (key='synced_until', | + | value=) | + +------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `ControllersSink` | `src/lib.rs` | 260 | Sink — wraps `ControllersStore`, reqwest client, GraphQL URL | +| `ControllersSink::new(db_url, max_conns, api_url)` | `src/lib.rs` | 267 | Opens the store, initialises the HTTP client | +| `CONTROLLERS_TABLE`, `CONTROLLERS_STATE_TABLE` | `src/lib.rs` | 21 / 22 | Table-name constants | +| `DEFAULT_API_QUERY_URL` | `src/lib.rs` | 20 | `https://api.cartridge.gg/query` | + +Internal (not re-exported): `ControllersStore`, `StoredController`, `ControllerNode`, `CONTROLLERS_TYPE = TypeId::new("controllers.sync")`. + +### Internal Modules + +- `lib.rs` — the entire crate; one file. Sections: GraphQL response types, `ControllersStore` (schema + upsert + cursor), `ControllersSink` (GraphQL query + polling loop + `Sink` impl). + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"controllers"` | +| `interested_types` | `[TypeId::new("controllers.sync")]` — ignored; `process` uses `batch`, not envelopes | +| `process(_envelopes, batch)` | Computes `batch_time_window`; if greater than stored `synced_until`, issues GraphQL query filtered by `createdAtGT` / `createdAtLTE`, upserts results in SQLite-sized chunks (199) or Postgres-sized chunks (10 000) | +| `topics` | `Vec::new()` — no EventBus topic | +| `build_routes` | `Router::new()` — no HTTP endpoints | +| `initialize` | Creates schema; if table is empty, performs a **full sync** from epoch to `now` to seed the data | + +### Storage + +- **Tables** (both created with backend-aware DDL): + - `controllers`: `id PK` (hex address), `address UNIQUE`, `username`, `deployed_at` (RFC 3339), `updated_at` (unix seconds). + - `torii_controller_sync_state`: key/value pair; tracks `synced_until` cursor. +- **Upsert batching**: `SQLITE_CONTROLLER_UPSERT_BATCH_SIZE = 199`, `POSTGRES_CONTROLLER_UPSERT_BATCH_SIZE = 10_000` (both avoid the 32 767-parameter SQLite limit and fit well within PG). +- **Address normalisation**: `0x` + `hex::encode(felt.to_be_bytes_vec())` so lookups match whatever shape the on-chain address landed in. + +### GraphQL contract + +Query shape (`src/lib.rs::ControllersSink::build_query`): + +```graphql +query { + controllers( + where: { createdAtGT: "", createdAtLTE: "" }, + orderBy: { field: CREATED_AT, direction: ASC } + ) { + edges { node { address createdAt account { username } } } + } +} +``` + +Retries: `MAX_RETRIES = 3`, exponential backoff starting at +`INITIAL_BACKOFF = 2s`. Partial errors from the API are logged and +dropped; total failures bubble up from `process`. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-arcade`, `bins/torii-introspect-bin`. +- **Downstream deps**: `torii`, `torii-runtime-common`, `torii-sql`, `sqlx` (sqlite/postgres/any), `reqwest`, `chrono`, `hex`, `metrics`, `tracing`, `serde`. + +### Extension Points + +- Point at a different backend API → pass `api_url` override to `ControllersSink::new`. +- Add more fields → extend `ControllerNode`, `StoredController`, schema migration, and the upsert SQL in `ControllersStore::upsert_controllers_and_store_synced_until`. +- Expose the data over gRPC → build a second sink that reads the `controllers` table; keep this sink single-responsibility (sync only). diff --git a/crates/torii-controllers-sink/src/lib.rs b/crates/torii-controllers-sink/src/lib.rs index ce02c551..a4f5d05c 100644 --- a/crates/torii-controllers-sink/src/lib.rs +++ b/crates/torii-controllers-sink/src/lib.rs @@ -1,22 +1,21 @@ -use std::str::FromStr; -use std::sync::Arc; -use std::time::Duration; - use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use chrono::{DateTime, TimeZone, Utc}; use reqwest::Client; use serde::Deserialize; use serde_json::json; -use sqlx::{ - any::AnyPoolOptions, sqlite::SqliteConnectOptions, Any, ConnectOptions, Pool, QueryBuilder, Row, -}; -use starknet::core::types::Felt; +use sqlx::any::AnyPoolOptions; +use sqlx::{Any, Pool, QueryBuilder, Row}; +use starknet_types_raw::Felt; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; use torii::axum::Router; use torii::etl::extractor::ExtractionBatch; use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; use torii::etl::TypeId; -use torii_runtime_common::database::DEFAULT_SQLITE_MAX_CONNECTIONS; +use torii_runtime_common::database::{backend_from_url_or_path, DEFAULT_SQLITE_MAX_CONNECTIONS}; +use torii_sql::DbBackend; pub const DEFAULT_API_QUERY_URL: &str = "https://api.cartridge.gg/query"; pub const CONTROLLERS_TABLE: &str = "controllers"; @@ -28,23 +27,6 @@ const CONTROLLERS_TYPE: TypeId = TypeId::new("controllers.sync"); const CONTROLLER_PROCESSING_BATCH_SIZE: usize = 10_000; const SQLITE_CONTROLLER_UPSERT_BATCH_SIZE: usize = 199; const POSTGRES_CONTROLLER_UPSERT_BATCH_SIZE: usize = 10_000; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DbBackend { - Sqlite, - Postgres, -} - -impl DbBackend { - fn detect(database_url: &str) -> Self { - if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { - Self::Postgres - } else { - Self::Sqlite - } - } -} - #[derive(Debug, Clone, Deserialize)] struct ControllerAccount { username: String, @@ -111,7 +93,7 @@ impl TryFrom for StoredController { value.address ) })?; - let normalized = format!("{felt_addr:#066x}"); + let normalized = normalize_controller_address(&felt_addr); Ok(Self { id: normalized.clone(), address: normalized, @@ -122,6 +104,10 @@ impl TryFrom for StoredController { } } +fn normalize_controller_address(address: &Felt) -> String { + format!("0x{}", hex::encode(address.to_be_bytes_vec())) +} + struct ControllersStore { pool: Pool, backend: DbBackend, @@ -131,7 +117,7 @@ impl ControllersStore { async fn new(database_url: &str, max_connections: Option) -> Result { sqlx::any::install_default_drivers(); - let backend = DbBackend::detect(database_url); + let backend = backend_from_url_or_path(database_url); let database_url = match backend { DbBackend::Postgres => database_url.to_string(), DbBackend::Sqlite => sqlite_url(database_url)?, @@ -554,16 +540,17 @@ fn sqlite_url(path: &str) -> Result { if path.starts_with("sqlite:") { return Ok(path.to_string()); } - let options = SqliteConnectOptions::from_str(&format!("sqlite://{path}")) - .or_else(|_| Ok::<_, sqlx::Error>(SqliteConnectOptions::new().filename(path)))?; - if let Some(parent) = options - .get_filename() + let path = std::path::Path::new(path); + if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } - Ok(options.to_url_lossy().to_string()) + if !path.exists() { + std::fs::File::create(path)?; + } + Ok(format!("sqlite://{}", path.display())) } #[cfg(test)] @@ -572,7 +559,9 @@ mod tests { use serde_json::Value; use sqlx::query_scalar; use tokio::net::TcpListener; - use torii::axum::{extract::State, routing::post, Json, Router}; + use torii::axum::extract::State; + use torii::axum::routing::post; + use torii::axum::{Json, Router}; use torii::command::CommandBus; use torii::grpc::SubscriptionManager; @@ -620,6 +609,26 @@ mod tests { batch } + fn temp_sqlite_path(name: &str) -> String { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("torii-controllers-sink-{name}-{nonce}.db")) + .to_string_lossy() + .to_string() + } + + #[tokio::test] + async fn store_accepts_plain_sqlite_path() { + let path = temp_sqlite_path("plain-path"); + let store = ControllersStore::new(&path, Some(1)).await.unwrap(); + + store.initialize().await.unwrap(); + assert!(std::path::Path::new(&path).exists()); + } + #[tokio::test] async fn controllers_sink_persists_rows_and_progress() { let api_url = spawn_graphql_server(json!({ @@ -649,13 +658,18 @@ mod tests { .await .unwrap(); - let username: String = query_scalar(&format!( - "SELECT username FROM {CONTROLLERS_TABLE} WHERE address = ?1" - )) - .bind("0x0000000000000000000000000000000000000000000000000000000000000123") - .fetch_one(&sink.store.pool) - .await - .unwrap(); + let controller_count: i64 = + query_scalar(&format!("SELECT COUNT(*) FROM {CONTROLLERS_TABLE}")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); + assert_eq!(controller_count, 1); + + let username: String = + query_scalar(&format!("SELECT username FROM {CONTROLLERS_TABLE} LIMIT 1")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); assert_eq!(username, "test_user"); let synced_until: i64 = query_scalar(&format!( @@ -709,13 +723,18 @@ mod tests { .await .unwrap(); - let username: String = query_scalar(&format!( - "SELECT username FROM {CONTROLLERS_TABLE} WHERE id = ?1" - )) - .bind("0x0000000000000000000000000000000000000000000000000000000000000123") - .fetch_one(&sink.store.pool) - .await - .unwrap(); + let controller_count: i64 = + query_scalar(&format!("SELECT COUNT(*) FROM {CONTROLLERS_TABLE}")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); + assert_eq!(controller_count, 1); + + let username: String = + query_scalar(&format!("SELECT username FROM {CONTROLLERS_TABLE} LIMIT 1")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); assert_eq!(username, "user_one"); } @@ -802,6 +821,24 @@ mod tests { assert_eq!(username, "test_user"); } + #[test] + fn stored_controller_normalizes_addresses_to_fixed_hex() { + let controller = StoredController::try_from(ControllerNode { + address: "0x123".to_string(), + created_at: "2024-03-20T12:00:00Z".to_string(), + account: ControllerAccount { + username: "test_user".to_string(), + }, + }) + .unwrap(); + + assert_eq!( + controller.address, + "0x0000000000000000000000000000000000000000000000000000000000000123" + ); + assert_eq!(controller.id, controller.address); + } + #[tokio::test] async fn initialize_runs_full_sync_when_table_is_empty() { let api_url = spawn_graphql_server(json!({ @@ -827,13 +864,18 @@ mod tests { .unwrap(); initialize_sink(&mut sink).await; - let username: String = query_scalar(&format!( - "SELECT username FROM {CONTROLLERS_TABLE} WHERE address = ?1" - )) - .bind("0x0000000000000000000000000000000000000000000000000000000000000456") - .fetch_one(&sink.store.pool) - .await - .unwrap(); + let controller_count: i64 = + query_scalar(&format!("SELECT COUNT(*) FROM {CONTROLLERS_TABLE}")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); + assert_eq!(controller_count, 1); + + let username: String = + query_scalar(&format!("SELECT username FROM {CONTROLLERS_TABLE} LIMIT 1")) + .fetch_one(&sink.store.pool) + .await + .unwrap(); assert_eq!(username, "boot_user"); let synced_until: i64 = query_scalar(&format!( diff --git a/crates/torii-ecs-sink/Cargo.toml b/crates/torii-ecs-sink/Cargo.toml index 3598c8c9..f582e783 100644 --- a/crates/torii-ecs-sink/Cargo.toml +++ b/crates/torii-ecs-sink/Cargo.toml @@ -4,12 +4,15 @@ version = "0.1.0" edition = "2021" [dependencies] -torii = { path = "../.." } -torii-dojo = { path = "../dojo" } -torii-introspect = { path = "../introspect" } +torii.workspace = true +torii-dojo = { workspace = true, features = ["postgres", "sqlite"] } +torii-introspect.workspace = true torii-runtime-common.workspace = true +torii-sql.workspace = true + dojo-introspect.workspace = true introspect-types.workspace = true +starknet-types-raw.workspace = true anyhow.workspace = true async-trait.workspace = true @@ -20,8 +23,12 @@ prost.workspace = true prost-types.workspace = true serde.workspace = true serde_json.workspace = true -sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "postgres", "any"] } -starknet.workspace = true +sqlx = { workspace = true, features = [ + "runtime-tokio", + "sqlite", + "postgres", + "any", +] } tokio.workspace = true tokio-stream.workspace = true tonic.workspace = true diff --git a/crates/torii-ecs-sink/README.md b/crates/torii-ecs-sink/README.md index 67cf3597..a6440acc 100644 --- a/crates/torii-ecs-sink/README.md +++ b/crates/torii-ecs-sink/README.md @@ -1,88 +1,129 @@ -# Torii ECS Sink - -`torii-ecs-sink` exposes the legacy `world.World` gRPC API on top of the data already indexed by `torii-introspect`. - -## What It Serves - -The sink mounts these legacy RPCs into `torii-server` on the same gRPC port: - -- `Worlds` -- `RetrieveEntities` -- `RetrieveEventMessages` -- `RetrieveEvents` -- `SubscribeContracts` -- `SubscribeEntities` -- `SubscribeEventMessages` -- `SubscribeEvents` -- `UpdateEntitiesSubscription` -- `UpdateEventMessagesSubscription` - -The read path is backed by: - -- `torii_dojo_manager_state` for Dojo table metadata -- `torii_ecs_entity_meta` and `torii_ecs_entity_models` for entity and event-message snapshots -- `torii_ecs_events` for raw events -- `torii_ecs_table_kinds` for entity vs event-message classification - -## Manual Validation - -With `torii-server` running on the default port: - -```bash -grpcurl -plaintext localhost:3000 list -grpcurl -plaintext localhost:3000 describe world.World -grpcurl -plaintext -d '{}' localhost:3000 world.World/Worlds -grpcurl -max-time 10 -plaintext -d '{"query":{"pagination":{"limit":1,"direction":"FORWARD"}}}' localhost:3000 world.World/RetrieveEvents +# torii-ecs-sink + +Serves the **legacy `world.World` gRPC API** on top of the data already +persisted by `introspect-sql-sink`. It is the compatibility shim that lets +existing Dojo clients (Cartridge UI, dojo.js SDK) talk to the refactored +Torii pipeline. + +## Role in Torii + +Dojo worlds emit introspect events that `introspect-sql-sink` turns into +relational rows. `torii-ecs-sink` consumes the same `DojoEvent` stream but +only cares about two things: classifying each table as an *entity* or +*event-message*, and handling runtime registration of external contracts +(ERC20/ERC721/ERC1155) so the `EcsService` can enrich its responses. It +does not write business data; it reads from the introspect tables populated +earlier in the sink pipeline to answer `RetrieveEntities`, +`RetrieveEventMessages`, `RetrieveEvents`, and the matching subscription +streams. + +## Architecture + +```text +DojoDecoder (torii-dojo) + | + v DojoBody { DojoEvent::Introspect(...) | DojoEvent::External(...) } + | ++----------------------------------------------+ +| EcsSink | +| | +| process(envelopes, batch): | +| - IntrospectMsg → update table_kind cache | +| (entity | event_message) | +| - ExternalContractRegistered → dispatch | +| RegisterExternalContractCommand on the | +| command bus (if indexing enabled) | +| | +| installed_external_decoders: HashSet | +| contract_types: SharedContractTypeRegistry | ++----------------------+-----------------------+ + | + v ++----------------------------------------------+ +| EcsService | +| | +| sqlx pools: world + optional erc20/721/1155 | +| tables: torii_dojo_manager_state, | +| torii_ecs_entity_meta, | +| torii_ecs_entity_models, | +| torii_ecs_events, | +| torii_ecs_table_kinds | +| | +| gRPC server: world.World | +| - Worlds | +| - RetrieveEntities | +| - RetrieveEventMessages | +| - RetrieveEvents | +| - SubscribeContracts | +| - SubscribeEntities | +| - SubscribeEventMessages | +| - SubscribeEvents | +| - UpdateEntitiesSubscription | +| - UpdateEventMessagesSubscription | ++----------------------------------------------+ ``` -Important: +## Deep Dive -- `RetrieveEntities` only returns models classified as `entity` -- `RetrieveEventMessages` only returns models classified as `event_message` -- a model such as `ARCADE-Sale` can be valid for `RetrieveEventMessages` and intentionally return nothing from `RetrieveEntities` +### Public API -## End-to-End Test Script +| Item | File | Line | Purpose | +|---|---|---|---| +| `EcsSink` | `src/sink.rs` | 23 | Sink wrapping `EcsService` + the shared contract-type registry | +| `EcsSink::new(db_url, max_conns, erc20?, erc721?, erc1155?, types_registry, from_block, enable_indexing, installed_decoders)` | `src/sink.rs` | 33 | Multi-URL constructor | +| `EcsSink::get_grpc_service_impl` | `src/sink.rs` | 63 | Returns `Arc` for `with_grpc_router` | +| `EcsService` | `src/grpc_service.rs` | — | Tonic impl of `world.World` | +| `TableKind` | `src/grpc_service.rs` | — | `Entity` / `EventMessage` classifier | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs` | — | `world.proto` + `types.proto` descriptor bytes | -The repository ships a runnable script at `./scripts/test-ecs-grpc-e2e.sh`. +### Internal Modules -From the repository root: +- `lib.rs` — re-exports + generated `world`, `types`, reflection bytes. +- `sink.rs` — `Sink` impl; consumes `DojoEvent::Introspect` for `TableKind` classification, `DojoEvent::External` for external-contract registration (dispatched via `CommandBusSender` → `RegisterExternalContractCommand`). +- `grpc_service.rs` — `EcsService`; pagination, subscription streams, cross-DB joins between introspect + token tables. -```bash -./scripts/test-ecs-grpc-e2e.sh -``` +### Sink trait wiring -By default the script: +| Method | Behavior | +|---|---| +| `name` | `"ecs"` | +| `interested_types` | `[DOJO_TYPE_ID]` | +| `process(envelopes, batch)` | Downcasts each envelope to `DojoBody`; for `IntrospectMsg::CreateTable/RenameTable/DropTable` updates `torii_ecs_table_kinds`; for `ExternalContractRegistered` dispatches `RegisterExternalContractCommand` through the `CommandBusSender` captured at `initialize` | +| `topics` | Empty — uses gRPC subscription streams directly, not the central EventBus | +| `build_routes` | `Router::new()` — gRPC only | +| `initialize(event_bus, ctx)` | Captures `ctx.command_bus` into its `RwLock>` so `process` can dispatch commands | -- targets `localhost:3000` -- reads sqlite state from `./torii-data/introspect.db` -- autodiscovers one world that has both `entity` and `event_message` models -- validates reflection, `Worlds`, `RetrieveEntities`, `RetrieveEventMessages`, and `RetrieveEvents` +### Storage (reads only — writes via introspect-sql-sink) -For Postgres-backed deployments, set `DB_URL` or `DATABASE_URL` to the same storage database used by `torii-server`. The script will use `psql` for metadata discovery instead of `sqlite3`. +- `torii_dojo_manager_state` — Dojo table metadata +- `torii_ecs_entity_meta` — entity snapshots +- `torii_ecs_entity_models` — per-entity model rows +- `torii_ecs_events` — raw events +- `torii_ecs_table_kinds` — `entity` vs `event_message` classification -### Environment Overrides +**Gotcha**: `RetrieveEntities` only returns tables classified as `entity`; `RetrieveEventMessages` only returns `event_message`. A model like `ARCADE-Sale` can be a valid `event_message` and intentionally return nothing from `RetrieveEntities`. -You can override discovery when needed: +### Interactions -```bash -GRPC_ADDR=localhost:3000 \ -DB_PATH=./torii-data/introspect.db \ -WORLD_ADDRESS=0x7a079295990e43441a7389fdc3b9ba063c6cd6aee16fb846f598c42a9f04ff7 \ -ENTITY_MODEL=ARCADE-Order \ -EVENT_MESSAGE_MODEL=ARCADE-Sale \ -./scripts/test-ecs-grpc-e2e.sh -``` +- **Upstream (consumers)**: `bins/torii-introspect-bin`, `bins/torii-arcade`. +- **Downstream deps**: `torii`, `torii-dojo`, `torii-introspect`, `torii-runtime-common`, `torii-sql`, `sqlx`, `tonic`, `prost`, `starknet-types-raw`, `tokio`, `async-trait`, `tracing`, `metrics`, `hex`. -If `WORLD_ADDRESS`, `ENTITY_MODEL`, and `EVENT_MESSAGE_MODEL` are provided, the script does not need sqlite introspection metadata to choose test cases. +### End-to-end validation -Postgres example: +Run the bundled script against a live `torii-server`: ```bash -GRPC_ADDR=localhost:3000 \ +./scripts/test-ecs-grpc-e2e.sh # auto-discovers via SQLite DB_URL=postgres://torii:torii@localhost:5432/torii \ -./scripts/test-ecs-grpc-e2e.sh + ./scripts/test-ecs-grpc-e2e.sh # Postgres-backed ``` -## Kulala +Overrides: `GRPC_ADDR`, `DB_PATH`, `WORLD_ADDRESS`, `ENTITY_MODEL`, `EVENT_MESSAGE_MODEL`. + +Editor-driven checks: `crates/torii-ecs-sink/ecs-grpc.http` (Kulala-compatible). + +### Extension Points -For editor-driven checks, the crate also includes `./crates/torii-ecs-sink/ecs-grpc.http`. +- New RPCs → `proto/world.proto` + `EcsService`. Keep the stable `world.World` service name — dojo.js depends on it. +- Cross-DB joins → widen `EcsService::new` with a new URL parameter and a new pool. The sink already uses one pool per token standard. +- Avoid adding writes here. Writes belong in `introspect-sql-sink`; this sink is the *read* facade. diff --git a/crates/torii-ecs-sink/src/grpc_service.rs b/crates/torii-ecs-sink/src/grpc_service.rs index 565828cf..060840d6 100644 --- a/crates/torii-ecs-sink/src/grpc_service.rs +++ b/crates/torii-ecs-sink/src/grpc_service.rs @@ -1,10 +1,24 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::pin::Pin; -use std::str::FromStr; -use std::sync::Arc; -use std::task::{Context, Poll}; -use std::time::Instant; - +use crate::proto::types::clause::ClauseType; +use crate::proto::types::member_value::ValueType; +use crate::proto::types::{ + self, ComparisonOperator, ContractType, LogicalOperator, PaginationDirection, PatternMatching, +}; +use crate::proto::world::world_server::World; +use crate::proto::world::{ + RetrieveContractsRequest, RetrieveContractsResponse, RetrieveControllersRequest, + RetrieveControllersResponse, RetrieveEntitiesRequest, RetrieveEntitiesResponse, + RetrieveEventsRequest, RetrieveEventsResponse, RetrieveTokenBalancesRequest, + RetrieveTokenBalancesResponse, RetrieveTokenContractsRequest, RetrieveTokenContractsResponse, + RetrieveTokenTransfersRequest, RetrieveTokenTransfersResponse, RetrieveTokensRequest, + RetrieveTokensResponse, RetrieveTransactionsRequest, RetrieveTransactionsResponse, + SubscribeContractsRequest, SubscribeContractsResponse, SubscribeEntitiesRequest, + SubscribeEntityResponse, SubscribeEventsRequest, SubscribeEventsResponse, + SubscribeTokenBalancesRequest, SubscribeTokenBalancesResponse, SubscribeTokenTransfersRequest, + SubscribeTokenTransfersResponse, SubscribeTokensRequest, SubscribeTokensResponse, + SubscribeTransactionsRequest, SubscribeTransactionsResponse, UpdateEntitiesSubscriptionRequest, + UpdateTokenBalancesSubscriptionRequest, UpdateTokenSubscriptionRequest, + UpdateTokenTransfersSubscriptionRequest, WorldsRequest, WorldsResponse, +}; use anyhow::{anyhow, Result}; use chrono::Utc; use introspect_types::serialize::ToCairoDeSeFrom; @@ -19,46 +33,32 @@ use prost::Message; use serde::ser::SerializeMap; use serde::Serializer; use serde_json::{Map, Serializer as JsonSerializer, Value}; -use sqlx::AnyConnection; -use sqlx::{ - any::AnyPoolOptions, pool::PoolConnection, postgres::PgPoolOptions, - sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Any, Column, ConnectOptions, Pool, - QueryBuilder, Row, -}; -use starknet::core::types::Felt; +use sqlx::any::AnyPoolOptions; +use sqlx::pool::PoolConnection; +use sqlx::postgres::PgPoolOptions; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{Any, AnyConnection, Column, Pool, QueryBuilder, Row}; +use starknet_types_raw::Felt; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs; +use std::pin::Pin; +use std::str::FromStr; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Instant; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::{mpsc, Mutex, RwLock}; use tokio::time::{sleep, Duration}; -use tokio_stream::{wrappers::ReceiverStream, Stream}; +use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::Stream; use tonic::{Request, Response, Status}; -use torii_dojo::store::postgres::PgStore; use torii_dojo::store::sqlite::SqliteStore; use torii_dojo::store::DojoStoreTrait; use torii_dojo::DojoTable; use torii_introspect::events::{CreateTable, Record, UpdateTable}; use torii_introspect::schema::TableSchema; use torii_runtime_common::database::DEFAULT_SQLITE_MAX_CONNECTIONS; - -use crate::proto::types::{ - self, clause::ClauseType, member_value::ValueType, ComparisonOperator, ContractType, - LogicalOperator, PaginationDirection, PatternMatching, -}; -use crate::proto::world::{ - world_server::World, RetrieveContractsRequest, RetrieveContractsResponse, - RetrieveControllersRequest, RetrieveControllersResponse, RetrieveEntitiesRequest, - RetrieveEntitiesResponse, RetrieveEventsRequest, RetrieveEventsResponse, - RetrieveTokenBalancesRequest, RetrieveTokenBalancesResponse, RetrieveTokenContractsRequest, - RetrieveTokenContractsResponse, RetrieveTokenTransfersRequest, RetrieveTokenTransfersResponse, - RetrieveTokensRequest, RetrieveTokensResponse, RetrieveTransactionsRequest, - RetrieveTransactionsResponse, SubscribeContractsRequest, SubscribeContractsResponse, - SubscribeEntitiesRequest, SubscribeEntityResponse, SubscribeEventsRequest, - SubscribeEventsResponse, SubscribeTokenBalancesRequest, SubscribeTokenBalancesResponse, - SubscribeTokenTransfersRequest, SubscribeTokenTransfersResponse, SubscribeTokensRequest, - SubscribeTokensResponse, SubscribeTransactionsRequest, SubscribeTransactionsResponse, - UpdateEntitiesSubscriptionRequest, UpdateTokenBalancesSubscriptionRequest, - UpdateTokenSubscriptionRequest, UpdateTokenTransfersSubscriptionRequest, WorldsRequest, - WorldsResponse, -}; +use torii_sql::DbBackend; const SUBSCRIPTION_SEEN_CACHE_CAPACITY: usize = 4096; @@ -85,29 +85,6 @@ impl TableKind { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DbBackend { - Sqlite, - Postgres, -} - -impl DbBackend { - fn detect(database_url: &str) -> Self { - if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { - Self::Postgres - } else { - Self::Sqlite - } - } - - fn as_str(self) -> &'static str { - match self { - Self::Sqlite => "sqlite", - Self::Postgres => "postgres", - } - } -} - #[derive(Clone)] pub struct EcsService { state: Arc, @@ -646,18 +623,17 @@ impl EcsService { ) -> Result { sqlx::any::install_default_drivers(); - let backend = DbBackend::detect(database_url); + let backend = get_db_backend(database_url); let database_url = match backend { DbBackend::Postgres => database_url.to_string(), DbBackend::Sqlite => sqlite_url(database_url)?, }; - let has_erc20 = erc20_url.is_some(); let has_erc721 = erc721_url.is_some(); let has_erc1155 = erc1155_url.is_some(); - let erc20_url = erc20_url.map(std::string::ToString::to_string); - let erc721_url = erc721_url.map(std::string::ToString::to_string); - let erc1155_url = erc1155_url.map(std::string::ToString::to_string); + let erc20_url = normalize_attached_database_url(backend, erc20_url)?; + let erc721_url = normalize_attached_database_url(backend, erc721_url)?; + let erc1155_url = normalize_attached_database_url(backend, erc1155_url)?; let pool_options = AnyPoolOptions::new().max_connections(max_connections.unwrap_or( if backend == DbBackend::Sqlite { @@ -912,15 +888,23 @@ impl EcsService { ("erc1155", &self.state.erc1155_url), ] { if let Some(url) = url { - let path = sqlite_db_path(url); - let file_exists = std::path::Path::new(&path).exists(); - tracing::info!( - schema, - path = %path, - file_exists, - "Attaching ERC database" - ); - attach_sqlite_database(&mut conn, schema, url).await?; + let options = SqliteConnectOptions::from_str(url)?; + #[allow(clippy::match_bool)] + #[allow(clippy::single_match_else)] + match options.is_in_memory() { + true => tracing::info!(schema, "Attaching in-memory ERC database"), + false => { + let path = options.get_filename(); + let file_exists = path.exists(); + tracing::info!( + schema, + path = %path.display(), + file_exists, + "Attaching ERC database" + ); + } + } + attach_sqlite_database(&mut conn, schema, &options).await?; match sqlx::query(sqlite_master_preview_sql(schema)) .fetch_all(&mut *conn) .await @@ -1013,7 +997,7 @@ impl EcsService { let contract = self .load_contracts(&types::ContractQuery { - contract_addresses: vec![contract_address.to_bytes_be().to_vec()], + contract_addresses: vec![contract_address.into()], contract_types: vec![], }) .await? @@ -1166,7 +1150,7 @@ impl EcsService { .bind(kind.as_str()) .bind(felt_hex(world_address)) .bind(felt_hex(table_id)) - .bind(felt_hex(Felt::from_bytes_be(&record.id))) + .bind(felt_hex(record.id)) .bind(row_json) .bind(executed_at as i64) .execute(&self.state.pool) @@ -1263,15 +1247,9 @@ impl EcsService { .await?; self.publish_event_update(types::Event { - keys: keys - .iter() - .map(|felt| felt.to_bytes_be().to_vec()) - .collect(), - data: data - .iter() - .map(|felt| felt.to_bytes_be().to_vec()) - .collect(), - transaction_hash: transaction_hash.to_bytes_be().to_vec(), + keys: keys.iter().map(Into::into).collect(), + data: data.iter().map(Into::into).collect(), + transaction_hash: transaction_hash.into(), }) .await; @@ -1466,7 +1444,7 @@ impl EcsService { { let mut separated = builder.separated(", "); for address in &query.contract_addresses { - separated.push_bind(felt_hex(felt_from_bytes(address)?)); + separated.push_bind(felt_hex(Felt::from_be_bytes_slice(address)?)); } } builder.push(")"); @@ -1492,7 +1470,7 @@ impl EcsService { let contract_address = row.try_get::("contract_address")?; let contract_type = row.try_get::("contract_type")?; let contract = types::Contract { - contract_address: felt_from_hex(&contract_address)?.to_bytes_be().to_vec(), + contract_address: felt_from_hex(&contract_address)?.into(), contract_type, head: row .try_get::, _>("head")? @@ -1507,7 +1485,7 @@ impl EcsService { .try_get::, _>("last_pending_block_tx")? .map(|value| felt_from_hex(&value)) .transpose()? - .map(|felt| felt.to_bytes_be().to_vec()), + .map(Into::into), updated_at: row.try_get::("updated_at")? as u64, created_at: row.try_get::("created_at")? as u64, }; @@ -1544,7 +1522,7 @@ impl EcsService { { let mut separated = builder.separated(", "); for address in &query.contract_addresses { - separated.push_bind(format!("{:#066x}", felt_from_bytes(address)?)); + separated.push_bind(felt_hex(Felt::from_be_bytes_slice(address)?)); } } builder.push(")"); @@ -1601,7 +1579,7 @@ impl EcsService { .map(|value| value.timestamp() as u64) .unwrap_or_default(); Ok(types::Controller { - address: felt_from_hex(&address)?.to_bytes_be().to_vec(), + address: felt_from_hex(&address)?.into(), username: row .try_get::, _>("username")? .unwrap_or_default(), @@ -1992,7 +1970,8 @@ impl EcsService { Ok(rows) => { for row in rows { let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + Felt::from_be_bytes_slice(&row.try_get::, _>("token")?)? + .to_be_bytes_vec(); // Why not just get vec? items.push(types::TokenContract { contract_address, contract_type: ContractType::Erc20 as i32, @@ -2036,8 +2015,7 @@ impl EcsService { )); push_blob_in_filter(&mut builder, "tm.token", &query.contract_addresses); for row in builder.build().fetch_all(&mut *conn).await? { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? let total_supply = canonical_optional_u256_bytes_from_db( row.try_get::>, _>("total_supply")?, )? @@ -2078,8 +2056,7 @@ impl EcsService { )); push_blob_in_filter(&mut builder, "tm.token", &query.contract_addresses); for row in builder.build().fetch_all(&mut *conn).await? { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let contract_address = row.try_get::, _>("token")?; //TODO: Can it just be a vec? items.push(types::TokenContract { contract_address, contract_type: ContractType::Erc1155 as i32, @@ -2120,8 +2097,7 @@ impl EcsService { )); push_blob_in_filter(&mut builder, "token", &query.contract_addresses); for row in builder.build().fetch_all(&mut *conn).await? { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let contract_address = row.try_get::, _>("token")?; //TODO: Can it just be a vec? items.push(types::Token { token_id: None, contract_address, @@ -2218,8 +2194,7 @@ impl EcsService { .map(|row| { let token_id = canonical_u256_bytes_from_db(&row.try_get::, _>("token_id")?)?; - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? Ok(types::Token { token_id: Some(token_id), contract_address, @@ -2308,10 +2283,8 @@ impl EcsService { for row in rows { let balance = canonical_u256_bytes_from_db(&row.try_get::, _>("balance")?)?; - let account_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("wallet")?)?; - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let account_address = row.try_get::, _>("wallet")?; // TODO: can it just be a vec? + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? items.push(types::TokenBalance { balance, account_address, @@ -2376,10 +2349,8 @@ impl EcsService { "RetrieveTokenBalances queried ERC721 ownership" ); for row in rows { - let account_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("owner")?)?; - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; + let account_address = row.try_get::, _>("owner")?; // TODO: can it just be a vec? + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? let token_id = canonical_u256_bytes_from_db(&row.try_get::, _>("token_id")?)?; items.push(types::TokenBalance { @@ -2449,10 +2420,8 @@ impl EcsService { for row in rows { let balance = canonical_u256_bytes_from_db(&row.try_get::, _>("balance")?)?; - let account_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("wallet")?)?; - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("contract")?)?; + let account_address = row.try_get::, _>("wallet")?; // TODO: can it just be a vec? + let contract_address = row.try_get::, _>("contract")?; // TODO: can it just be a vec? let token_id = canonical_u256_bytes_from_db(&row.try_get::, _>("token_id")?)?; items.push(types::TokenBalance { @@ -2621,7 +2590,8 @@ impl EcsService { { let mut separated = builder.separated(", "); for contract in &filter.contract_addresses { - separated.push_bind(felt_hex(felt_from_bytes(contract)?)); + separated.push_bind(hex::encode(contract)); // TODO: Is okay? + // separated.push_bind(felt_hex(felt_from_bytes(contract)?)); } } builder.push(")"); @@ -2630,9 +2600,7 @@ impl EcsService { rows.into_iter() .map(|row| { Ok(( - felt_from_hex(&row.try_get::("transaction_hash")?)? - .to_bytes_be() - .to_vec(), + felt_from_hex(&row.try_get::("transaction_hash")?)?.into(), row.try_get::("block_number")? as u64, row.try_get::("executed_at")? as u64, )) @@ -2738,12 +2706,9 @@ impl EcsService { let rows = builder.build().fetch_all(&mut **conn).await?; rows.into_iter() .map(|row| { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; - let from_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("from_addr")?)?; - let to_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("to_addr")?)?; + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? + let from_address = row.try_get::, _>("from_addr")?; // TODO: can it just be a vec? + let to_address = row.try_get::, _>("to_addr")?; // TODO: can it just be a vec? let amount = canonical_u256_bytes_from_db(&row.try_get::, _>("amount")?)?; Ok(types::TokenTransfer { id: format!("erc20:{}", row.try_get::("id")?), @@ -2782,12 +2747,9 @@ impl EcsService { let rows = builder.build().fetch_all(&mut **conn).await?; rows.into_iter() .map(|row| { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; - let from_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("from_addr")?)?; - let to_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("to_addr")?)?; + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? + let from_address = row.try_get::, _>("from_addr")?; // TODO: can it just be a vec? + let to_address = row.try_get::, _>("to_addr")?; // TODO: can it just be a vec? let token_id = canonical_u256_bytes_from_db(&row.try_get::, _>("token_id")?)?; Ok(types::TokenTransfer { @@ -2827,12 +2789,9 @@ impl EcsService { let rows = builder.build().fetch_all(&mut **conn).await?; rows.into_iter() .map(|row| { - let contract_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("token")?)?; - let from_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("from_addr")?)?; - let to_address = - canonical_felt_bytes_from_db(&row.try_get::, _>("to_addr")?)?; + let contract_address = row.try_get::, _>("token")?; // TODO: can it just be a vec? + let from_address = row.try_get::, _>("from_addr")?; // TODO: can it just be a vec? + let to_address = row.try_get::, _>("to_addr")?; // TODO: can it just be a vec? let token_id = canonical_u256_bytes_from_db(&row.try_get::, _>("token_id")?)?; let amount = canonical_u256_bytes_from_db(&row.try_get::, _>("amount")?)?; @@ -3244,7 +3203,7 @@ impl EcsService { continue; } by_world - .entry(table.world_address.to_bytes_be().to_vec()) + .entry(table.world_address.into()) .or_default() .push(model_from_table(&table)); } @@ -3369,7 +3328,7 @@ impl EcsService { .max_connections(1) .connect_with(SqliteConnectOptions::from_str(&self.state.database_url)?) .await?; - let store = SqliteStore(Arc::new(pool)); + let store = SqliteStore(pool); Ok(store.read_tables(&[]).await?) } DbBackend::Postgres => { @@ -3377,8 +3336,7 @@ impl EcsService { .max_connections(1) .connect(&self.state.database_url) .await?; - let store = PgStore(Arc::new(pool)); - Ok(store.read_tables(&[]).await?) + Ok(pool.read_tables(&[]).await?) } } } @@ -3613,7 +3571,7 @@ impl EcsService { for table in models { let rows = self.fetch_table_rows(&table).await?; let meta = self - .load_entity_meta(table.kind, table.world_address, table.table.id) + .load_entity_meta(table.kind, table.world_address, table.table.id.into()) .await?; for row in rows { let entity_id = row @@ -3622,7 +3580,7 @@ impl EcsService { .unwrap_or(Value::Null); let entity_felt = value_to_primary_felt(&entity_id, &table.table.primary.type_def) .unwrap_or(Felt::ZERO); - let entity_key = entity_felt.to_bytes_be().to_vec(); + let entity_key: Vec = entity_felt.into(); if meta .get(&felt_hex(entity_felt)) .is_some_and(|meta| meta.deleted) @@ -3632,12 +3590,9 @@ impl EcsService { let model = row_to_model_struct(&table.table, &row)?; let aggregate = entities - .entry(( - table.world_address.to_bytes_be().to_vec(), - entity_key.clone(), - )) + .entry((table.world_address.into(), entity_key.clone())) .or_insert_with(|| EntityAggregate { - world_address: table.world_address.to_bytes_be().to_vec(), + world_address: table.world_address.into(), hashed_keys: entity_key.clone(), ..EntityAggregate::default() }); @@ -3724,14 +3679,14 @@ impl EcsService { let query = types::Query { clause: Some(types::Clause { clause_type: Some(ClauseType::HashedKeys(types::HashedKeysClause { - hashed_keys: vec![entity_id.to_bytes_be().to_vec()], + hashed_keys: vec![entity_id.into()], })), }), no_hashed_keys: false, models: vec![], pagination: None, historical: false, - world_addresses: vec![world_address.to_bytes_be().to_vec()], + world_addresses: vec![world_address.into()], }; Ok(self .load_entity_page(kind, &query) @@ -3949,8 +3904,8 @@ impl EcsService { .await?; let managed_tables = self.load_managed_table_map().await?; let mut aggregate = EntityAggregate { - world_address: world_address.to_bytes_be().to_vec(), - hashed_keys: entity_id.to_bytes_be().to_vec(), + world_address: world_address.into(), + hashed_keys: entity_id.into(), ..EntityAggregate::default() }; @@ -4147,15 +4102,14 @@ impl EcsService { let event = types::Event { keys: keys_hex .into_iter() - .map(|value| felt_from_hex(&value).map(|felt| felt.to_bytes_be().to_vec())) - .collect::>>()?, + .map(|value| Felt::from_hex(&value).map(|felt| felt.to_be_bytes_vec())) + .collect::, _>>()?, data: data_hex .into_iter() - .map(|value| felt_from_hex(&value).map(|felt| felt.to_bytes_be().to_vec())) - .collect::>>()?, - transaction_hash: felt_from_hex(&row.try_get::("transaction_hash")?)? - .to_bytes_be() - .to_vec(), + .map(|value| Felt::from_hex(&value).map(|felt| felt.to_be_bytes_vec())) + .collect::, _>>()?, + transaction_hash: Felt::from_hex(&row.try_get::("transaction_hash")?)? + .to_be_bytes_vec(), }; if query .keys @@ -4922,16 +4876,28 @@ async fn attach_sqlite_databases( ("erc1155", erc1155_url), ] { if let Some(url) = url { - attach_sqlite_database(conn, schema, url).await?; + let options = SqliteConnectOptions::from_str(url)?; + attach_sqlite_database(conn, schema, &options).await?; } } Ok(()) } +fn normalize_attached_database_url( + backend: DbBackend, + url: Option<&str>, +) -> Result> { + url.map(|url| match backend { + DbBackend::Postgres => Ok(url.to_string()), + DbBackend::Sqlite => sqlite_url(url), + }) + .transpose() +} + async fn attach_sqlite_database( conn: &mut AnyConnection, schema: &str, - url: &str, + options: &SqliteConnectOptions, ) -> sqlx::Result<()> { let attached = sqlx::query_scalar::("SELECT 1 FROM pragma_database_list WHERE name = ? LIMIT 1") @@ -4942,23 +4908,15 @@ async fn attach_sqlite_database( if attached { return Ok(()); } + let path = options.get_filename(); - let db_path = sqlite_db_path(url); - if !is_sqlite_memory_url(url) { - let path = std::path::Path::new(&db_path); - if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) { - std::fs::create_dir_all(parent)?; - } - if !path.exists() { - std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(path)?; - } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; } - - let path = db_path.replace('\'', "''"); + if !path.exists() { + fs::File::create(path)?; + } + let path = path.to_string_lossy().replace('\'', "''"); // sqlite-dynamic-ok: ATTACH requires the database path and schema identifier in SQL text. sqlx::query(&format!("ATTACH DATABASE '{path}' AS {schema}")) .execute(&mut *conn) @@ -4966,11 +4924,6 @@ async fn attach_sqlite_database( Ok(()) } -fn is_sqlite_memory_url(url: &str) -> bool { - matches!(url, ":memory:" | "sqlite::memory:") - || (url.starts_with("sqlite:file:") && url.contains("mode=memory")) -} - fn sqlite_master_preview_sql(schema: &str) -> &'static str { match schema { "erc20" => "SELECT name FROM erc20.sqlite_master WHERE type='table' LIMIT 5", @@ -4980,18 +4933,12 @@ fn sqlite_master_preview_sql(schema: &str) -> &'static str { } } -fn sqlite_db_path(url: &str) -> String { - let path = url - .strip_prefix("sqlite://") - .or_else(|| url.strip_prefix("sqlite:")) - .unwrap_or(url); - let p = std::path::Path::new(path); - if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) { - if let Ok(abs_parent) = parent.canonicalize() { - return abs_parent.join(file_name).to_string_lossy().into_owned(); - } +fn get_db_backend(url: &str) -> DbBackend { + if url.starts_with("postgres") { + DbBackend::Postgres + } else { + DbBackend::Sqlite } - path.to_string() } fn sqlite_url(path: &str) -> Result { @@ -5001,16 +4948,14 @@ fn sqlite_url(path: &str) -> Result { if path.starts_with("sqlite:") { return Ok(path.to_string()); } - let options = SqliteConnectOptions::from_str(&format!("sqlite://{path}")) - .or_else(|_| Ok::<_, sqlx::Error>(SqliteConnectOptions::new().filename(path)))?; - if let Some(parent) = options - .get_filename() - .parent() - .filter(|path| !path.as_os_str().is_empty()) - { + let path = std::path::Path::new(path); + if let Some(parent) = path.parent().filter(|path| !path.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } - Ok(options.to_url_lossy().to_string()) + if !path.exists() { + std::fs::File::create(path)?; + } + Ok(format!("sqlite://{}", path.display())) } impl CairoTypeSerialization for SnapshotJsonSerializer { @@ -5118,7 +5063,7 @@ fn model_from_table(table: &ManagedTable) -> types::Model { schema: serde_json::to_vec(&table.table).unwrap_or_default(), contract_address: Vec::new(), use_legacy_store: table.table.legacy, - world_address: table.world_address.to_bytes_be().to_vec(), + world_address: Vec::::from(table.world_address), } } @@ -5161,7 +5106,7 @@ fn record_to_json_map( .columns .iter() .cloned() - .map(|column| (column.id, column)) + .map(|column| (Felt::from(column.id), column)) .collect::>(); let schema_columns = columns .iter() @@ -5173,15 +5118,15 @@ fn record_to_json_map( .collect::>>()? .into_iter() .cloned() - .map(|column| { - let (_, info): (Felt, ColumnInfo) = column.into(); - info + .map(|column| ColumnInfo { + name: column.name, + attributes: column.attributes, + type_def: column.type_def, }) .collect::>(); - let schema = torii_introspect::tables::RecordSchema::new( - &schema_table.primary, - schema_columns.iter().collect(), - ); + let primary = schema_table.primary.into(); + let schema = + torii_introspect::tables::RecordSchema::new(&primary, schema_columns.iter().collect()); let mut bytes = Vec::new(); let mut serializer = JsonSerializer::new(&mut bytes); @@ -5541,13 +5486,13 @@ fn value_as_string(value: &Value) -> String { fn value_as_felt_bytes(value: &Value) -> Result> { if value.is_null() { - return Ok(Felt::ZERO.to_bytes_be().to_vec()); + return Ok(Felt::ZERO.into()); } let string = value_as_string(value); let trimmed = string.trim(); if trimmed.is_empty() { - return Ok(Felt::ZERO.to_bytes_be().to_vec()); + return Ok(Felt::ZERO.into()); } felt_like_string_to_bytes(trimmed) @@ -5694,7 +5639,7 @@ fn felt_like_string_to_bytes(raw: &str) -> Result> { return Ok(bytes); } - Ok(felt_from_hex(trimmed)?.to_bytes_be().to_vec()) + Ok(felt_from_hex(trimmed)?.into()) } fn decode_prefixed_hex_to_padded_bytes(raw: &str, width: usize) -> Result>> { @@ -5888,10 +5833,6 @@ fn token_balance_subscription_state(balances: &[types::TokenBalance]) -> HashMap .collect() } -fn canonical_felt_bytes_from_db(bytes: &[u8]) -> Result> { - Ok(felt_from_bytes(bytes)?.to_bytes_be().to_vec()) -} - fn canonical_optional_u256_bytes_from_db(bytes: Option>) -> Result>> { bytes .as_deref() @@ -6336,16 +6277,17 @@ fn contract_matches_query(contract: &types::Contract, query: &types::ContractQue || query.contract_types.contains(&contract.contract_type)) } -fn felt_hex(value: Felt) -> String { - format!("{value:#x}") +fn felt_hex>(value: T) -> String { + let value: Felt = value.into(); + format!("0x{}", hex::encode(<[u8; 32]>::from(value))) } -fn felt_from_hex(value: &str) -> Result { - Felt::from_str(value).map_err(|err| anyhow!("invalid felt {value}: {err}")) +fn felt_from_bytes(value: &[u8]) -> Result { + Felt::from_be_bytes_slice(value).map_err(|err| anyhow!("invalid felt bytes: {err}")) } -fn felt_from_bytes(value: &[u8]) -> Result { - Ok(Felt::from_bytes_be_slice(value)) +fn felt_from_hex(value: &str) -> Result { + Felt::from_str(value).map_err(|err| anyhow!("invalid felt {value}: {err}")) } #[cfg(test)] @@ -6374,6 +6316,17 @@ mod tests { format!("sqlite://{}", path.display()) } + fn temp_sqlite_path(name: &str) -> String { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("torii-ecs-sink-{name}-{nonce}.db")) + .to_string_lossy() + .to_string() + } + fn sql_rows_to_maps(rows: &[types::SqlRow]) -> Vec> { rows.iter() .map(|row| { @@ -6630,7 +6583,7 @@ mod tests { .expect("create dojo_columns"); let table = CreateTable { - id: table_id, + id: table_id.into(), name: table_name.to_string(), attributes: vec![], primary: PrimaryDef { @@ -6639,11 +6592,12 @@ mod tests { type_def: PrimaryTypeDef::Felt252, }, columns: vec![ColumnDef { - id: Felt::from(1_u64), + id: Felt::from(1_u64).into(), name: "open".to_string(), attributes: vec![], type_def: TypeDef::Bool, }], + append_only: false, }; service.cache_created_table(world_address, &table).await; @@ -6725,7 +6679,7 @@ mod tests { .expect("create dojo_columns"); let table = CreateTable { - id: table_id, + id: table_id.into(), name: table_name.to_string(), attributes: vec![], primary: PrimaryDef { @@ -6734,11 +6688,12 @@ mod tests { type_def: PrimaryTypeDef::Felt252, }, columns: vec![ColumnDef { - id: Felt::from(1_u64), + id: Felt::from(1_u64).into(), name: field_name.to_string(), attributes: vec![], type_def: TypeDef::Bool, }], + append_only: false, }; service.cache_created_table(world_address, &table).await; @@ -6781,9 +6736,7 @@ mod tests { key: true, ty: Some(types::Ty { ty_type: Some(types::ty::TyType::Primitive(types::Primitive { - primitive_type: Some(types::primitive::PrimitiveType::Felt252( - value.to_bytes_be().to_vec(), - )), + primitive_type: Some(types::primitive::PrimitiveType::Felt252(value.into())), })), }), } @@ -6834,7 +6787,7 @@ mod tests { .expect("create dojo_columns"); let table = CreateTable { - id: table_id, + id: table_id.into(), name: table_name.to_string(), attributes: vec![], primary: PrimaryDef { @@ -6843,11 +6796,12 @@ mod tests { type_def: PrimaryTypeDef::Felt252, }, columns: vec![ColumnDef { - id: Felt::from(1_u64), + id: Felt::from(1_u64).into(), name: key_field_name.to_string(), attributes: vec![Attribute::new_empty("key".to_string())], type_def: TypeDef::Felt252, }], + append_only: false, }; service.cache_created_table(world_address, &table).await; @@ -6884,6 +6838,43 @@ mod tests { .expect("insert entity model"); } + #[tokio::test] + async fn service_accepts_plain_sqlite_paths_for_attached_token_databases() { + let db_path = temp_sqlite_path("ecs-main-plain"); + let erc20_url = temp_sqlite_path("erc20-plain"); + let erc721_url = temp_sqlite_path("erc721-plain"); + let erc1155_url = temp_sqlite_path("erc1155-plain"); + + let service = EcsService::new( + &db_path, + Some(1), + Some(&erc20_url), + Some(&erc721_url), + Some(&erc1155_url), + ) + .await + .expect("service init"); + + assert!(service.state.has_erc20); + assert!(service.state.has_erc721); + assert!(service.state.has_erc1155); + assert!(service + .state + .erc20_url + .as_deref() + .is_some_and(|url| url.starts_with("sqlite:"))); + assert!(service + .state + .erc721_url + .as_deref() + .is_some_and(|url| url.starts_with("sqlite:"))); + assert!(service + .state + .erc1155_url + .as_deref() + .is_some_and(|url| url.starts_with("sqlite:"))); + } + #[tokio::test] async fn subscribe_entities_update_flow() { let db_path = test_db_path("entities-sub"); @@ -6907,7 +6898,7 @@ mod tests { let response = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: Some(member_bool_clause("test-Lobby", "open", true)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities"); @@ -6932,13 +6923,13 @@ mod tests { .expect("entity ok"); assert_eq!(matched.subscription_id, subscription_id); let matched_entity = matched.entity.expect("entity payload"); - assert_eq!(matched_entity.world_address, world.to_bytes_be().to_vec()); + assert_eq!(matched_entity.world_address, Vec::::from(world)); service .update_entities_subscription(Request::new(UpdateEntitiesSubscriptionRequest { subscription_id, clause: Some(member_bool_clause("test-Lobby", "open", false)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("update entities subscription"); @@ -6962,7 +6953,7 @@ mod tests { let response = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: None, - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities"); @@ -6992,7 +6983,7 @@ mod tests { let response = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: None, - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities"); @@ -7023,14 +7014,14 @@ mod tests { let response_a = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: Some(clause.clone()), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities a"); let response_b = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: Some(clause), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities b"); @@ -7072,14 +7063,14 @@ mod tests { let response_a = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: Some(member_bool_clause("test-Lobby", "open", true)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities a"); let response_b = service .subscribe_entities(Request::new(SubscribeEntitiesRequest { clause: Some(member_bool_clause("test-Lobby", "open", true)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe entities b"); @@ -7103,7 +7094,7 @@ mod tests { .update_entities_subscription(Request::new(UpdateEntitiesSubscriptionRequest { subscription_id: setup_a.subscription_id, clause: Some(member_bool_clause("test-Lobby", "open", false)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("update entities subscription"); @@ -7140,7 +7131,7 @@ mod tests { let response = service .subscribe_event_messages(Request::new(SubscribeEntitiesRequest { clause: Some(member_bool_clause("test-EventMessage", "open", true)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe event messages"); @@ -7170,7 +7161,7 @@ mod tests { .update_event_messages_subscription(Request::new(UpdateEntitiesSubscriptionRequest { subscription_id, clause: Some(member_bool_clause("test-EventMessage", "open", false)), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("update event messages subscription"); @@ -7194,7 +7185,7 @@ mod tests { let response = service .subscribe_event_messages(Request::new(SubscribeEntitiesRequest { clause: None, - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], })) .await .expect("subscribe event messages"); @@ -7226,7 +7217,7 @@ mod tests { let response = service .subscribe_events(Request::new(SubscribeEventsRequest { keys: vec![types::KeysClause { - keys: vec![key_match.to_bytes_be().to_vec()], + keys: vec![key_match.into()], pattern_matching: PatternMatching::VariableLen as i32, models: vec![], }], @@ -7270,10 +7261,10 @@ mod tests { .expect("matched frame") .expect("matched ok"); let event = matched.event.expect("event payload"); - assert_eq!(event.transaction_hash, tx2.to_bytes_be().to_vec()); + assert_eq!(event.transaction_hash, Vec::::from(tx2)); assert_eq!( event.keys.first().cloned(), - Some(key_match.to_bytes_be().to_vec()) + Some(Vec::::from(key_match)) ); } @@ -7314,7 +7305,7 @@ mod tests { let response = service .subscribe_contracts(Request::new(SubscribeContractsRequest { query: Some(types::ContractQuery { - contract_addresses: vec![world_contract.to_bytes_be().to_vec()], + contract_addresses: vec![world_contract.into()], contract_types: vec![ContractType::World as i32], }), })) @@ -7342,10 +7333,7 @@ mod tests { .expect("matched frame") .expect("matched ok"); let contract = matched.contract.expect("contract payload"); - assert_eq!( - contract.contract_address, - world_contract.to_bytes_be().to_vec() - ); + assert_eq!(contract.contract_address, Vec::::from(world_contract)); assert_eq!(contract.contract_type, ContractType::World as i32); } @@ -7471,8 +7459,8 @@ mod tests { .await .expect("create erc20 balances"); - let token = Felt::from(0x99_u64).to_bytes_be().to_vec(); - let wallet = Felt::from(0x55_u64).to_bytes_be().to_vec(); + let token: Vec = Felt::from(0x99_u64).into(); + let wallet: Vec = Felt::from(0x55_u64).into(); let original_balance = u256_bytes_from_u64(1); let updated_balance = u256_bytes_from_u64(5); @@ -7680,7 +7668,7 @@ mod tests { clause_type: Some(ClauseType::Keys(types::KeysClause { models: vec!["NUMS-Config".to_string()], pattern_matching: PatternMatching::VariableLen as i32, - keys: vec![Felt::from(0x222_u64).to_bytes_be().to_vec()], + keys: vec![Felt::from(0x222_u64).into()], })), }; @@ -7781,7 +7769,7 @@ mod tests { direction: PaginationDirection::Forward as i32, order_by: vec![], }), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], models: vec![], clause: Some(member_bool_clause("NUMS-QuestDefinition", "enabled", true)), no_hashed_keys: false, @@ -7844,7 +7832,7 @@ mod tests { direction: PaginationDirection::Forward as i32, order_by: vec![], }), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], models: vec![], clause: Some(member_bool_clause("NUMS-QuestDefinition", "enabled", true)), no_hashed_keys: false, @@ -7912,13 +7900,13 @@ mod tests { direction: PaginationDirection::Forward as i32, order_by: vec![], }), - world_addresses: vec![world.to_bytes_be().to_vec()], + world_addresses: vec![world.into()], models: vec![], clause: Some(types::Clause { clause_type: Some(ClauseType::Keys(types::KeysClause { models: vec!["NUMS-Config".to_string()], pattern_matching: PatternMatching::VariableLen as i32, - keys: vec![matching_key.to_bytes_be().to_vec()], + keys: vec![matching_key.into()], })), }), no_hashed_keys: false, @@ -7932,7 +7920,7 @@ mod tests { assert_eq!(response.entities.len(), 1); assert_eq!( response.entities[0].hashed_keys, - matching_entity.to_bytes_be().to_vec() + Vec::::from(matching_entity) ); assert!(response.next_cursor.is_empty()); } @@ -8085,8 +8073,8 @@ mod tests { "INSERT INTO erc20.balances (token, wallet, balance, last_block, last_tx_hash) VALUES (?1, ?2, ?3, ?4, ?5)", ) - .bind(Felt::from(0x99_u64).to_bytes_be().to_vec()) - .bind(Felt::from(0x55_u64).to_bytes_be().to_vec()) + .bind(Vec::::from(Felt::from(0x99_u64))) + .bind(Vec::::from(Felt::from(0x55_u64))) .bind(vec![1_u8]) .bind("1") .bind(vec![0_u8; 32]) @@ -8112,9 +8100,9 @@ mod tests { "INSERT INTO erc721.nft_ownership (token, token_id, owner, block_number, tx_hash, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", ) - .bind(Felt::from(0x1137_u64).to_bytes_be().to_vec()) + .bind(Vec::::from(Felt::from(0x1137_u64))) .bind(vec![0x04_u8]) - .bind(Felt::from(0x25145_u64).to_bytes_be().to_vec()) + .bind(Vec::::from(Felt::from(0x25145_u64))) .bind("1") .bind(vec![1_u8; 32]) .bind("1774350232") @@ -8228,8 +8216,8 @@ mod tests { "INSERT INTO erc20.balances (token, wallet, balance, last_block, last_tx_hash) VALUES (?1, ?2, ?3, ?4, ?5)", ) - .bind(Felt::from(0x99_u64).to_bytes_be().to_vec()) - .bind(Felt::from(0x55_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x99_u64).as_be_bytes_slice()) + .bind(Felt::from(0x55_u64).as_be_bytes_slice()) .bind(vec![1_u8]) .bind("1") .bind(vec![0_u8; 32]) @@ -8264,11 +8252,11 @@ mod tests { assert_eq!(response.balances.len(), 1); assert_eq!( response.balances[0].contract_address, - Felt::from(0x99_u64).to_bytes_be().to_vec() + Felt::from(0x99_u64).to_be_bytes_vec() ); assert_eq!( response.balances[0].account_address, - Felt::from(0x55_u64).to_bytes_be().to_vec() + Felt::from(0x55_u64).to_be_bytes_vec() ); assert_eq!(response.balances[0].balance, u256_bytes_from_u64(1)); assert_eq!(response.balances[0].token_id, None); @@ -8428,7 +8416,7 @@ mod tests { "INSERT INTO erc20.token_metadata (token, name, symbol, decimals, total_supply) VALUES (?1, ?2, ?3, ?4, ?5)", ) - .bind(Felt::from(0x99_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x99_u64).to_be_bytes_vec()) .bind("Nums") .bind("NUMS") .bind("18") @@ -8461,7 +8449,7 @@ mod tests { canonical_u256_bytes_from_db(&[0x12, 0x34, 0x56]).expect("canonical"); assert_eq!( contract.contract_address, - Felt::from(0x99_u64).to_bytes_be().to_vec() + Felt::from(0x99_u64).to_be_bytes_vec() ); assert_eq!(contract.total_supply.as_ref(), Some(&expected_total_supply)); assert_eq!(contract.metadata, Vec::::new()); @@ -8499,9 +8487,9 @@ mod tests { "INSERT INTO erc721.nft_ownership (token, token_id, owner) VALUES (?1, ?2, ?3)", ) - .bind(Felt::from(0x1137_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x1137_u64).to_be_bytes_vec()) .bind(vec![0x04_u8]) - .bind(Felt::from(0x25145_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x25145_u64).to_be_bytes_vec()) .execute(&mut *conn) .await .expect("insert ownership"); @@ -8530,11 +8518,11 @@ mod tests { let expected_token_id = u256_bytes_from_u64(4); assert_eq!( balance.contract_address, - Felt::from(0x1137_u64).to_bytes_be().to_vec() + Felt::from(0x1137_u64).to_be_bytes_vec() ); assert_eq!( balance.account_address, - Felt::from(0x25145_u64).to_bytes_be().to_vec() + Felt::from(0x25145_u64).to_be_bytes_vec() ); assert_eq!(balance.balance, u256_bytes_from_u64(1)); assert_eq!(balance.token_id.as_ref(), Some(&expected_token_id)); @@ -8606,8 +8594,8 @@ mod tests { "INSERT INTO erc20.balances (token, wallet, balance, last_block, last_tx_hash) VALUES (?1, ?2, ?3, ?4, ?5)", ) - .bind(Felt::from(0x30_u64).to_bytes_be().to_vec()) - .bind(Felt::from(0x10_u64).to_bytes_be().to_vec()) + .bind(Vec::::from(Felt::from(0x30_u64))) + .bind(Vec::::from(Felt::from(0x10_u64))) .bind(vec![9_u8]) .bind("1") .bind(vec![0_u8; 32]) @@ -8618,9 +8606,9 @@ mod tests { "INSERT INTO erc721.nft_ownership (token, token_id, owner) VALUES (?1, ?2, ?3)", ) - .bind(Felt::from(0x10_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x10_u64).to_be_bytes_vec()) .bind(vec![0x02_u8]) - .bind(Felt::from(0x20_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x20_u64).to_be_bytes_vec()) .execute(&mut *conn) .await .expect("insert erc721 ownership"); @@ -8628,8 +8616,8 @@ mod tests { "INSERT INTO erc1155.erc1155_balances (contract, wallet, token_id, balance, last_block) VALUES (?1, ?2, ?3, ?4, ?5)", ) - .bind(Felt::from(0x20_u64).to_bytes_be().to_vec()) - .bind(Felt::from(0x05_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x20_u64).to_be_bytes_vec()) + .bind(Felt::from(0x05_u64).to_be_bytes_vec()) .bind(vec![0x01_u8]) .bind(vec![7_u8]) .bind("1") @@ -8659,11 +8647,11 @@ mod tests { assert_eq!(response.balances.len(), 2); assert_eq!( response.balances[0].contract_address, - Felt::from(0x10_u64).to_bytes_be().to_vec() + Felt::from(0x10_u64).to_be_bytes_vec() ); assert_eq!( response.balances[0].account_address, - Felt::from(0x20_u64).to_bytes_be().to_vec() + Felt::from(0x20_u64).to_be_bytes_vec() ); assert_eq!( response.balances[0].token_id.as_ref(), @@ -8672,11 +8660,11 @@ mod tests { assert_eq!( response.balances[1].contract_address, - Felt::from(0x20_u64).to_bytes_be().to_vec() + Felt::from(0x20_u64).to_be_bytes_vec() ); assert_eq!( response.balances[1].account_address, - Felt::from(0x05_u64).to_bytes_be().to_vec() + Felt::from(0x05_u64).to_be_bytes_vec() ); assert_eq!( response.balances[1].token_id.as_ref(), @@ -8715,9 +8703,9 @@ mod tests { "INSERT INTO erc721.nft_ownership (token, token_id, owner) VALUES (?1, ?2, ?3)", ) - .bind(Felt::from(0x99_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x99_u64).to_be_bytes_vec()) .bind(vec![0x04_u8]) - .bind(Felt::from(0x77_u64).to_bytes_be().to_vec()) + .bind(Felt::from(0x77_u64).to_be_bytes_vec()) .execute(&mut *conn) .await .expect("insert ownership"); @@ -8744,11 +8732,11 @@ mod tests { assert_eq!(response.balances.len(), 1); assert_eq!( response.balances[0].contract_address, - Felt::from(0x99_u64).to_bytes_be().to_vec() + Felt::from(0x99_u64).to_be_bytes_vec() ); assert_eq!( response.balances[0].account_address, - Felt::from(0x77_u64).to_bytes_be().to_vec() + Felt::from(0x77_u64).to_be_bytes_vec() ); assert_eq!( response.balances[0].token_id.as_ref(), @@ -8790,7 +8778,7 @@ mod tests { let service = EcsService::new(&db_path, Some(1), None, None, None) .await .expect("service init"); - let controller = format!("{:#066x}", Felt::from(0x123_u64)); + let controller = felt_hex(Felt::from(0x123_u64)); sqlx::query( "CREATE TABLE controllers ( @@ -8820,7 +8808,7 @@ mod tests { let response = service .retrieve_controllers(Request::new(RetrieveControllersRequest { query: Some(types::ControllerQuery { - contract_addresses: vec![Felt::from(0x123_u64).to_bytes_be().to_vec()], + contract_addresses: vec![Felt::from(0x123_u64).into()], usernames: Vec::new(), pagination: Some(types::Pagination { cursor: String::new(), @@ -8838,7 +8826,7 @@ mod tests { assert!(response.next_cursor.is_empty()); assert_eq!( response.controllers[0].address, - Felt::from(0x123_u64).to_bytes_be().to_vec() + Vec::::from(Felt::from(0x123_u64)) ); assert_eq!(response.controllers[0].username, "alice"); assert_eq!(response.controllers[0].deployed_at_timestamp, 1_710_936_000); @@ -8871,7 +8859,7 @@ mod tests { .await .expect("begin controller insert tx"); for i in 0..1_500_u64 { - let controller = format!("{:#066x}", Felt::from(i + 1)); + let controller = felt_hex(Felt::from(i + 1)); sqlx::query( "INSERT INTO controllers (id, address, username, deployed_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -8911,14 +8899,14 @@ mod tests { .controllers .first() .map(|controller| controller.address.clone()), - Some(Felt::from(1_u64).to_bytes_be().to_vec()) + Some(Vec::::from(Felt::from(1_u64))) ); assert_eq!( response .controllers .last() .map(|controller| controller.address.clone()), - Some(Felt::from(1_200_u64).to_bytes_be().to_vec()) + Some(Vec::::from(Felt::from(1_200_u64))) ); } } diff --git a/crates/torii-ecs-sink/src/sink.rs b/crates/torii-ecs-sink/src/sink.rs index 2e0680d2..30013862 100644 --- a/crates/torii-ecs-sink/src/sink.rs +++ b/crates/torii-ecs-sink/src/sink.rs @@ -3,24 +3,20 @@ use std::sync::{Arc, RwLock}; use anyhow::Result; use async_trait::async_trait; -use dojo_introspect::events::{ - EventEmitted, StoreDelRecord, StoreSetRecord, StoreUpdateMember, StoreUpdateRecord, -}; -use introspect_types::event::CairoEventInfo; -use starknet::core::types::Felt; +use starknet_types_raw::Felt; use torii::axum::Router; use torii::command::CommandBusSender; use torii::etl::decoder::DecoderId; -use torii::etl::{ - envelope::{Envelope, TypeId}, - extractor::ExtractionBatch, - sink::{EventBus, Sink, SinkContext, TopicInfo}, -}; +use torii::etl::envelope::{Envelope, TypeId}; +use torii::etl::extractor::ExtractionBatch; +use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; +use torii::etl::EventContext; use torii_dojo::external_contract::{ - resolve_external_contract, ExternalContractRegisteredBody, RegisterExternalContractCommand, + resolve_external_contract, ExternalContractRegistered, RegisterExternalContractCommand, RegisteredContractType, SharedContractTypeRegistry, }; -use torii_introspect::events::{IntrospectBody, IntrospectMsg}; +use torii_dojo::{DojoBody, DojoEvent, DOJO_TYPE_ID}; +use torii_introspect::events::IntrospectMsg; use crate::grpc_service::{EcsService, TableKind}; @@ -73,7 +69,7 @@ impl EcsSink { .contract_types .read() .await - .get(&contract_address) + .get(&contract_address.into()) .copied() .unwrap_or(RegisteredContractType::World); @@ -86,22 +82,26 @@ impl EcsSink { } } - async fn handle_external_contract_registered(&self, body: &ExternalContractRegisteredBody) { + async fn handle_external_contract_registered( + &self, + context: &EventContext, + msg: &ExternalContractRegistered, + ) { if !self.external_contract_indexing { tracing::debug!( target: "torii::ecs_sink", - contract = format!("{:#x}", body.msg.contract_address), + contract = format!("{:#x}", msg.contract_address), "External contract registration ignored because runtime indexing is disabled" ); return; } - let Some(resolved) = resolve_external_contract(&body.msg.contract_name) else { + let Some(resolved) = resolve_external_contract(&msg.contract_name) else { tracing::warn!( target: "torii::ecs_sink", - world = format!("{:#x}", body.metadata.from_address), - contract = format!("{:#x}", body.msg.contract_address), - contract_name = %body.msg.contract_name, + world = format!("{:#x}", context.from_address), + contract = format!("{:#x}", msg.contract_address), + contract_name = %msg.contract_name, "Unsupported external contract registration; skipping runtime indexing" ); return; @@ -116,9 +116,9 @@ impl EcsSink { if !missing_decoders.is_empty() { tracing::warn!( target: "torii::ecs_sink", - world = format!("{:#x}", body.metadata.from_address), - contract = format!("{:#x}", body.msg.contract_address), - contract_name = %body.msg.contract_name, + world = format!("{:#x}", context.from_address), + contract = format!("{:#x}", msg.contract_address), + contract_name = %msg.contract_name, missing_decoders = ?missing_decoders, "External contract registration skipped because matching decoders are not installed" ); @@ -133,27 +133,27 @@ impl EcsSink { let Some(command_bus) = command_bus else { tracing::warn!( target: "torii::ecs_sink", - contract = format!("{:#x}", body.msg.contract_address), + contract = format!("{:#x}", msg.contract_address), "External contract registration dropped because the command bus is unavailable" ); return; }; if let Err(error) = command_bus.dispatch(RegisterExternalContractCommand { - world_address: body.metadata.from_address, - contract_address: body.msg.contract_address, - contract_name: body.msg.contract_name.clone(), - namespace: body.msg.namespace.clone(), - instance_name: body.msg.instance_name.clone(), + world_address: context.from_address.into(), + contract_address: msg.contract_address, + contract_name: msg.contract_name.clone(), + namespace: msg.namespace.clone(), + instance_name: msg.instance_name.clone(), from_block: self.external_contract_from_block, - registration_block: body.msg.registration_block, + registration_block: msg.registration_block, contract_type: resolved.contract_type, decoder_ids: resolved.decoder_ids, }) { tracing::warn!( target: "torii::ecs_sink", - world = format!("{:#x}", body.metadata.from_address), - contract = format!("{:#x}", body.msg.contract_address), + world = format!("{:#x}", context.from_address), + contract = format!("{:#x}", msg.contract_address), error = %error, "Failed to enqueue external contract registration command" ); @@ -168,23 +168,25 @@ impl Sink for EcsSink { } fn interested_types(&self) -> Vec { - vec![ - TypeId::new("introspect"), - TypeId::new("dojo.external_contract_registered"), - ] + vec![DOJO_TYPE_ID] } async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> Result<()> { for (ordinal, event) in batch.events.iter().enumerate() { - let context = batch - .get_event_context(&event.transaction_hash, event.from_address) - .unwrap_or_default(); + let block_number = batch + .transactions + .get(&event.transaction_hash) + .map_or(event.block_number, |tx| tx.block_number); + let timestamp = batch + .blocks + .get(&block_number) + .map_or(0, |block| block.timestamp); self.service .store_event( event.from_address, event.transaction_hash, - context.transaction.block_number, - context.block.timestamp, + block_number, + timestamp, &event.keys, &event.data, ordinal, @@ -195,8 +197,8 @@ impl Sink for EcsSink { .record_contract_progress( event.from_address, self.contract_type_for(event.from_address).await, - context.transaction.block_number, - context.block.timestamp, + block_number, + timestamp, None, ) .await?; @@ -204,13 +206,13 @@ impl Sink for EcsSink { let Some(selector) = event.keys.first() else { continue; }; - let selector_raw = selector.to_raw(); + let selector_raw = *selector; if matches!( selector_raw, - StoreSetRecord::SELECTOR_RAW - | StoreUpdateRecord::SELECTOR_RAW - | StoreUpdateMember::SELECTOR_RAW - | StoreDelRecord::SELECTOR_RAW + s if s == Felt::selector("StoreSetRecord") + || s == Felt::selector("StoreUpdateRecord") + || s == Felt::selector("StoreUpdateMember") + || s == Felt::selector("StoreDelRecord") ) { if event.keys.len() >= 3 { self.service @@ -222,12 +224,12 @@ impl Sink for EcsSink { event.from_address, event.keys[1], event.keys[2], - context.block.timestamp, - selector_raw == StoreDelRecord::SELECTOR_RAW, + timestamp, + selector_raw == Felt::selector("StoreDelRecord"), ) .await?; } - } else if selector_raw == EventEmitted::SELECTOR_RAW && event.keys.len() >= 2 { + } else if selector_raw == Felt::selector("EventEmitted") && event.keys.len() >= 2 { self.service .record_table_kind(event.from_address, event.keys[1], TableKind::EventMessage) .await?; @@ -235,48 +237,59 @@ impl Sink for EcsSink { } for envelope in envelopes { - if envelope.type_id == TypeId::new("dojo.external_contract_registered") { - if let Some(body) = envelope.downcast_ref::() { - self.handle_external_contract_registered(body).await; - } + if envelope.type_id != DOJO_TYPE_ID { continue; } - if envelope.type_id != TypeId::new("introspect") { + let Some(body) = envelope.downcast_ref::() else { continue; - } - - let Some(body) = envelope.downcast_ref::() else { + }; + let DojoEvent::Introspect(msg) = &body.msg else { + if let DojoEvent::ExternalContractRegistered(msg) = &body.msg { + self.handle_external_contract_registered(&body.context, msg) + .await; + } continue; }; - let context = batch - .get_event_context(&body.metadata.transaction_hash, body.metadata.from_address) - .unwrap_or_default(); - match &body.msg { + let timestamp = batch + .transactions + .get(&body.context.transaction_hash) + .and_then(|tx| batch.blocks.get(&tx.block_number)) + .map_or(0, |block| block.timestamp); + + match msg { IntrospectMsg::CreateTable(table) => { self.service - .cache_created_table(body.metadata.from_address, table) + .cache_created_table(body.context.from_address, table) .await; self.service - .record_table_kind(body.metadata.from_address, table.id, TableKind::Entity) + .record_table_kind( + body.context.from_address, + table.id.into(), + TableKind::Entity, + ) .await .ok(); } IntrospectMsg::UpdateTable(table) => { self.service - .cache_updated_table(body.metadata.from_address, table) + .cache_updated_table(body.context.from_address, table) .await; self.service - .record_table_kind(body.metadata.from_address, table.id, TableKind::Entity) + .record_table_kind( + body.context.from_address, + table.id.into(), + TableKind::Entity, + ) .await .ok(); } IntrospectMsg::InsertsFields(insert) => { let kind = if batch.events.iter().any(|event| { event.keys.first().is_some_and(|selector| { - selector.to_raw() == EventEmitted::SELECTOR_RAW - && event.keys.get(1).copied() == Some(insert.table) + *selector == Felt::selector("EventEmitted") + && event.keys.get(1).copied() == Some(insert.table.into()) }) }) { TableKind::EventMessage @@ -284,59 +297,65 @@ impl Sink for EcsSink { TableKind::Entity }; self.service - .record_table_kind(body.metadata.from_address, insert.table, kind) + .record_table_kind(body.context.from_address, insert.table.into(), kind) .await?; + let raw_columns: Vec = + insert.columns.iter().copied().map(Into::into).collect(); for record in &insert.records { - let entity_id = Felt::from_bytes_be(&record.id); + let entity_id = Felt::from_be_bytes_slice(&record.id)?; self.service .upsert_entity_meta( kind, - body.metadata.from_address, - insert.table, + body.context.from_address, + insert.table.into(), entity_id, - context.block.timestamp, + timestamp, false, ) .await?; self.service .upsert_entity_model( kind, - body.metadata.from_address, - insert.table, - &insert.columns, + body.context.from_address, + insert.table.into(), + &raw_columns, record, - context.block.timestamp, + timestamp, ) .await?; self.service - .publish_entity_update(kind, body.metadata.from_address, entity_id) + .publish_entity_update(kind, body.context.from_address, entity_id) .await?; } } IntrospectMsg::DeleteRecords(delete) => { - let kind = self.service.table_kind(delete.table).await?; + let kind = self.service.table_kind(delete.table.into()).await?; for row in &delete.rows { let entity_id = row.to_felt(); self.service .upsert_entity_meta( kind, - body.metadata.from_address, - delete.table, - entity_id, - context.block.timestamp, + body.context.from_address, + delete.table.into(), + entity_id.into(), + timestamp, true, ) .await?; self.service .delete_entity_model( kind, - body.metadata.from_address, - delete.table, - entity_id, + body.context.from_address, + delete.table.into(), + entity_id.into(), ) .await?; self.service - .publish_entity_update(kind, body.metadata.from_address, entity_id) + .publish_entity_update( + kind, + body.context.from_address, + entity_id.into(), + ) .await?; } } diff --git a/crates/torii-ecs-sink/tests/sqlite_prepared_statements.rs b/crates/torii-ecs-sink/tests/sqlite_prepared_statements.rs index 58487346..c6a1c590 100644 --- a/crates/torii-ecs-sink/tests/sqlite_prepared_statements.rs +++ b/crates/torii-ecs-sink/tests/sqlite_prepared_statements.rs @@ -23,8 +23,7 @@ fn runtime_sqlite_queries_avoid_uncached_prepare_and_inline_format_sql() { let root = workspace_root(); let files = [ "crates/torii-ecs-sink/src/grpc_service.rs", - "crates/torii-entities-historical-sink/src/lib.rs", - "crates/introspect-sqlite-sink/src/processor.rs", + "crates/introspect-sql-sink/src/sqlite/table.rs", "crates/torii-erc20/src/storage.rs", "crates/torii-erc721/src/storage.rs", "crates/torii-erc1155/src/storage.rs", diff --git a/crates/torii-entities-historical-sink/Cargo.toml b/crates/torii-entities-historical-sink/Cargo.toml deleted file mode 100644 index 710230ef..00000000 --- a/crates/torii-entities-historical-sink/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "torii-entities-historical-sink" -version = "0.1.0" -edition = "2021" -description = "Append-only historical entity sink for Torii introspect models" -authors = ["Torii Runtime "] -license = "Apache-2.0" - -[dependencies] -anyhow.workspace = true -async-trait.workspace = true -serde.workspace = true -serde_json.workspace = true -sqlx = { workspace = true, features = ["any", "postgres", "sqlite", "runtime-tokio-rustls"] } -starknet.workspace = true -tokio.workspace = true -torii.workspace = true -torii-runtime-common.workspace = true -tracing.workspace = true -thiserror.workspace = true -introspect-types.workspace = true - -torii-introspect.workspace = true - -[lints] -workspace = true diff --git a/crates/torii-entities-historical-sink/src/lib.rs b/crates/torii-entities-historical-sink/src/lib.rs deleted file mode 100644 index 025d2182..00000000 --- a/crates/torii-entities-historical-sink/src/lib.rs +++ /dev/null @@ -1,1199 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use anyhow::{anyhow, Context, Result}; -use async_trait::async_trait; -use sqlx::any::AnyPoolOptions; -use sqlx::{Any, Pool, Row}; -use starknet::core::types::Felt; -use tokio::sync::RwLock; -use torii::axum::Router; -use torii::etl::envelope::{Envelope, TypeId}; -use torii::etl::extractor::ExtractionBatch; -use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; -use torii_introspect::events::{CreateTable, IntrospectBody, IntrospectMsg, UpdateTable}; -use torii_introspect::schema::TableSchema; -use torii_runtime_common::database::DEFAULT_SQLITE_MAX_CONNECTIONS; - -const INTROSPECT_TYPE: TypeId = TypeId::new("introspect"); -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DbBackend { - Sqlite, - Postgres, -} - -impl DbBackend { - fn detect(database_url: &str) -> Self { - if database_url.starts_with("postgres://") || database_url.starts_with("postgresql://") { - Self::Postgres - } else { - Self::Sqlite - } - } -} - -#[derive(Clone, Debug, Default)] -pub enum HistoricalNamespace { - #[default] - Default, - Custom(String), -} - -impl HistoricalNamespace { - fn sqlite_prefix(&self) -> &str { - match self { - Self::Default => "", - Self::Custom(prefix) => prefix, - } - } - - fn postgres_schema(&self) -> &str { - match self { - Self::Default => "public", - Self::Custom(schema) => schema, - } - } -} - -impl From<()> for HistoricalNamespace { - fn from((): ()) -> Self { - Self::Default - } -} - -impl From for HistoricalNamespace { - fn from(value: String) -> Self { - if value.is_empty() { - Self::Default - } else { - Self::Custom(value) - } - } -} - -impl From<&str> for HistoricalNamespace { - fn from(value: &str) -> Self { - if value.is_empty() { - Self::Default - } else { - Self::Custom(value.to_string()) - } - } -} - -#[derive(Clone, Debug)] -struct HistoryColumn { - name: String, - type_sql: String, -} - -#[derive(Clone, Debug)] -struct TrackedTable { - logical_name: String, - base_name: String, - history_name: String, - columns: Vec, - sqlite_queries: Option, -} - -#[derive(Clone, Debug)] -struct TrackedTableSqliteQueries { - next_revision: String, - copy_current_row: String, - insert_tombstone: String, -} - -#[derive(Clone)] -pub struct EntitiesHistoricalSink { - pool: Pool, - backend: DbBackend, - namespace: HistoricalNamespace, - tracked_names: HashSet, - tracked_tables: Arc>>, -} - -impl EntitiesHistoricalSink { - pub async fn new( - database_url: &str, - max_connections: Option, - namespace: impl Into, - tracked_models: Vec, - ) -> Result { - sqlx::any::install_default_drivers(); - - let backend = DbBackend::detect(database_url); - let database_url = match backend { - DbBackend::Postgres => database_url.to_string(), - DbBackend::Sqlite => sqlite_url(database_url)?, - }; - let pool = AnyPoolOptions::new() - .max_connections(max_connections.unwrap_or(if backend == DbBackend::Sqlite { - DEFAULT_SQLITE_MAX_CONNECTIONS - } else { - 5 - })) - .connect(&database_url) - .await?; - - Ok(Self { - pool, - backend, - namespace: namespace.into(), - tracked_names: tracked_models - .into_iter() - .filter(|name| !name.is_empty()) - .collect(), - tracked_tables: Arc::new(RwLock::new(HashMap::new())), - }) - } - - async fn bootstrap(&self) -> Result<()> { - if self.tracked_names.is_empty() { - return Ok(()); - } - - if self.backend == DbBackend::Sqlite { - sqlx::query("PRAGMA journal_mode=WAL") - .execute(&self.pool) - .await - .ok(); - } - - match self.backend { - DbBackend::Sqlite => { - let rows = sqlx::query( - "SELECT table_schema_json - FROM introspect_sink_schema_state - WHERE alive != 0", - ) - .fetch_all(&self.pool) - .await?; - for row in rows { - let schema_json: String = row.try_get("table_schema_json")?; - let schema: TableSchema = serde_json::from_str(&schema_json)?; - if self.tracked_names.contains(&schema.name) { - self.sync_tracked_table(schema.id, &schema.name).await?; - } - } - } - DbBackend::Postgres => { - let rows = sqlx::query( - "SELECT id, name - FROM introspect.db_tables - WHERE \"schema\" = $1", - ) - .bind(self.namespace.postgres_schema()) - .fetch_all(&self.pool) - .await?; - for row in rows { - let table_name: String = row.try_get("name")?; - if !self.tracked_names.contains(&table_name) { - continue; - } - let table_id = felt_from_row_hex(&row, "id")?; - self.sync_tracked_table(table_id, &table_name).await?; - } - } - } - - Ok(()) - } - - async fn resolve_tracked_table(&self, table_id: Felt) -> Result> { - let tracked = { - let tracked_tables = self.tracked_tables.read().await; - tracked_tables.get(&table_id).cloned() - }; - - if let Some(table) = tracked { - return Ok(Some(table)); - } - - let resolved = self.lookup_table_name(table_id).await?; - let Some(table_name) = resolved else { - return Ok(None); - }; - if !self.tracked_names.contains(&table_name) { - return Ok(None); - } - - let tracked = self.sync_tracked_table(table_id, &table_name).await?; - Ok(Some(tracked)) - } - - async fn lookup_table_name(&self, table_id: Felt) -> Result> { - let (canonical_table_id, compact_table_id) = felt_hex_variants(table_id); - match self.backend { - DbBackend::Sqlite => { - let row = sqlx::query( - "SELECT table_schema_json - FROM introspect_sink_schema_state - WHERE table_id = ?1 OR table_id = ?2 - LIMIT 1", - ) - .bind(canonical_table_id) - .bind(compact_table_id) - .fetch_optional(&self.pool) - .await?; - row.map(|row| { - let schema_json: String = row.try_get("table_schema_json")?; - let schema: TableSchema = serde_json::from_str(&schema_json)?; - Ok::<_, anyhow::Error>(schema.name) - }) - .transpose() - } - DbBackend::Postgres => { - let row = sqlx::query( - "SELECT name - FROM introspect.db_tables - WHERE \"schema\" = $1 AND (id::text = $2 OR id::text = $3) - LIMIT 1", - ) - .bind(self.namespace.postgres_schema()) - .bind(canonical_table_id) - .bind(compact_table_id) - .fetch_optional(&self.pool) - .await?; - row.map(|row| row.try_get("name").map_err(Into::into)) - .transpose() - } - } - } - - async fn sync_tracked_table(&self, table_id: Felt, logical_name: &str) -> Result { - let base_name = match self.backend { - DbBackend::Sqlite => sqlite_storage_name(self.namespace.sqlite_prefix(), logical_name), - DbBackend::Postgres => logical_name.to_string(), - }; - let history_name = match self.backend { - DbBackend::Sqlite => sqlite_storage_name( - self.namespace.sqlite_prefix(), - &format!("{logical_name}_historical"), - ), - DbBackend::Postgres => format!("{logical_name}_historical"), - }; - let columns = self.load_source_columns(&base_name).await?; - if !columns.iter().any(|column| column.name == "entity_id") { - return Err(anyhow!( - "tracked table '{logical_name}' does not expose entity_id column" - )); - } - let sqlite_queries = (self.backend == DbBackend::Sqlite) - .then(|| build_tracked_table_sqlite_queries(&base_name, &history_name, &columns)); - let tracked = TrackedTable { - logical_name: logical_name.to_string(), - base_name, - history_name, - sqlite_queries, - columns, - }; - self.ensure_history_table(&tracked).await?; - self.tracked_tables - .write() - .await - .insert(table_id, tracked.clone()); - Ok(tracked) - } - - async fn load_source_columns(&self, base_name: &str) -> Result> { - match self.backend { - DbBackend::Sqlite => { - // sqlite-dynamic-ok: PRAGMA table_info requires the table identifier in SQL text. - let rows = sqlx::query(&format!( - "PRAGMA table_info({})", - quote_sqlite_identifier(base_name) - )) - .fetch_all(&self.pool) - .await?; - let mut columns = Vec::with_capacity(rows.len()); - for row in rows { - let name: String = row.try_get("name")?; - let type_sql: String = row.try_get("type")?; - columns.push(HistoryColumn { name, type_sql }); - } - Ok(columns) - } - DbBackend::Postgres => { - let rows = sqlx::query( - "SELECT a.attname AS column_name, - pg_catalog.format_type(a.atttypid, a.atttypmod) AS column_type - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON c.oid = a.attrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped - ORDER BY a.attnum", - ) - .bind(self.namespace.postgres_schema()) - .bind(base_name) - .fetch_all(&self.pool) - .await?; - - let mut columns = Vec::new(); - for row in rows { - let name: String = row.try_get("column_name")?; - if name.starts_with("__") { - continue; - } - let type_sql: String = row.try_get("column_type")?; - columns.push(HistoryColumn { name, type_sql }); - } - Ok(columns) - } - } - } - - async fn ensure_history_table(&self, tracked: &TrackedTable) -> Result<()> { - let create_sql = match self.backend { - DbBackend::Sqlite => self.create_history_table_sqlite(tracked), - DbBackend::Postgres => self.create_history_table_postgres(tracked), - }; - sqlx::query(&create_sql).execute(&self.pool).await?; - - let existing_columns = self.load_existing_history_columns(tracked).await?; - for column in &tracked.columns { - if existing_columns.contains(&column.name) { - continue; - } - let add_sql = match self.backend { - DbBackend::Sqlite => format!( - "ALTER TABLE {} ADD COLUMN {} {}", - quote_sqlite_identifier(&tracked.history_name), - quote_ident(&column.name), - column.type_sql - ), - DbBackend::Postgres => format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS {} {}", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name), - quote_ident(&column.name), - column.type_sql - ), - }; - sqlx::query(&add_sql).execute(&self.pool).await?; - } - - for meta_sql in self.ensure_history_meta_columns_sql(tracked, &existing_columns) { - sqlx::query(&meta_sql).execute(&self.pool).await?; - } - - for index_sql in self.ensure_history_indexes_sql(tracked) { - sqlx::query(&index_sql).execute(&self.pool).await?; - } - - Ok(()) - } - - async fn load_existing_history_columns( - &self, - tracked: &TrackedTable, - ) -> Result> { - let rows = match self.backend { - DbBackend::Sqlite => { - // sqlite-dynamic-ok: PRAGMA table_info requires the table identifier in SQL text. - sqlx::query(&format!( - "PRAGMA table_info({})", - quote_sqlite_identifier(&tracked.history_name) - )) - .fetch_all(&self.pool) - .await? - } - DbBackend::Postgres => { - sqlx::query( - "SELECT a.attname AS column_name - FROM pg_catalog.pg_attribute a - JOIN pg_catalog.pg_class c ON c.oid = a.attrelid - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = $1 - AND c.relname = $2 - AND a.attnum > 0 - AND NOT a.attisdropped", - ) - .bind(self.namespace.postgres_schema()) - .bind(&tracked.history_name) - .fetch_all(&self.pool) - .await? - } - }; - let mut columns = HashSet::with_capacity(rows.len()); - for row in rows { - let name = if self.backend == DbBackend::Sqlite { - row.try_get("name")? - } else { - row.try_get("column_name")? - }; - columns.insert(name); - } - Ok(columns) - } - - fn create_history_table_sqlite(&self, tracked: &TrackedTable) -> String { - let model_columns = tracked - .columns - .iter() - .map(|column| { - if column.name == "entity_id" { - format!("{} {} NOT NULL", quote_ident(&column.name), column.type_sql) - } else { - format!("{} {}", quote_ident(&column.name), column.type_sql) - } - }) - .collect::>() - .join(", "); - format!( - "CREATE TABLE IF NOT EXISTS {} ({model_columns}, \ - \"revision\" INTEGER NOT NULL DEFAULT 0, \ - \"historical_deleted\" INTEGER NOT NULL DEFAULT 0, \ - \"historical_block_number\" INTEGER, \ - \"historical_tx_hash\" TEXT NOT NULL DEFAULT '', \ - \"historical_executed_at\" INTEGER NOT NULL DEFAULT 0)", - quote_sqlite_identifier(&tracked.history_name) - ) - } - - fn create_history_table_postgres(&self, tracked: &TrackedTable) -> String { - let model_columns = tracked - .columns - .iter() - .map(|column| { - if column.name == "entity_id" { - format!("{} {} NOT NULL", quote_ident(&column.name), column.type_sql) - } else { - format!("{} {}", quote_ident(&column.name), column.type_sql) - } - }) - .collect::>() - .join(", "); - format!( - "CREATE TABLE IF NOT EXISTS {} ({model_columns}, \ - \"revision\" BIGINT NOT NULL DEFAULT 0, \ - \"historical_deleted\" BOOLEAN NOT NULL DEFAULT FALSE, \ - \"historical_block_number\" BIGINT, \ - \"historical_tx_hash\" TEXT NOT NULL DEFAULT '', \ - \"historical_executed_at\" BIGINT NOT NULL DEFAULT 0)", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ) - } - - fn ensure_history_meta_columns_sql( - &self, - tracked: &TrackedTable, - existing_columns: &HashSet, - ) -> Vec { - match self.backend { - DbBackend::Sqlite => { - let mut sql = Vec::new(); - let target = quote_sqlite_identifier(&tracked.history_name); - if !existing_columns.contains("revision") { - sql.push(format!( - "ALTER TABLE {target} ADD COLUMN \"revision\" INTEGER NOT NULL DEFAULT 0" - )); - } - if !existing_columns.contains("historical_deleted") { - sql.push(format!( - "ALTER TABLE {target} ADD COLUMN \"historical_deleted\" INTEGER NOT NULL DEFAULT 0" - )); - } - if !existing_columns.contains("historical_block_number") { - sql.push(format!( - "ALTER TABLE {target} ADD COLUMN \"historical_block_number\" INTEGER" - )); - } - if !existing_columns.contains("historical_tx_hash") { - sql.push(format!( - "ALTER TABLE {target} ADD COLUMN \"historical_tx_hash\" TEXT NOT NULL DEFAULT ''" - )); - } - if !existing_columns.contains("historical_executed_at") { - sql.push(format!( - "ALTER TABLE {target} ADD COLUMN \"historical_executed_at\" INTEGER NOT NULL DEFAULT 0" - )); - } - sql - } - DbBackend::Postgres => vec![ - format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS \"revision\" BIGINT NOT NULL DEFAULT 0", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS \"historical_deleted\" BOOLEAN NOT NULL DEFAULT FALSE", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS \"historical_block_number\" BIGINT", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS \"historical_tx_hash\" TEXT NOT NULL DEFAULT ''", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - format!( - "ALTER TABLE {} ADD COLUMN IF NOT EXISTS \"historical_executed_at\" BIGINT NOT NULL DEFAULT 0", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - ], - } - } - - fn ensure_history_indexes_sql(&self, tracked: &TrackedTable) -> Vec { - let unique_index = format!("{}_entity_revision_idx", tracked.history_name); - let entity_index = format!("{}_entity_idx", tracked.history_name); - match self.backend { - DbBackend::Sqlite => vec![ - format!( - "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {} (\"entity_id\", \"revision\")", - quote_ident(&unique_index), - quote_sqlite_identifier(&tracked.history_name) - ), - format!( - "CREATE INDEX IF NOT EXISTS {} ON {} (\"entity_id\")", - quote_ident(&entity_index), - quote_sqlite_identifier(&tracked.history_name) - ), - ], - DbBackend::Postgres => vec![ - format!( - "CREATE UNIQUE INDEX IF NOT EXISTS {} ON {} (\"entity_id\", \"revision\")", - quote_ident(&unique_index), - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - format!( - "CREATE INDEX IF NOT EXISTS {} ON {} (\"entity_id\")", - quote_ident(&entity_index), - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - ], - } - } - - async fn next_revision( - &self, - tracked: &TrackedTable, - canonical_entity_id_hex: &str, - compact_entity_id_hex: &str, - ) -> Result { - let sql = match self.backend { - DbBackend::Sqlite => tracked - .sqlite_queries - .as_ref() - .expect("sqlite queries available for sqlite backend") - .next_revision - .clone(), - DbBackend::Postgres => format!( - "SELECT COALESCE(MAX(\"revision\"), 0) + 1 AS next_revision \ - FROM {} WHERE \"entity_id\"::text = $1 OR \"entity_id\"::text = $2", - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name) - ), - }; - let row = sqlx::query(&sql) - .bind(canonical_entity_id_hex) - .bind(compact_entity_id_hex) - .fetch_one(&self.pool) - .await?; - Ok(row.try_get::("next_revision")?) - } - - async fn persist_snapshot( - &self, - tracked: &TrackedTable, - entity_id: Felt, - block_number: Option, - tx_hash: Felt, - executed_at: u64, - deleted: bool, - ) -> Result<()> { - let (canonical_entity_id_hex, compact_entity_id_hex) = felt_hex_variants(entity_id); - let revision = self - .next_revision(tracked, &canonical_entity_id_hex, &compact_entity_id_hex) - .await?; - let copied = self - .copy_current_row_into_history( - tracked, - &canonical_entity_id_hex, - &compact_entity_id_hex, - revision, - block_number, - tx_hash, - executed_at, - deleted, - ) - .await?; - if copied == 0 { - if deleted { - self.insert_tombstone_only( - tracked, - &canonical_entity_id_hex, - revision, - block_number, - tx_hash, - executed_at, - ) - .await?; - return Ok(()); - } - return Err(anyhow!( - "unable to load latest row for tracked model '{}' entity {}", - tracked.logical_name, - canonical_entity_id_hex - )); - } - Ok(()) - } - - async fn copy_current_row_into_history( - &self, - tracked: &TrackedTable, - canonical_entity_id_hex: &str, - compact_entity_id_hex: &str, - revision: i64, - block_number: Option, - tx_hash: Felt, - executed_at: u64, - deleted: bool, - ) -> Result { - let sql = match self.backend { - DbBackend::Sqlite => tracked - .sqlite_queries - .as_ref() - .expect("sqlite queries available for sqlite backend") - .copy_current_row - .clone(), - DbBackend::Postgres => { - let source_columns = tracked - .columns - .iter() - .map(|column| quote_ident(&column.name)) - .collect::>() - .join(", "); - let insert_columns = format!( - "{source_columns}, \"revision\", \"historical_deleted\", \ - \"historical_block_number\", \"historical_tx_hash\", \"historical_executed_at\"" - ); - let history_target = - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name); - let source_target = - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.base_name); - format!( - "INSERT INTO {history_target} ({insert_columns}) \ - SELECT {source_columns}, $1, $2, $3, $4, $5 \ - FROM {source_target} WHERE (\"entity_id\"::text = $6 OR \"entity_id\"::text = $7) LIMIT 1" - ) - } - }; - let mut query = sqlx::query(&sql).bind(revision); - query = match self.backend { - DbBackend::Sqlite => query.bind(i64::from(deleted)), - DbBackend::Postgres => query.bind(deleted), - }; - let result = query - .bind(block_number.map(|value| value as i64)) - .bind(felt_hex(tx_hash)) - .bind(executed_at as i64) - .bind(canonical_entity_id_hex) - .bind(compact_entity_id_hex) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - - async fn insert_tombstone_only( - &self, - tracked: &TrackedTable, - entity_id_hex: &str, - revision: i64, - block_number: Option, - tx_hash: Felt, - executed_at: u64, - ) -> Result<()> { - let sql = match self.backend { - DbBackend::Sqlite => tracked - .sqlite_queries - .as_ref() - .expect("sqlite queries available for sqlite backend") - .insert_tombstone - .clone(), - DbBackend::Postgres => { - let mut columns = Vec::with_capacity(tracked.columns.len() + 5); - let mut values_sql = Vec::with_capacity(tracked.columns.len() + 5); - - let mut bind_index = 1_usize; - for column in &tracked.columns { - columns.push(quote_ident(&column.name)); - if column.name == "entity_id" { - values_sql.push(self.entity_cast_placeholder(bind_index, &column.type_sql)); - bind_index += 1; - } else { - values_sql.push("NULL".to_string()); - } - } - - columns.extend([ - "\"revision\"".to_string(), - "\"historical_deleted\"".to_string(), - "\"historical_block_number\"".to_string(), - "\"historical_tx_hash\"".to_string(), - "\"historical_executed_at\"".to_string(), - ]); - values_sql.push(self.value_placeholder(bind_index)); - bind_index += 1; - values_sql.push(self.value_placeholder(bind_index)); - bind_index += 1; - values_sql.push(self.value_placeholder(bind_index)); - bind_index += 1; - values_sql.push(self.value_placeholder(bind_index)); - bind_index += 1; - values_sql.push(self.value_placeholder(bind_index)); - - let target = - quote_pg_qualified(self.namespace.postgres_schema(), &tracked.history_name); - format!( - "INSERT INTO {target} ({}) VALUES ({})", - columns.join(", "), - values_sql.join(", ") - ) - } - }; - - let mut query = sqlx::query(&sql).bind(entity_id_hex).bind(revision); - query = match self.backend { - DbBackend::Sqlite => query.bind(1_i64), - DbBackend::Postgres => query.bind(true), - }; - query - .bind(block_number.map(|value| value as i64)) - .bind(felt_hex(tx_hash)) - .bind(executed_at as i64) - .execute(&self.pool) - .await?; - - Ok(()) - } - - fn value_placeholder(&self, index: usize) -> String { - match self.backend { - DbBackend::Sqlite => "?".to_string(), - DbBackend::Postgres => format!("${index}"), - } - } - - fn entity_cast_placeholder(&self, index: usize, type_sql: &str) -> String { - match self.backend { - DbBackend::Sqlite => "?".to_string(), - DbBackend::Postgres => format!("CAST(${index} AS {type_sql})"), - } - } - - async fn process_table_schema(&self, table: &CreateTable) -> Result<()> { - if self.tracked_names.contains(&table.name) { - self.sync_tracked_table(table.id, &table.name).await?; - } - Ok(()) - } - - async fn process_table_update(&self, table: &UpdateTable) -> Result<()> { - if self.tracked_names.contains(&table.name) { - self.sync_tracked_table(table.id, &table.name).await?; - } - Ok(()) - } -} - -#[async_trait] -impl Sink for EntitiesHistoricalSink { - fn name(&self) -> &'static str { - "entities-historical" - } - - fn interested_types(&self) -> Vec { - vec![INTROSPECT_TYPE] - } - - async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> Result<()> { - for envelope in envelopes { - if envelope.type_id != INTROSPECT_TYPE { - continue; - } - let Some(body) = envelope.downcast_ref::() else { - continue; - }; - let context = batch - .get_event_context(&body.metadata.transaction_hash, body.metadata.from_address) - .unwrap_or_default(); - let block_number = body - .metadata - .block_number - .or(Some(context.transaction.block_number)); - let executed_at = context.block.timestamp; - - match &body.msg { - IntrospectMsg::CreateTable(table) => { - self.process_table_schema(table).await?; - } - IntrospectMsg::UpdateTable(table) => { - self.process_table_update(table).await?; - } - IntrospectMsg::InsertsFields(insert) => { - let Some(tracked) = self.resolve_tracked_table(insert.table).await? else { - continue; - }; - for record in &insert.records { - self.persist_snapshot( - &tracked, - Felt::from_bytes_be(&record.id), - block_number, - body.metadata.transaction_hash, - executed_at, - false, - ) - .await - .with_context(|| { - format!( - "failed to persist historical snapshot for '{}' insert", - tracked.logical_name - ) - })?; - } - } - IntrospectMsg::DeleteRecords(delete) => { - let Some(tracked) = self.resolve_tracked_table(delete.table).await? else { - continue; - }; - for row in &delete.rows { - self.persist_snapshot( - &tracked, - row.to_felt(), - block_number, - body.metadata.transaction_hash, - executed_at, - true, - ) - .await - .with_context(|| { - format!( - "failed to persist historical tombstone for '{}'", - tracked.logical_name - ) - })?; - } - } - _ => {} - } - } - - Ok(()) - } - - fn topics(&self) -> Vec { - Vec::new() - } - - fn build_routes(&self) -> Router { - Router::new() - } - - async fn initialize( - &mut self, - _event_bus: Arc, - _context: &SinkContext, - ) -> Result<()> { - self.bootstrap().await?; - Ok(()) - } -} - -fn sqlite_storage_name(prefix: &str, logical_name: &str) -> String { - if prefix.is_empty() { - logical_name.to_string() - } else { - format!("{prefix}__{logical_name}") - } -} - -fn sqlite_url(database_url: &str) -> Result { - if database_url == ":memory:" { - return Ok("sqlite::memory:".to_string()); - } - if database_url.starts_with("sqlite:") { - return Ok(database_url.to_string()); - } - Ok(format!("sqlite://{database_url}")) -} - -fn quote_ident(identifier: &str) -> String { - format!("\"{}\"", identifier.replace('"', "\"\"")) -} - -fn quote_sqlite_identifier(table: &str) -> String { - quote_ident(table) -} - -fn quote_pg_qualified(schema: &str, table: &str) -> String { - format!("{}.{}", quote_ident(schema), quote_ident(table)) -} - -fn build_tracked_table_sqlite_queries( - base_name: &str, - history_name: &str, - columns: &[HistoryColumn], -) -> TrackedTableSqliteQueries { - let history_target = quote_sqlite_identifier(history_name); - let source_target = quote_sqlite_identifier(base_name); - let source_columns = columns - .iter() - .map(|column| quote_ident(&column.name)) - .collect::>() - .join(", "); - let insert_columns = format!( - "{source_columns}, \"revision\", \"historical_deleted\", \ - \"historical_block_number\", \"historical_tx_hash\", \"historical_executed_at\"" - ); - - let mut tombstone_columns = Vec::with_capacity(columns.len() + 5); - let mut tombstone_values = Vec::with_capacity(columns.len() + 5); - for column in columns { - tombstone_columns.push(quote_ident(&column.name)); - if column.name == "entity_id" { - tombstone_values.push("?".to_string()); - } else { - tombstone_values.push("NULL".to_string()); - } - } - tombstone_columns.extend([ - "\"revision\"".to_string(), - "\"historical_deleted\"".to_string(), - "\"historical_block_number\"".to_string(), - "\"historical_tx_hash\"".to_string(), - "\"historical_executed_at\"".to_string(), - ]); - tombstone_values.extend([ - "?".to_string(), - "?".to_string(), - "?".to_string(), - "?".to_string(), - "?".to_string(), - ]); - - TrackedTableSqliteQueries { - next_revision: format!( - "SELECT COALESCE(MAX(\"revision\"), 0) + 1 AS next_revision \ - FROM {history_target} WHERE \"entity_id\" = ?1 OR \"entity_id\" = ?2" - ), - copy_current_row: format!( - "INSERT INTO {history_target} ({insert_columns}) \ - SELECT {source_columns}, ?1, ?2, ?3, ?4, ?5 \ - FROM {source_target} \ - WHERE (\"entity_id\" = ?6 OR \"entity_id\" = ?7) \ - LIMIT 1" - ), - insert_tombstone: format!( - "INSERT INTO {history_target} ({}) VALUES ({})", - tombstone_columns.join(", "), - tombstone_values.join(", ") - ), - } -} - -fn felt_hex(value: Felt) -> String { - format!("{value:#x}") -} - -fn canonical_felt_hex(value: Felt) -> String { - format!("{value:#066x}") -} - -fn compact_hex_str(value: &str) -> String { - let value = value.trim(); - let Some(hex) = value.strip_prefix("0x") else { - return value.to_string(); - }; - let trimmed = hex.trim_start_matches('0'); - if trimmed.is_empty() { - "0x0".to_string() - } else { - format!("0x{trimmed}") - } -} - -fn felt_hex_variants(value: Felt) -> (String, String) { - let canonical = canonical_felt_hex(value); - let compact = compact_hex_str(&canonical); - (canonical, compact) -} - -fn felt_from_row_hex(row: &sqlx::any::AnyRow, column: &str) -> Result { - let value: String = row.try_get(column)?; - Felt::from_hex(&value).map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::*; - use introspect_types::{ColumnDef, PrimaryDef, PrimaryTypeDef, TypeDef}; - use torii::etl::envelope::{EventBody, MetaData}; - use torii::etl::extractor::ExtractionBatch; - use torii_introspect::events::{DeleteRecords, InsertsFields, IntrospectMsg, Record}; - - fn sqlite_table_schema(name: &str, id: Felt) -> TableSchema { - TableSchema { - id, - name: name.to_string(), - attributes: Vec::new(), - primary: PrimaryDef { - name: "entity_id".to_string(), - attributes: Vec::new(), - type_def: PrimaryTypeDef::Felt252, - }, - columns: vec![ - ColumnDef { - id: Felt::from(1_u8), - name: "name".to_string(), - attributes: Vec::new(), - type_def: TypeDef::ByteArray, - }, - ColumnDef { - id: Felt::from(2_u8), - name: "score".to_string(), - attributes: Vec::new(), - type_def: TypeDef::U32, - }, - ], - } - } - - async fn sqlite_sink() -> Result { - let sink = EntitiesHistoricalSink::new( - "sqlite::memory:", - Some(1), - (), - vec!["NUMS-Game".to_string()], - ) - .await?; - sqlx::query( - "CREATE TABLE introspect_sink_schema_state ( - table_id TEXT PRIMARY KEY, - table_schema_json TEXT NOT NULL, - alive INTEGER NOT NULL DEFAULT 1, - updated_at INTEGER NOT NULL DEFAULT (unixepoch()) - )", - ) - .execute(&sink.pool) - .await?; - sqlx::query( - "CREATE TABLE \"NUMS-Game\" ( - \"entity_id\" TEXT PRIMARY KEY, - \"name\" TEXT, - \"score\" INTEGER - )", - ) - .execute(&sink.pool) - .await?; - let schema = sqlite_table_schema("NUMS-Game", Felt::from(9_u8)); - sqlx::query( - "INSERT INTO introspect_sink_schema_state (table_id, table_schema_json, alive, updated_at) - VALUES (?1, ?2, 1, unixepoch())", - ) - .bind(canonical_felt_hex(schema.id)) - .bind(serde_json::to_string(&schema)?) - .execute(&sink.pool) - .await?; - Ok(sink) - } - - #[tokio::test] - async fn initializes_history_table_from_sqlite_schema_state() -> Result<()> { - let sink = sqlite_sink().await?; - sink.bootstrap().await?; - - let row = sqlx::query( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'NUMS-Game_historical'", - ) - .fetch_one(&sink.pool) - .await?; - let name: String = row.try_get("name")?; - assert_eq!(name, "NUMS-Game_historical"); - Ok(()) - } - - #[tokio::test] - async fn appends_revisions_and_delete_tombstones_in_sqlite() -> Result<()> { - let sink = sqlite_sink().await?; - sink.bootstrap().await?; - - let entity_id = Felt::from(77_u8); - sqlx::query( - "INSERT INTO \"NUMS-Game\" (\"entity_id\", \"name\", \"score\") VALUES (?1, ?2, ?3)", - ) - .bind(canonical_felt_hex(entity_id)) - .bind("first") - .bind(10_i64) - .execute(&sink.pool) - .await?; - - let insert = EventBody { - metadata: MetaData { - block_number: Some(1), - transaction_hash: Felt::from(100_u16), - from_address: Felt::ZERO, - }, - msg: IntrospectMsg::InsertsFields(InsertsFields::new( - Felt::from(9_u8), - Vec::new(), - vec![Record::new(entity_id, Vec::new())], - )), - }; - sink.process(&[Envelope::from(insert)], &ExtractionBatch::empty()) - .await?; - - sqlx::query("UPDATE \"NUMS-Game\" SET \"score\" = 25 WHERE \"entity_id\" = ?1") - .bind(canonical_felt_hex(entity_id)) - .execute(&sink.pool) - .await?; - let update = EventBody { - metadata: MetaData { - block_number: Some(2), - transaction_hash: Felt::from(101_u16), - from_address: Felt::ZERO, - }, - msg: IntrospectMsg::InsertsFields(InsertsFields::new( - Felt::from(9_u8), - Vec::new(), - vec![Record::new(entity_id, Vec::new())], - )), - }; - sink.process(&[Envelope::from(update)], &ExtractionBatch::empty()) - .await?; - - let delete = EventBody { - metadata: MetaData { - block_number: Some(3), - transaction_hash: Felt::from(102_u16), - from_address: Felt::ZERO, - }, - msg: IntrospectMsg::DeleteRecords(DeleteRecords::new( - Felt::from(9_u8), - vec![entity_id.into()], - )), - }; - sink.process(&[Envelope::from(delete)], &ExtractionBatch::empty()) - .await?; - - let rows = sqlx::query( - "SELECT revision, historical_deleted, score FROM \"NUMS-Game_historical\" ORDER BY revision", - ) - .fetch_all(&sink.pool) - .await?; - - assert_eq!(rows.len(), 3); - assert_eq!(rows[0].try_get::("revision")?, 1); - assert_eq!(rows[0].try_get::("historical_deleted")?, 0); - assert_eq!(rows[0].try_get::("score")?, 10); - assert_eq!(rows[1].try_get::("revision")?, 2); - assert_eq!(rows[1].try_get::("score")?, 25); - assert_eq!(rows[2].try_get::("revision")?, 3); - assert_eq!(rows[2].try_get::("historical_deleted")?, 1); - assert_eq!(rows[2].try_get::("score")?, 25); - Ok(()) - } -} diff --git a/crates/torii-erc1155/Cargo.toml b/crates/torii-erc1155/Cargo.toml index 961ca495..0d660758 100644 --- a/crates/torii-erc1155/Cargo.toml +++ b/crates/torii-erc1155/Cargo.toml @@ -8,6 +8,8 @@ description = "ERC1155 semi-fungible token indexer library for Torii" # Torii core torii = { path = "../.." } torii-common = { path = "../torii-common" } +torii-types = { path = "../types" } +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } # Async tokio = { version = "1", features = ["full"] } @@ -15,6 +17,7 @@ async-trait = "0.1" # Starknet starknet = "0.17" +starknet-types-raw.workspace = true # Storage rusqlite = { version = "0.32", features = ["bundled"] } @@ -26,6 +29,7 @@ serde_json = "1.0" # Encoding hex = "0.4" +primitive-types.workspace = true # gRPC/Protobuf prost = "0.13" @@ -43,6 +47,7 @@ axum = "0.7" # Utilities anyhow = "1.0" tracing = "0.1" +thiserror.workspace = true [build-dependencies] tonic-build = "0.12" diff --git a/crates/torii-erc1155/README.md b/crates/torii-erc1155/README.md new file mode 100644 index 00000000..4ba7d080 --- /dev/null +++ b/crates/torii-erc1155/README.md @@ -0,0 +1,130 @@ +# torii-erc1155 + +End-to-end **ERC1155 semi-fungible token indexer**. Decodes TransferSingle, +TransferBatch, ApprovalForAll, and URI events; stores transfer history, +operator approvals, and URI updates; serves a gRPC query/subscribe API. +**Balance tracking is intentionally not authoritative** — the sink writes +balance deltas but clients should query the chain for authoritative state +(see note below). + +## Role in Torii + +Same shape as `torii-erc20` / `torii-erc721` but for ERC1155. The +`Erc1155Rule` auto-identifier keys off `safeTransferFrom` + `balanceOf` + +`TransferSingle`. The sink emits three EventBus topics: `erc1155.transfer`, +`erc1155.metadata`, `erc1155.uri`. + +## Architecture + +```text +StarknetEvent (ERC1155 selector) + | + v ++-------------------------------+ +| Erc1155Decoder | identification::Erc1155Rule +| TransferSingle | Batch | | (safeTransferFrom + balanceOf + +| ApprovalForAll | URI | TransferSingle) +| → Envelope(ERC1155_TYPE_ID) | ++---------------+---------------+ + | + v ++-------------------------------+ +| Erc1155Sink | +| | +| process(): | +| - persist transfers | +| (batch expanded to N rows| +| one per (id,amount)) | +| - update operator approval | +| - URI update → enqueue | +| Erc1155TokenUriCommand | +| - balance delta tracked | +| in erc1155_balance | +| (reconciled on negative | +| via Erc1155BalanceFetcher +| when enabled) | +| - broadcast only when | +| batch.is_live(100) | ++---------------+---------------+ + | + v ++-------------------------------+ +| Erc1155Storage | +| erc1155_transfer (cursor) | +| erc1155_operator_approval | +| erc1155_uri | +| erc1155_balance | +| erc1155_balance_adjustment | +| erc1155_metadata | +| erc1155_token_uri | ++---------------+---------------+ + | + v ++-------------------------------+ +| Erc1155Service | +| gRPC: torii.sinks.erc1155 | ++-------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Purpose | +|---|---|---| +| `Erc1155Decoder`, `Erc1155Message`, `Erc1155Body`, `ERC1155_TYPE_ID` | `src/decoder.rs` | Decoder + event enum + wrapper body | +| `TransferData`, `OperatorApproval`, `UriUpdate` | `src/decoder.rs` | Typed event payloads | +| `Erc1155Rule` | `src/identification.rs` | ABI-based identification | +| `Erc1155Sink` | `src/sink.rs` | `Sink` impl | +| `Erc1155Storage`, `TokenTransferData`, `TokenUriData`, `Erc1155BalanceData`, `Erc1155BalanceAdjustment`, `TransferCursor` | `src/storage.rs` | Persistence layer | +| `Erc1155Service` | `src/grpc_service.rs` | gRPC service | +| `Erc1155BalanceFetcher`, `Erc1155BalanceFetchRequest` | `src/balance_fetcher.rs` | On-chain balance reconciliation | +| `Erc1155MetadataCommandHandler`, `Erc1155TokenUriCommandHandler` | `src/handlers.rs` | Async command handlers | +| `SyntheticErc1155Config`, `SyntheticErc1155Extractor` | `src/synthetic.rs` | Deterministic generator | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs:55` | Descriptor bytes | + +### Internal Modules + +- `decoder` — matches ERC1155 selectors, **expands `TransferBatch` into N transfers** (one per `(id, amount)` pair) so downstream code treats single and batch identically. +- `identification` — requires `safeTransferFrom`, `balanceOf`, `TransferSingle`. +- `sink` — processes envelopes; dispatches metadata + token-URI commands. +- `storage` — transfer history + balance + URI tables. +- `balance_fetcher` — optional reconciliation path using `JsonRpcClient`. +- `handlers` — two `CommandHandler`s parallel to ERC721. +- `grpc_service` — pagination + broadcast channel. +- `synthetic` — `SyntheticErc1155Extractor`. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"erc1155"` | +| `interested_types` | `[ERC1155_TYPE_ID]` | +| `process` | Persists expanded transfers, updates operator-approval bitmap, stores URI updates, updates balance delta, broadcasts when `batch.is_live(100)` | +| `topics` | 3 topics: `erc1155.transfer` (filters: `token`, `from`, `to`, `wallet`), `erc1155.metadata` (filter: `token`), `erc1155.uri` (filters: `token`, `token_id`) | +| `build_routes` | `Router::new()` — gRPC only | +| `initialize` | Captures `event_bus` + `ctx.command_bus` | + +### Storage note + +ERC1155 maintains `erc1155_balance (token, account, token_id, amount)` and +an `erc1155_balance_adjustment` audit table, but the lib docs (`src/lib.rs:3`) state: + +> Like ERC20, we only track transfer history - NOT balances. Clients should +> query the chain for actual balances to avoid inaccuracies from genesis +> allocations. + +Treat the balance tables as a **best-effort cache** that the optional +`Erc1155BalanceFetcher` reconciles on divergence; authoritative balance +queries belong on-chain. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-tokens`, `bins/torii-tokens-synth`, `bins/torii-introspect-bin`, `bins/torii-arcade`. +- **Downstream deps**: `torii`, `torii-common`, `torii-types`, `sqlx`, `tonic`, `prost`, `starknet`, `starknet-types-raw`, `primitive-types`, `tokio`, `async-trait`, `chrono`, `hex`, `metrics`, `tracing`. + +### Extension Points + +- TransferBatch-level aggregation → add a `TokenBatchTransferData` alongside expanded rows. +- Additional filters → extend the `TopicInfo.available_filters` lists and the matching `matches_*_filters` helper in `sink.rs`. +- Flip balance tracking from best-effort to authoritative → wire a continuous reconciler through `Erc1155BalanceFetcher` + CommandBus; keep the note in `lib.rs` in sync. diff --git a/crates/torii-erc1155/src/balance_fetcher.rs b/crates/torii-erc1155/src/balance_fetcher.rs index 5fe7dbf8..c5b0d49e 100644 --- a/crates/torii-erc1155/src/balance_fetcher.rs +++ b/crates/torii-erc1155/src/balance_fetcher.rs @@ -6,11 +6,14 @@ //! ERC1155 has a different signature: balance_of(account, token_id) use anyhow::{Context, Result}; -use starknet::core::types::{requests::CallRequest, BlockId, Felt, FunctionCall, U256}; +use primitive_types::U256; +use starknet::core::types::{requests::CallRequest, BlockId, FunctionCall}; use starknet::macros::selector; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::sync::Arc; +use torii_common::utils::{felt_pair_to_u256, felt_to_u256}; /// Request for fetching an ERC1155 balance at a specific block #[derive(Debug, Clone)] @@ -54,15 +57,20 @@ impl Erc1155BalanceFetcher { let calldata = build_balance_of_calldata(wallet, token_id); let call = FunctionCall { - contract_address: contract, + contract_address: to_starknet_felt(contract), entry_point_selector: selector!("balance_of"), - calldata, + calldata: calldata.into_iter().map(to_starknet_felt).collect(), }; let block_id = BlockId::Number(block_number); match self.provider.call(call, block_id).await { - Ok(result) => Ok(parse_u256_result(&result)), + Ok(result) => Ok(parse_u256_result( + &result + .into_iter() + .map(from_starknet_felt) + .collect::>(), + )), Err(e) => { tracing::warn!( target: "torii_erc1155::balance_fetcher", @@ -98,9 +106,9 @@ impl Erc1155BalanceFetcher { let calldata = build_balance_of_calldata(req.wallet, req.token_id); ProviderRequestData::Call(CallRequest { request: FunctionCall { - contract_address: req.contract, + contract_address: to_starknet_felt(req.contract), entry_point_selector: selector!("balance_of"), - calldata, + calldata: calldata.into_iter().map(to_starknet_felt).collect(), }, block_id: BlockId::Number(req.block_number), }) @@ -119,7 +127,12 @@ impl Erc1155BalanceFetcher { for (idx, response) in responses.into_iter().enumerate() { let req = &requests[idx]; let balance = if let ProviderResponseData::Call(felts) = response { - parse_u256_result(&felts) + parse_u256_result( + &felts + .into_iter() + .map(from_starknet_felt) + .collect::>(), + ) } else { tracing::warn!( target: "torii_erc1155::balance_fetcher", @@ -147,9 +160,7 @@ impl Erc1155BalanceFetcher { /// Build calldata for balance_of(account, token_id) /// ERC1155 expects: [account, token_id_low, token_id_high] fn build_balance_of_calldata(wallet: Felt, token_id: U256) -> Vec { - // Convert U256 to two Felts (low, high) - let low = Felt::from(token_id.low()); - let high = Felt::from(token_id.high()); + let (low, high) = u256_low_high(token_id); vec![wallet, low, high] } @@ -161,23 +172,25 @@ fn build_balance_of_calldata(wallet: Felt, token_id: U256) -> Vec { fn parse_u256_result(result: &[Felt]) -> U256 { match result.len() { 0 => U256::from(0u64), - 1 => { - // Single felt - convert to U256 - let bytes = result[0].to_bytes_be(); - let low = u128::from_be_bytes(bytes[16..32].try_into().unwrap()); - U256::from_words(low, 0) - } - _ => { - // Two felts: [low, high] for u256 - let low_bytes = result[0].to_bytes_be(); - let high_bytes = result[1].to_bytes_be(); + 1 => felt_to_u256(result[0]), + _ => felt_pair_to_u256(result[0], result[1]), + } +} + +fn to_starknet_felt(value: Felt) -> starknet::core::types::Felt { + starknet::core::types::Felt::from_bytes_be(&value.to_be_bytes()) +} - let low = u128::from_be_bytes(low_bytes[16..32].try_into().unwrap()); - let high = u128::from_be_bytes(high_bytes[16..32].try_into().unwrap()); +fn from_starknet_felt(value: starknet::core::types::Felt) -> Felt { + Felt::from_be_bytes_slice(&value.to_bytes_be()).unwrap_or(Felt::ZERO) +} - U256::from_words(low, high) - } - } +fn u256_low_high(value: U256) -> (Felt, Felt) { + let [l0, l1, h0, h1] = value.0; + ( + Felt::from_le_words([l0, l1, 0, 0]), + Felt::from_le_words([h0, h1, 0, 0]), + ) } #[cfg(test)] diff --git a/crates/torii-erc1155/src/decoder.rs b/crates/torii-erc1155/src/decoder.rs index 22b7b5ea..0950ed38 100644 --- a/crates/torii-erc1155/src/decoder.rs +++ b/crates/torii-erc1155/src/decoder.rs @@ -2,77 +2,34 @@ use anyhow::Result; use async_trait::async_trait; +use primitive_types::U256; use starknet::core::codec::Decode; -use starknet::core::types::{ByteArray, EmittedEvent, Felt, U256}; +use starknet::core::types::ByteArray; use starknet::core::utils::parse_cairo_short_string; -use starknet::macros::selector; -use std::any::Any; -use std::collections::HashMap; -use torii::etl::{Decoder, Envelope, TypedBody}; -use torii_common::bytes_to_u256; - -/// TransferSingle event from ERC1155 token -#[derive(Debug, Clone)] -pub struct TransferSingle { - pub operator: Felt, - pub from: Felt, - pub to: Felt, - /// Token ID as U256 (256-bit) - pub id: U256, - /// Amount as U256 (256-bit) - pub value: U256, - pub token: Felt, - pub block_number: u64, - pub transaction_hash: Felt, -} - -impl TypedBody for TransferSingle { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc1155.transfer_single") - } +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt; +use torii::etl::envelope::EventMsg; +use torii::etl::{Decoder, Envelope, EventBody, TypeId}; +use torii_common::utils::{felt_pair_to_u256, felt_to_u256}; +use torii_types::event::EventContext; - fn as_any(&self) -> &dyn Any { - self - } +pub const ERC1155_TYPE_ID: TypeId = TypeId::new("erc1155"); - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -/// TransferBatch event from ERC1155 token (denormalized into individual transfers) -#[derive(Debug, Clone)] -pub struct TransferBatch { +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransferData { pub operator: Felt, pub from: Felt, pub to: Felt, - /// Token ID for this specific transfer in the batch - pub id: U256, - /// Amount for this specific transfer in the batch - pub value: U256, - /// Index in the original batch + pub token_id: U256, + pub amount: U256, + pub is_batch: bool, pub batch_index: u32, pub token: Felt, pub block_number: u64, pub transaction_hash: Felt, } -impl TypedBody for TransferBatch { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc1155.transfer_batch") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -/// ApprovalForAll event from ERC1155 token -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct OperatorApproval { pub owner: Felt, pub operator: Felt, @@ -82,22 +39,7 @@ pub struct OperatorApproval { pub transaction_hash: Felt, } -impl TypedBody for OperatorApproval { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc1155.approval_for_all") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - -/// URI event from ERC1155 token -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct UriUpdate { pub token: Felt, pub token_id: U256, @@ -106,28 +48,66 @@ pub struct UriUpdate { pub transaction_hash: Felt, } -impl TypedBody for UriUpdate { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc1155.uri") - } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Erc1155Message { + Transfer(TransferData), + ApprovalForAll(OperatorApproval), + Uri(UriUpdate), +} - fn as_any(&self) -> &dyn Any { - self +pub type Erc1155Body = EventBody; + +impl EventMsg for Erc1155Message { + fn event_id(&self) -> String { + match self { + Self::Transfer(transfer) => { + if transfer.is_batch { + format!( + "erc1155_transfer_batch_{}_{}_{}", + transfer.block_number, + format!("{:#x}", transfer.transaction_hash), + transfer.batch_index + ) + } else { + format!( + "erc1155_transfer_single_{}_{}", + transfer.block_number, + format!("{:#x}", transfer.transaction_hash) + ) + } + } + Self::ApprovalForAll(approval) => { + format!( + "erc1155_approval_for_all_{}_{}", + approval.block_number, + format!("{:#x}", approval.transaction_hash) + ) + } + Self::Uri(uri) => format!( + "erc1155_uri_{}_{}", + uri.block_number, + format!("{:#x}", uri.transaction_hash) + ), + } } - fn as_any_mut(&mut self) -> &mut dyn Any { - self + fn envelope_type_id(&self) -> TypeId { + ERC1155_TYPE_ID } } -/// ERC1155 event decoder -/// -/// Decodes multiple ERC1155 events: -/// - TransferSingle(operator, from, to, id, value) -/// - TransferBatch(operator, from, to, ids, values) -/// - ApprovalForAll(owner, operator, approved) -/// -/// Supports both modern (keys) and legacy (data-only) formats. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct TransferPart { + operator: Felt, + from: Felt, + to: Felt, + token_id: U256, + amount: U256, + batch_index: u32, + is_batch: bool, +} + +/// ERC1155 event decoder. pub struct Erc1155Decoder; impl Erc1155Decoder { @@ -135,28 +115,20 @@ impl Erc1155Decoder { Self } - fn felt_to_u256(felt: Felt) -> U256 { - bytes_to_u256(&felt.to_bytes_be()) - } - - /// TransferSingle event selector: sn_keccak("TransferSingle") - fn transfer_single_selector() -> Felt { - selector!("TransferSingle") + pub fn transfer_single_selector() -> Felt { + Felt::selector("TransferSingle") } - /// TransferBatch event selector: sn_keccak("TransferBatch") - fn transfer_batch_selector() -> Felt { - selector!("TransferBatch") + pub fn transfer_batch_selector() -> Felt { + Felt::selector("TransferBatch") } - /// ApprovalForAll event selector: sn_keccak("ApprovalForAll") - fn approval_for_all_selector() -> Felt { - selector!("ApprovalForAll") + pub fn approval_for_all_selector() -> Felt { + Felt::selector("ApprovalForAll") } - /// URI event selector: sn_keccak("URI") - fn uri_selector() -> Felt { - selector!("URI") + pub fn uri_selector() -> Felt { + Felt::selector("URI") } fn decode_string_result(result: &[Felt]) -> Option { @@ -165,12 +137,17 @@ impl Erc1155Decoder { } if result.len() == 1 { - return parse_cairo_short_string(&result[0]) + return parse_cairo_short_string(&to_starknet_felt(result[0])) .ok() .filter(|s| !s.is_empty()); } - if let Ok(byte_array) = ByteArray::decode(result) { + let starknet_felts = result + .iter() + .copied() + .map(to_starknet_felt) + .collect::>(); + if let Ok(byte_array) = ByteArray::decode(&starknet_felts) { if let Ok(s) = String::try_from(byte_array) { if !s.is_empty() { return Some(s); @@ -178,11 +155,11 @@ impl Erc1155Decoder { } } - let len: usize = result[0].try_into().unwrap_or(0usize); + let len = felt_to_usize(result[0]); if len > 0 && len < 1000 && result.len() > len { let mut out = String::new(); for felt in &result[1..=len] { - if let Ok(chunk) = parse_cairo_short_string(felt) { + if let Ok(chunk) = parse_cairo_short_string(&to_starknet_felt(*felt)) { out.push_str(&chunk); } } @@ -194,190 +171,77 @@ impl Erc1155Decoder { None } - /// Decode TransferSingle event into envelope - /// - /// TransferSingle event signatures: - /// - /// Modern ERC1155: - /// - keys[0]: TransferSingle selector - /// - keys[1]: operator address - /// - keys[2]: from address - /// - keys[3]: to address - /// - data[0]: id_low (u128) - /// - data[1]: id_high (u128) - /// - data[2]: value_low (u128) - /// - data[3]: value_high (u128) - /// - /// Legacy ERC1155: - /// - keys[0]: TransferSingle selector - /// - data[0]: operator address - /// - data[1]: from address - /// - data[2]: to address - /// - data[3]: id_low (u128) - /// - data[4]: id_high (u128) - /// - data[5]: value_low (u128) - /// - data[6]: value_high (u128) - async fn decode_transfer_single(&self, event: &EmittedEvent) -> Result> { - let operator; - let from; - let to; - let id: U256; - let value: U256; - + fn parse_transfer_single(&self, event: &EmittedEvent) -> Option { if event.keys.len() == 4 && event.data.len() == 4 { - // Modern format: operator, from, to in keys; id, value in data - operator = event.keys[1]; - from = event.keys[2]; - to = event.keys[3]; - let id_low: u128 = event.data[0].try_into().unwrap_or(0); - let id_high: u128 = event.data[1].try_into().unwrap_or(0); - id = U256::from_words(id_low, id_high); - let value_low: u128 = event.data[2].try_into().unwrap_or(0); - let value_high: u128 = event.data[3].try_into().unwrap_or(0); - value = U256::from_words(value_low, value_high); - } else if event.keys.len() == 1 && event.data.len() == 7 { - // Legacy format: all in data - operator = event.data[0]; - from = event.data[1]; - to = event.data[2]; - let id_low: u128 = event.data[3].try_into().unwrap_or(0); - let id_high: u128 = event.data[4].try_into().unwrap_or(0); - id = U256::from_words(id_low, id_high); - let value_low: u128 = event.data[5].try_into().unwrap_or(0); - let value_high: u128 = event.data[6].try_into().unwrap_or(0); - value = U256::from_words(value_low, value_high); - } else if event.keys.len() == 4 && event.data.len() == 2 { - // Alternative modern format with single felt id and value - operator = event.keys[1]; - from = event.keys[2]; - to = event.keys[3]; - id = Self::felt_to_u256(event.data[0]); - value = Self::felt_to_u256(event.data[1]); - } else { - tracing::warn!( - target: "torii_erc1155::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC1155 TransferSingle event" - ); - return Ok(None); + return Some(TransferPart { + operator: event.keys[1], + from: event.keys[2], + to: event.keys[3], + token_id: felt_pair_to_u256(event.data[0], event.data[1]), + amount: felt_pair_to_u256(event.data[2], event.data[3]), + batch_index: 0, + is_batch: false, + }); } - let transfer = TransferSingle { - operator, - from, - to, - id, - value, - token: event.from_address, - block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + if event.keys.len() == 1 && event.data.len() == 7 { + return Some(TransferPart { + operator: event.data[0], + from: event.data[1], + to: event.data[2], + token_id: felt_pair_to_u256(event.data[3], event.data[4]), + amount: felt_pair_to_u256(event.data[5], event.data[6]), + batch_index: 0, + is_batch: false, + }); + } - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); + if event.keys.len() == 4 && event.data.len() == 2 { + return Some(TransferPart { + operator: event.keys[1], + from: event.keys[2], + to: event.keys[3], + token_id: felt_to_u256(event.data[0]), + amount: felt_to_u256(event.data[1]), + batch_index: 0, + is_batch: false, + }); + } - let envelope_id = format!( - "erc1155_transfer_single_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); + None + } + + fn parse_u256_slice(items: &[Felt], as_pairs: bool) -> Vec { + if as_pairs { + return items + .chunks_exact(2) + .map(|chunk| felt_pair_to_u256(chunk[0], chunk[1])) + .collect(); + } - Ok(Some(Envelope::new( - envelope_id, - Box::new(transfer), - metadata, - ))) + items.iter().copied().map(felt_to_u256).collect() } - /// Decode TransferBatch event into multiple envelopes (one per id/value pair) - /// - /// TransferBatch event signatures: - /// - /// Modern ERC1155: - /// - keys[0]: TransferBatch selector - /// - keys[1]: operator address - /// - keys[2]: from address - /// - keys[3]: to address - /// - data[0]: ids_len - /// - data[1..1+ids_len*2]: ids (low, high pairs) - /// - data[1+ids_len*2]: values_len - /// - data[2+ids_len*2..]: values (low, high pairs) - /// - /// Legacy: all in data - async fn decode_transfer_batch(&self, event: &EmittedEvent) -> Result> { - let operator; - let from; - let to; - let mut data_offset = 0; - - if event.keys.len() == 4 { - // Modern format: operator, from, to in keys - operator = event.keys[1]; - from = event.keys[2]; - to = event.keys[3]; + fn parse_transfer_batch(&self, event: &EmittedEvent) -> Option> { + let (operator, from, to, mut data_offset) = if event.keys.len() == 4 { + (event.keys[1], event.keys[2], event.keys[3], 0usize) } else if event.keys.len() == 1 && event.data.len() >= 3 { - // Legacy format: operator, from, to at start of data - operator = event.data[0]; - from = event.data[1]; - to = event.data[2]; - data_offset = 3; + (event.data[0], event.data[1], event.data[2], 3usize) } else { - tracing::warn!( - target: "torii_erc1155::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC1155 TransferBatch event" - ); - return Ok(vec![]); - } + return None; + }; - // Parse ids array if event.data.len() <= data_offset { - return Ok(vec![]); + return Some(Vec::new()); } - let ids_len: usize = event.data[data_offset].try_into().unwrap_or(0); + let ids_len = felt_to_usize(event.data[data_offset]); data_offset += 1; - fn parse_u256_slice(items: &[Felt], as_pairs: bool) -> Vec { - if as_pairs { - let mut out = Vec::with_capacity(items.len() / 2); - for chunk in items.chunks_exact(2) { - let low: u128 = chunk[0].try_into().unwrap_or(0); - let high: u128 = chunk[1].try_into().unwrap_or(0); - out.push(U256::from_words(low, high)); - } - out - } else { - items - .iter() - .copied() - .map(Erc1155Decoder::felt_to_u256) - .collect() - } - } - - let mut ids: Vec = Vec::new(); - let mut values: Vec = Vec::new(); + let mut ids = Vec::new(); + let mut values = Vec::new(); let mut parsed = false; - // Try both pair-based and single-felt array layouts for ids and values. - // Standard Starknet ERC1155 uses U256 pairs, but some contracts emit felt arrays. for ids_as_pairs in [true, false] { let id_words = if ids_as_pairs { 2 } else { 1 }; let ids_end = data_offset.saturating_add(ids_len.saturating_mul(id_words)); @@ -385,8 +249,9 @@ impl Erc1155Decoder { continue; } - let candidate_ids = parse_u256_slice(&event.data[data_offset..ids_end], ids_as_pairs); - let values_len: usize = event.data[ids_end].try_into().unwrap_or(0); + let candidate_ids = + Self::parse_u256_slice(&event.data[data_offset..ids_end], ids_as_pairs); + let values_len = felt_to_usize(event.data[ids_end]); let values_start = ids_end + 1; for values_as_pairs in [true, false] { @@ -398,7 +263,8 @@ impl Erc1155Decoder { } ids.clone_from(&candidate_ids); - values = parse_u256_slice(&event.data[values_start..values_end], values_as_pairs); + values = + Self::parse_u256_slice(&event.data[values_start..values_end], values_as_pairs); parsed = true; break; } @@ -408,166 +274,99 @@ impl Erc1155Decoder { } } - if !parsed { - tracing::warn!( - target: "torii_erc1155::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - ids_len = ids_len, - "Failed to parse ERC1155 TransferBatch ids/values arrays" - ); - return Ok(vec![]); + if !parsed || ids.len() != values.len() { + return None; } - // Create envelope for each id/value pair - let mut envelopes = Vec::new(); - for (i, (id, value)) in ids.iter().zip(values.iter()).enumerate() { - let transfer = TransferBatch { - operator, - from, - to, - id: *id, - value: *value, - batch_index: i as u32, + Some( + ids.into_iter() + .zip(values) + .enumerate() + .map(|(batch_index, (token_id, amount))| TransferPart { + operator, + from, + to, + token_id, + amount, + batch_index: batch_index as u32, + is_batch: true, + }) + .collect(), + ) + } + + fn parse_approval_for_all(&self, event: &EmittedEvent) -> Option { + if event.keys.len() == 3 && event.data.len() == 1 { + return Some(OperatorApproval { + owner: event.keys[1], + operator: event.keys[2], + approved: event.data[0] != Felt::ZERO, token: event.from_address, block_number: event.block_number.unwrap_or(0), transaction_hash: event.transaction_hash, - }; - - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); - metadata.insert("batch_index".to_string(), i.to_string()); - - let envelope_id = format!( - "erc1155_transfer_batch_{}_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash), - i - ); + }); + } - envelopes.push(Envelope::new(envelope_id, Box::new(transfer), metadata)); + if event.keys.len() == 1 && event.data.len() == 3 { + return Some(OperatorApproval { + owner: event.data[0], + operator: event.data[1], + approved: event.data[2] != Felt::ZERO, + token: event.from_address, + block_number: event.block_number.unwrap_or(0), + transaction_hash: event.transaction_hash, + }); } - Ok(envelopes) + None } - /// Decode ApprovalForAll event into envelope - async fn decode_approval_for_all(&self, event: &EmittedEvent) -> Result> { - let owner; - let operator; - let approved: bool; - - if event.keys.len() == 3 && event.data.len() == 1 { - // Modern format: owner, operator in keys; approved in data - owner = event.keys[1]; - operator = event.keys[2]; - approved = event.data[0] != Felt::ZERO; - } else if event.keys.len() == 1 && event.data.len() == 3 { - // Legacy format: all in data - owner = event.data[0]; - operator = event.data[1]; - approved = event.data[2] != Felt::ZERO; - } else { - tracing::warn!( - target: "torii_erc1155::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC1155 ApprovalForAll event" - ); - return Ok(None); + fn parse_uri(&self, event: &EmittedEvent) -> Option { + if event.keys.len() < 2 || event.data.is_empty() { + return None; } - let approval = OperatorApproval { - owner, - operator, - approved, + let uri = Self::decode_string_result(&event.data)?; + Some(UriUpdate { token: event.from_address, + token_id: felt_to_u256(event.keys[1]), + uri, block_number: event.block_number.unwrap_or(0), transaction_hash: event.transaction_hash, - }; - - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); + }) + } - let envelope_id = format!( - "erc1155_approval_for_all_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) + fn log_malformed(&self, event: &EmittedEvent, event_name: &str) { + tracing::warn!( + target: "torii_erc1155::decoder", + token = %format!("{:#x}", event.from_address), + tx_hash = %format!("{:#x}", event.transaction_hash), + block_number = event.block_number.unwrap_or(0), + keys_len = event.keys.len(), + data_len = event.data.len(), + "Malformed ERC1155 {event_name} event" ); - - Ok(Some(Envelope::new( - envelope_id, - Box::new(approval), - metadata, - ))) } - /// Decode URI event into envelope - /// - /// Common ERC1155 Starknet layout: - /// - keys[0]: URI selector - /// - keys[1]: token id (felt-encoded) - /// - data: URI payload (short string or ByteArray) - async fn decode_uri(&self, event: &EmittedEvent) -> Result> { - if event.keys.len() < 2 || event.data.is_empty() { - return Ok(None); - } - - let token_id = Self::felt_to_u256(event.keys[1]); - let Some(uri) = Self::decode_string_result(&event.data) else { - return Ok(None); - }; - - let uri_update = UriUpdate { + fn transfer_envelope( + &self, + event: &EmittedEvent, + context: EventContext, + part: TransferPart, + ) -> Envelope { + Erc1155Message::Transfer(TransferData { + operator: part.operator, + from: part.from, + to: part.to, + token_id: part.token_id, + amount: part.amount, + is_batch: part.is_batch, + batch_index: part.batch_index, token: event.from_address, - token_id, - uri, block_number: event.block_number.unwrap_or(0), transaction_hash: event.transaction_hash, - }; - - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); - - let envelope_id = format!( - "erc1155_uri_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); - - Ok(Some(Envelope::new( - envelope_id, - Box::new(uri_update), - metadata, - ))) + }) + .to_envelope(context) } } @@ -583,128 +382,185 @@ impl Decoder for Erc1155Decoder { "erc1155" } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { - if event.keys.is_empty() { + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> Result> { + let event = EmittedEvent { + from_address: context.from_address, + keys: keys.to_vec(), + data: data.to_vec(), + block_hash: None, + block_number: Some(context.block_number), + transaction_hash: context.transaction_hash, + }; + + let Some(selector) = event.keys.first().copied() else { return Ok(Vec::new()); + }; + + if selector == Self::transfer_single_selector() { + return Ok(if let Some(part) = self.parse_transfer_single(&event) { + vec![self.transfer_envelope(&event, context, part)] + } else { + self.log_malformed(&event, "TransferSingle"); + Vec::new() + }); } - let selector = event.keys[0]; + if selector == Self::transfer_batch_selector() { + return Ok(if let Some(parts) = self.parse_transfer_batch(&event) { + parts + .into_iter() + .map(|part| self.transfer_envelope(&event, context, part)) + .collect() + } else { + self.log_malformed(&event, "TransferBatch"); + Vec::new() + }); + } - if selector == Self::transfer_single_selector() { - if let Some(envelope) = self.decode_transfer_single(event).await? { - return Ok(vec![envelope]); - } - } else if selector == Self::transfer_batch_selector() { - return self.decode_transfer_batch(event).await; - } else if selector == Self::approval_for_all_selector() { - if let Some(envelope) = self.decode_approval_for_all(event).await? { - return Ok(vec![envelope]); - } - } else if selector == Self::uri_selector() { - if let Some(envelope) = self.decode_uri(event).await? { - return Ok(vec![envelope]); - } - } else { - tracing::trace!( - target: "torii_erc1155::decoder", - token = %format!("{:#x}", event.from_address), - selector = %format!("{:#x}", selector), - keys_len = event.keys.len(), - data_len = event.data.len(), - block_number = event.block_number.unwrap_or(0), - tx_hash = %format!("{:#x}", event.transaction_hash), - "Unhandled event selector" + if selector == Self::approval_for_all_selector() { + return Ok( + if let Some(approval) = self.parse_approval_for_all(&event) { + vec![Erc1155Message::ApprovalForAll(approval).to_envelope(context)] + } else { + self.log_malformed(&event, "ApprovalForAll"); + Vec::new() + }, ); } + if selector == Self::uri_selector() { + return Ok(match self.parse_uri(&event) { + Some(uri) => vec![Erc1155Message::Uri(uri).to_envelope(context)], + None => Vec::new(), + }); + } + + tracing::trace!( + target: "torii_erc1155::decoder", + token = %format!("{:#x}", event.from_address), + selector = %format!("{:#x}", selector), + keys_len = event.keys.len(), + data_len = event.data.len(), + block_number = event.block_number.unwrap_or(0), + tx_hash = %format!("{:#x}", event.transaction_hash), + "Unhandled event selector" + ); + Ok(Vec::new()) } } +fn to_starknet_felt(value: Felt) -> starknet::core::types::Felt { + starknet::core::types::Felt::from_bytes_be(&value.to_be_bytes()) +} + +fn felt_to_usize(value: Felt) -> usize { + usize::try_from(value.to_le_words()[0]).unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; + fn decode_raw(decoder: &Erc1155Decoder, event: &EmittedEvent) -> Vec { + futures::executor::block_on(decoder.decode( + &event.keys, + &event.data, + EventContext { + from_address: event.from_address, + block_number: event.block_number.unwrap_or(0), + transaction_hash: event.transaction_hash, + }, + )) + .unwrap() + } + + fn body(envelope: &Envelope) -> &Erc1155Body { + envelope + .body + .as_any() + .downcast_ref::() + .unwrap() + } + #[tokio::test] async fn test_decode_transfer_single_modern() { let decoder = Erc1155Decoder::new(); - - // Modern format: operator, from, to in keys; id, value in data let event = EmittedEvent { from_address: Felt::from(0x123u64), keys: vec![ Erc1155Decoder::transfer_single_selector(), - Felt::from(0x1u64), // operator - Felt::from(0x2u64), // from - Felt::from(0x3u64), // to + Felt::from(0x1u64), + Felt::from(0x2u64), + Felt::from(0x3u64), ], data: vec![ - Felt::from(42u64), // id_low - Felt::ZERO, // id_high - Felt::from(100u64), // value_low - Felt::ZERO, // value_high + Felt::from(42u64), + Felt::ZERO, + Felt::from(100u64), + Felt::ZERO, ], block_hash: None, block_number: Some(100), transaction_hash: Felt::from(0xabcdu64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decode_raw(&decoder, &event); assert_eq!(envelopes.len(), 1); - - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(transfer.operator, Felt::from(0x1u64)); - assert_eq!(transfer.from, Felt::from(0x2u64)); - assert_eq!(transfer.to, Felt::from(0x3u64)); - assert_eq!(transfer.id, U256::from(42u64)); - assert_eq!(transfer.value, U256::from(100u64)); + assert_eq!(envelopes[0].type_id, ERC1155_TYPE_ID); + + match &body(&envelopes[0]).msg { + Erc1155Message::Transfer(transfer) => { + assert_eq!(transfer.operator, Felt::from(0x1u64)); + assert_eq!(transfer.from, Felt::from(0x2u64)); + assert_eq!(transfer.to, Felt::from(0x3u64)); + assert_eq!(transfer.token_id, U256::from(42u64)); + assert_eq!(transfer.amount, U256::from(100u64)); + assert!(!transfer.is_batch); + } + other => panic!("unexpected message: {other:?}"), + } } #[tokio::test] async fn test_decode_approval_for_all() { let decoder = Erc1155Decoder::new(); - let event = EmittedEvent { from_address: Felt::from(0x456u64), keys: vec![ Erc1155Decoder::approval_for_all_selector(), - Felt::from(0xau64), // owner - Felt::from(0xbu64), // operator + Felt::from(0xau64), + Felt::from(0xbu64), ], - data: vec![Felt::from(1u64)], // approved = true + data: vec![Felt::from(1u64)], block_hash: None, block_number: Some(200), transaction_hash: Felt::from(0xef01u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decode_raw(&decoder, &event); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(approval.owner, Felt::from(0xau64)); - assert_eq!(approval.operator, Felt::from(0xbu64)); - assert!(approval.approved); + match &body(&envelopes[0]).msg { + Erc1155Message::ApprovalForAll(approval) => { + assert_eq!(approval.owner, Felt::from(0xau64)); + assert_eq!(approval.operator, Felt::from(0xbu64)); + assert!(approval.approved); + } + other => panic!("unexpected message: {other:?}"), + } } #[tokio::test] async fn test_decode_transfer_single_single_felt_preserves_full_felt() { let decoder = Erc1155Decoder::new(); - - let id_felt = - Felt::from_hex("0x100000000000000000000000000000001").expect("invalid id felt"); - let value_felt = - Felt::from_hex("0x200000000000000000000000000000003").expect("invalid value felt"); - + let id_felt = Felt::from_hex("0x100000000000000000000000000000001").unwrap(); + let value_felt = Felt::from_hex("0x200000000000000000000000000000003").unwrap(); let event = EmittedEvent { from_address: Felt::from(0x123u64), keys: vec![ @@ -719,92 +575,79 @@ mod tests { transaction_hash: Felt::from(0xabcdu64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); - assert_eq!(envelopes.len(), 1); - - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(transfer.id, Erc1155Decoder::felt_to_u256(id_felt)); - assert_eq!(transfer.value, Erc1155Decoder::felt_to_u256(value_felt)); + let envelopes = decode_raw(&decoder, &event); + match &body(&envelopes[0]).msg { + Erc1155Message::Transfer(transfer) => { + assert_eq!(transfer.token_id, felt_to_u256(id_felt)); + assert_eq!(transfer.amount, felt_to_u256(value_felt)); + } + other => panic!("unexpected message: {other:?}"), + } } #[tokio::test] async fn test_decode_transfer_batch_single_felt_arrays() { let decoder = Erc1155Decoder::new(); - let event = EmittedEvent { from_address: Felt::from(0x123u64), keys: vec![ Erc1155Decoder::transfer_batch_selector(), - Felt::from(0x1u64), // operator - Felt::from(0x2u64), // from - Felt::from(0x3u64), // to + Felt::from(0x1u64), + Felt::from(0x2u64), + Felt::from(0x3u64), ], data: vec![ - Felt::from(2u64), // ids_len - Felt::from(11u64), // id[0] - Felt::from(12u64), // id[1] - Felt::from(2u64), // values_len - Felt::from(101u64), // value[0] - Felt::from(102u64), // value[1] + Felt::from(2u64), + Felt::from(11u64), + Felt::from(12u64), + Felt::from(2u64), + Felt::from(101u64), + Felt::from(102u64), ], block_hash: None, block_number: Some(102), transaction_hash: Felt::from(0xabcfu64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decode_raw(&decoder, &event); assert_eq!(envelopes.len(), 2); - let first = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - let second = envelopes[1] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(first.id, U256::from(11u64)); - assert_eq!(first.value, U256::from(101u64)); - assert_eq!(second.id, U256::from(12u64)); - assert_eq!(second.value, U256::from(102u64)); + match (&body(&envelopes[0]).msg, &body(&envelopes[1]).msg) { + (Erc1155Message::Transfer(first), Erc1155Message::Transfer(second)) => { + assert_eq!(first.token_id, U256::from(11u64)); + assert_eq!(first.amount, U256::from(101u64)); + assert!(first.is_batch); + assert_eq!(first.batch_index, 0); + assert_eq!(second.token_id, U256::from(12u64)); + assert_eq!(second.amount, U256::from(102u64)); + assert_eq!(second.batch_index, 1); + } + other => panic!("unexpected messages: {other:?}"), + } } #[tokio::test] async fn test_decode_uri_event() { let decoder = Erc1155Decoder::new(); - - // "abc" as short string felt - let uri_felt = Felt::from(0x616263u64); let event = EmittedEvent { from_address: Felt::from(0x123u64), - keys: vec![ - Erc1155Decoder::uri_selector(), - Felt::from(7u64), // token id - ], - data: vec![uri_felt], + keys: vec![Erc1155Decoder::uri_selector(), Felt::from(7u64)], + data: vec![Felt::from(0x616263u64)], block_hash: None, block_number: Some(103), transaction_hash: Felt::from(0xabd0u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decode_raw(&decoder, &event); assert_eq!(envelopes.len(), 1); - let uri = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - assert_eq!(uri.token, Felt::from(0x123u64)); - assert_eq!(uri.token_id, U256::from(7u64)); - assert_eq!(uri.uri, "abc".to_string()); + match &body(&envelopes[0]).msg { + Erc1155Message::Uri(uri) => { + assert_eq!(uri.token, Felt::from(0x123u64)); + assert_eq!(uri.token_id, U256::from(7u64)); + assert_eq!(uri.uri, "abc"); + } + other => panic!("unexpected message: {other:?}"), + } } } diff --git a/crates/torii-erc1155/src/grpc_service.rs b/crates/torii-erc1155/src/grpc_service.rs index d9b99c74..41ac9fbc 100644 --- a/crates/torii-erc1155/src/grpc_service.rs +++ b/crates/torii-erc1155/src/grpc_service.rs @@ -13,17 +13,21 @@ use crate::proto::{ use crate::storage::{Erc1155Storage, TokenTransferData, TransferCursor}; use async_trait::async_trait; use futures::stream::Stream; -use starknet::core::types::Felt; -use starknet::core::types::U256; +use primitive_types::U256; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet}; use std::pin::Pin; use std::sync::Arc; use tokio::sync::broadcast; use tonic::{Request, Response, Status}; -use torii_common::{bytes_to_felt, bytes_to_u256, u256_to_bytes}; +use torii_common::{bytes_to_u256, u256_to_bytes}; const DEFAULT_PROJECT_ID: &str = "arcade-main"; +fn bytes_to_felt(bytes: &[u8]) -> Option { + Felt::from_be_bytes_slice(bytes).ok() +} + /// gRPC service implementation for ERC1155 #[derive(Clone)] pub struct Erc1155Service { @@ -55,14 +59,14 @@ impl Erc1155Service { /// Convert storage TokenTransferData to proto TokenTransfer fn transfer_data_to_proto(data: &TokenTransferData) -> TokenTransfer { TokenTransfer { - token: data.token.to_bytes_be().to_vec(), - operator: data.operator.to_bytes_be().to_vec(), - from: data.from.to_bytes_be().to_vec(), - to: data.to.to_bytes_be().to_vec(), + token: data.token.to_be_bytes_vec(), + operator: data.operator.to_be_bytes_vec(), + from: data.from.to_be_bytes_vec(), + to: data.to.to_be_bytes_vec(), token_id: u256_to_bytes(data.token_id), amount: u256_to_bytes(data.amount), block_number: data.block_number, - tx_hash: data.tx_hash.to_bytes_be().to_vec(), + tx_hash: data.tx_hash.to_be_bytes_vec(), timestamp: data.timestamp.unwrap_or(0), is_batch: data.is_batch, batch_index: data.batch_index, @@ -223,7 +227,7 @@ impl Erc1155Service { }; CollectionToken { - contract_address: contract.to_bytes_be().to_vec(), + contract_address: contract.to_be_bytes_vec(), token_id: token_id_bytes, uri, metadata_json, @@ -361,7 +365,7 @@ impl Erc1155Trait for Erc1155Service { let entries = match self.storage.get_token_metadata(token).await { Ok(Some((name, symbol, total_supply))) => vec![TokenMetadataEntry { - token: token.to_bytes_be().to_vec(), + token: token.to_be_bytes_vec(), name, symbol, total_supply: total_supply.map(u256_to_bytes), @@ -392,7 +396,7 @@ impl Erc1155Trait for Erc1155Service { let entries = all .into_iter() .map(|(token, name, symbol, total_supply)| TokenMetadataEntry { - token: token.to_bytes_be().to_vec(), + token: token.to_be_bytes_vec(), name, symbol, total_supply: total_supply.map(u256_to_bytes), @@ -401,7 +405,7 @@ impl Erc1155Trait for Erc1155Service { Ok(Response::new(GetTokenMetadataResponse { tokens: entries, - next_cursor: next_cursor.map(|c| c.to_bytes_be().to_vec()), + next_cursor: next_cursor.map(|c| c.to_be_bytes_vec()), })) } @@ -586,7 +590,7 @@ impl Erc1155Trait for Erc1155Service { let traits = Self::build_trait_summaries(&facets); overviews.push(ContractCollectionOverview { - contract_address: contract.to_bytes_be().to_vec(), + contract_address: contract.to_be_bytes_vec(), tokens, next_cursor_token_id, total_hits, diff --git a/crates/torii-erc1155/src/handlers.rs b/crates/torii-erc1155/src/handlers.rs index f82ae36a..ac7ce153 100644 --- a/crates/torii-erc1155/src/handlers.rs +++ b/crates/torii-erc1155/src/handlers.rs @@ -1,16 +1,19 @@ use anyhow::Result; use async_trait::async_trait; +use primitive_types::U256; use prost::Message; use prost_types::Any; -use starknet::core::types::Felt; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use starknet_types_raw::Felt; use std::collections::HashSet; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use torii::command::CommandHandler; use torii::etl::sink::EventBus; use torii::UpdateType; -use torii_common::{process_token_uri_request, u256_to_bytes, MetadataFetcher, TokenUriRequest}; +use torii_common::{ + bytes_to_u256, process_token_uri_request, u256_to_bytes, MetadataFetcher, TokenUriRequest, +}; use crate::proto; use crate::storage::Erc1155Storage; @@ -23,7 +26,7 @@ pub struct FetchErc1155MetadataCommand { #[derive(Debug, Clone)] pub struct RefreshErc1155TokenUriCommand { pub contract: Felt, - pub token_id: starknet::core::types::U256, + pub token_id: U256, } pub struct Erc1155MetadataCommandHandler { @@ -98,14 +101,15 @@ impl CommandHandler for Erc1155MetadataCommandHandler { command.token, meta.name.as_deref(), meta.symbol.as_deref(), - meta.total_supply, + meta.total_supply + .map(|value| bytes_to_u256(&u256_to_bytes(value))), ) .await?; let event_bus = self.event_bus.lock().unwrap().clone(); if let Some(event_bus) = event_bus { let meta_entry = proto::TokenMetadataEntry { - token: command.token.to_bytes_be().to_vec(), + token: command.token.to_be_bytes_vec(), name: meta.name, symbol: meta.symbol, total_supply: meta.total_supply.map(u256_to_bytes), @@ -142,7 +146,7 @@ pub struct Erc1155TokenUriCommandHandler { fetcher: Arc, storage: Arc, image_cache_dir: Option, - in_flight: Mutex>, + in_flight: Mutex>, } impl Erc1155TokenUriCommandHandler { @@ -193,7 +197,7 @@ impl CommandHandler for Erc1155TokenUriCommandHandler { self.storage.as_ref(), &TokenUriRequest { contract: command.contract, - token_id: command.token_id, + token_id: bytes_to_u256(&u256_to_bytes(command.token_id)), standard: torii_common::TokenStandard::Erc1155, }, self.image_cache_dir.as_deref(), diff --git a/crates/torii-erc1155/src/identification.rs b/crates/torii-erc1155/src/identification.rs index 80a8b44d..a400d958 100644 --- a/crates/torii-erc1155/src/identification.rs +++ b/crates/torii-erc1155/src/identification.rs @@ -6,7 +6,7 @@ //! - `TransferBatch` event use anyhow::Result; -use starknet::core::types::Felt; +use starknet_types_raw::Felt; use torii::etl::decoder::DecoderId; use torii::etl::extractor::ContractAbi; use torii::etl::identification::IdentificationRule; diff --git a/crates/torii-erc1155/src/lib.rs b/crates/torii-erc1155/src/lib.rs index 23b94621..6aed0f6d 100644 --- a/crates/torii-erc1155/src/lib.rs +++ b/crates/torii-erc1155/src/lib.rs @@ -20,7 +20,8 @@ //! use torii_erc1155::proto::erc1155_server::Erc1155Server; //! //! // Create storage -//! let storage = Arc::new(Erc1155Storage::new("./erc1155.db")?); +//! let pool = torii_sql::DbPoolOptions::new().connect_any("./erc1155.db").await?; +//! let storage = Arc::new(Erc1155Storage::new(pool, "./erc1155.db").await?); //! //! // Create gRPC service //! let grpc_service = Erc1155Service::new(storage.clone()); @@ -55,7 +56,10 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/erc1155_descrip // Re-export main types for convenience pub use balance_fetcher::{Erc1155BalanceFetchRequest, Erc1155BalanceFetcher}; -pub use decoder::{Erc1155Decoder, OperatorApproval, TransferBatch, TransferSingle, UriUpdate}; +pub use decoder::{ + Erc1155Body, Erc1155Decoder, Erc1155Message, OperatorApproval, TransferData, UriUpdate, + ERC1155_TYPE_ID, +}; pub use grpc_service::Erc1155Service; pub use handlers::{Erc1155MetadataCommandHandler, Erc1155TokenUriCommandHandler}; pub use identification::Erc1155Rule; diff --git a/crates/torii-erc1155/src/sink.rs b/crates/torii-erc1155/src/sink.rs index a3b1b1e8..d3b195ad 100644 --- a/crates/torii-erc1155/src/sink.rs +++ b/crates/torii-erc1155/src/sink.rs @@ -12,21 +12,18 @@ //! fetches the actual balance from the chain and adjusts use crate::balance_fetcher::Erc1155BalanceFetcher; -use crate::decoder::{ - OperatorApproval as DecodedOperatorApproval, TransferBatch as DecodedTransferBatch, - TransferSingle as DecodedTransferSingle, UriUpdate as DecodedUriUpdate, -}; +use crate::decoder::{Erc1155Body as DecodedErc1155Body, Erc1155Message, ERC1155_TYPE_ID}; use crate::grpc_service::Erc1155Service; use crate::handlers::{FetchErc1155MetadataCommand, RefreshErc1155TokenUriCommand}; use crate::proto; use crate::storage::{Erc1155Storage, OperatorApprovalData, TokenTransferData, TokenUriData}; -use anyhow::Result; use async_trait::async_trait; use axum::Router; +use primitive_types::U256; use prost::Message; use prost_types::Any; -use starknet::core::types::{Felt, U256}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -34,12 +31,26 @@ use torii::command::CommandBusSender; use torii::etl::sink::{EventBus, TopicInfo}; use torii::etl::{Envelope, ExtractionBatch, Sink, TypeId}; use torii::grpc::UpdateType; -use torii_common::{u256_to_bytes, TokenStandard, TokenUriRequest, TokenUriSender}; +use torii_common::{bytes_to_u256, u256_to_bytes, TokenStandard, TokenUriRequest, TokenUriSender}; /// Default threshold for "live" detection: 100 blocks from chain head. /// Events from blocks older than this won't be broadcast to real-time subscribers. const LIVE_THRESHOLD_BLOCKS: u64 = 100; +type SinkResult = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Erc1155SinkError { + #[error("failed to batch insert transfers")] + InsertTransfers(#[source] anyhow::Error), + #[error("failed to batch insert operator approvals")] + InsertOperatorApprovals(#[source] anyhow::Error), + #[error("failed to batch upsert token URI updates")] + UpsertTokenUriUpdates(#[source] anyhow::Error), + #[error("failed to encode ERC1155 protobuf payload")] + Encode(#[from] prost::EncodeError), +} + /// ERC1155 token sink /// /// Processes ERC1155 TransferSingle, TransferBatch, and ApprovalForAll events: @@ -193,7 +204,7 @@ impl Erc1155Sink { if let Some(sender) = &self.token_uri_sender { return sender.request_update(TokenUriRequest { contract, - token_id, + token_id: bytes_to_u256(&u256_to_bytes(token_id)), standard: TokenStandard::Erc1155, }); } @@ -214,35 +225,23 @@ impl Erc1155Sink { false } -} - -#[async_trait] -impl Sink for Erc1155Sink { - fn name(&self) -> &'static str { - "erc1155" - } - - fn interested_types(&self) -> Vec { - vec![ - TypeId::new("erc1155.transfer_single"), - TypeId::new("erc1155.transfer_batch"), - TypeId::new("erc1155.approval_for_all"), - TypeId::new("erc1155.uri"), - ] - } - async fn initialize( + async fn initialize_sink( &mut self, event_bus: Arc, context: &torii::etl::sink::SinkContext, - ) -> Result<()> { + ) -> SinkResult<()> { self.event_bus = Some(event_bus); self.command_bus = Some(context.command_bus.clone()); tracing::info!(target: "torii_erc1155::sink", "ERC1155 sink initialized"); Ok(()) } - async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> Result<()> { + async fn process_sink( + &self, + envelopes: &[Envelope], + batch: &ExtractionBatch, + ) -> SinkResult<()> { let mut transfers: Vec = Vec::with_capacity(envelopes.len()); let mut operator_approvals: Vec = Vec::with_capacity(envelopes.len()); let mut uri_updates: Vec = Vec::with_capacity(envelopes.len()); @@ -258,86 +257,53 @@ impl Sink for Erc1155Sink { .collect(); for envelope in envelopes { - // Handle single transfers - if envelope.type_id == TypeId::new("erc1155.transfer_single") { - if let Some(transfer) = envelope - .body - .as_any() - .downcast_ref::() - { - let timestamp = block_timestamps.get(&transfer.block_number).copied(); - transfers.push(TokenTransferData { - id: None, - token: transfer.token, - operator: transfer.operator, - from: transfer.from, - to: transfer.to, - token_id: transfer.id, - amount: transfer.value, - is_batch: false, - batch_index: 0, - block_number: transfer.block_number, - tx_hash: transfer.transaction_hash, - timestamp, - }); - } + if envelope.type_id != ERC1155_TYPE_ID { + continue; } - // Handle batch transfers - else if envelope.type_id == TypeId::new("erc1155.transfer_batch") { - if let Some(transfer) = envelope - .body - .as_any() - .downcast_ref::() - { - let timestamp = block_timestamps.get(&transfer.block_number).copied(); - transfers.push(TokenTransferData { - id: None, - token: transfer.token, - operator: transfer.operator, - from: transfer.from, - to: transfer.to, - token_id: transfer.id, - amount: transfer.value, - is_batch: true, - batch_index: transfer.batch_index, - block_number: transfer.block_number, - tx_hash: transfer.transaction_hash, - timestamp, - }); - } - } - // Handle approval for all - else if envelope.type_id == TypeId::new("erc1155.approval_for_all") { - if let Some(approval) = envelope - .body - .as_any() - .downcast_ref::() - { - let timestamp = block_timestamps.get(&approval.block_number).copied(); - operator_approvals.push(OperatorApprovalData { - id: None, - token: approval.token, - owner: approval.owner, - operator: approval.operator, - approved: approval.approved, - block_number: approval.block_number, - tx_hash: approval.transaction_hash, - timestamp, - }); - } - } - // Handle URI updates - else if envelope.type_id == TypeId::new("erc1155.uri") { - if let Some(uri) = envelope.body.as_any().downcast_ref::() { - let timestamp = block_timestamps.get(&uri.block_number).copied(); - uri_updates.push(TokenUriData { - token: uri.token, - token_id: uri.token_id, - uri: uri.uri.clone(), - block_number: uri.block_number, - tx_hash: uri.transaction_hash, - timestamp, - }); + + if let Some(body) = envelope.body.as_any().downcast_ref::() { + match &body.msg { + Erc1155Message::Transfer(transfer) => { + let timestamp = block_timestamps.get(&transfer.block_number).copied(); + transfers.push(TokenTransferData { + id: None, + token: transfer.token, + operator: transfer.operator, + from: transfer.from, + to: transfer.to, + token_id: transfer.token_id, + amount: transfer.amount, + is_batch: transfer.is_batch, + batch_index: transfer.batch_index, + block_number: transfer.block_number, + tx_hash: transfer.transaction_hash, + timestamp, + }); + } + Erc1155Message::ApprovalForAll(approval) => { + let timestamp = block_timestamps.get(&approval.block_number).copied(); + operator_approvals.push(OperatorApprovalData { + id: None, + token: approval.token, + owner: approval.owner, + operator: approval.operator, + approved: approval.approved, + block_number: approval.block_number, + tx_hash: approval.transaction_hash, + timestamp, + }); + } + Erc1155Message::Uri(uri) => { + let timestamp = block_timestamps.get(&uri.block_number).copied(); + uri_updates.push(TokenUriData { + token: uri.token, + token_id: uri.token_id, + uri: uri.uri.clone(), + block_number: uri.block_number, + tx_hash: uri.transaction_hash, + timestamp, + }); + } } } } @@ -449,14 +415,14 @@ impl Sink for Erc1155Sink { if !transfers.is_empty() { let transfer_count = match self.storage.insert_transfers_batch(&transfers).await { Ok(count) => count, - Err(e) => { + Err(error) => { tracing::error!( target: "torii_erc1155::sink", count = transfers.len(), - error = %e, + error = %error, "Failed to batch insert transfers" ); - return Err(e); + return Err(Erc1155SinkError::InsertTransfers(error)); } }; @@ -471,9 +437,7 @@ impl Sink for Erc1155Sink { "Batch inserted token transfers" ); - // Update balances if balance tracking is enabled if let Some(ref fetcher) = self.balance_fetcher { - // Step 1: Check which balances need adjustment (would go negative) let adjustment_requests = match self .storage .check_balances_batch(&transfers) @@ -490,7 +454,6 @@ impl Sink for Erc1155Sink { } }; - // Step 2: Batch fetch actual balances from RPC for inconsistent wallets let mut adjustments: HashMap<(Felt, Felt, U256), U256> = HashMap::new(); if !adjustment_requests.is_empty() { tracing::info!( @@ -511,7 +474,6 @@ impl Sink for Erc1155Sink { error = %e, "Failed to fetch balances from RPC, using 0 for adjustments" ); - // On failure, use 0 for all requested adjustments for req in &adjustment_requests { adjustments.insert( (req.contract, req.wallet, req.token_id), @@ -522,7 +484,6 @@ impl Sink for Erc1155Sink { } } - // Step 3: Apply transfers with adjustments to update balances if let Err(e) = self .storage .apply_transfers_with_adjustments(&transfers, &adjustments) @@ -533,30 +494,26 @@ impl Sink for Erc1155Sink { error = %e, "Failed to apply balance updates" ); - // Don't fail the whole batch - transfers are already inserted } } - // Only broadcast to real-time subscribers when near chain head let is_live = batch.is_live(LIVE_THRESHOLD_BLOCKS); if is_live { - // Publish transfer events for transfer in &transfers { let proto_transfer = proto::TokenTransfer { - token: transfer.token.to_bytes_be().to_vec(), - operator: transfer.operator.to_bytes_be().to_vec(), - from: transfer.from.to_bytes_be().to_vec(), - to: transfer.to.to_bytes_be().to_vec(), + token: transfer.token.to_be_bytes_vec(), + operator: transfer.operator.to_be_bytes_vec(), + from: transfer.from.to_be_bytes_vec(), + to: transfer.to.to_be_bytes_vec(), token_id: u256_to_bytes(transfer.token_id), amount: u256_to_bytes(transfer.amount), block_number: transfer.block_number, - tx_hash: transfer.tx_hash.to_bytes_be().to_vec(), + tx_hash: transfer.tx_hash.to_be_bytes_vec(), timestamp: transfer.timestamp.unwrap_or(0), is_batch: transfer.is_batch, batch_index: transfer.batch_index, }; - // Publish to EventBus if let Some(event_bus) = &self.event_bus { let mut buf = Vec::new(); proto_transfer.encode(&mut buf)?; @@ -576,7 +533,6 @@ impl Sink for Erc1155Sink { ); } - // Broadcast to gRPC service if let Some(grpc_service) = &self.grpc_service { grpc_service.broadcast_transfer(proto_transfer); } @@ -585,7 +541,6 @@ impl Sink for Erc1155Sink { } } - // Batch insert operator approvals if !operator_approvals.is_empty() { match self .storage @@ -603,19 +558,18 @@ impl Sink for Erc1155Sink { "Batch inserted operator approvals" ); } - Err(e) => { + Err(error) => { tracing::error!( target: "torii_erc1155::sink", - "Failed to batch insert {} operator approvals: {}", - operator_approvals.len(), - e + count = operator_approvals.len(), + error = %error, + "Failed to batch insert operator approvals" ); - return Err(e); + return Err(Erc1155SinkError::InsertOperatorApprovals(error)); } } } - // Batch upsert token URI updates if !uri_updates.is_empty() { match self.storage.upsert_token_uris_batch(&uri_updates).await { Ok(count) => { @@ -629,11 +583,10 @@ impl Sink for Erc1155Sink { "Batch upserted token URI updates" ); - // Publish URI updates to topic subscribers if let Some(event_bus) = &self.event_bus { for uri in &uri_updates { let proto_uri = proto::TokenUri { - token: uri.token.to_bytes_be().to_vec(), + token: uri.token.to_be_bytes_vec(), token_id: u256_to_bytes(uri.token_id), uri: uri.uri.clone(), block_number: uri.block_number, @@ -658,19 +611,18 @@ impl Sink for Erc1155Sink { } } } - Err(e) => { + Err(error) => { tracing::error!( target: "torii_erc1155::sink", count = uri_updates.len(), - error = %e, + error = %error, "Failed to batch upsert token URI updates" ); - return Err(e); + return Err(Erc1155SinkError::UpsertTokenUriUpdates(error)); } } } - // Log combined statistics without full-table scans. if inserted_transfers > 0 || inserted_operator_approvals > 0 || inserted_uri_updates > 0 { tracing::info!( target: "torii_erc1155::sink", @@ -687,6 +639,33 @@ impl Sink for Erc1155Sink { Ok(()) } +} + +#[async_trait] +impl Sink for Erc1155Sink { + fn name(&self) -> &'static str { + "erc1155" + } + + fn interested_types(&self) -> Vec { + vec![ERC1155_TYPE_ID] + } + + async fn initialize( + &mut self, + event_bus: Arc, + context: &torii::etl::sink::SinkContext, + ) -> anyhow::Result<()> { + self.initialize_sink(event_bus, context) + .await + .map_err(Into::into) + } + + async fn process(&self, envelopes: &[Envelope], batch: &ExtractionBatch) -> anyhow::Result<()> { + self.process_sink(envelopes, batch) + .await + .map_err(Into::into) + } fn topics(&self) -> Vec { vec![ diff --git a/crates/torii-erc1155/src/storage.rs b/crates/torii-erc1155/src/storage.rs index 1886977b..d672c9ac 100644 --- a/crates/torii-erc1155/src/storage.rs +++ b/crates/torii-erc1155/src/storage.rs @@ -7,14 +7,14 @@ //! - Records all adjustments in an audit table for debugging use anyhow::Result; +use primitive_types::U256; use rusqlite::{params, params_from_iter, Connection, ToSql}; -use starknet::core::types::{Felt, U256}; +use starknet_types_raw::Felt; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex}; use tokio_postgres::{types::ToSql as PgToSql, Client, NoTls}; -use torii_common::{ - blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob, TokenUriResult, TokenUriStore, -}; +use torii_common::{blob_to_u256, u256_to_blob, TokenUriResult, TokenUriStore}; +use torii_sql::DbPool; use crate::balance_fetcher::Erc1155BalanceFetchRequest; @@ -23,7 +23,15 @@ const SQLITE_TOKEN_BATCH_SIZE: usize = SQLITE_MAX_BIND_VARS; const SQLITE_TOKEN_PAIR_BATCH_SIZE: usize = SQLITE_MAX_BIND_VARS / 2; /// Maximum value for U256 (2^256 - 1) -const U256_MAX: U256 = U256::from_words(u128::MAX, u128::MAX); +const U256_MAX: U256 = U256([u64::MAX, u64::MAX, u64::MAX, u64::MAX]); + +fn felt_to_blob(value: Felt) -> Vec { + value.to_be_bytes_vec() +} + +fn blob_to_felt(bytes: &[u8]) -> Felt { + Felt::from_be_bytes_slice(bytes).unwrap_or(Felt::ZERO) +} /// Safely adds two U256 values, capping at U256::MAX on overflow. /// @@ -54,6 +62,7 @@ fn safe_u256_add(a: U256, b: U256) -> U256 { /// Storage for ERC1155 token data pub struct Erc1155Storage { + pool: DbPool, backend: StorageBackend, conn: Arc>, pg_conn: Option>>, @@ -157,10 +166,14 @@ pub struct Erc1155BalanceAdjustment { } impl Erc1155Storage { + pub fn pool(&self) -> &DbPool { + &self.pool + } + /// Create or open the database - pub async fn new(db_path: &str) -> Result { - if db_path.starts_with("postgres://") || db_path.starts_with("postgresql://") { - let (client, connection) = tokio_postgres::connect(db_path, NoTls).await?; + pub async fn new(pool: DbPool, database_url: &str) -> Result { + if matches!(&pool, DbPool::Postgres(_)) { + let (client, connection) = tokio_postgres::connect(database_url, NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::error!(target: "torii_erc1155::storage", error = %e, "PostgreSQL connection task failed"); @@ -305,13 +318,14 @@ impl Erc1155Storage { tracing::info!(target: "torii_erc1155::storage", "PostgreSQL storage initialized"); return Ok(Self { + pool, backend: StorageBackend::Postgres, conn: Arc::new(Mutex::new(Connection::open_in_memory()?)), pg_conn: Some(Arc::new(tokio::sync::Mutex::new(client))), }); } - let conn = Connection::open(db_path)?; + let conn = Connection::open(database_url)?; // Enable WAL mode + Performance PRAGMAs conn.execute_batch( @@ -554,9 +568,10 @@ impl Erc1155Storage { [], )?; - tracing::info!(target: "torii_erc1155::storage", db_path = %db_path, "ERC1155 database initialized"); + tracing::info!(target: "torii_erc1155::storage", db_path = %database_url, "ERC1155 database initialized"); Ok(Self { + pool, backend: StorageBackend::Sqlite, conn: Arc::new(Mutex::new(conn)), pg_conn: None, @@ -3090,7 +3105,13 @@ impl TokenUriStore for Erc1155Storage { .await?; } - pg_sync_facets_for_token(&tx, result.contract, result.token_id, attrs).await?; + pg_sync_facets_for_token( + &tx, + result.contract, + blob_to_u256(&u256_to_blob(result.token_id)), + attrs, + ) + .await?; } tx.commit().await?; @@ -3145,7 +3166,12 @@ impl TokenUriStore for Erc1155Storage { insert_attr_stmt.execute(params![&token_blob, &token_id_blob, key, value])?; } - sqlite_sync_facets_for_token(&tx, result.contract, result.token_id, attrs)?; + sqlite_sync_facets_for_token( + &tx, + result.contract, + blob_to_u256(&u256_to_blob(result.token_id)), + attrs, + )?; } } diff --git a/crates/torii-erc1155/src/synthetic.rs b/crates/torii-erc1155/src/synthetic.rs index 6b11ec97..a9609e01 100644 --- a/crates/torii-erc1155/src/synthetic.rs +++ b/crates/torii-erc1155/src/synthetic.rs @@ -5,9 +5,11 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt, U256}; -use starknet::macros::selector; +use primitive_types::U256; +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt; use torii::etl::extractor::{ExtractionBatch, SyntheticExtractor}; +use torii_types::event::StarknetEvent; const EXTRACTOR_NAME: &str = "synthetic_erc1155"; @@ -239,16 +241,13 @@ impl SyntheticErc1155Extractor { Erc1155EventType::TransferSingle => { let id = self.token_id_for(block_number, tx_index, 0); let value = self.value_for(block_number, tx_index, 0); + let (id_low, id_high) = u256_low_high(id); + let (value_low, value_high) = u256_low_high(value); EmittedEvent { from_address: token, - keys: vec![selector!("TransferSingle"), operator, from, to], - data: vec![ - Felt::from(id.low()), - Felt::from(id.high()), - Felt::from(value.low()), - Felt::from(value.high()), - ], + keys: vec![Felt::selector("TransferSingle"), operator, from, to], + data: vec![id_low, id_high, value_low, value_high], block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), block_number: Some(block_number), transaction_hash: tx_hash, @@ -261,22 +260,24 @@ impl SyntheticErc1155Extractor { for i in 0..batch_size { let id = self.token_id_for(block_number, tx_index, i); - ids_data.push(Felt::from(id.low())); - ids_data.push(Felt::from(id.high())); + let (low, high) = u256_low_high(id); + ids_data.push(low); + ids_data.push(high); } values_data.push(Felt::from(batch_size as u64)); for i in 0..batch_size { let value = self.value_for(block_number, tx_index, i); - values_data.push(Felt::from(value.low())); - values_data.push(Felt::from(value.high())); + let (low, high) = u256_low_high(value); + values_data.push(low); + values_data.push(high); } ids_data.extend(values_data); EmittedEvent { from_address: token, - keys: vec![selector!("TransferBatch"), operator, from, to], + keys: vec![Felt::selector("TransferBatch"), operator, from, to], data: ids_data, block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), block_number: Some(block_number), @@ -285,7 +286,7 @@ impl SyntheticErc1155Extractor { } Erc1155EventType::ApprovalForAll => EmittedEvent { from_address: token, - keys: vec![selector!("ApprovalForAll"), from, to], + keys: vec![Felt::selector("ApprovalForAll"), from, to], data: vec![Felt::from(1u64)], block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), block_number: Some(block_number), @@ -294,9 +295,10 @@ impl SyntheticErc1155Extractor { Erc1155EventType::Uri => { let id = self.token_id_for(block_number, tx_index, 0); let uri_felt = Felt::from(0x69706673u64); + let (id_low, _) = u256_low_high(id); EmittedEvent { from_address: token, - keys: vec![selector!("URI"), Felt::from(id.low())], + keys: vec![Felt::selector("URI"), id_low], data: vec![uri_felt], block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), block_number: Some(block_number), @@ -304,7 +306,17 @@ impl SyntheticErc1155Extractor { } } }; - batch.add_event_with_tx_context(event, Some(operator), vec![token, from, to]); + batch.add_event_with_tx_context( + StarknetEvent::new( + event.from_address, + event.keys, + event.data, + event.block_number.unwrap_or(0), + event.transaction_hash, + ), + Some(operator), + vec![token, from, to], + ); } } @@ -314,6 +326,14 @@ impl SyntheticErc1155Extractor { } } +fn u256_low_high(value: U256) -> (Felt, Felt) { + let [l0, l1, h0, h1] = value.0; + ( + Felt::from_le_words([l0, l1, 0, 0]), + Felt::from_le_words([h0, h1, 0, 0]), + ) +} + #[derive(Debug, Clone, Copy, PartialEq)] enum Erc1155EventType { TransferSingle, @@ -407,10 +427,10 @@ mod tests { let mut extractor = SyntheticErc1155Extractor::new(cfg).unwrap(); let batch = extractor.extract(None).await.unwrap(); - let transfer_single_selector = selector!("TransferSingle"); - let transfer_batch_selector = selector!("TransferBatch"); - let approval_for_all_selector = selector!("ApprovalForAll"); - let uri_selector = selector!("URI"); + let transfer_single_selector = Felt::selector("TransferSingle"); + let transfer_batch_selector = Felt::selector("TransferBatch"); + let approval_for_all_selector = Felt::selector("ApprovalForAll"); + let uri_selector = Felt::selector("URI"); let mut has_transfer_single = false; let mut has_transfer_batch = false; @@ -451,12 +471,12 @@ mod tests { let mut extractor = SyntheticErc1155Extractor::new(cfg).unwrap(); let batch = extractor.extract(None).await.unwrap(); - let transfer_batch_selector = selector!("TransferBatch"); + let transfer_batch_selector = Felt::selector("TransferBatch"); let mut batch_sizes = Vec::new(); for event in &batch.events { if event.keys[0] == transfer_batch_selector { - let batch_size: usize = event.data[0].try_into().unwrap_or(0); + let batch_size = usize::try_from(event.data[0].to_le_words()[0]).unwrap_or(0); batch_sizes.push(batch_size); } } diff --git a/crates/torii-erc20/Cargo.toml b/crates/torii-erc20/Cargo.toml index aac69e0e..43d63788 100644 --- a/crates/torii-erc20/Cargo.toml +++ b/crates/torii-erc20/Cargo.toml @@ -6,47 +6,50 @@ description = "ERC20 token indexer library for Torii" [dependencies] # Torii core -torii = { path = "../.." } -torii-common = { path = "../torii-common" } +torii.workspace = true +torii-common.workspace = true +torii-types.workspace = true # Async -tokio = { version = "1", features = ["full"] } -async-trait = "0.1" +tokio = { workspace = true, features = ["full"] } +async-trait.workspace = true -# Starknet -starknet = "0.17" +starknet-types-raw.workspace = true +starknet.workspace = true # Storage -rusqlite = { version = "0.32", features = ["bundled"] } +rusqlite = { workspace = true } tokio-postgres = "0.7" +torii-sql = { workspace = true, features = ["postgres", "sqlite"] } # Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true # Encoding -hex = "0.4" +hex.workspace = true +primitive-types.workspace = true # gRPC/Protobuf -prost = "0.13" -prost-types = "0.13" -tonic = "0.12" -chrono = "0.4" +prost.workspace = true +prost-types.workspace = true +tonic.workspace = true +chrono.workspace = true # Streaming -async-stream = "0.3" -futures = "0.3" +async-stream.workspace = true +futures.workspace = true # Web framework -axum = "0.7" +axum.workspace = true # Utilities -anyhow = "1.0" -tracing = "0.1" -metrics = "0.24" +anyhow.workspace = true +tracing.workspace = true +metrics.workspace = true [build-dependencies] -tonic-build = "0.12" +tonic-build.workspace = true [lints] workspace = true diff --git a/crates/torii-erc20/README.md b/crates/torii-erc20/README.md new file mode 100644 index 00000000..75fb6491 --- /dev/null +++ b/crates/torii-erc20/README.md @@ -0,0 +1,145 @@ +# torii-erc20 + +End-to-end **ERC20 token indexer**. Ships a `Decoder` for Transfer / Approval +events, an `IdentificationRule` for ABI-based auto-discovery, a `Sink` that +persists the history + maintains balances with on-chain reconciliation, +and a gRPC service that answers queries and pushes live updates. A paired +synthetic extractor makes reproducible profiling workloads possible +without a live RPC endpoint. + +## Role in Torii + +Slotted into any binary as (a) an auto-identification rule registered with +the `ContractRegistry` so Torii learns which contracts are ERC20s, (b) the +`Erc20Decoder` that turns Transfer/Approval selectors into typed envelopes, +and (c) the `Erc20Sink` that writes the rows, keeps balances sane, and +serves the `torii.sinks.erc20.Erc20` gRPC service. Wires cleanly into +`torii::run` via `ToriiConfigBuilder::with_grpc_router` + +`with_contract_identifier`. + +## Architecture + +```text +StarknetEvent (Transfer/Approval selector) + | + v ++------------------------------+ +| Erc20Decoder | identification::Erc20Rule +| Transfer|Approval → Envelope| (ABI-based auto-discovery) +| ERC20_TYPE_ID | ++--------------+---------------+ + | + v ++------------------------------+ +| Erc20Sink | +| | +| process(): | +| - store transfer/approval | +| - compute balance delta | +| - detect negative | +| balance → enqueue | +| BalanceFetcher | +| - if batch.is_live(100): | +| EventBus publish | +| grpc broadcast | +| - dispatch | +| FetchErc20MetadataCmd | +| for unseen tokens | +| AtomicU64 counters | ++--------------+---------------+ + | + v ++------------------------------+ +| Erc20Storage | +| | +| erc20_transfer (cursor PK) | +| erc20_approval (cursor PK) | +| erc20_balance (token, acc,| +| amount U256)| +| erc20_balance_adjustment | +| erc20_metadata | +| | +| BLOB encoded via torii- | +| common::U256Blob / FeltBlob | ++--------------+---------------+ + | + v ++------------------------------+ +| Erc20Service | +| gRPC: torii.sinks.erc20 | +| cursor-paginated queries | +| broadcast subscriptions | ++------------------------------+ + ^ + | ++------------------------------+ +| Erc20MetadataCommandHandler| <-- CommandBus +| (fetches name/symbol/ | FetchErc20MetadataCommand +| decimals via JsonRpcClient| +| + torii-common::Metadata- | +| Fetcher) | ++------------------------------+ + +(optional) synthetic::SyntheticErc20Extractor feeds deterministic events +for benchmarking — impl SyntheticExtractor, fed via +ToriiConfigBuilder::with_synthetic_extractor +``` + +## Deep Dive + +### Public API + +| Item | File | Purpose | +|---|---|---| +| `Erc20Decoder`, `Transfer`, `Approval`, `Erc20Event`, `ERC20_TYPE_ID` | `src/decoder.rs` | Decoder impl + event variants + the `TypeId` sinks subscribe to | +| `Erc20Rule` | `src/identification.rs` | `IdentificationRule` — ABI must expose `transfer`, `balance_of`, and a `Transfer` event | +| `Erc20Sink` | `src/sink.rs` | `Sink` impl with fluent setters: `with_grpc_service`, `with_balance_tracking(provider)`, `with_metadata_pipeline(...)` | +| `Erc20Storage`, `TransferData`, `ApprovalData`, `BalanceData`, `BalanceAdjustment`, cursors | `src/storage.rs` | Persistence layer | +| `Erc20Service` | `src/grpc_service.rs` | gRPC queries + subscriptions | +| `BalanceFetcher`, `BalanceFetchRequest` | `src/balance_fetcher.rs` | Queued on-chain balance lookups (via JsonRpcClient) | +| `Erc20MetadataCommandHandler`, `FetchErc20MetadataCommand` | `src/handlers.rs` | `CommandHandler` that fetches name/symbol/decimals out of band | +| `SyntheticErc20Config`, `SyntheticErc20Extractor` | `src/synthetic.rs` | Deterministic generator for profiling | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs:53` | Descriptor bytes for reflection | + +### Internal Modules + +- `decoder` — matches the Transfer/Approval selectors, parses the felt payload, attaches block+tx metadata to the envelope. +- `identification` — `Erc20Rule`: returns `[DecoderId::new("erc20")]` when the ABI has the three required items. +- `sink` — the core `Sink` impl (see "Sink trait wiring" below). +- `storage` — sqlx-based CRUD over `erc20_transfer` / `erc20_approval` / `erc20_balance` / `erc20_balance_adjustment` / `erc20_metadata`. +- `balance_fetcher` — fetches on-chain `balance_of` results to reconcile negative-balance anomalies (genesis, airdrop). +- `handlers` — async metadata pipeline; the sink dispatches `FetchErc20MetadataCommand`s that the handler processes off the hot path. +- `grpc_service` — tonic service; cursor-paginated queries + `tokio::sync::broadcast` live updates. +- `synthetic` — `SyntheticErc20Extractor` implementing `SyntheticExtractor`. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"erc20"` | +| `interested_types` | `[ERC20_TYPE_ID]` | +| `process(envelopes, batch)` | Persists rows; updates the `AtomicU64` counters; updates balance; on negative balance queues a `BalanceFetcher` request. **Broadcasts to EventBus + gRPC only when `batch.is_live(LIVE_THRESHOLD_BLOCKS=100)`** — historical indexing stays silent to avoid flooding clients. Dispatches a metadata command once per new token. | +| `topics` | 3 topics: `erc20.transfer` (filters: `token`, `from`, `to`, `wallet` — OR semantics for `wallet`), `erc20.approval` (filters: `token`, `owner`, `spender`, `account`), `erc20.metadata` (filter: `token`) | +| `build_routes` | `Router::new()` — gRPC only | +| `initialize` | Captures `event_bus` and `ctx.command_bus` | + +### Storage + +- Amounts stored via `torii_common::u256_to_bytes` → **variable-length big-endian BLOB** (zero is `[0]`, no leading-zero padding). +- Addresses via `FeltBlob`. +- Cursors: `(block_number, event_idx)` monotonic pagination keys. +- Balances: optimistic — computed delta, fetched and adjusted on inconsistency. +- Audit: `erc20_balance_adjustment` records every reconciled value for traceability. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-erc20`, `bins/torii-erc20-synth`, `bins/torii-tokens`, `bins/torii-tokens-synth`, `bins/torii-introspect-bin`, `bins/torii-arcade` (any binary with ERC20 support). +- **Downstream deps**: `torii`, `torii-common`, `torii-types`, `sqlx`, `tonic`, `prost`, `starknet`, `starknet-types-raw`, `primitive-types`, `tokio`, `async-trait`, `chrono`, `hex`, `metrics`, `tracing`. + +### Extension Points + +- New event type → add a variant to `Erc20Event` + decoder branch + sink branch + proto message. +- New filter key → add to the relevant `TopicInfo.available_filters` + the matching `matches_*_filters` helper. +- New RPC → proto + `Erc20Service` + regenerate via `build.rs`. +- Different balance strategy → swap `BalanceFetcher` for a batched variant; `Erc20Sink::with_balance_tracking` is the only integration point. +- Benchmarking → compose `SyntheticErc20Extractor` with `ToriiConfigBuilder::with_synthetic_extractor` (see `bins/torii-erc20-synth`). diff --git a/crates/torii-erc20/src/balance_fetcher.rs b/crates/torii-erc20/src/balance_fetcher.rs index 83339202..530f3a18 100644 --- a/crates/torii-erc20/src/balance_fetcher.rs +++ b/crates/torii-erc20/src/balance_fetcher.rs @@ -7,11 +7,15 @@ use anyhow::{Context, Result}; /// Maximum number of requests per batch to avoid RPC limits const MAX_BATCH_SIZE: usize = 500; -use starknet::core::types::{requests::CallRequest, BlockId, Felt, FunctionCall, U256}; +use primitive_types::U256; +use starknet::core::types::requests::CallRequest; +use starknet::core::types::{BlockId, FunctionCall}; use starknet::macros::selector; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::sync::Arc; +use torii_common::utils::parse_u256_result; /// Request for fetching a balance at a specific block #[derive(Debug, Clone)] @@ -49,17 +53,19 @@ impl BalanceFetcher { block_number: u64, ) -> Result { let call = FunctionCall { - contract_address: token, + contract_address: token.into(), // TODO: Some contracts use balance_of (snake_case), others use balanceOf (camelCase). // We need a strategy to handle both - possibly try one, fallback to other, or use ABI. entry_point_selector: selector!("balanceOf"), - calldata: vec![wallet], + calldata: vec![wallet.into()], }; let block_id = BlockId::Number(block_number); match self.provider.call(call, block_id).await { - Ok(result) => Ok(parse_u256_result(&result)), + Ok(result) => Ok(parse_u256_result( + result.into_iter().map(Into::into).collect(), + )), Err(e) => { tracing::warn!( target: "torii_erc20::balance_fetcher", @@ -101,9 +107,9 @@ impl BalanceFetcher { .map(|req| { ProviderRequestData::Call(CallRequest { request: FunctionCall { - contract_address: req.token, + contract_address: req.token.into(), entry_point_selector: selector!("balanceOf"), - calldata: vec![req.wallet], + calldata: vec![req.wallet.into()], }, block_id: BlockId::Number(req.block_number), }) @@ -121,7 +127,7 @@ impl BalanceFetcher { for (idx, response) in responses.into_iter().enumerate() { let req = &chunk[idx]; let balance = if let ProviderResponseData::Call(felts) = response { - parse_u256_result(&felts) + parse_u256_result(felts.into_iter().map(Into::into).collect()) } else { tracing::warn!( target: "torii_erc20::balance_fetcher", @@ -145,67 +151,3 @@ impl BalanceFetcher { Ok(all_results) } } - -/// Parse a U256 result from balance_of return value -/// -/// ERC20 balance_of typically returns: -/// - Cairo 0: A single felt (fits in 252 bits, usually enough for balances) -/// - Cairo 1 with u256: Two felts [low, high] representing a 256-bit value -fn parse_u256_result(result: &[Felt]) -> U256 { - match result.len() { - 0 => U256::from(0u64), - 1 => { - // Single felt - convert to U256 - // Felt is 252 bits max, so it fits in the low part - let bytes = result[0].to_bytes_be(); - // Take the lower 16 bytes for u128 (fits any felt value) - let low = u128::from_be_bytes(bytes[16..32].try_into().unwrap()); - U256::from_words(low, 0) - } - _ => { - // Two felts: [low, high] for u256 - let low_bytes = result[0].to_bytes_be(); - let high_bytes = result[1].to_bytes_be(); - - let low = u128::from_be_bytes(low_bytes[16..32].try_into().unwrap()); - let high = u128::from_be_bytes(high_bytes[16..32].try_into().unwrap()); - - U256::from_words(low, high) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_u256_empty() { - let result = parse_u256_result(&[]); - assert_eq!(result, U256::from(0u64)); - } - - #[test] - fn test_parse_u256_single_felt() { - let felt = Felt::from(1000u64); - let result = parse_u256_result(&[felt]); - assert_eq!(result, U256::from(1000u64)); - } - - #[test] - fn test_parse_u256_two_felts() { - // low = 100, high = 0 - let low = Felt::from(100u64); - let high = Felt::from(0u64); - let result = parse_u256_result(&[low, high]); - assert_eq!(result, U256::from(100u64)); - - // Test with high value - let low = Felt::from(0u64); - let high = Felt::from(1u64); - let result = parse_u256_result(&[low, high]); - // high = 1 means value = 1 * 2^128 - let expected = U256::from_words(0, 1); - assert_eq!(result, expected); - } -} diff --git a/crates/torii-erc20/src/decoder.rs b/crates/torii-erc20/src/decoder.rs index b54dcb79..aa26d235 100644 --- a/crates/torii-erc20/src/decoder.rs +++ b/crates/torii-erc20/src/decoder.rs @@ -2,12 +2,18 @@ use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt, U256}; -use starknet::macros::selector; +use primitive_types::U256; +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt; use std::any::Any; use std::collections::HashMap; -use torii::etl::{Decoder, Envelope, TypedBody}; +use torii::etl::{Decoder, Envelope, TypeId, TypedBody}; +use torii_common::utils::{felt_pair_to_u256, felt_to_u256}; +use torii_types::event::EventContext; +pub const ERC20_TYPE_ID: TypeId = torii::etl::envelope::TypeId::new("erc20"); +pub const TRANSFER_SELECTOR: Felt = Felt::selector("Transfer"); +pub const APPROVAL_SELECTOR: Felt = Felt::selector("Approval"); /// Transfer event from ERC20 token #[derive(Debug, Clone)] pub struct Transfer { @@ -20,20 +26,6 @@ pub struct Transfer { pub transaction_hash: Felt, } -impl TypedBody for Transfer { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc20.transfer") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - /// Approval event from ERC20 token #[derive(Debug, Clone)] pub struct Approval { @@ -46,9 +38,15 @@ pub struct Approval { pub transaction_hash: Felt, } -impl TypedBody for Approval { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc20.approval") +#[derive(Debug, Clone)] +pub enum Erc20Event { + Transfer(Transfer), + Approval(Approval), +} + +impl TypedBody for Erc20Event { + fn envelope_type_id(&self) -> TypeId { + ERC20_TYPE_ID } fn as_any(&self) -> &dyn Any { @@ -60,6 +58,37 @@ impl TypedBody for Approval { } } +impl Erc20Event { + fn event_id(&self) -> String { + match self { + Self::Transfer(transfer) => format!( + "erc20_transfer_{}_{:#x}", + transfer.block_number, transfer.transaction_hash + ), + Self::Approval(approval) => format!( + "erc20_approval_{}_{:#x}", + approval.block_number, approval.transaction_hash + ), + } + } + + fn into_envelope(self, event: &EmittedEvent) -> Envelope { + let envelope_id = self.event_id(); + let mut metadata = HashMap::new(); + metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); + metadata.insert( + "block_number".to_string(), + event.block_number.unwrap_or(0).to_string(), + ); + metadata.insert( + "tx_hash".to_string(), + format!("{:#x}", event.transaction_hash), + ); + + Envelope::new(envelope_id, Box::new(self), metadata) + } +} + /// ERC20 event decoder /// /// Decodes multiple ERC20 events: @@ -71,7 +100,7 @@ impl TypedBody for Approval { /// This decoder showcases the recommended pattern for handling multiple event types: /// 1. Check selector first (fast O(1) comparison) /// 2. Dispatch to specific decode_X() method -/// 3. Each method returns Result> (None if not interested/malformed) +/// 3. Each method returns Result> (None if malformed) /// 4. Main decode_event() collects results /// /// This pattern scales cleanly to many event types without complex branching. @@ -84,12 +113,43 @@ impl Erc20Decoder { /// Transfer event selector: sn_keccak("Transfer") fn transfer_selector() -> Felt { - selector!("Transfer") + TRANSFER_SELECTOR } /// Approval event selector: sn_keccak("Approval") fn approval_selector() -> Felt { - selector!("Approval") + APPROVAL_SELECTOR + } + + pub async fn decode(&self, event: &EmittedEvent) -> Result> { + if event.keys.is_empty() { + return Ok(Vec::new()); + } + + let selector = event.keys[0]; + + if selector == Self::transfer_selector() { + if let Some(event_body) = self.decode_transfer(event).await? { + return Ok(vec![event_body.into_envelope(event)]); + } + } else if selector == Self::approval_selector() { + if let Some(event_body) = self.decode_approval(event).await? { + return Ok(vec![event_body.into_envelope(event)]); + } + } else { + tracing::trace!( + target: "torii_erc20::decoder", + token = %format!("{:#x}", event.from_address), + selector = %format!("{:#x}", selector), + keys_len = event.keys.len(), + data_len = event.data.len(), + block_number = event.block_number.unwrap_or(0), + tx_hash = %format!("{:#x}", event.transaction_hash), + "Unhandled event selector" + ); + } + + Ok(Vec::new()) } /// Decode Transfer event into envelope @@ -119,7 +179,7 @@ impl Erc20Decoder { /// - data[3]: amount_high (u128) /// /// Felt-based variants use single felt for amount instead of U256. - async fn decode_transfer(&self, event: &EmittedEvent) -> Result> { + async fn decode_transfer(&self, event: &EmittedEvent) -> Result> { let from; let to; let amount: U256; @@ -128,9 +188,9 @@ impl Erc20Decoder { // All-in-keys format: selector, from, to, amount_low, amount_high all in keys from = event.keys[1]; to = event.keys[2]; - let low: u128 = event.keys[3].try_into().unwrap_or(0); - let high: u128 = event.keys[4].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + let low: Felt = event.keys[3]; + let high: Felt = event.keys[4]; + amount = felt_pair_to_u256(low, high); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -143,8 +203,7 @@ impl Erc20Decoder { // All-in-keys format with felt amount: selector, from, to, amount in keys from = event.keys[1]; to = event.keys[2]; - let amount_felt: u128 = event.keys[3].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.keys[3]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -157,22 +216,17 @@ impl Erc20Decoder { // Legacy ERC20: from, to, amount_low, amount_high in data from = event.data[0]; to = event.data[1]; - let low: u128 = event.data[2].try_into().unwrap_or(0); - let high: u128 = event.data[3].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + amount = felt_pair_to_u256(event.data[2], event.data[3]); } else if event.keys.len() == 3 && event.data.len() == 2 { // Modern ERC20: from, to in keys; amount_low, amount_high in data from = event.keys[1]; to = event.keys[2]; - let low: u128 = event.data[0].try_into().unwrap_or(0); - let high: u128 = event.data[1].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + amount = felt_pair_to_u256(event.data[0], event.data[1]); } else if event.keys.len() == 3 && event.data.len() == 1 { // Felt-based modern format: from, to in keys; amount as single felt (fits in u128) from = event.keys[1]; to = event.keys[2]; - let amount_felt: u128 = event.data[0].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.data[0]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -185,8 +239,7 @@ impl Erc20Decoder { // Felt-based legacy format: from, to, amount in data from = event.data[0]; to = event.data[1]; - let amount_felt: u128 = event.data[2].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.data[2]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -199,7 +252,7 @@ impl Erc20Decoder { // All-in-keys format with zero amount (or amount omitted): from, to in keys, no data from = event.keys[1]; to = event.keys[2]; - amount = U256::from(0u64); + amount = U256::zero(); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -229,28 +282,7 @@ impl Erc20Decoder { transaction_hash: event.transaction_hash, }; - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); - - let envelope_id = format!( - "erc20_transfer_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); - - Ok(Some(Envelope::new( - envelope_id, - Box::new(transfer), - metadata, - ))) + Ok(Some(Erc20Event::Transfer(transfer))) } /// Decode Approval event into envelope @@ -280,7 +312,7 @@ impl Erc20Decoder { /// - data[3]: amount_high (u128) /// /// Felt-based variants use single felt for amount instead of U256. - async fn decode_approval(&self, event: &EmittedEvent) -> Result> { + async fn decode_approval(&self, event: &EmittedEvent) -> Result> { let owner; let spender; let amount: U256; @@ -289,9 +321,7 @@ impl Erc20Decoder { // All-in-keys format: selector, owner, spender, amount_low, amount_high all in keys owner = event.keys[1]; spender = event.keys[2]; - let low: u128 = event.keys[3].try_into().unwrap_or(0); - let high: u128 = event.keys[4].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + amount = felt_pair_to_u256(event.keys[3], event.keys[4]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -304,8 +334,7 @@ impl Erc20Decoder { // All-in-keys format with felt amount: selector, owner, spender, amount in keys owner = event.keys[1]; spender = event.keys[2]; - let amount_felt: u128 = event.keys[3].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.keys[3]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -318,22 +347,17 @@ impl Erc20Decoder { // Legacy ERC20: owner, spender, amount_low, amount_high in data owner = event.data[0]; spender = event.data[1]; - let low: u128 = event.data[2].try_into().unwrap_or(0); - let high: u128 = event.data[3].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + amount = felt_pair_to_u256(event.data[2], event.data[3]); } else if event.keys.len() == 3 && event.data.len() == 2 { // Modern ERC20: owner, spender in keys; amount_low, amount_high in data owner = event.keys[1]; spender = event.keys[2]; - let low: u128 = event.data[0].try_into().unwrap_or(0); - let high: u128 = event.data[1].try_into().unwrap_or(0); - amount = U256::from_words(low, high); + amount = felt_pair_to_u256(event.data[0], event.data[1]); } else if event.keys.len() == 3 && event.data.len() == 1 { // Felt-based modern format: owner, spender in keys; amount as single felt owner = event.keys[1]; spender = event.keys[2]; - let amount_felt: u128 = event.data[0].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.data[0]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -346,8 +370,7 @@ impl Erc20Decoder { // Felt-based legacy format: owner, spender, amount in data owner = event.data[0]; spender = event.data[1]; - let amount_felt: u128 = event.data[2].try_into().unwrap_or(0); - amount = U256::from(amount_felt); + amount = felt_to_u256(event.data[2]); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -360,7 +383,7 @@ impl Erc20Decoder { // All-in-keys format with zero amount (or amount omitted) owner = event.keys[1]; spender = event.keys[2]; - amount = U256::from(0u64); + amount = U256::zero(); tracing::debug!( target: "torii_erc20::decoder", token = %format!("{:#x}", event.from_address), @@ -390,28 +413,7 @@ impl Erc20Decoder { transaction_hash: event.transaction_hash, }; - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); - - let envelope_id = format!( - "erc20_approval_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); - - Ok(Some(Envelope::new( - envelope_id, - Box::new(approval), - metadata, - ))) + Ok(Some(Erc20Event::Approval(approval))) } } @@ -427,37 +429,22 @@ impl Decoder for Erc20Decoder { "erc20" } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { - if event.keys.is_empty() { - return Ok(Vec::new()); - } - - let selector = event.keys[0]; - - if selector == Self::transfer_selector() { - if let Some(envelope) = self.decode_transfer(event).await? { - return Ok(vec![envelope]); - } - } else if selector == Self::approval_selector() { - if let Some(envelope) = self.decode_approval(event).await? { - return Ok(vec![envelope]); - } - } else { - // Log unhandled selectors to help identify missing event types. - // This is expected for contracts that emit other events besides ERC20 Transfer/Approval. - tracing::trace!( - target: "torii_erc20::decoder", - token = %format!("{:#x}", event.from_address), - selector = %format!("{:#x}", selector), - keys_len = event.keys.len(), - data_len = event.data.len(), - block_number = event.block_number.unwrap_or(0), - tx_hash = %format!("{:#x}", event.transaction_hash), - "Unhandled event selector" - ); - } + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> Result> { + let event = EmittedEvent { + from_address: context.from_address, + keys: keys.to_vec(), + data: data.to_vec(), + block_hash: None, + block_number: Some(context.block_number), + transaction_hash: context.transaction_hash, + }; - Ok(Vec::new()) + Erc20Decoder::decode(self, &event).await } } @@ -465,6 +452,22 @@ impl Decoder for Erc20Decoder { mod tests { use super::*; + fn transfer(envelope: &Envelope) -> &Transfer { + assert_eq!(envelope.type_id, ERC20_TYPE_ID); + match envelope.body.as_any().downcast_ref::().unwrap() { + Erc20Event::Transfer(transfer) => transfer, + Erc20Event::Approval(_) => panic!("expected transfer event"), + } + } + + fn approval(envelope: &Envelope) -> &Approval { + assert_eq!(envelope.type_id, ERC20_TYPE_ID); + match envelope.body.as_any().downcast_ref::().unwrap() { + Erc20Event::Approval(approval) => approval, + Erc20Event::Transfer(_) => panic!("expected approval event"), + } + } + #[tokio::test] async fn test_decode_transfer() { let decoder = Erc20Decoder::new(); @@ -485,14 +488,10 @@ mod tests { transaction_hash: Felt::from(0xabcdu64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let transfer = transfer(&envelopes[0]); assert_eq!(transfer.from, Felt::from(0x1u64)); assert_eq!(transfer.to, Felt::from(0x2u64)); @@ -520,14 +519,10 @@ mod tests { transaction_hash: Felt::from(0xdef0u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0xau64)); assert_eq!(approval.spender, Felt::from(0xbu64)); @@ -551,7 +546,7 @@ mod tests { transaction_hash: Felt::from(0x1234u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 0); // Should return empty vec for unknown events } @@ -575,14 +570,10 @@ mod tests { transaction_hash: Felt::from(0xabcdu64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let transfer = transfer(&envelopes[0]); assert_eq!(transfer.from, Felt::from(0x1u64)); assert_eq!(transfer.to, Felt::from(0x2u64)); @@ -608,14 +599,10 @@ mod tests { transaction_hash: Felt::from(0xef01u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let transfer = transfer(&envelopes[0]); assert_eq!(transfer.from, Felt::from(0xau64)); assert_eq!(transfer.to, Felt::from(0xbu64)); @@ -642,14 +629,10 @@ mod tests { transaction_hash: Felt::from(0xabc1u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0xcu64)); assert_eq!(approval.spender, Felt::from(0xdu64)); @@ -677,14 +660,10 @@ mod tests { transaction_hash: Felt::from(0xdef2u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0xeu64)); assert_eq!(approval.spender, Felt::from(0xfu64)); @@ -710,14 +689,10 @@ mod tests { transaction_hash: Felt::from(0xef03u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0x10u64)); assert_eq!(approval.spender, Felt::from(0x11u64)); @@ -745,14 +720,10 @@ mod tests { transaction_hash: Felt::from(0xfff1u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let transfer = transfer(&envelopes[0]); assert_eq!(transfer.from, Felt::from(0x20u64)); assert_eq!(transfer.to, Felt::from(0x21u64)); @@ -779,14 +750,10 @@ mod tests { transaction_hash: Felt::from(0xfff2u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let transfer = transfer(&envelopes[0]); assert_eq!(transfer.from, Felt::from(0x30u64)); assert_eq!(transfer.to, Felt::from(0x31u64)); @@ -814,14 +781,10 @@ mod tests { transaction_hash: Felt::from(0xfff3u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0x40u64)); assert_eq!(approval.spender, Felt::from(0x41u64)); @@ -848,14 +811,10 @@ mod tests { transaction_hash: Felt::from(0xfff4u64), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); + let envelopes = decoder.decode(&event).await.unwrap(); assert_eq!(envelopes.len(), 1); - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); + let approval = approval(&envelopes[0]); assert_eq!(approval.owner, Felt::from(0x50u64)); assert_eq!(approval.spender, Felt::from(0x51u64)); diff --git a/crates/torii-erc20/src/grpc_service.rs b/crates/torii-erc20/src/grpc_service.rs index 8d2bc4e6..fbdcae21 100644 --- a/crates/torii-erc20/src/grpc_service.rs +++ b/crates/torii-erc20/src/grpc_service.rs @@ -5,25 +5,26 @@ //! - Real-time subscriptions with filtering (SubscribeTransfers, SubscribeApprovals) //! - Indexer statistics (GetStats) +use crate::proto::erc20_server::Erc20 as Erc20Trait; use crate::proto::{ - erc20_server::Erc20 as Erc20Trait, Approval, ApprovalFilter, ApprovalUpdate, BalanceEntry, - Cursor, GetApprovalsRequest, GetApprovalsResponse, GetBalanceRequest, GetBalanceResponse, - GetBalancesRequest, GetBalancesResponse, GetStatsRequest, GetStatsResponse, - GetTokenMetadataRequest, GetTokenMetadataResponse, GetTransfersRequest, GetTransfersResponse, - SubscribeApprovalsRequest, SubscribeTransfersRequest, TokenMetadataEntry, Transfer, - TransferFilter, TransferUpdate, + Approval, ApprovalFilter, ApprovalUpdate, BalanceEntry, Cursor, GetApprovalsRequest, + GetApprovalsResponse, GetBalanceRequest, GetBalanceResponse, GetBalancesRequest, + GetBalancesResponse, GetStatsRequest, GetStatsResponse, GetTokenMetadataRequest, + GetTokenMetadataResponse, GetTransfersRequest, GetTransfersResponse, SubscribeApprovalsRequest, + SubscribeTransfersRequest, TokenMetadataEntry, Transfer, TransferFilter, TransferUpdate, }; use crate::storage::{ ApprovalCursor, ApprovalData, Erc20Storage, TransferCursor, TransferData, TransferDirection, }; use async_trait::async_trait; use futures::stream::Stream; -use starknet::core::types::Felt; +use primitive_types::U256; +use starknet_types_raw::Felt; use std::pin::Pin; use std::sync::Arc; use tokio::sync::broadcast; use tonic::{Request, Response, Status}; -use torii_common::{bytes_to_felt, u256_to_bytes}; +use torii_common::u256_to_bytes; /// gRPC service implementation for ERC20 #[derive(Clone)] @@ -72,12 +73,12 @@ impl Erc20Service { /// Convert storage TransferData to proto Transfer fn transfer_data_to_proto(data: &TransferData) -> Transfer { Transfer { - token: data.token.to_bytes_be().to_vec(), - from: data.from.to_bytes_be().to_vec(), - to: data.to.to_bytes_be().to_vec(), + token: data.token.to_be_bytes_vec(), + from: data.from.to_be_bytes_vec(), + to: data.to.to_be_bytes_vec(), amount: u256_to_bytes(data.amount), block_number: data.block_number, - tx_hash: data.tx_hash.to_bytes_be().to_vec(), + tx_hash: data.tx_hash.to_be_bytes_vec(), timestamp: data.timestamp.unwrap_or(0), } } @@ -85,12 +86,12 @@ impl Erc20Service { /// Convert storage ApprovalData to proto Approval fn approval_data_to_proto(data: &ApprovalData) -> Approval { Approval { - token: data.token.to_bytes_be().to_vec(), - owner: data.owner.to_bytes_be().to_vec(), - spender: data.spender.to_bytes_be().to_vec(), + token: data.token.to_be_bytes_vec(), + owner: data.owner.to_be_bytes_vec(), + spender: data.spender.to_be_bytes_vec(), amount: u256_to_bytes(data.amount), block_number: data.block_number, - tx_hash: data.tx_hash.to_bytes_be().to_vec(), + tx_hash: data.tx_hash.to_be_bytes_vec(), timestamp: data.timestamp.unwrap_or(0), } } @@ -213,13 +214,22 @@ impl Erc20Trait for Erc20Service { let filter = req.filter.unwrap_or_default(); // Parse filter fields - let wallet = filter.wallet.as_ref().and_then(|b| bytes_to_felt(b)); - let from = filter.from.as_ref().and_then(|b| bytes_to_felt(b)); - let to = filter.to.as_ref().and_then(|b| bytes_to_felt(b)); + let wallet = filter + .wallet + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); + let from = filter + .from + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); + let to = filter + .to + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); let tokens: Vec = filter .tokens .iter() - .filter_map(|b| bytes_to_felt(b)) + .filter_map(|b| Felt::from_be_bytes_slice(b).ok()) .collect(); let direction = match crate::proto::TransferDirection::try_from(filter.direction) { @@ -298,13 +308,22 @@ impl Erc20Trait for Erc20Service { let filter = req.filter.unwrap_or_default(); // Parse filter fields - let account = filter.account.as_ref().and_then(|b| bytes_to_felt(b)); - let owner = filter.owner.as_ref().and_then(|b| bytes_to_felt(b)); - let spender = filter.spender.as_ref().and_then(|b| bytes_to_felt(b)); + let account = filter + .account + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); + let owner = filter + .owner + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); + let spender = filter + .spender + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); let tokens: Vec = filter .tokens .iter() - .filter_map(|b| bytes_to_felt(b)) + .filter_map(|b| Felt::from_be_bytes_slice(b).ok()) .collect(); // Parse cursor @@ -374,10 +393,10 @@ impl Erc20Trait for Erc20Service { ) -> Result, Status> { let req = request.into_inner(); - let token = bytes_to_felt(&req.token) - .ok_or_else(|| Status::invalid_argument("Invalid token address"))?; - let wallet = bytes_to_felt(&req.wallet) - .ok_or_else(|| Status::invalid_argument("Invalid wallet address"))?; + let token = Felt::from_be_bytes_slice(&req.token) + .map_err(|_| Status::invalid_argument("Invalid token address"))?; + let wallet = Felt::from_be_bytes_slice(&req.wallet) + .map_err(|_| Status::invalid_argument("Invalid wallet address"))?; tracing::debug!( target: "torii_erc20::grpc", @@ -391,7 +410,7 @@ impl Erc20Trait for Erc20Service { .get_balance_with_block(token, wallet) .await .map_err(|e| Status::internal(format!("Query failed: {e}")))? - .unwrap_or((starknet::core::types::U256::from(0u64), 0)); + .unwrap_or((U256::zero(), 0)); Ok(Response::new(GetBalanceResponse { balance: u256_to_bytes(balance), @@ -406,8 +425,14 @@ impl Erc20Trait for Erc20Service { ) -> Result, Status> { let req = request.into_inner(); - let token = req.token.as_ref().and_then(|b| bytes_to_felt(b)); - let wallet = req.wallet.as_ref().and_then(|b| bytes_to_felt(b)); + let token = req + .token + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); + let wallet = req + .wallet + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); let cursor = req.cursor; let limit = if req.limit == 0 { 1000 @@ -433,8 +458,8 @@ impl Erc20Trait for Erc20Service { let rows = balances .into_iter() .map(|b| BalanceEntry { - token: b.token.to_bytes_be().to_vec(), - wallet: b.wallet.to_bytes_be().to_vec(), + token: b.token.to_be_bytes_vec(), + wallet: b.wallet.to_be_bytes_vec(), balance: u256_to_bytes(b.balance), last_block: b.last_block, }) @@ -454,12 +479,12 @@ impl Erc20Trait for Erc20Service { let req = request.into_inner(); if let Some(token_bytes) = req.token { - let token = bytes_to_felt(&token_bytes) - .ok_or_else(|| Status::invalid_argument("Invalid token address"))?; + let token = Felt::from_be_bytes_slice(&token_bytes) + .map_err(|_| Status::invalid_argument("Invalid token address"))?; let entries = match self.storage.get_token_metadata(token).await { Ok(Some((name, symbol, decimals, total_supply))) => vec![TokenMetadataEntry { - token: token.to_bytes_be().to_vec(), + token: token.to_be_bytes_vec(), name, symbol, decimals: decimals.map(|d| d as u32), @@ -475,7 +500,10 @@ impl Erc20Trait for Erc20Service { })); } - let cursor = req.cursor.as_ref().and_then(|b| bytes_to_felt(b)); + let cursor = req + .cursor + .as_ref() + .and_then(|b| Felt::from_be_bytes_slice(b).ok()); let limit = if req.limit == 0 { 100 } else { @@ -492,7 +520,7 @@ impl Erc20Trait for Erc20Service { .into_iter() .map( |(token, name, symbol, decimals, total_supply)| TokenMetadataEntry { - token: token.to_bytes_be().to_vec(), + token: token.to_be_bytes_vec(), name, symbol, decimals: decimals.map(|d| d as u32), @@ -503,7 +531,7 @@ impl Erc20Trait for Erc20Service { Ok(Response::new(GetTokenMetadataResponse { tokens: entries, - next_cursor: next_cursor.map(|c| c.to_bytes_be().to_vec()), + next_cursor: next_cursor.map(|c| c.to_be_bytes_vec()), })) } diff --git a/crates/torii-erc20/src/handlers.rs b/crates/torii-erc20/src/handlers.rs index cc4b4f8a..3fbd03dc 100644 --- a/crates/torii-erc20/src/handlers.rs +++ b/crates/torii-erc20/src/handlers.rs @@ -2,8 +2,8 @@ use anyhow::Result; use async_trait::async_trait; use prost::Message; use prost_types::Any; -use starknet::core::types::Felt; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use starknet_types_raw::Felt; use std::collections::HashSet; use std::sync::{Arc, Mutex}; use torii::command::CommandHandler; @@ -112,7 +112,7 @@ impl CommandHandler for Erc20MetadataCommandHandler { let event_bus = self.event_bus.lock().unwrap().clone(); if let Some(event_bus) = event_bus { let meta_entry = proto::TokenMetadataEntry { - token: command.token.to_bytes_be().to_vec(), + token: command.token.to_be_bytes_vec(), name: meta.name, symbol: meta.symbol, decimals: meta.decimals.map(|d| d as u32), diff --git a/crates/torii-erc20/src/identification.rs b/crates/torii-erc20/src/identification.rs index 2868ba3c..98ea750f 100644 --- a/crates/torii-erc20/src/identification.rs +++ b/crates/torii-erc20/src/identification.rs @@ -6,7 +6,7 @@ //! - `Transfer` event use anyhow::Result; -use starknet::core::types::Felt; +use starknet_types_raw::Felt; use torii::etl::decoder::DecoderId; use torii::etl::extractor::ContractAbi; use torii::etl::identification::IdentificationRule; diff --git a/crates/torii-erc20/src/lib.rs b/crates/torii-erc20/src/lib.rs index 70e2b54c..eb596175 100644 --- a/crates/torii-erc20/src/lib.rs +++ b/crates/torii-erc20/src/lib.rs @@ -19,7 +19,7 @@ //! use torii_erc20::proto::erc20_server::Erc20Server; //! //! // Create storage -//! let storage = Arc::new(Erc20Storage::new("./erc20.db")?); +//! let storage = Arc::new(Erc20Storage::new("./erc20.db").await?); //! //! // Create gRPC service //! let grpc_service = Erc20Service::new(storage.clone()); @@ -54,7 +54,7 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/erc20_descripto // Re-export main types for convenience pub use balance_fetcher::{BalanceFetchRequest, BalanceFetcher}; -pub use decoder::{Approval, Erc20Decoder, Transfer}; +pub use decoder::{Approval, Erc20Decoder, Erc20Event, Transfer, ERC20_TYPE_ID}; pub use grpc_service::Erc20Service; pub use handlers::Erc20MetadataCommandHandler; pub use identification::Erc20Rule; diff --git a/crates/torii-erc20/src/sink.rs b/crates/torii-erc20/src/sink.rs index 4a09e295..65534c0e 100644 --- a/crates/torii-erc20/src/sink.rs +++ b/crates/torii-erc20/src/sink.rs @@ -12,7 +12,7 @@ //! fetches the actual balance from the chain and adjusts use crate::balance_fetcher::BalanceFetcher; -use crate::decoder::{Approval as DecodedApproval, Transfer as DecodedTransfer}; +use crate::decoder::{Erc20Event, ERC20_TYPE_ID}; use crate::grpc_service::Erc20Service; use crate::handlers::FetchErc20MetadataCommand; use crate::proto; @@ -20,10 +20,11 @@ use crate::storage::{ApprovalData, Erc20Storage, TransferData}; use anyhow::Result; use async_trait::async_trait; use axum::Router; +use primitive_types::U256; use prost::Message; use prost_types::Any; -use starknet::core::types::{Felt, U256}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -217,7 +218,7 @@ impl Sink for Erc20Sink { } fn interested_types(&self) -> Vec { - vec![TypeId::new("erc20.transfer"), TypeId::new("erc20.approval")] + vec![ERC20_TYPE_ID] } async fn initialize( @@ -245,36 +246,38 @@ impl Sink for Erc20Sink { .collect(); for envelope in envelopes { - // Handle transfers - if envelope.type_id == TypeId::new("erc20.transfer") { - if let Some(transfer) = envelope.body.as_any().downcast_ref::() { - let timestamp = block_timestamps.get(&transfer.block_number).copied(); - transfers.push(TransferData { - id: None, - token: transfer.token, - from: transfer.from, - to: transfer.to, - amount: transfer.amount, - block_number: transfer.block_number, - tx_hash: transfer.transaction_hash, - timestamp, - }); - } + if envelope.type_id != ERC20_TYPE_ID { + continue; } - // Handle approvals - else if envelope.type_id == TypeId::new("erc20.approval") { - if let Some(approval) = envelope.body.as_any().downcast_ref::() { - let timestamp = block_timestamps.get(&approval.block_number).copied(); - approvals.push(ApprovalData { - id: None, - token: approval.token, - owner: approval.owner, - spender: approval.spender, - amount: approval.amount, - block_number: approval.block_number, - tx_hash: approval.transaction_hash, - timestamp, - }); + + if let Some(event) = envelope.body.as_any().downcast_ref::() { + match event { + Erc20Event::Transfer(transfer) => { + let timestamp = block_timestamps.get(&transfer.block_number).copied(); + transfers.push(TransferData { + id: None, + token: transfer.token, + from: transfer.from, + to: transfer.to, + amount: transfer.amount, + block_number: transfer.block_number, + tx_hash: transfer.transaction_hash, + timestamp, + }); + } + Erc20Event::Approval(approval) => { + let timestamp = block_timestamps.get(&approval.block_number).copied(); + approvals.push(ApprovalData { + id: None, + token: approval.token, + owner: approval.owner, + spender: approval.spender, + amount: approval.amount, + block_number: approval.block_number, + tx_hash: approval.transaction_hash, + timestamp, + }); + } } } } @@ -449,12 +452,12 @@ impl Sink for Erc20Sink { // Publish transfer events for transfer in &transfers { let proto_transfer = proto::Transfer { - token: transfer.token.to_bytes_be().to_vec(), - from: transfer.from.to_bytes_be().to_vec(), - to: transfer.to.to_bytes_be().to_vec(), + token: transfer.token.to_be_bytes_vec(), + from: transfer.from.to_be_bytes_vec(), + to: transfer.to.to_be_bytes_vec(), amount: u256_to_bytes(transfer.amount), block_number: transfer.block_number, - tx_hash: transfer.tx_hash.to_bytes_be().to_vec(), + tx_hash: transfer.tx_hash.to_be_bytes_vec(), timestamp: transfer.timestamp.unwrap_or(0), }; @@ -522,12 +525,12 @@ impl Sink for Erc20Sink { // Publish approval events for approval in &approvals { let proto_approval = proto::Approval { - token: approval.token.to_bytes_be().to_vec(), - owner: approval.owner.to_bytes_be().to_vec(), - spender: approval.spender.to_bytes_be().to_vec(), + token: approval.token.to_be_bytes_vec(), + owner: approval.owner.to_be_bytes_vec(), + spender: approval.spender.to_be_bytes_vec(), amount: u256_to_bytes(approval.amount), block_number: approval.block_number, - tx_hash: approval.tx_hash.to_bytes_be().to_vec(), + tx_hash: approval.tx_hash.to_be_bytes_vec(), timestamp: approval.timestamp.unwrap_or(0), }; diff --git a/crates/torii-erc20/src/storage.rs b/crates/torii-erc20/src/storage.rs index 82b0296e..aa50ecd9 100644 --- a/crates/torii-erc20/src/storage.rs +++ b/crates/torii-erc20/src/storage.rs @@ -10,19 +10,19 @@ //! - Records all adjustments in an audit table for debugging use anyhow::Result; +use primitive_types::U256; use rusqlite::{params, params_from_iter, Connection, ToSql}; -use starknet::core::types::{Felt, U256}; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use tokio_postgres::types::ToSql as PgToSql; use tokio_postgres::{Client, NoTls}; use torii_common::{blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob}; +use torii_sql::{DbPool, DbPoolOptions}; use crate::balance_fetcher::BalanceFetchRequest; /// Maximum value for U256 (2^256 - 1) -const U256_MAX: U256 = U256::from_words(u128::MAX, u128::MAX); const SQLITE_MAX_BIND_VARS: usize = 900; const SQLITE_TOKEN_WALLET_QUERY_CHUNK: usize = SQLITE_MAX_BIND_VARS - 1; const SQLITE_ACTIVITY_INSERT_CHUNK: usize = SQLITE_MAX_BIND_VARS / 5; @@ -30,6 +30,21 @@ const SQLITE_BALANCE_UPSERT_CHUNK: usize = SQLITE_MAX_BIND_VARS / 5; const SQLITE_ADJUSTMENT_INSERT_CHUNK: usize = SQLITE_MAX_BIND_VARS / 6; const DEFAULT_BALANCE_CACHE_CAPACITY: usize = 300_000; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StorageBackend { + Sqlite, + Postgres, +} + +/// Storage for ERC20 token data. +pub struct Erc20Storage { + pool: DbPool, + backend: StorageBackend, + conn: Arc>, + balance_cache: Arc>, + pg_conn: Option>>, +} + #[derive(Debug)] struct BalanceCacheState { enabled: bool, @@ -108,6 +123,21 @@ impl BalanceCacheState { } } +fn db_pool_url(db_path: &str) -> String { + if db_path == ":memory:" || db_path == "sqlite::memory:" { + return "sqlite::memory:".to_owned(); + } + + if db_path.starts_with("postgres://") + || db_path.starts_with("postgresql://") + || db_path.starts_with("sqlite:") + { + return db_path.to_owned(); + } + + format!("sqlite://{db_path}?mode=rwc") +} + struct ActivityInsertRow { account: Vec, token: Vec, @@ -146,15 +176,15 @@ struct AdjustmentInsertRow { /// - Accumulation of many transfers to the same address fn safe_u256_add(a: U256, b: U256) -> U256 { // Check if addition would overflow - // If a > U256_MAX - b, then a + b would overflow - let max_minus_b = U256_MAX - b; + // If a > U256::MAX - b, then a + b would overflow + let max_minus_b = U256::MAX - b; if a > max_minus_b { tracing::warn!( "U256 addition overflow detected: {} + {} would exceed U256::MAX, capping at maximum", a, b ); - U256_MAX + U256::MAX } else { a + b } @@ -172,21 +202,6 @@ pub enum TransferDirection { Received, } -/// Storage for ERC20 transfers and approvals -pub struct Erc20Storage { - backend: StorageBackend, - conn: Arc>, - balance_cache: Arc>, - pg_conns: Option>>>, - pg_rr: AtomicUsize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StorageBackend { - Sqlite, - Postgres, -} - /// Transfer data for batch insertion pub struct TransferData { pub id: Option, @@ -319,33 +334,32 @@ impl Erc20Storage { ::metrics::gauge!("torii_erc20_balance_cache_size").set(cache.size() as f64); } + pub fn pool(&self) -> &DbPool { + &self.pool + } + + pub async fn connect_pool(db_path: &str) -> Result { + Ok(DbPoolOptions::new() + .connect_any(&db_pool_url(db_path)) + .await?) + } + /// Create or open the database pub async fn new(db_path: &str) -> Result { + let pool = Self::connect_pool(db_path).await?; + Self::from_pool(db_path, pool).await + } + + pub async fn from_pool(db_path: &str, pool: DbPool) -> Result { let balance_cache = Self::build_balance_cache(); - if db_path.starts_with("postgres://") || db_path.starts_with("postgresql://") { - let pool_size = std::env::var("TORII_ERC20_PG_POOL_SIZE") - .ok() - .and_then(|s| s.parse::().ok()) - .unwrap_or(8) - .max(1); - - let mut pg_conns = Vec::with_capacity(pool_size); - - for _ in 0..pool_size { - let (client, connection) = tokio_postgres::connect(db_path, NoTls).await?; - tokio::spawn(async move { - if let Err(e) = connection.await { - tracing::error!(target: "torii_erc20::storage", error = %e, "PostgreSQL connection task failed"); - } - }); - pg_conns.push(Arc::new(tokio::sync::Mutex::new(client))); - } + if matches!(&pool, DbPool::Postgres(_)) { + let (client, connection) = tokio_postgres::connect(db_path, NoTls).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!(target: "torii_erc20::storage", error = %e, "PostgreSQL connection task failed"); + } + }); - let schema_client = pg_conns - .first() - .expect("PostgreSQL connection pool must contain at least one client") - .clone(); - let client = schema_client.lock().await; client .batch_execute( r" @@ -446,13 +460,13 @@ impl Erc20Storage { ) .await?; - tracing::info!(target: "torii_erc20::storage", pool_size, "PostgreSQL storage initialized"); + tracing::info!(target: "torii_erc20::storage", "PostgreSQL storage initialized"); return Ok(Self { + pool, backend: StorageBackend::Postgres, conn: Arc::new(Mutex::new(Connection::open_in_memory()?)), balance_cache, - pg_conns: Some(pg_conns), - pg_rr: AtomicUsize::new(0), + pg_conn: Some(Arc::new(tokio::sync::Mutex::new(client))), }); } @@ -737,11 +751,11 @@ impl Erc20Storage { tracing::info!(target: "torii_erc20::storage", db_path = %db_path, "Database initialized"); Ok(Self { + pool, backend: StorageBackend::Sqlite, conn: Arc::new(Mutex::new(conn)), balance_cache, - pg_conns: None, - pg_rr: AtomicUsize::new(0), + pg_conn: None, }) } @@ -2181,12 +2195,11 @@ impl Erc20Storage { } async fn pg_client(&self) -> Result> { - let conns = self - .pg_conns + let conn = self + .pg_conn .as_ref() - .ok_or_else(|| anyhow::anyhow!("PostgreSQL connections not initialized"))?; - let idx = self.pg_rr.fetch_add(1, Ordering::Relaxed) % conns.len(); - Ok(conns[idx].lock().await) + .ok_or_else(|| anyhow::anyhow!("PostgreSQL connection not initialized"))?; + Ok(conn.lock().await) } fn pg_next_param( diff --git a/crates/torii-erc20/src/synthetic.rs b/crates/torii-erc20/src/synthetic.rs index e0032323..7d9798eb 100644 --- a/crates/torii-erc20/src/synthetic.rs +++ b/crates/torii-erc20/src/synthetic.rs @@ -5,9 +5,11 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; -use starknet::macros::selector; +use starknet_types_raw::Felt; use torii::etl::extractor::{ExtractionBatch, SyntheticExtractor}; +use torii::etl::StarknetEvent; + +use crate::decoder::{APPROVAL_SELECTOR, TRANSFER_SELECTOR}; const EXTRACTOR_NAME: &str = "synthetic_erc20"; @@ -160,23 +162,21 @@ impl SyntheticErc20Extractor { let amount_low = self.amount_low_for(block_number, tx_index); let event = if self.is_approval(tx_index) { - EmittedEvent { - from_address: token, - keys: vec![selector!("Approval"), from, to], - data: vec![amount_low, Felt::ZERO], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - } + StarknetEvent::new( + token, + vec![APPROVAL_SELECTOR, from, to], + vec![amount_low, Felt::ZERO], + block_number, + tx_hash, + ) } else { - EmittedEvent { - from_address: token, - keys: vec![selector!("Transfer"), from, to], - data: vec![amount_low, Felt::ZERO], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - } + StarknetEvent::new( + token, + vec![TRANSFER_SELECTOR, from, to], + vec![amount_low, Felt::ZERO], + block_number, + tx_hash, + ) }; batch.add_event_with_tx_context( event, diff --git a/crates/torii-erc721/Cargo.toml b/crates/torii-erc721/Cargo.toml index 078e257f..87298e96 100644 --- a/crates/torii-erc721/Cargo.toml +++ b/crates/torii-erc721/Cargo.toml @@ -8,6 +8,7 @@ description = "ERC721 NFT token indexer library for Torii" # Torii core torii = { path = "../.." } torii-common = { path = "../torii-common" } +torii-sql = { path = "../sql", features = ["postgres", "sqlite"] } # Async tokio = { version = "1", features = ["full"] } @@ -15,6 +16,8 @@ async-trait = "0.1" # Starknet starknet = "0.17" +starknet-types-raw.workspace = true +primitive-types.workspace = true # Storage rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/torii-erc721/README.md b/crates/torii-erc721/README.md new file mode 100644 index 00000000..bc07a246 --- /dev/null +++ b/crates/torii-erc721/README.md @@ -0,0 +1,133 @@ +# torii-erc721 + +End-to-end **ERC721 NFT indexer**. Decodes Transfer / Approval / +ApprovalForAll / MetadataUpdate / BatchMetadataUpdate (EIP-4906) events, +tracks per-token ownership in a dedicated ownership table, maintains +token-URI metadata out of band, and exposes a gRPC service for queries and +live updates. + +## Role in Torii + +Mirror of `torii-erc20` but for non-fungible tokens. The big shape +differences: balance tracking is replaced with an explicit `ownership` +row per `(token, token_id)`, metadata has a richer lifecycle because +individual tokens can change URI, and the `Erc721Rule` uses the presence +of `owner_of` (not `balance_of`) to distinguish ERC721 from ERC20. + +## Architecture + +```text +StarknetEvent (ERC721 selector) + | + v ++--------------------------------+ +| Erc721Decoder | identification::Erc721Rule +| Transfer | Approval | | (ABI needs owner_of + Transfer evt) +| ApprovalForAll | | +| MetadataUpdate | | +| BatchMetadataUpdate | +| → Envelope(ERC721_TYPE) | ++---------------+----------------+ + | + v ++--------------------------------+ +| Erc721Sink | +| | +| process(): | +| - store transfer / approval | +| - upsert ownership | +| - MetadataUpdate → enqueue | +| Erc721TokenUriCommand | +| via command bus | +| - if batch.is_live(100): | +| EventBus publish | +| gRPC broadcast | ++---------------+----------------+ + | + v ++--------------------------------+ +| Erc721Storage | +| erc721_transfer (cursor) | +| erc721_ownership | +| erc721_approval | +| erc721_operator_approval | +| erc721_metadata | +| erc721_token_uri | ++---------------+----------------+ + | + v ++--------------------------------+ +| Erc721Service | +| gRPC: torii.sinks.erc721 | ++--------------------------------+ + ^ + | ++--------------------------------+ +| Erc721MetadataCommandHandler | <- CommandBus +| Erc721TokenUriCommandHandler | (name/symbol, +| (uses torii-common:: | token URIs, +| MetadataFetcher + | per-token meta) +| TokenUriService) | ++--------------------------------+ +``` + +## Deep Dive + +### Public API + +| Item | File | Purpose | +|---|---|---| +| `Erc721Decoder`, `Erc721Msg`, `Erc721Body`, `ERC721_TYPE` | `src/decoder.rs` | Decoder + event enum + wrapper body | +| `NftTransfer`, `NftApproval`, `OperatorApproval` | `src/decoder.rs` | Transfer + per-token Approval + ApprovalForAll | +| `MetadataUpdate`, `BatchMetadataUpdate` | `src/decoder.rs` | EIP-4906 metadata invalidation events | +| `Erc721Rule` | `src/identification.rs` | ABI-based identification (requires `owner_of`) | +| `Erc721Sink` | `src/sink.rs` | `Sink` impl with `with_grpc_service`, `with_token_uri_pipeline`, etc. | +| `Erc721Storage`, `NftOwnershipData`, `NftTransferData`, `TransferCursor` | `src/storage.rs` | Persistence + ownership | +| `Erc721Service` | `src/grpc_service.rs` | tonic service | +| `Erc721MetadataCommandHandler`, `Erc721TokenUriCommandHandler` | `src/handlers.rs` | `CommandHandler` implementations | +| `SyntheticErc721Config`, `SyntheticErc721Extractor` | `src/synthetic.rs` | Deterministic generator | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs:53` | Descriptor bytes | + +### Internal Modules + +- `decoder` — matches Transfer / Approval / ApprovalForAll / MetadataUpdate / BatchMetadataUpdate. Token IDs are `U256`, stored via `torii-common::U256Blob`. +- `identification` — `Erc721Rule`: requires `owner_of` + `balance_of` + `Transfer` event. Keeps ERC721 and ERC20 distinct under auto-identification (ERC20 has `balance_of` but no `owner_of`). +- `sink` — writes the row, updates ownership, dispatches metadata + token-URI commands, broadcasts when live. +- `storage` — `erc721_transfer`, `erc721_ownership`, `erc721_approval`, `erc721_operator_approval`, `erc721_metadata`, `erc721_token_uri`. +- `grpc_service` — paginated queries + broadcast channel. +- `handlers` — two `CommandHandler`s: contract-level metadata + per-token URI. +- `synthetic` — `SyntheticErc721Extractor` implementing `SyntheticExtractor`. +- `conversions` — proto ↔ storage conversions. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"erc721"` | +| `interested_types` | `[ERC721_TYPE]` (one `TypeId::new("erc721")`) | +| `process` | Persist transfer + upsert ownership + optional approval row; dispatch metadata/token-URI commands; broadcast live updates if `batch.is_live(100)` | +| `topics` | 2 topics: `erc721.transfer` (filters: `token`, `from`, `to`, `wallet`) and `erc721.metadata` (filter: `token`) | +| `build_routes` | `Router::new()` — gRPC only | +| `initialize` | Captures `event_bus` + `ctx.command_bus` | + +### Storage (key tables) + +- `erc721_transfer (token, token_id, from, to, block_number, tx_hash, ..)` — event history +- `erc721_ownership (token, token_id, owner)` — one row per NFT; upserted on every Transfer +- `erc721_approval (token, token_id, owner, approved)` — per-token approvals (overwritten) +- `erc721_operator_approval (token, owner, operator, approved)` — ApprovalForAll bitmap +- `erc721_metadata (token, name, symbol, …)` — collection-level metadata +- `erc721_token_uri (token, token_id, uri, fetched_json)` — per-token URI + cached off-chain JSON (populated asynchronously via command handler) + +All `Felt`/`U256` fields are BLOB encoded via `torii-common::{FeltBlob,U256Blob}`. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-tokens`, `bins/torii-introspect-bin`, `bins/torii-arcade` (via `ArcadeService`), `bins/torii-tokens-synth`. +- **Downstream deps**: `torii`, `torii-common`, `torii-types`, `sqlx`, `tonic`, `prost`, `starknet`, `starknet-types-raw`, `primitive-types`, `tokio`, `async-trait`, `chrono`, `hex`, `metrics`, `tracing`. + +### Extension Points + +- Additional per-token metadata → add columns to `erc721_token_uri` + proto + `Erc721TokenUriCommandHandler`. +- New event (e.g. Pausable, EnumerableExt) → extend `Erc721Msg` + decoder branch + sink branch. +- Ownership history → add an `erc721_ownership_history` table; the sink already has the transfer insert site to hook into. diff --git a/crates/torii-erc721/src/conversions.rs b/crates/torii-erc721/src/conversions.rs new file mode 100644 index 00000000..ad012384 --- /dev/null +++ b/crates/torii-erc721/src/conversions.rs @@ -0,0 +1,55 @@ +use primitive_types::U256 as PrimitiveU256; +use starknet::core::types::{Felt, U256}; +use starknet_types_raw::Felt as RawFelt; + +pub(crate) fn felt_to_blob(value: Felt) -> Vec { + value.to_bytes_be().to_vec() +} + +pub(crate) fn blob_to_felt(bytes: &[u8]) -> Felt { + assert!(bytes.len() <= 32, "database stores canonical felt blobs"); + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(bytes); + Felt::from_bytes_be(&padded) +} + +pub(crate) fn u256_to_blob(value: U256) -> Vec { + if value == U256::from(0u8) { + return vec![0]; + } + + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(&value.high().to_be_bytes()); + bytes[16..].copy_from_slice(&value.low().to_be_bytes()); + let start = bytes.iter().position(|&b| b != 0).unwrap_or(31); + bytes[start..].to_vec() +} + +pub(crate) fn blob_to_u256(bytes: &[u8]) -> U256 { + let mut padded = [0u8; 32]; + let offset = 32usize.saturating_sub(bytes.len()); + padded[offset..].copy_from_slice(&bytes[bytes.len().saturating_sub(32)..]); + let high = u128::from_be_bytes(padded[..16].try_into().unwrap()); + let low = u128::from_be_bytes(padded[16..].try_into().unwrap()); + U256::from_words(low, high) +} + +pub(crate) fn u256_to_bytes(value: U256) -> Vec { + u256_to_blob(value) +} + +pub(crate) fn bytes_to_u256(bytes: &[u8]) -> U256 { + blob_to_u256(bytes) +} + +pub(crate) fn core_to_raw_felt(value: Felt) -> RawFelt { + RawFelt::from_be_bytes(value.to_bytes_be()).unwrap() +} + +pub(crate) fn core_to_primitive_u256(value: U256) -> PrimitiveU256 { + PrimitiveU256::from_big_endian(&u256_to_bytes(value)) +} + +pub(crate) fn primitive_to_core_u256(value: PrimitiveU256) -> U256 { + blob_to_u256(&value.to_big_endian()) +} diff --git a/crates/torii-erc721/src/decoder.rs b/crates/torii-erc721/src/decoder.rs index 15ed89a9..6a99faf4 100644 --- a/crates/torii-erc721/src/decoder.rs +++ b/crates/torii-erc721/src/decoder.rs @@ -2,11 +2,14 @@ use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt, U256}; +use starknet::core::types::{Felt, U256}; use starknet::macros::selector; +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt as RawFelt; use std::any::Any; -use std::collections::HashMap; -use torii::etl::{Decoder, Envelope, TypedBody}; +use torii::etl::{Decoder, Envelope, EventContext, TypeId, TypedBody}; + +pub const ERC721_TYPE: TypeId = TypeId::new("erc721"); /// Transfer event from ERC721 token #[derive(Debug, Clone)] @@ -20,20 +23,6 @@ pub struct NftTransfer { pub transaction_hash: Felt, } -impl TypedBody for NftTransfer { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc721.transfer") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - /// Approval event from ERC721 token (single token approval) #[derive(Debug, Clone)] pub struct NftApproval { @@ -46,20 +35,6 @@ pub struct NftApproval { pub transaction_hash: Felt, } -impl TypedBody for NftApproval { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc721.approval") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - /// ApprovalForAll event from ERC721 token (operator approval) #[derive(Debug, Clone)] pub struct OperatorApproval { @@ -71,20 +46,6 @@ pub struct OperatorApproval { pub transaction_hash: Felt, } -impl TypedBody for OperatorApproval { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc721.approval_for_all") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - /// MetadataUpdate event (EIP-4906) — single token #[derive(Debug, Clone)] pub struct MetadataUpdate { @@ -94,20 +55,6 @@ pub struct MetadataUpdate { pub transaction_hash: Felt, } -impl TypedBody for MetadataUpdate { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc721.metadata_update") - } - - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } -} - /// BatchMetadataUpdate event (EIP-4906) — range of tokens #[derive(Debug, Clone)] pub struct BatchMetadataUpdate { @@ -118,9 +65,31 @@ pub struct BatchMetadataUpdate { pub transaction_hash: Felt, } -impl TypedBody for BatchMetadataUpdate { - fn envelope_type_id(&self) -> torii::etl::envelope::TypeId { - torii::etl::envelope::TypeId::new("erc721.batch_metadata_update") +#[derive(Debug, Clone)] +pub enum Erc721Msg { + Transfer(NftTransfer), + Approval(NftApproval), + ApprovalForAll(OperatorApproval), + MetadataUpdate(MetadataUpdate), + BatchMetadataUpdate(BatchMetadataUpdate), +} + +#[derive(Debug, Clone, Copy)] +pub struct Erc721Metadata { + pub block_number: u64, + pub transaction_hash: Felt, + pub from_address: Felt, +} + +#[derive(Debug, Clone)] +pub struct Erc721Body { + pub metadata: Erc721Metadata, + pub msg: Erc721Msg, +} + +impl TypedBody for Erc721Body { + fn envelope_type_id(&self) -> TypeId { + ERC721_TYPE } fn as_any(&self) -> &dyn Any { @@ -132,6 +101,56 @@ impl TypedBody for BatchMetadataUpdate { } } +impl Erc721Msg { + fn event_id(&self) -> String { + match self { + Self::Transfer(msg) => { + format!( + "erc721.transfer.{}.{}.{:#x}.", + msg.block_number, msg.transaction_hash, msg.token + ) + &msg.token_id.to_string() + } + Self::Approval(msg) => { + format!( + "erc721.approval.{}.{}.{:#x}.", + msg.block_number, msg.transaction_hash, msg.token + ) + &msg.token_id.to_string() + } + Self::ApprovalForAll(msg) => format!( + "erc721.approval_for_all.{}.{:#x}.{:#x}.{:#x}", + msg.block_number, msg.transaction_hash, msg.token, msg.owner + ), + Self::MetadataUpdate(msg) => { + format!( + "erc721.metadata_update.{}.{}.{:#x}.", + msg.block_number, msg.transaction_hash, msg.token + ) + &msg.token_id.to_string() + } + Self::BatchMetadataUpdate(msg) => { + format!( + "erc721.batch_metadata_update.{}.{}.{:#x}.{}.", + msg.block_number, msg.transaction_hash, msg.token, msg.from_token_id + ) + &msg.to_token_id.to_string() + } + } + } + + fn into_envelope(self, event: &EmittedEvent) -> Envelope { + Envelope::new( + self.event_id(), + Box::new(Erc721Body { + metadata: Erc721Metadata { + block_number: event.block_number.unwrap_or(0), + transaction_hash: Erc721Decoder::felt(event.transaction_hash), + from_address: Erc721Decoder::felt(event.from_address), + }, + msg: self, + }), + Default::default(), + ) + } +} + /// ERC721 event decoder /// /// Decodes multiple ERC721 events: @@ -174,417 +193,234 @@ impl Erc721Decoder { selector!("BatchMetadataUpdate") } - /// Decode Transfer event into envelope - /// - /// Transfer event signatures (supports both modern and legacy): - /// - /// Modern ERC721: - /// - keys[0]: Transfer selector - /// - keys[1]: from address - /// - keys[2]: to address - /// - keys[3]: token_id_low (u128) - /// - keys[4]: token_id_high (u128) - /// - /// Legacy ERC721 (all in data): - /// - keys[0]: Transfer selector - /// - data[0]: from address - /// - data[1]: to address - /// - data[2]: token_id_low (u128) - /// - data[3]: token_id_high (u128) - async fn decode_transfer(&self, event: &EmittedEvent) -> Result> { - let from; - let to; - let token_id: U256; + fn felt(value: RawFelt) -> Felt { + Felt::from_bytes_be(&value.to_be_bytes()) + } + + fn felt_to_u128(value: RawFelt) -> u128 { + value.try_into().unwrap_or(0) + } + fn parse_split_or_felt_u256(first: RawFelt, second: Option) -> U256 { + match second { + Some(high) => U256::from_words(Self::felt_to_u128(first), Self::felt_to_u128(high)), + None => U256::from(Self::felt_to_u128(first)), + } + } + + fn parse_address_pair_and_token_id(event: &EmittedEvent) -> Option<(Felt, Felt, U256)> { if event.keys.len() == 5 && event.data.is_empty() { - // Modern ERC721: from, to, token_id_low, token_id_high all in keys - from = event.keys[1]; - to = event.keys[2]; - let low: u128 = event.keys[3].try_into().unwrap_or(0); - let high: u128 = event.keys[4].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.keys.len() == 1 && event.data.len() == 4 { - // Legacy ERC721: from, to, token_id_low, token_id_high in data - from = event.data[0]; - to = event.data[1]; - let low: u128 = event.data[2].try_into().unwrap_or(0); - let high: u128 = event.data[3].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.keys.len() == 4 && event.data.is_empty() { - // Alternative modern format with single felt token_id - from = event.keys[1]; - to = event.keys[2]; - let id_felt: u128 = event.keys[3].try_into().unwrap_or(0); - token_id = U256::from(id_felt); - } else if event.keys.len() == 1 && event.data.len() == 3 { - // Alternative legacy format with single felt token_id - from = event.data[0]; - to = event.data[1]; - let id_felt: u128 = event.data[2].try_into().unwrap_or(0); - token_id = U256::from(id_felt); - } else { - tracing::warn!( - target: "torii_erc721::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC721 Transfer event" - ); - return Ok(None); + return Some(( + Self::felt(event.keys[1]), + Self::felt(event.keys[2]), + Self::parse_split_or_felt_u256(event.keys[3], Some(event.keys[4])), + )); } - let transfer = NftTransfer { - from, - to, - token_id, - token: event.from_address, - block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + if event.keys.len() == 1 && event.data.len() == 4 { + return Some(( + Self::felt(event.data[0]), + Self::felt(event.data[1]), + Self::parse_split_or_felt_u256(event.data[2], Some(event.data[3])), + )); + } - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); + if event.keys.len() == 4 && event.data.is_empty() { + return Some(( + Self::felt(event.keys[1]), + Self::felt(event.keys[2]), + Self::parse_split_or_felt_u256(event.keys[3], None), + )); + } - let envelope_id = format!( - "erc721_transfer_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); + if event.keys.len() == 1 && event.data.len() == 3 { + return Some(( + Self::felt(event.data[0]), + Self::felt(event.data[1]), + Self::parse_split_or_felt_u256(event.data[2], None), + )); + } - Ok(Some(Envelope::new( - envelope_id, - Box::new(transfer), - metadata, - ))) - } - - /// Decode Approval event into envelope - /// - /// Approval event signatures (supports both modern and legacy): - /// - /// Modern ERC721: - /// - keys[0]: Approval selector - /// - keys[1]: owner address - /// - keys[2]: approved address - /// - keys[3]: token_id_low (u128) - /// - keys[4]: token_id_high (u128) - /// - /// Legacy ERC721: - /// - keys[0]: Approval selector - /// - data[0]: owner address - /// - data[1]: approved address - /// - data[2]: token_id_low (u128) - /// - data[3]: token_id_high (u128) - async fn decode_approval(&self, event: &EmittedEvent) -> Result> { - let owner; - let approved; - let token_id: U256; + None + } - if event.keys.len() == 5 && event.data.is_empty() { - // Modern ERC721: owner, approved, token_id_low, token_id_high all in keys - owner = event.keys[1]; - approved = event.keys[2]; - let low: u128 = event.keys[3].try_into().unwrap_or(0); - let high: u128 = event.keys[4].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.keys.len() == 1 && event.data.len() == 4 { - // Legacy ERC721: owner, approved, token_id_low, token_id_high in data - owner = event.data[0]; - approved = event.data[1]; - let low: u128 = event.data[2].try_into().unwrap_or(0); - let high: u128 = event.data[3].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.keys.len() == 4 && event.data.is_empty() { - // Alternative modern format with single felt token_id - owner = event.keys[1]; - approved = event.keys[2]; - let id_felt: u128 = event.keys[3].try_into().unwrap_or(0); - token_id = U256::from(id_felt); - } else if event.keys.len() == 1 && event.data.len() == 3 { - // Alternative legacy format with single felt token_id - owner = event.data[0]; - approved = event.data[1]; - let id_felt: u128 = event.data[2].try_into().unwrap_or(0); - token_id = U256::from(id_felt); - } else { - tracing::warn!( - target: "torii_erc721::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC721 Approval event" - ); - return Ok(None); + fn parse_operator_approval(event: &EmittedEvent) -> Option<(Felt, Felt, bool)> { + if event.keys.len() == 3 && event.data.len() == 1 { + return Some(( + Self::felt(event.keys[1]), + Self::felt(event.keys[2]), + event.data[0] != RawFelt::ZERO, + )); } - let approval = NftApproval { - owner, - approved, - token_id, - token: event.from_address, - block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + if event.keys.len() == 1 && event.data.len() == 3 { + return Some(( + Self::felt(event.data[0]), + Self::felt(event.data[1]), + event.data[2] != RawFelt::ZERO, + )); + } - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); + None + } - let envelope_id = format!( - "erc721_approval_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); + fn parse_single_token_id(event: &EmittedEvent) -> Option { + if event.data.len() >= 2 { + return Some(Self::parse_split_or_felt_u256( + event.data[0], + Some(event.data[1]), + )); + } - Ok(Some(Envelope::new( - envelope_id, - Box::new(approval), - metadata, - ))) - } - - /// Decode ApprovalForAll event into envelope - /// - /// ApprovalForAll event signatures (supports both modern and legacy): - /// - /// Modern ERC721: - /// - keys[0]: ApprovalForAll selector - /// - keys[1]: owner address - /// - keys[2]: operator address - /// - data[0]: approved (bool as felt) - /// - /// Legacy ERC721: - /// - keys[0]: ApprovalForAll selector - /// - data[0]: owner address - /// - data[1]: operator address - /// - data[2]: approved (bool as felt) - async fn decode_approval_for_all(&self, event: &EmittedEvent) -> Result> { - let owner; - let operator; - let approved: bool; + if event.keys.len() >= 3 { + return Some(Self::parse_split_or_felt_u256( + event.keys[1], + Some(event.keys[2]), + )); + } - if event.keys.len() == 3 && event.data.len() == 1 { - // Modern ERC721: owner, operator in keys; approved in data - owner = event.keys[1]; - operator = event.keys[2]; - approved = event.data[0] != Felt::ZERO; - } else if event.keys.len() == 1 && event.data.len() == 3 { - // Legacy ERC721: owner, operator, approved all in data - owner = event.data[0]; - operator = event.data[1]; - approved = event.data[2] != Felt::ZERO; - } else { - tracing::warn!( - target: "torii_erc721::decoder", - token = %format!("{:#x}", event.from_address), - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number.unwrap_or(0), - keys_len = event.keys.len(), - data_len = event.data.len(), - "Malformed ERC721 ApprovalForAll event" - ); - return Ok(None); + if event.data.len() == 1 { + return Some(Self::parse_split_or_felt_u256(event.data[0], None)); } - let approval = OperatorApproval { - owner, - operator, - approved, - token: event.from_address, - block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + if event.keys.len() == 2 { + return Some(Self::parse_split_or_felt_u256(event.keys[1], None)); + } - let mut metadata = HashMap::new(); - metadata.insert("token".to_string(), format!("{:#x}", event.from_address)); - metadata.insert( - "block_number".to_string(), - event.block_number.unwrap_or(0).to_string(), - ); - metadata.insert( - "tx_hash".to_string(), - format!("{:#x}", event.transaction_hash), - ); + None + } - let envelope_id = format!( - "erc721_approval_for_all_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) - ); + fn parse_token_range(event: &EmittedEvent) -> Option<(U256, U256)> { + if event.data.len() >= 4 { + return Some(( + Self::parse_split_or_felt_u256(event.data[0], Some(event.data[1])), + Self::parse_split_or_felt_u256(event.data[2], Some(event.data[3])), + )); + } - Ok(Some(Envelope::new( - envelope_id, - Box::new(approval), - metadata, - ))) - } - - /// Decode MetadataUpdate event (EIP-4906) - /// - /// MetadataUpdate(uint256 tokenId): - /// - keys[0]: selector - /// - data[0]: token_id_low - /// - data[1]: token_id_high - /// OR: - /// - keys[0]: selector - /// - keys[1]: token_id_low - /// - keys[2]: token_id_high - async fn decode_metadata_update(&self, event: &EmittedEvent) -> Result> { - let token_id: U256; + if event.keys.len() >= 5 { + return Some(( + Self::parse_split_or_felt_u256(event.keys[1], Some(event.keys[2])), + Self::parse_split_or_felt_u256(event.keys[3], Some(event.keys[4])), + )); + } - if event.data.len() >= 2 { - let low: u128 = event.data[0].try_into().unwrap_or(0); - let high: u128 = event.data[1].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.keys.len() >= 3 { - let low: u128 = event.keys[1].try_into().unwrap_or(0); - let high: u128 = event.keys[2].try_into().unwrap_or(0); - token_id = U256::from_words(low, high); - } else if event.data.len() == 1 { - let id: u128 = event.data[0].try_into().unwrap_or(0); - token_id = U256::from(id); - } else if event.keys.len() == 2 { - let id: u128 = event.keys[1].try_into().unwrap_or(0); - token_id = U256::from(id); - } else { - tracing::warn!( - target: "torii_erc721::decoder", - token = %format!("{:#x}", event.from_address), - "Malformed MetadataUpdate event" - ); + None + } + + fn log_malformed_event(&self, name: &str, event: &EmittedEvent) { + tracing::warn!( + target: "torii_erc721::decoder", + token = %format!("{:#x}", event.from_address), + tx_hash = %format!("{:#x}", event.transaction_hash), + block_number = event.block_number.unwrap_or(0), + keys_len = event.keys.len(), + data_len = event.data.len(), + "Malformed ERC721 {name} event" + ); + } + + /// Decode Transfer event into a message. + async fn decode_transfer(&self, event: &EmittedEvent) -> Result> { + let Some((from, to, token_id)) = Self::parse_address_pair_and_token_id(event) else { + self.log_malformed_event("Transfer", event); return Ok(None); - } + }; - let update = MetadataUpdate { - token: event.from_address, + Ok(Some(Erc721Msg::Transfer(NftTransfer { + from, + to, token_id, + token: Self::felt(event.from_address), block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + transaction_hash: Self::felt(event.transaction_hash), + }))) + } - let envelope_id = format!( - "erc721_metadata_update_{}_{:#x}", - event.block_number.unwrap_or(0), - event.transaction_hash - ); + /// Decode Approval event into a message. + async fn decode_approval(&self, event: &EmittedEvent) -> Result> { + let Some((owner, approved, token_id)) = Self::parse_address_pair_and_token_id(event) else { + self.log_malformed_event("Approval", event); + return Ok(None); + }; - Ok(Some(Envelope::new( - envelope_id, - Box::new(update), - HashMap::new(), - ))) + Ok(Some(Erc721Msg::Approval(NftApproval { + owner, + approved, + token_id, + token: Self::felt(event.from_address), + block_number: event.block_number.unwrap_or(0), + transaction_hash: Self::felt(event.transaction_hash), + }))) } - /// Decode BatchMetadataUpdate event (EIP-4906) - /// - /// BatchMetadataUpdate(uint256 fromTokenId, uint256 toTokenId) - async fn decode_batch_metadata_update(&self, event: &EmittedEvent) -> Result> { - let from_token_id: U256; - let to_token_id: U256; - - if event.data.len() >= 4 { - let low: u128 = event.data[0].try_into().unwrap_or(0); - let high: u128 = event.data[1].try_into().unwrap_or(0); - from_token_id = U256::from_words(low, high); - let low: u128 = event.data[2].try_into().unwrap_or(0); - let high: u128 = event.data[3].try_into().unwrap_or(0); - to_token_id = U256::from_words(low, high); - } else if event.keys.len() >= 5 { - let low: u128 = event.keys[1].try_into().unwrap_or(0); - let high: u128 = event.keys[2].try_into().unwrap_or(0); - from_token_id = U256::from_words(low, high); - let low: u128 = event.keys[3].try_into().unwrap_or(0); - let high: u128 = event.keys[4].try_into().unwrap_or(0); - to_token_id = U256::from_words(low, high); - } else { - tracing::warn!( - target: "torii_erc721::decoder", - token = %format!("{:#x}", event.from_address), - "Malformed BatchMetadataUpdate event" - ); + /// Decode ApprovalForAll event into a message. + async fn decode_approval_for_all(&self, event: &EmittedEvent) -> Result> { + let Some((owner, operator, approved)) = Self::parse_operator_approval(event) else { + self.log_malformed_event("ApprovalForAll", event); return Ok(None); - } + }; - let update = BatchMetadataUpdate { - token: event.from_address, - from_token_id, - to_token_id, + Ok(Some(Erc721Msg::ApprovalForAll(OperatorApproval { + owner, + operator, + approved, + token: Self::felt(event.from_address), block_number: event.block_number.unwrap_or(0), - transaction_hash: event.transaction_hash, - }; + transaction_hash: Self::felt(event.transaction_hash), + }))) + } - let envelope_id = format!( - "erc721_batch_metadata_update_{}_{:#x}", - event.block_number.unwrap_or(0), - event.transaction_hash - ); + /// Decode MetadataUpdate event (EIP-4906). + async fn decode_metadata_update(&self, event: &EmittedEvent) -> Result> { + let Some(token_id) = Self::parse_single_token_id(event) else { + self.log_malformed_event("MetadataUpdate", event); + return Ok(None); + }; - Ok(Some(Envelope::new( - envelope_id, - Box::new(update), - HashMap::new(), - ))) + Ok(Some(Erc721Msg::MetadataUpdate(MetadataUpdate { + token: Self::felt(event.from_address), + token_id, + block_number: event.block_number.unwrap_or(0), + transaction_hash: Self::felt(event.transaction_hash), + }))) } -} -impl Default for Erc721Decoder { - fn default() -> Self { - Self::new() - } -} + /// Decode BatchMetadataUpdate event (EIP-4906). + async fn decode_batch_metadata_update( + &self, + event: &EmittedEvent, + ) -> Result> { + let Some((from_token_id, to_token_id)) = Self::parse_token_range(event) else { + self.log_malformed_event("BatchMetadataUpdate", event); + return Ok(None); + }; -#[async_trait] -impl Decoder for Erc721Decoder { - fn decoder_name(&self) -> &'static str { - "erc721" + Ok(Some(Erc721Msg::BatchMetadataUpdate(BatchMetadataUpdate { + token: Self::felt(event.from_address), + from_token_id, + to_token_id, + block_number: event.block_number.unwrap_or(0), + transaction_hash: Self::felt(event.transaction_hash), + }))) } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { + async fn decode_raw_event(&self, event: &EmittedEvent) -> Result> { if event.keys.is_empty() { return Ok(Vec::new()); } - let selector = event.keys[0]; - - if selector == Self::transfer_selector() { - if let Some(envelope) = self.decode_transfer(event).await? { - return Ok(vec![envelope]); - } + let selector = Self::felt(event.keys[0]); + let msg = if selector == Self::transfer_selector() { + self.decode_transfer(event).await? } else if selector == Self::approval_selector() { - if let Some(envelope) = self.decode_approval(event).await? { - return Ok(vec![envelope]); - } + self.decode_approval(event).await? } else if selector == Self::approval_for_all_selector() { - if let Some(envelope) = self.decode_approval_for_all(event).await? { - return Ok(vec![envelope]); - } + self.decode_approval_for_all(event).await? } else if selector == Self::metadata_update_selector() { - if let Some(envelope) = self.decode_metadata_update(event).await? { - return Ok(vec![envelope]); - } + self.decode_metadata_update(event).await? } else if selector == Self::batch_metadata_update_selector() { - if let Some(envelope) = self.decode_batch_metadata_update(event).await? { - return Ok(vec![envelope]); - } + self.decode_batch_metadata_update(event).await? } else { tracing::trace!( target: "torii_erc721::decoder", @@ -596,113 +432,157 @@ impl Decoder for Erc721Decoder { tx_hash = %format!("{:#x}", event.transaction_hash), "Unhandled event selector" ); - } + None + }; + + Ok(msg + .into_iter() + .map(|msg| msg.into_envelope(event)) + .collect()) + } +} + +impl Default for Erc721Decoder { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Decoder for Erc721Decoder { + fn decoder_name(&self) -> &'static str { + "erc721" + } + + async fn decode( + &self, + keys: &[RawFelt], + data: &[RawFelt], + context: EventContext, + ) -> Result> { + let event = EmittedEvent { + from_address: context.from_address, + keys: keys.to_vec(), + data: data.to_vec(), + block_hash: None, + block_number: Some(context.block_number), + transaction_hash: context.transaction_hash, + }; - Ok(Vec::new()) + self.decode_raw_event(&event).await } } #[cfg(test)] mod tests { use super::*; + use torii::etl::StarknetEvent; - #[tokio::test] - async fn test_decode_modern_transfer() { + fn raw(value: u64) -> RawFelt { + RawFelt::from(value) + } + + fn raw_selector(selector: Felt) -> RawFelt { + RawFelt::from_be_bytes(selector.to_bytes_be()).unwrap() + } + + fn starknet_event(event: EmittedEvent) -> StarknetEvent { + StarknetEvent::new( + event.from_address, + event.keys, + event.data, + event.block_number.unwrap_or(0), + event.transaction_hash, + ) + } + + fn decode_body(event: EmittedEvent) -> Erc721Body { let decoder = Erc721Decoder::new(); + let envelopes = + futures::executor::block_on(decoder.decode_event(&starknet_event(event))).unwrap(); + assert_eq!(envelopes.len(), 1); + envelopes[0] + .body + .as_any() + .downcast_ref::() + .unwrap() + .clone() + } - // Modern format: all in keys + #[tokio::test] + async fn test_decode_modern_transfer() { let event = EmittedEvent { - from_address: Felt::from(0x123u64), + from_address: raw(0x123), keys: vec![ - Erc721Decoder::transfer_selector(), - Felt::from(0x1u64), // from - Felt::from(0x2u64), // to - Felt::from(42u64), // token_id_low - Felt::ZERO, // token_id_high + raw_selector(Erc721Decoder::transfer_selector()), + raw(0x1), + raw(0x2), + raw(42), + RawFelt::ZERO, ], data: vec![], block_hash: None, block_number: Some(100), - transaction_hash: Felt::from(0xabcdu64), + transaction_hash: raw(0xabcd), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); - assert_eq!(envelopes.len(), 1); - - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(transfer.from, Felt::from(0x1u64)); - assert_eq!(transfer.to, Felt::from(0x2u64)); - assert_eq!(transfer.token_id, U256::from(42u64)); - assert_eq!(transfer.token, Felt::from(0x123u64)); + let body = decode_body(event); + match body.msg { + Erc721Msg::Transfer(transfer) => { + assert_eq!(transfer.from, Felt::from(0x1u64)); + assert_eq!(transfer.to, Felt::from(0x2u64)); + assert_eq!(transfer.token_id, U256::from(42u64)); + assert_eq!(transfer.token, Felt::from(0x123u64)); + } + msg => panic!("unexpected message: {msg:?}"), + } } #[tokio::test] async fn test_decode_legacy_transfer() { - let decoder = Erc721Decoder::new(); - - // Legacy format: all in data let event = EmittedEvent { - from_address: Felt::from(0x456u64), - keys: vec![Erc721Decoder::transfer_selector()], - data: vec![ - Felt::from(0xau64), // from - Felt::from(0xbu64), // to - Felt::from(100u64), // token_id_low - Felt::ZERO, // token_id_high - ], + from_address: raw(0x456), + keys: vec![raw_selector(Erc721Decoder::transfer_selector())], + data: vec![raw(0xa), raw(0xb), raw(100), RawFelt::ZERO], block_hash: None, block_number: Some(200), - transaction_hash: Felt::from(0xef01u64), + transaction_hash: raw(0xef01), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); - assert_eq!(envelopes.len(), 1); - - let transfer = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(transfer.from, Felt::from(0xau64)); - assert_eq!(transfer.to, Felt::from(0xbu64)); - assert_eq!(transfer.token_id, U256::from(100u64)); + let body = decode_body(event); + match body.msg { + Erc721Msg::Transfer(transfer) => { + assert_eq!(transfer.from, Felt::from(0xau64)); + assert_eq!(transfer.to, Felt::from(0xbu64)); + assert_eq!(transfer.token_id, U256::from(100u64)); + } + msg => panic!("unexpected message: {msg:?}"), + } } #[tokio::test] async fn test_decode_approval_for_all() { - let decoder = Erc721Decoder::new(); - - // Modern format let event = EmittedEvent { - from_address: Felt::from(0x789u64), + from_address: raw(0x789), keys: vec![ - Erc721Decoder::approval_for_all_selector(), - Felt::from(0xcu64), // owner - Felt::from(0xdu64), // operator + raw_selector(Erc721Decoder::approval_for_all_selector()), + raw(0xc), + raw(0xd), ], - data: vec![Felt::from(1u64)], // approved = true + data: vec![raw(1)], block_hash: None, block_number: Some(300), - transaction_hash: Felt::from(0x2345u64), + transaction_hash: raw(0x2345), }; - let envelopes = decoder.decode_event(&event).await.unwrap(); - assert_eq!(envelopes.len(), 1); - - let approval = envelopes[0] - .body - .as_any() - .downcast_ref::() - .unwrap(); - - assert_eq!(approval.owner, Felt::from(0xcu64)); - assert_eq!(approval.operator, Felt::from(0xdu64)); - assert!(approval.approved); + let body = decode_body(event); + match body.msg { + Erc721Msg::ApprovalForAll(approval) => { + assert_eq!(approval.owner, Felt::from(0xcu64)); + assert_eq!(approval.operator, Felt::from(0xdu64)); + assert!(approval.approved); + } + msg => panic!("unexpected message: {msg:?}"), + } } } diff --git a/crates/torii-erc721/src/grpc_service.rs b/crates/torii-erc721/src/grpc_service.rs index ae25479a..52daf44b 100644 --- a/crates/torii-erc721/src/grpc_service.rs +++ b/crates/torii-erc721/src/grpc_service.rs @@ -1,5 +1,6 @@ //! gRPC service implementation for ERC721 queries and subscriptions +use crate::conversions::{bytes_to_u256, u256_to_bytes}; use crate::proto::{ erc721_server::Erc721 as Erc721Trait, AttributeFacetCount, CollectionToken, ContractCollectionOverview, Cursor, GetCollectionOverviewRequest, @@ -20,7 +21,16 @@ use std::pin::Pin; use std::sync::Arc; use tokio::sync::broadcast; use tonic::{Request, Response, Status}; -use torii_common::{bytes_to_felt, bytes_to_u256, u256_to_bytes}; + +fn bytes_to_felt(bytes: &[u8]) -> Option { + if bytes.len() > 32 { + return None; + } + + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(bytes); + Some(Felt::from_bytes_be(&padded)) +} const DEFAULT_PROJECT_ID: &str = "arcade-main"; diff --git a/crates/torii-erc721/src/handlers.rs b/crates/torii-erc721/src/handlers.rs index bfe65d47..066847e0 100644 --- a/crates/torii-erc721/src/handlers.rs +++ b/crates/torii-erc721/src/handlers.rs @@ -1,3 +1,6 @@ +use crate::conversions::{ + core_to_primitive_u256, core_to_raw_felt, primitive_to_core_u256, u256_to_bytes, +}; use anyhow::Result; use async_trait::async_trait; use prost::Message; @@ -10,7 +13,7 @@ use std::sync::{Arc, Mutex}; use torii::command::CommandHandler; use torii::etl::sink::EventBus; use torii::UpdateType; -use torii_common::{process_token_uri_request, u256_to_bytes, MetadataFetcher, TokenUriRequest}; +use torii_common::{process_token_uri_request, MetadataFetcher, TokenUriRequest}; use crate::proto; use crate::storage::Erc721Storage; @@ -114,7 +117,10 @@ impl CommandHandler for Erc721MetadataCommandHandler { let mut attempt = 1; let result = loop { - let meta = self.fetcher.fetch_erc721_metadata(command.token).await; + let meta = self + .fetcher + .fetch_erc721_metadata(core_to_raw_felt(command.token)) + .await; if !Self::metadata_complete(&meta) && attempt < self.max_retries { let next_attempt = attempt + 1; let delay = Self::metadata_retry_delay(next_attempt); @@ -140,7 +146,7 @@ impl CommandHandler for Erc721MetadataCommandHandler { command.token, meta.name.as_deref(), meta.symbol.as_deref(), - meta.total_supply, + meta.total_supply.map(primitive_to_core_u256), ) .await?; @@ -150,7 +156,10 @@ impl CommandHandler for Erc721MetadataCommandHandler { token: command.token.to_bytes_be().to_vec(), name: meta.name, symbol: meta.symbol, - total_supply: meta.total_supply.map(u256_to_bytes), + total_supply: meta + .total_supply + .map(primitive_to_core_u256) + .map(u256_to_bytes), }; let mut buf = Vec::new(); @@ -255,8 +264,8 @@ impl CommandHandler for Erc721TokenUriCommandHandler { self.fetcher.as_ref(), self.storage.as_ref(), &TokenUriRequest { - contract: command.contract, - token_id: command.token_id, + contract: core_to_raw_felt(command.contract), + token_id: core_to_primitive_u256(command.token_id), standard: torii_common::TokenStandard::Erc721, }, self.image_cache_dir.as_deref(), diff --git a/crates/torii-erc721/src/identification.rs b/crates/torii-erc721/src/identification.rs index 13a7b22a..f158c433 100644 --- a/crates/torii-erc721/src/identification.rs +++ b/crates/torii-erc721/src/identification.rs @@ -6,7 +6,7 @@ //! - `Transfer` event (with token_id) use anyhow::Result; -use starknet::core::types::Felt; +use starknet_types_raw::Felt; use torii::etl::decoder::DecoderId; use torii::etl::extractor::ContractAbi; use torii::etl::identification::IdentificationRule; diff --git a/crates/torii-erc721/src/lib.rs b/crates/torii-erc721/src/lib.rs index ec73d2d1..5acc1841 100644 --- a/crates/torii-erc721/src/lib.rs +++ b/crates/torii-erc721/src/lib.rs @@ -7,8 +7,8 @@ //! # Components //! //! - [`Erc721Decoder`]: Decodes ERC721 Transfer, Approval, and ApprovalForAll events -//! - [`Erc721Sink`]: Processes decoded events, stores in SQLite, and publishes updates -//! - [`Erc721Storage`]: SQLite storage with ownership tracking and efficient pagination +//! - [`Erc721Sink`]: Processes decoded events, stores them, and publishes updates +//! - [`Erc721Storage`]: DbPool-backed storage with ownership tracking and efficient pagination //! - [`Erc721Service`]: gRPC service for queries and real-time subscriptions //! //! # Example @@ -35,6 +35,7 @@ //! .add_service(Erc721Server::new(grpc_service)); //! ``` +mod conversions; pub mod decoder; pub mod grpc_service; pub mod handlers; @@ -53,7 +54,8 @@ pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/erc721_descript // Re-export main types for convenience pub use decoder::{ - BatchMetadataUpdate, Erc721Decoder, MetadataUpdate, NftApproval, NftTransfer, OperatorApproval, + BatchMetadataUpdate, Erc721Body, Erc721Decoder, Erc721Msg, MetadataUpdate, NftApproval, + NftTransfer, OperatorApproval, ERC721_TYPE, }; pub use grpc_service::Erc721Service; pub use handlers::{Erc721MetadataCommandHandler, Erc721TokenUriCommandHandler}; diff --git a/crates/torii-erc721/src/sink.rs b/crates/torii-erc721/src/sink.rs index cd8c8438..de12be89 100644 --- a/crates/torii-erc721/src/sink.rs +++ b/crates/torii-erc721/src/sink.rs @@ -1,9 +1,7 @@ //! ERC721 sink for processing NFT transfers, approvals, and ownership -use crate::decoder::{ - BatchMetadataUpdate as DecodedBatchMetadataUpdate, MetadataUpdate as DecodedMetadataUpdate, - NftTransfer as DecodedNftTransfer, OperatorApproval as DecodedOperatorApproval, -}; +use crate::conversions::{core_to_primitive_u256, core_to_raw_felt, u256_to_bytes}; +use crate::decoder::{Erc721Body, Erc721Msg, ERC721_TYPE}; use crate::grpc_service::Erc721Service; use crate::handlers::{FetchErc721MetadataCommand, RefreshErc721TokenUriCommand}; use crate::proto; @@ -22,7 +20,7 @@ use torii::command::CommandBusSender; use torii::etl::sink::{EventBus, TopicInfo}; use torii::etl::{Envelope, ExtractionBatch, Sink, TypeId}; use torii::grpc::UpdateType; -use torii_common::{u256_to_bytes, TokenStandard, TokenUriRequest, TokenUriSender}; +use torii_common::{TokenStandard, TokenUriRequest, TokenUriSender}; /// Default threshold for "live" detection: 100 blocks from chain head. /// Events from blocks older than this won't be broadcast to real-time subscribers. @@ -135,8 +133,8 @@ impl Erc721Sink { fn enqueue_token_uri_request(&self, contract: Felt, token_id: U256) -> bool { if let Some(sender) = &self.token_uri_sender { return sender.request_update(TokenUriRequest { - contract, - token_id, + contract: core_to_raw_felt(contract), + token_id: core_to_primitive_u256(token_id), standard: TokenStandard::Erc721, }); } @@ -174,7 +172,16 @@ impl Erc721Sink { .collect::>(); if let Some(sender) = &self.token_uri_sender { - let accepted = sender.request_batch(contract, &token_ids, TokenStandard::Erc721); + let primitive_token_ids = token_ids + .iter() + .copied() + .map(core_to_primitive_u256) + .collect::>(); + let accepted = sender.request_batch( + core_to_raw_felt(contract), + &primitive_token_ids, + TokenStandard::Erc721, + ); if accepted != token_ids.len() { tracing::warn!( target: "torii_erc721::sink", @@ -200,13 +207,7 @@ impl Sink for Erc721Sink { } fn interested_types(&self) -> Vec { - vec![ - TypeId::new("erc721.transfer"), - TypeId::new("erc721.approval"), - TypeId::new("erc721.approval_for_all"), - TypeId::new("erc721.metadata_update"), - TypeId::new("erc721.batch_metadata_update"), - ] + vec![ERC721_TYPE] } async fn initialize( @@ -234,10 +235,16 @@ impl Sink for Erc721Sink { .collect(); for envelope in envelopes { - // Handle transfers - if envelope.type_id == TypeId::new("erc721.transfer") { - if let Some(transfer) = envelope.body.as_any().downcast_ref::() - { + if envelope.type_id != ERC721_TYPE { + continue; + } + + let Some(body) = envelope.body.as_any().downcast_ref::() else { + continue; + }; + + match &body.msg { + Erc721Msg::Transfer(transfer) => { let timestamp = block_timestamps.get(&transfer.block_number).copied(); transfers.push(NftTransferData { id: None, @@ -250,14 +257,7 @@ impl Sink for Erc721Sink { timestamp, }); } - } - // Handle approval for all - else if envelope.type_id == TypeId::new("erc721.approval_for_all") { - if let Some(approval) = envelope - .body - .as_any() - .downcast_ref::() - { + Erc721Msg::ApprovalForAll(approval) => { let timestamp = block_timestamps.get(&approval.block_number).copied(); operator_approvals.push(OperatorApprovalData { id: None, @@ -270,26 +270,12 @@ impl Sink for Erc721Sink { timestamp, }); } - } - // Handle MetadataUpdate (EIP-4906) — single token - else if envelope.type_id == TypeId::new("erc721.metadata_update") { - if let Some(update) = envelope - .body - .as_any() - .downcast_ref::() - { + Erc721Msg::MetadataUpdate(update) => { if self.token_uri_commands_enabled { self.enqueue_token_uri_request(update.token, update.token_id); } } - } - // Handle BatchMetadataUpdate (EIP-4906) — range of tokens - else if envelope.type_id == TypeId::new("erc721.batch_metadata_update") { - if let Some(update) = envelope - .body - .as_any() - .downcast_ref::() - { + Erc721Msg::BatchMetadataUpdate(update) => { if self.token_uri_commands_enabled { self.enqueue_token_uri_range_refresh( update.token, @@ -299,9 +285,10 @@ impl Sink for Erc721Sink { .await; } } + Erc721Msg::Approval(_) => { + // Single-token approvals are decoded for completeness but not stored yet. + } } - // Note: erc721.approval (single token approval) could be handled similarly - // but is less commonly needed for indexing purposes } // Fetch metadata for any new token contracts. diff --git a/crates/torii-erc721/src/storage.rs b/crates/torii-erc721/src/storage.rs index 658043fa..00541b68 100644 --- a/crates/torii-erc721/src/storage.rs +++ b/crates/torii-erc721/src/storage.rs @@ -3,15 +3,15 @@ //! Uses binary (BLOB) storage for efficiency. //! Tracks current NFT ownership state (who owns each token). +use crate::conversions::{blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob}; use anyhow::Result; use rusqlite::{params, params_from_iter, Connection, ToSql}; use starknet::core::types::{Felt, U256}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, Mutex}; use tokio_postgres::{types::ToSql as PgToSql, Client, NoTls}; -use torii_common::{ - blob_to_felt, blob_to_u256, felt_to_blob, u256_to_blob, TokenUriResult, TokenUriStore, -}; +use torii_common::{TokenUriResult, TokenUriStore}; +use torii_sql::{DbPool, DbPoolOptions}; const SQLITE_MAX_BIND_VARS: usize = 900; const SQLITE_TOKEN_BATCH_SIZE: usize = SQLITE_MAX_BIND_VARS; @@ -19,9 +19,40 @@ const SQLITE_TOKEN_PAIR_BATCH_SIZE: usize = SQLITE_MAX_BIND_VARS / 2; /// Storage for ERC721 NFT data pub struct Erc721Storage { - backend: StorageBackend, + db: Erc721Db, +} + +struct Erc721Db { + backend: Backend, + runtime: StorageRuntime, +} + +enum StorageRuntime { + Sqlite(SqliteStorageRuntime), + Postgres(PostgresStorageRuntime), +} + +struct SqliteStorageRuntime { conn: Arc>, - pg_conn: Option>>, +} + +struct PostgresStorageRuntime { + conn: Arc>, +} + +impl Erc721Db { + fn new(backend: Backend, runtime: StorageRuntime) -> Self { + Self { backend, runtime } + } +} + +impl StorageRuntime { + fn backend(&self) -> StorageBackend { + match self { + Self::Sqlite(_) => StorageBackend::Sqlite, + Self::Postgres(_) => StorageBackend::Postgres, + } + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -113,7 +144,18 @@ struct ResolvedFacetFilter { impl Erc721Storage { /// Create or open the database pub async fn new(db_path: &str) -> Result { - if db_path.starts_with("postgres://") || db_path.starts_with("postgresql://") { + let pool = Self::connect_pool(db_path).await?; + Self::from_pool(db_path, pool).await + } + + pub async fn connect_pool(db_path: &str) -> Result { + Ok(DbPoolOptions::new() + .connect_any(&db_pool_url(db_path)) + .await?) + } + + pub async fn from_pool(db_path: &str, pool: DbPool) -> Result { + let runtime = if matches!(pool, DbPool::Postgres(_)) { let (client, connection) = tokio_postgres::connect(db_path, NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { @@ -251,33 +293,30 @@ impl Erc721Storage { ).await?; tracing::info!(target: "torii_erc721::storage", "PostgreSQL storage initialized"); - return Ok(Self { - backend: StorageBackend::Postgres, - conn: Arc::new(Mutex::new(Connection::open_in_memory()?)), - pg_conn: Some(Arc::new(tokio::sync::Mutex::new(client))), - }); - } - - let conn = Connection::open(db_path)?; - - // Enable WAL mode + Performance PRAGMAs - conn.execute_batch( - "PRAGMA journal_mode=WAL; - PRAGMA synchronous=NORMAL; - PRAGMA foreign_keys=ON; - PRAGMA cache_size=-64000; - PRAGMA temp_store=MEMORY; - PRAGMA mmap_size=268435456; - PRAGMA wal_autocheckpoint=10000; - PRAGMA page_size=4096; - PRAGMA busy_timeout=5000;", - )?; + StorageRuntime::Postgres(PostgresStorageRuntime { + conn: Arc::new(tokio::sync::Mutex::new(client)), + }) + } else { + let conn = Connection::open(db_path)?; + + // Enable WAL mode + Performance PRAGMAs + conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA synchronous=NORMAL; + PRAGMA foreign_keys=ON; + PRAGMA cache_size=-64000; + PRAGMA temp_store=MEMORY; + PRAGMA mmap_size=268435456; + PRAGMA wal_autocheckpoint=10000; + PRAGMA page_size=4096; + PRAGMA busy_timeout=5000;", + )?; - tracing::info!(target: "torii_erc721::storage", "SQLite configured: WAL mode, 64MB cache, 256MB mmap, NORMAL sync"); + tracing::info!(target: "torii_erc721::storage", "SQLite configured: WAL mode, 64MB cache, 256MB mmap, NORMAL sync"); - // NFT ownership (current state) - one owner per NFT - conn.execute( - "CREATE TABLE IF NOT EXISTS nft_ownership ( + // NFT ownership (current state) - one owner per NFT + conn.execute( + "CREATE TABLE IF NOT EXISTS nft_ownership ( id INTEGER PRIMARY KEY AUTOINCREMENT, token BLOB NOT NULL, token_id BLOB NOT NULL, @@ -287,34 +326,34 @@ impl Erc721Storage { timestamp TEXT, UNIQUE(token, token_id) )", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_ownership_owner ON nft_ownership(owner)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_ownership_owner ON nft_ownership(owner)", + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_ownership_token ON nft_ownership(token)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_ownership_token ON nft_ownership(token)", + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_ownership_token_owner_token_id_ord + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_ownership_token_owner_token_id_ord ON nft_ownership(token, owner, length(token_id), token_id)", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_ownership_owner_token_token_id_ord + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_ownership_owner_token_token_id_ord ON nft_ownership(owner, token, length(token_id), token_id)", - [], - )?; + [], + )?; - // Transfer history - conn.execute( - "CREATE TABLE IF NOT EXISTS nft_transfers ( + // Transfer history + conn.execute( + "CREATE TABLE IF NOT EXISTS nft_transfers ( id INTEGER PRIMARY KEY AUTOINCREMENT, token BLOB NOT NULL, token_id BLOB NOT NULL, @@ -325,37 +364,37 @@ impl Erc721Storage { timestamp TEXT, UNIQUE(token, tx_hash, token_id, from_addr, to_addr) )", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_transfers_token ON nft_transfers(token)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_transfers_token ON nft_transfers(token)", + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_transfers_from ON nft_transfers(from_addr)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_transfers_from ON nft_transfers(from_addr)", + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_transfers_to ON nft_transfers(to_addr)", - [], - )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_transfers_to ON nft_transfers(to_addr)", + [], + )?; - conn.execute( + conn.execute( "CREATE INDEX IF NOT EXISTS idx_nft_transfers_block ON nft_transfers(block_number DESC)", [], )?; - conn.execute( + conn.execute( "CREATE INDEX IF NOT EXISTS idx_nft_transfers_token_id ON nft_transfers(token, token_id)", [], )?; - // Wallet activity table for efficient OR queries - conn.execute( - "CREATE TABLE IF NOT EXISTS nft_wallet_activity ( + // Wallet activity table for efficient OR queries + conn.execute( + "CREATE TABLE IF NOT EXISTS nft_wallet_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_address BLOB NOT NULL, token BLOB NOT NULL, @@ -364,24 +403,24 @@ impl Erc721Storage { block_number TEXT NOT NULL, FOREIGN KEY (transfer_id) REFERENCES nft_transfers(id) )", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_wallet_activity_wallet_block + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_wallet_activity_wallet_block ON nft_wallet_activity(wallet_address, block_number DESC)", - [], - )?; + [], + )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_nft_wallet_activity_wallet_token + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_nft_wallet_activity_wallet_token ON nft_wallet_activity(wallet_address, token, block_number DESC)", - [], - )?; + [], + )?; - // Approvals (single token) - conn.execute( - "CREATE TABLE IF NOT EXISTS nft_approvals ( + // Approvals (single token) + conn.execute( + "CREATE TABLE IF NOT EXISTS nft_approvals ( id INTEGER PRIMARY KEY AUTOINCREMENT, token BLOB NOT NULL, token_id BLOB NOT NULL, @@ -391,12 +430,12 @@ impl Erc721Storage { tx_hash BLOB NOT NULL, timestamp TEXT )", - [], - )?; + [], + )?; - // Operator approvals (all tokens) - conn.execute( - "CREATE TABLE IF NOT EXISTS nft_operators ( + // Operator approvals (all tokens) + conn.execute( + "CREATE TABLE IF NOT EXISTS nft_operators ( id INTEGER PRIMARY KEY AUTOINCREMENT, token BLOB NOT NULL, owner BLOB NOT NULL, @@ -407,21 +446,21 @@ impl Erc721Storage { timestamp TEXT, UNIQUE(token, owner, operator) )", - [], - )?; + [], + )?; - // Token metadata table - conn.execute( - "CREATE TABLE IF NOT EXISTS token_metadata ( + // Token metadata table + conn.execute( + "CREATE TABLE IF NOT EXISTS token_metadata ( token BLOB PRIMARY KEY, name TEXT, symbol TEXT, total_supply BLOB )", - [], - )?; + [], + )?; - conn.execute_batch( + conn.execute_batch( "CREATE TABLE IF NOT EXISTS token_uris ( token BLOB NOT NULL, token_id BLOB NOT NULL, @@ -480,25 +519,54 @@ impl Erc721Storage { CREATE INDEX IF NOT EXISTS idx_facet_token_map_token_token_id_key_value ON facet_token_map(token, token_id, facet_key_id, facet_value_id);", )?; - tracing::info!(target: "torii_erc721::storage", db_path = %db_path, "ERC721 database initialized"); + tracing::info!(target: "torii_erc721::storage", db_path = %db_path, "ERC721 database initialized"); + + StorageRuntime::Sqlite(SqliteStorageRuntime { + conn: Arc::new(Mutex::new(conn)), + }) + }; Ok(Self { - backend: StorageBackend::Sqlite, - conn: Arc::new(Mutex::new(conn)), - pg_conn: None, + db: Erc721Db::new(pool, runtime), }) } + pub fn pool(&self) -> &DbPool { + &self.db.backend + } + + fn is_postgres(&self) -> bool { + self.db.runtime.backend() == StorageBackend::Postgres + } + + fn sqlite_conn(&self) -> Result> { + match &self.db.runtime { + StorageRuntime::Sqlite(runtime) => Ok(runtime.conn.lock().unwrap()), + StorageRuntime::Postgres(_) => Err(anyhow::anyhow!( + "SQLite connection not initialized for ERC721 storage" + )), + } + } + + #[cfg(test)] + fn with_sqlite_conn( + &self, + f: impl FnOnce(&Connection) -> Result, + ) -> Result { + let conn = self.sqlite_conn()?; + Ok(f(&conn)?) + } + /// Insert multiple transfers and update ownership in a single transaction pub async fn insert_transfers_batch(&self, transfers: &[NftTransferData]) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_insert_transfers_batch(transfers).await; } if transfers.is_empty() { return Ok(0); } - let mut conn = self.conn.lock().unwrap(); + let mut conn = self.sqlite_conn()?; let tx = conn.transaction()?; let mut inserted = 0; @@ -602,14 +670,14 @@ impl Erc721Storage { &self, approvals: &[OperatorApprovalData], ) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_insert_operator_approvals_batch(approvals).await; } if approvals.is_empty() { return Ok(0); } - let mut conn = self.conn.lock().unwrap(); + let mut conn = self.sqlite_conn()?; let tx = conn.transaction()?; let mut inserted = 0; @@ -657,14 +725,14 @@ impl Erc721Storage { cursor: Option, limit: u32, ) -> Result<(Vec, Option)> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self .pg_get_transfers_filtered( wallet, from, to, tokens, token_ids, block_from, block_to, cursor, limit, ) .await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let mut query = String::new(); let mut params_vec: Vec> = Vec::new(); @@ -781,10 +849,10 @@ impl Erc721Storage { /// Get current owner of a specific NFT pub async fn get_owner(&self, token: Felt, token_id: U256) -> Result> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_owner(token, token_id).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let result: Result, _> = conn.query_row( "SELECT owner FROM nft_ownership WHERE token = ? AND token_id = ?", @@ -807,12 +875,12 @@ impl Erc721Storage { cursor: Option, limit: u32, ) -> Result<(Vec, Option)> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self .pg_get_ownership_by_owner(owner, tokens, cursor, limit) .await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let mut query = String::from( "SELECT id, token, token_id, owner, block_number FROM nft_ownership WHERE owner = ?", @@ -890,7 +958,7 @@ impl Erc721Storage { let page_fetch = page_limit as i64 + 1; let facet_limit = facet_limit.clamp(1, 1000) as i64; - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { let resolved_filters = self.pg_resolve_facet_filters(token, &filters).await?; let Some(resolved_filters) = resolved_filters else { return Ok(TokenAttributeQueryResult { @@ -912,7 +980,7 @@ impl Erc721Storage { .await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let resolved_filters = self.sqlite_resolve_facet_filters(&conn, token, &filters)?; let Some(resolved_filters) = resolved_filters else { return Ok(TokenAttributeQueryResult { @@ -936,10 +1004,10 @@ impl Erc721Storage { /// Get transfer count pub async fn get_transfer_count(&self) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_transfer_count().await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let count: i64 = conn.query_row("SELECT COUNT(*) FROM nft_transfers", [], |row| row.get(0))?; Ok(count as u64) @@ -947,10 +1015,10 @@ impl Erc721Storage { /// Get unique token contract count pub async fn get_token_count(&self) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_token_count().await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let count: i64 = conn.query_row( "SELECT COUNT(DISTINCT token) FROM nft_transfers", [], @@ -961,10 +1029,10 @@ impl Erc721Storage { /// Get unique NFT count pub async fn get_nft_count(&self) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_nft_count().await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let count: i64 = conn.query_row("SELECT COUNT(*) FROM nft_ownership", [], |row| row.get(0))?; Ok(count as u64) @@ -972,10 +1040,10 @@ impl Erc721Storage { /// Get latest block number indexed pub async fn get_latest_block(&self) -> Result> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_latest_block().await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let block: Option = conn .query_row("SELECT MAX(block_number) FROM nft_transfers", [], |row| { row.get(0) @@ -989,10 +1057,10 @@ impl Erc721Storage { /// Check if metadata exists for a token pub async fn has_token_metadata(&self, token: Felt) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_has_token_metadata(token).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let count: i64 = conn.query_row( "SELECT COUNT(*) FROM token_metadata @@ -1012,11 +1080,11 @@ impl Erc721Storage { if tokens.is_empty() { return Ok(HashSet::new()); } - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_has_token_metadata_batch(tokens).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let mut existing = HashSet::with_capacity(tokens.len()); for chunk in tokens.chunks(SQLITE_TOKEN_BATCH_SIZE) { @@ -1054,12 +1122,12 @@ impl Erc721Storage { symbol: Option<&str>, total_supply: Option, ) -> Result<()> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self .pg_upsert_token_metadata(token, name, symbol, total_supply) .await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let supply_blob = total_supply.map(u256_to_blob); conn.execute( @@ -1079,10 +1147,10 @@ impl Erc721Storage { &self, token: Felt, ) -> Result, Option, Option)>> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_token_metadata(token).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let result = conn .query_row( @@ -1103,7 +1171,7 @@ impl Erc721Storage { pub async fn get_all_token_metadata( &self, ) -> Result, Option, Option)>> { - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let mut stmt = conn.prepare_cached("SELECT token, name, symbol, total_supply FROM token_metadata")?; let rows = stmt.query_map([], |row| { @@ -1132,10 +1200,10 @@ impl Erc721Storage { Vec<(Felt, Option, Option, Option)>, Option, )> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_token_metadata_paginated(cursor, limit).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let fetch_limit = limit.clamp(1, 1000) as usize + 1; let mut out = if let Some(cursor_token) = cursor { @@ -1196,10 +1264,10 @@ impl Erc721Storage { /// Returns true if a token URI row exists for `(token, token_id)`. pub async fn has_token_uri(&self, token: Felt, token_id: U256) -> Result { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_has_token_uri(token, token_id).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let token_id_blob = u256_to_blob(token_id); let count: i64 = conn.query_row( @@ -1219,11 +1287,11 @@ impl Erc721Storage { if tokens.is_empty() { return Ok(HashSet::new()); } - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_has_token_uri_batch(tokens).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let mut existing = HashSet::with_capacity(tokens.len()); for chunk in tokens.chunks(SQLITE_TOKEN_PAIR_BATCH_SIZE) { @@ -1256,10 +1324,10 @@ impl Erc721Storage { &self, token: Felt, ) -> Result, Option)>> { - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_token_uris_by_contract(token).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let mut stmt = conn.prepare_cached( "SELECT token_id, uri, metadata_json @@ -1284,11 +1352,11 @@ impl Erc721Storage { if token_ids.is_empty() { return Ok(Vec::new()); } - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { return self.pg_get_token_uris_batch(token, token_ids).await; } - let conn = self.conn.lock().unwrap(); + let conn = self.sqlite_conn()?; let token_blob = felt_to_blob(token); let mut rows_out = Vec::new(); for chunk in token_ids.chunks(SQLITE_TOKEN_BATCH_SIZE) { @@ -1319,11 +1387,12 @@ impl Erc721Storage { } async fn pg_client(&self) -> Result> { - let conn = self - .pg_conn - .as_ref() - .ok_or_else(|| anyhow::anyhow!("PostgreSQL connection not initialized"))?; - Ok(conn.lock().await) + match &self.db.runtime { + StorageRuntime::Postgres(runtime) => Ok(runtime.conn.lock().await), + StorageRuntime::Sqlite(_) => { + Err(anyhow::anyhow!("PostgreSQL connection not initialized")) + } + } } fn pg_next_param( @@ -2329,6 +2398,21 @@ impl Erc721Storage { } } +fn db_pool_url(db_path: &str) -> String { + if db_path == ":memory:" || db_path == "sqlite::memory:" { + return "sqlite::memory:".to_owned(); + } + + if db_path.starts_with("postgres://") + || db_path.starts_with("postgresql://") + || db_path.starts_with("sqlite:") + { + return db_path.to_owned(); + } + + format!("sqlite://{db_path}?mode=rwc") +} + #[async_trait::async_trait] impl TokenUriStore for Erc721Storage { async fn store_token_uris_batch(&self, results: &[TokenUriResult]) -> Result<()> { @@ -2342,14 +2426,14 @@ impl TokenUriStore for Erc721Storage { .collect::>(); let token_blobs = results .iter() - .map(|result| felt_to_blob(result.contract)) + .map(|result| result.contract.to_be_bytes_vec()) .collect::>(); let token_id_blobs = results .iter() - .map(|result| u256_to_blob(result.token_id)) + .map(|result| result.token_id.to_big_endian().to_vec()) .collect::>(); - if self.backend == StorageBackend::Postgres { + if self.is_postgres() { let mut client = self.pg_client().await?; let mut changed_indexes = Vec::new(); for (idx, result) in results.iter().enumerate() { @@ -2429,7 +2513,7 @@ impl TokenUriStore for Erc721Storage { return Ok(()); } - let mut conn = self.conn.lock().unwrap(); + let mut conn = self.sqlite_conn()?; let mut changed_indexes = Vec::new(); for (idx, result) in results.iter().enumerate() { if !sqlite_token_uri_state_matches(&conn, result, &expected_attributes[idx])? { @@ -2942,8 +3026,8 @@ fn sqlite_token_uri_state_matches( result: &TokenUriResult, expected_attributes: &[(String, String)], ) -> Result { - let token_blob = felt_to_blob(result.contract); - let token_id_blob = u256_to_blob(result.token_id); + let token_blob = result.contract.to_be_bytes_vec(); + let token_id_blob = result.token_id.to_big_endian().to_vec(); let row = conn.query_row( "SELECT uri, metadata_json FROM token_uris WHERE token = ?1 AND token_id = ?2", params![&token_blob, &token_id_blob], @@ -2984,8 +3068,8 @@ async fn pg_token_uri_state_matches( result: &TokenUriResult, expected_attributes: &[(String, String)], ) -> Result { - let token_blob = felt_to_blob(result.contract); - let token_id_blob = u256_to_blob(result.token_id); + let token_blob = result.contract.to_be_bytes_vec(); + let token_id_blob = result.token_id.to_big_endian().to_vec(); let row = client .query_opt( "SELECT uri, metadata_json FROM erc721.token_uris WHERE token = $1 AND token_id = $2", @@ -3033,6 +3117,16 @@ fn sanitize_metadata_text(input: &str) -> Option { mod tests { use super::*; + fn raw_contract() -> starknet_types_raw::Felt { + starknet_types_raw::Felt::from_hex_unchecked( + "0x046da8955829adf2bda310099a0063451923f02e648cf25a1203aac6335cf0e4", + ) + } + + fn primitive_token_id(value: u64) -> primitive_types::U256 { + primitive_types::U256::from(value) + } + fn temp_db_path(test_name: &str) -> String { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -3078,10 +3172,8 @@ mod tests { let db_path = temp_db_path("token-uri-noop"); let storage = Erc721Storage::new(&db_path).await.expect("create storage"); let result = TokenUriResult { - contract: Felt::from_hex_unchecked( - "0x046da8955829adf2bda310099a0063451923f02e648cf25a1203aac6335cf0e4", - ), - token_id: U256::from(1u64), + contract: raw_contract(), + token_id: primitive_token_id(1), uri: Some("ipfs://beasts/1".to_owned()), metadata_json: Some( r#"{"name":"Beast #1","attributes":[{"trait_type":"Class","value":"Wolf"}]}"# @@ -3094,38 +3186,45 @@ mod tests { .await .expect("insert token uri"); - { - let conn = storage.conn.lock().unwrap(); - conn.execute( - "UPDATE token_uris SET updated_at = '123' WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], - ) + storage + .with_sqlite_conn(|conn| { + conn.execute( + "UPDATE token_uris SET updated_at = '123' WHERE token = ?1 AND token_id = ?2", + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], + )?; + Ok(()) + }) .expect("set sentinel updated_at"); - } storage .store_token_uri(&result) .await .expect("store unchanged token uri"); - let (updated_at, attr_count): (String, i64) = { - let conn = storage.conn.lock().unwrap(); - let updated_at: String = conn - .query_row( + let (updated_at, attr_count): (String, i64) = storage + .with_sqlite_conn(|conn| { + let updated_at: String = conn.query_row( "SELECT updated_at FROM token_uris WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], |row| row.get(0), - ) - .expect("read updated_at"); - let attr_count: i64 = conn - .query_row( + )?; + let attr_count: i64 = conn.query_row( "SELECT COUNT(*) FROM token_attributes WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], |row| row.get(0), - ) - .expect("count attributes"); - (updated_at, attr_count) - }; + )?; + Ok((updated_at, attr_count)) + }) + .expect("read sqlite assertions"); assert_eq!(updated_at, "123"); assert_eq!(attr_count, 1); @@ -3138,10 +3237,8 @@ mod tests { let db_path = temp_db_path("token-uri-backfill"); let storage = Erc721Storage::new(&db_path).await.expect("create storage"); let result = TokenUriResult { - contract: Felt::from_hex_unchecked( - "0x046da8955829adf2bda310099a0063451923f02e648cf25a1203aac6335cf0e4", - ), - token_id: U256::from(2u64), + contract: raw_contract(), + token_id: primitive_token_id(2), uri: Some("ipfs://beasts/2".to_owned()), metadata_json: Some( r#"{"name":"Beast #2","attributes":[{"trait_type":"Class","value":"Bear"}]}"# @@ -3154,29 +3251,36 @@ mod tests { .await .expect("insert token uri"); - { - let conn = storage.conn.lock().unwrap(); - conn.execute( - "DELETE FROM token_attributes WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], - ) + storage + .with_sqlite_conn(|conn| { + conn.execute( + "DELETE FROM token_attributes WHERE token = ?1 AND token_id = ?2", + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], + )?; + Ok(()) + }) .expect("delete attributes"); - } storage .store_token_uri(&result) .await .expect("repair missing attributes"); - let attr_count: i64 = { - let conn = storage.conn.lock().unwrap(); - conn.query_row( - "SELECT COUNT(*) FROM token_attributes WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], - |row| row.get(0), - ) - .expect("count attributes") - }; + let attr_count: i64 = storage + .with_sqlite_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM token_attributes WHERE token = ?1 AND token_id = ?2", + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], + |row| row.get(0), + ) + }) + .expect("count attributes"); assert_eq!(attr_count, 1); @@ -3188,10 +3292,8 @@ mod tests { let db_path = temp_db_path("token-uri-facet-backfill"); let storage = Erc721Storage::new(&db_path).await.expect("create storage"); let result = TokenUriResult { - contract: Felt::from_hex_unchecked( - "0x046da8955829adf2bda310099a0063451923f02e648cf25a1203aac6335cf0e4", - ), - token_id: U256::from(3u64), + contract: raw_contract(), + token_id: primitive_token_id(3), uri: Some("ipfs://beasts/3".to_owned()), metadata_json: Some( r#"{"name":"Beast #3","attributes":[{"trait_type":"Class","value":"Wolf"},{"trait_type":"Color","value":"Red"}]}"# @@ -3204,45 +3306,45 @@ mod tests { .await .expect("insert token uri"); - { - let conn = storage.conn.lock().unwrap(); - conn.execute( - "UPDATE facet_values SET token_count = '0' WHERE id IN ( - SELECT facet_value_id FROM facet_token_map WHERE token = ?1 AND token_id = ?2 - )", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], - ) - .expect("zero facet counts"); - conn.execute( - "DELETE FROM facet_token_map WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], - ) + storage + .with_sqlite_conn(|conn| { + conn.execute( + "UPDATE facet_values SET token_count = '0' WHERE id IN ( + SELECT facet_value_id FROM facet_token_map WHERE token = ?1 AND token_id = ?2 + )", + params![result.contract.to_be_bytes_vec(), result.token_id.to_big_endian().to_vec()], + )?; + conn.execute( + "DELETE FROM facet_token_map WHERE token = ?1 AND token_id = ?2", + params![result.contract.to_be_bytes_vec(), result.token_id.to_big_endian().to_vec()], + )?; + Ok(()) + }) .expect("delete facet mappings"); - } storage .store_token_uri(&result) .await .expect("repair missing facets"); - let (facet_map_count, positive_counts): (i64, i64) = { - let conn = storage.conn.lock().unwrap(); - let facet_map_count: i64 = conn - .query_row( + let (facet_map_count, positive_counts): (i64, i64) = storage + .with_sqlite_conn(|conn| { + let facet_map_count: i64 = conn.query_row( "SELECT COUNT(*) FROM facet_token_map WHERE token = ?1 AND token_id = ?2", - params![felt_to_blob(result.contract), u256_to_blob(result.token_id)], + params![ + result.contract.to_be_bytes_vec(), + result.token_id.to_big_endian().to_vec() + ], |row| row.get(0), - ) - .expect("count facet mappings"); - let positive_counts: i64 = conn - .query_row( + )?; + let positive_counts: i64 = conn.query_row( "SELECT COUNT(*) FROM facet_values WHERE CAST(token_count AS INTEGER) > 0", [], |row| row.get(0), - ) - .expect("count positive facet values"); - (facet_map_count, positive_counts) - }; + )?; + Ok((facet_map_count, positive_counts)) + }) + .expect("read facet assertions"); assert_eq!(facet_map_count, 2); assert_eq!(positive_counts, 2); @@ -3276,8 +3378,8 @@ mod tests { for (token_id, metadata_json) in fixtures { storage .store_token_uri(&TokenUriResult { - contract, - token_id: U256::from(token_id), + contract: contract.into(), + token_id: primitive_types::U256::from(token_id), uri: Some(format!("ipfs://beasts/{token_id}")), metadata_json: Some(metadata_json.to_owned()), }) diff --git a/crates/torii-erc721/src/synthetic.rs b/crates/torii-erc721/src/synthetic.rs index 0857f716..667a422f 100644 --- a/crates/torii-erc721/src/synthetic.rs +++ b/crates/torii-erc721/src/synthetic.rs @@ -5,12 +5,27 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt, U256}; +use starknet::core::types::U256; use starknet::macros::selector; +use starknet_types_raw::Felt; use torii::etl::extractor::{ExtractionBatch, SyntheticExtractor}; +use torii::etl::StarknetEvent; + +use crate::conversions::core_to_raw_felt; const EXTRACTOR_NAME: &str = "synthetic_erc721"; +fn raw_selector(name: &str) -> Felt { + core_to_raw_felt(match name { + "Transfer" => selector!("Transfer"), + "Approval" => selector!("Approval"), + "ApprovalForAll" => selector!("ApprovalForAll"), + "MetadataUpdate" => selector!("MetadataUpdate"), + "BatchMetadataUpdate" => selector!("BatchMetadataUpdate"), + _ => unreachable!(), + }) +} + /// Configuration for the synthetic ERC721 workload. #[derive(Debug, Clone)] pub struct SyntheticErc721Config { @@ -210,65 +225,60 @@ impl SyntheticErc721Extractor { let token_id = self.token_id_for(block_number, tx_index); let event = match self.event_type_for(tx_index) { - Erc721EventType::Transfer => EmittedEvent { - from_address: token, - keys: vec![ - selector!("Transfer"), + Erc721EventType::Transfer => StarknetEvent::new( + token, + vec![ + raw_selector("Transfer"), from, to, Felt::from(token_id.low()), Felt::from(token_id.high()), ], - data: vec![], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - }, - Erc721EventType::Approval => EmittedEvent { - from_address: token, - keys: vec![ - selector!("Approval"), + vec![], + block_number, + tx_hash, + ), + Erc721EventType::Approval => StarknetEvent::new( + token, + vec![ + raw_selector("Approval"), from, to, Felt::from(token_id.low()), Felt::from(token_id.high()), ], - data: vec![], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - }, - Erc721EventType::ApprovalForAll => EmittedEvent { - from_address: token, - keys: vec![selector!("ApprovalForAll"), from, to], - data: vec![Felt::from(1u64)], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - }, - Erc721EventType::MetadataUpdate => EmittedEvent { - from_address: token, - keys: vec![selector!("MetadataUpdate")], - data: vec![Felt::from(token_id.low()), Felt::from(token_id.high())], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - }, + vec![], + block_number, + tx_hash, + ), + Erc721EventType::ApprovalForAll => StarknetEvent::new( + token, + vec![raw_selector("ApprovalForAll"), from, to], + vec![Felt::from(1u64)], + block_number, + tx_hash, + ), + Erc721EventType::MetadataUpdate => StarknetEvent::new( + token, + vec![raw_selector("MetadataUpdate")], + vec![Felt::from(token_id.low()), Felt::from(token_id.high())], + block_number, + tx_hash, + ), Erc721EventType::BatchMetadataUpdate => { let to_id = token_id + U256::from(100u64); - EmittedEvent { - from_address: token, - keys: vec![selector!("BatchMetadataUpdate")], - data: vec![ + StarknetEvent::new( + token, + vec![raw_selector("BatchMetadataUpdate")], + vec![ Felt::from(token_id.low()), Felt::from(token_id.high()), Felt::from(to_id.low()), Felt::from(to_id.high()), ], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), - transaction_hash: tx_hash, - } + block_number, + tx_hash, + ) } }; @@ -376,11 +386,11 @@ mod tests { let mut extractor = SyntheticErc721Extractor::new(cfg).unwrap(); let batch = extractor.extract(None).await.unwrap(); - let transfer_selector = selector!("Transfer"); - let approval_selector = selector!("Approval"); - let approval_for_all_selector = selector!("ApprovalForAll"); - let metadata_update_selector = selector!("MetadataUpdate"); - let batch_metadata_update_selector = selector!("BatchMetadataUpdate"); + let transfer_selector = raw_selector("Transfer"); + let approval_selector = raw_selector("Approval"); + let approval_for_all_selector = raw_selector("ApprovalForAll"); + let metadata_update_selector = raw_selector("MetadataUpdate"); + let batch_metadata_update_selector = raw_selector("BatchMetadataUpdate"); let mut has_transfer = false; let mut has_approval = false; diff --git a/crates/torii-log-sink/Cargo.toml b/crates/torii-log-sink/Cargo.toml index 536984b5..ea6dd97e 100644 --- a/crates/torii-log-sink/Cargo.toml +++ b/crates/torii-log-sink/Cargo.toml @@ -4,35 +4,36 @@ version = "0.1.0" edition = "2021" [dependencies] -torii = { path = "../../" } -starknet = "0.17" +torii.workspace = true # Async -tokio = { version = "1", features = ["full"] } -tokio-stream = { version = "0.1", features = ["sync"] } -async-trait = "0.1" +tokio.workspace = true +tokio-stream.workspace = true +async-trait.workspace = true # gRPC and protobuf -tonic = "0.12" -prost = "0.13" -prost-types = "0.13" +tonic.workspace = true +prost.workspace = true +prost-types.workspace = true # HTTP (use torii's re-exported axum) -serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde.workspace = true +serde_json.workspace = true # Logging -tracing = "0.1" +tracing.workspace = true # Utils -anyhow = "1" -chrono = "0.4" +anyhow.workspace = true +chrono.workspace = true + +starknet-types-raw.workspace = true [build-dependencies] -tonic-build = "0.12" +tonic-build.workspace = true [dev-dependencies] -tokio = { version = "1", features = ["full"] } +tokio.workspace = true [lints] workspace = true diff --git a/crates/torii-log-sink/README.md b/crates/torii-log-sink/README.md index 3cfe148d..a6c9aa88 100644 --- a/crates/torii-log-sink/README.md +++ b/crates/torii-log-sink/README.md @@ -1,174 +1,120 @@ -# Torii Log Sink +# torii-log-sink -A demonstration sink for Torii that collects and stores log entries from blockchain events. +A minimal, **in-memory** sink that turns decoded events into log entries, +publishes them on the EventBus, streams them over gRPC, and serves recent +entries over REST. Intended as a learning reference and a smoke-test sink +in the `multi_sink_example`. Data lives in a bounded `VecDeque` — old +entries fall off the back. -## Features +## Role in Torii -This sink demonstrates all three Torii extension points: +`LogSink` is the smallest possible real sink. It does not touch a database, +so binaries can drop it into a pipeline with zero infrastructure and still +observe the gRPC + EventBus + HTTP extension points. Use it alongside any +sink (`torii-sql-sink`, `torii-erc20`, etc.) to get a human-readable audit +log of events without provisioning a database. -1. **EventBus** - Publishes to central topic-based subscriptions (via `torii.Torii/Subscribe`) -2. **gRPC Service** - Provides `torii.sinks.log.LogSink` service with: - - `QueryLogs` - Query recent logs - - `SubscribeLogs` - Real-time streaming subscription -3. **REST HTTP** - Exposes: - - `GET /logs?limit=N` - Get recent logs (default: 5) - - `GET /logs/count` - Get total log count - -## Usage - -```rust -use std::sync::Arc; -use torii::{ToriiConfig, run}; -use torii_log_sink::{LogDecoder, LogSink}; -use tonic::transport::Server; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // 1. Create sink - let log_sink = LogSink::new(100); // Max 100 logs in memory - - // 2. Get the gRPC service implementation - let log_grpc_service = log_sink.get_grpc_service_impl(); - - // 3. Build gRPC router with log sink service - let grpc_router = { - use torii_log_sink::proto::log_sink_server::LogSinkServer; - - Server::builder() - .accept_http1(true) - .add_service(tonic_web::enable(LogSinkServer::new((*log_grpc_service).clone()))) - }; - - // 4. Create decoder - let log_decoder = Arc::new(LogDecoder::new(None)); // No filter - - // 5. Configure and run Torii - let config = ToriiConfig::builder() - .port(8080) - .add_sink_boxed(Box::new(log_sink)) - .add_decoder(log_decoder) - .with_grpc_router(grpc_router) - .build(); +## Architecture - run(config).await -} +```text ++---------------------------------------------------------+ +| LogSink | +| | +| +-------------+ +----------------+ +------------+ | +| | LogDecoder |-->| Envelope |-->| process() | | +| | log.entry | | LogEntry | +-----+------+ | +| +-------------+ +----------------+ | | +| v | +| +-----------------+--------+| +| | || +| v v| +| grpc_service.log_store() EventBus +| .add_log() publish_protobuf +| (VecDeque "logs" +| in Arc>) | +| | v| +| v gRPC topic +| LogSinkService "logs" +| (QueryLogs, | +| SubscribeLogs) | +| | | +| v | +| HTTP: /logs, /logs/count | ++---------------------------------------------------------+ ``` -## Protobuf Code Generation +## Deep Dive -The gRPC service definitions are in `proto/log.proto`. The Rust code is automatically generated during build. +### Public API -### Generated Files +| Item | File | Line | Purpose | +|---|---|---|---| +| `LogSink` | `src/lib.rs` | 36 | Zero-config in-memory sink | +| `LogSink::new(max_logs)` | `src/lib.rs` | 49 | Bounded ring buffer sized at construction | +| `LogSink::get_grpc_service_impl` | `src/lib.rs` | 90 | Returns `Arc` for `with_grpc_router` | +| `LogDecoder` | `src/decoder.rs` | — | Turns arbitrary events into `LogEntry` envelopes; optional filter | +| `LogEntry` | `src/decoder.rs` | — | In-memory body: `message`, `block_number`, `event_key` | +| `LogSinkService` | `src/grpc_service.rs` | — | Implements `QueryLogs`, `SubscribeLogs`; holds the `VecDeque` | +| `ProtoLogEntry`, `LogUpdate` | `src/proto` (generated) | — | Wire types from `proto/log.proto` | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs` | 11 | Re-exported bytes for reflection composition | -- **Location**: `src/generated/torii.sinks.log.rs` -- **Generated by**: `build.rs` (runs automatically with `cargo build`) -- **Includes**: Service trait, message types, client/server stubs +### Internal Modules -### How to Regenerate +- `lib.rs` — `Sink` impl; holds the counter and the gRPC service. +- `decoder.rs` — `LogDecoder` + `LogEntry` body; supports optional event-key filter. +- `grpc_service.rs` — `LogSinkService` with `VecDeque` storage and a `tokio::sync::broadcast` channel; server impl for `QueryLogs` / `SubscribeLogs`. +- `api.rs` — Axum handlers: `GET /logs?limit=N`, `GET /logs/count`. +- `generated/` — `torii.sinks.log.rs` + `log_descriptor.bin` from `proto/log.proto`. -The protobuf code is automatically regenerated when: +### Sink trait wiring -1. **Building the crate**: `cargo build -p torii-log-sink` -2. **Proto file changes**: Any modification to `proto/log.proto` triggers regeneration +| Method | Behavior | +|---|---| +| `name` | `"log"` | +| `interested_types` | `[TypeId::new("log.entry")]` | +| `process(envelopes, _batch)` | Stores via `log_store().add_log()` (evicts oldest when full), publishes on EventBus topic `"logs"`, broadcasts via gRPC channel | +| `topics` | One `TopicInfo { name: "logs", available_filters: [] }` | +| `build_routes` | Axum router: `GET /logs`, `GET /logs/count` | +| `initialize` | Stores the `event_bus` handle | -### Manual Regeneration +### Storage -If you need to force regeneration: +- In-memory `VecDeque` wrapped in `Arc>`. +- Configured size at construction (`LogSink::new(max_logs)`); overflow evicts oldest. +- No persistence — restart loses all state. -```bash -# Clean and rebuild -cargo clean -p torii-log-sink -cargo build -p torii-log-sink +### Interactions -# Or touch the proto file to trigger rebuild -touch crates/torii-log-sink/proto/log.proto -cargo build -p torii-log-sink -``` +- **Upstream (consumers)**: `examples/multi_sink_example`; any tutorial that wants visible side-effects without a DB. +- **Downstream deps**: `torii`, `tonic`, `prost`, `prost-types`, `tokio-stream`, `chrono`, `anyhow`, `tracing`. +- **Workspace deps**: `torii` only. -### Build Process - -The `build.rs` script: - -1. Creates `src/generated/` directory if it doesn't exist -2. Configures `tonic-build` to generate server code (no client) -3. Compiles `proto/log.proto` into Rust code -4. Outputs to `src/generated/torii.sinks.log.rs` - -See `build.rs` for implementation details. - -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ LogSink │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────┐ ┌─────────────────┐ │ -│ │ LogDecoder │─────▶│ LogEntry │ │ -│ │ │ │ (in-memory) │ │ -│ │ Events → Logs │ │ VecDeque │ │ -│ └────────────────┘ └─────────────────┘ │ -│ │ │ -│ ┌─────────────┼──────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────┐ ┌────────┐ ┌────┐│ -│ │ EventBus │ │ gRPC │ │HTTP││ -│ │ (central) │ │ APIs │ │API ││ -│ └──────────────┘ └────────┘ └────┘│ -│ │ -└─────────────────────────────────────────────────┘ -``` - -## Storage - -- **In-Memory**: Uses `VecDeque` with configurable max size -- **Thread-Safe**: Wrapped in `Arc>` for concurrent access -- **Circular Buffer**: Oldest logs are evicted when max size is reached - -## gRPC Service Registration - -Due to Rust's type system limitations, gRPC services must be registered by the user: +### gRPC registration pattern ```rust -// User code (e.g., main.rs or examples/) +use torii::{ToriiConfig, run}; +use torii_log_sink::{LogDecoder, LogSink}; use torii_log_sink::proto::log_sink_server::LogSinkServer; use tonic::transport::Server; +use std::sync::Arc; -let log_sink = LogSink::new(100); -let service = log_sink.get_grpc_service_impl(); +let sink = LogSink::new(500); +let service = sink.get_grpc_service_impl(); let grpc_router = Server::builder() .accept_http1(true) .add_service(tonic_web::enable(LogSinkServer::new((*service).clone()))); -// Pass router to Torii let config = ToriiConfig::builder() - .add_sink_boxed(Box::new(log_sink)) + .add_sink_boxed(Box::new(sink)) + .add_decoder(Arc::new(LogDecoder::new(None))) .with_grpc_router(grpc_router) .build(); +run(config).await?; ``` -This pattern keeps all gRPC services on the same port while maintaining type safety. - -## Testing - -```bash -# Build -cargo build -p torii-log-sink - -# Run tests -cargo test -p torii-log-sink - -# Test gRPC (with server running) -grpcurl -plaintext localhost:8080 torii.sinks.log.LogSink/QueryLogs - -# Test HTTP -curl http://localhost:8080/logs?limit=5 -curl http://localhost:8080/logs/count -``` - -## See Also +### Extension Points -- **Example**: `examples/multi_sink_example` - Multi-sink usage (SqlSink + LogSink) -- **SQL Sink**: `crates/torii-sql-sink` - Similar architecture with SQLite storage +- Persist logs → replace `VecDeque` with a DB-backed store in `grpc_service.rs`; keep the `add_log` API shape so `Sink::process` stays untouched. +- Structured filters → widen `TopicInfo.available_filters` and add a filter fn to `EventBus::publish_protobuf` (pattern: see `torii-sql-sink::matches_filters`). +- New REST endpoints → add routes in `build_routes` (re-use the existing `LogSinkService` state). diff --git a/crates/torii-log-sink/src/decoder.rs b/crates/torii-log-sink/src/decoder.rs index 85452a52..316d247c 100644 --- a/crates/torii-log-sink/src/decoder.rs +++ b/crates/torii-log-sink/src/decoder.rs @@ -1,11 +1,9 @@ use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::EmittedEvent; +use starknet_types_raw::Felt; use std::any::Any; -use torii::etl::{ - envelope::{Envelope, TypeId, TypedBody}, - Decoder, -}; +use torii::etl::envelope::{Envelope, TypeId, TypedBody}; +use torii::etl::{Decoder, EventContext}; /// Decoded log entry #[derive(Debug, Clone)] @@ -50,10 +48,15 @@ impl Decoder for LogDecoder { "log" } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> Result> { // Apply key filter if specified if let Some(ref filter) = self.key_filter { - let matches = event.keys.iter().any(|k| { + let matches = keys.iter().any(|k| { let key_hex = format!("{k:#x}"); key_hex.contains(filter) }); @@ -64,29 +67,29 @@ impl Decoder for LogDecoder { // Extract log message from event data. // For this example, we'll convert the first data field to a string representation. - let message = if event.data.is_empty() { + let message = if data.is_empty() { "Empty event data".to_string() } else { - format!("Event data: {:#x}", event.data[0]) + format!("Event data: {:#x}", data[0]) }; - let event_key = if event.keys.is_empty() { + let event_key = if keys.is_empty() { "no-key".to_string() } else { - format!("{:#x}", event.keys[0]) + format!("{:#x}", keys[0]) }; let log_entry = LogEntry { message, - block_number: event.block_number.unwrap_or(0), + block_number: context.block_number, event_key, }; // Create envelope with unique ID let envelope_id = format!( "log_{}_{}", - event.block_number.unwrap_or(0), - format!("{:#x}", event.transaction_hash) + context.block_number, + format!("{:#x}", context.transaction_hash) ); let envelope = Envelope::new( envelope_id, @@ -101,18 +104,18 @@ impl Decoder for LogDecoder { #[cfg(test)] mod tests { use super::*; - use starknet::core::types::Felt; + use starknet_types_raw::Felt; + use torii::etl::StarknetEvent; #[tokio::test] async fn test_decode_logs() { let decoder = LogDecoder::new(None); - let event = EmittedEvent { + let event = StarknetEvent { from_address: Felt::from(1u64), keys: vec![Felt::from(0x1234u64)], data: vec![Felt::from(0x5678u64)], - block_hash: None, - block_number: Some(100), + block_number: 100, transaction_hash: Felt::from(0xabcdu64), }; diff --git a/crates/torii-runtime-common/Cargo.toml b/crates/torii-runtime-common/Cargo.toml index 423d5e70..9cbc5ffd 100644 --- a/crates/torii-runtime-common/Cargo.toml +++ b/crates/torii-runtime-common/Cargo.toml @@ -10,6 +10,7 @@ tokio.workspace = true tokio-postgres = "0.7" tracing.workspace = true torii.workspace = true +torii-sql.workspace = true [lints] workspace = true diff --git a/crates/torii-runtime-common/README.md b/crates/torii-runtime-common/README.md index 853e155d..e0101b33 100644 --- a/crates/torii-runtime-common/README.md +++ b/crates/torii-runtime-common/README.md @@ -1,28 +1,84 @@ # torii-runtime-common -Shared runtime setup helpers for Torii binaries. +Shared **runtime** helpers used by every Torii binary. Where +`torii-config-common` validates CLI input, this crate resolves it into +concrete database URLs, initialises sinks, and handles destructive tasks +like dropping Postgres schemas for synthetic profilers. -## Database helpers +## Role in Torii -- `backend_from_url_or_path(...)` to classify Postgres vs SQLite. -- `resolve_single_db_setup(db_path, database_url)` for single-storage binaries. -- `resolve_token_db_setup(db_dir, engine_database_url, storage_database_url)` for multi-storage token binaries. +Each binary produces a `clap`-parsed `Config`, then calls helpers from this +crate to produce a `SingleDbSetup` / `TokenDbSetup`, wire up a throwaway +`EventBus` + `CommandBus` during `Sink::initialize`, and optionally reset +Postgres schemas between profiling runs. The resolved URLs are then fed to +`ToriiConfigBuilder::engine_database_url` and the sink-specific constructors. -## Sink helpers +## Architecture -- `initialize_sink(sink, database_root)` - - Standard sink initialization with EventBus and SinkContext. -- `drop_postgres_schemas(database_url, schemas, logging_target)` - - Shared schema reset utility used by synthetic profilers. +```text +CLI parsed Config (clap) + | + v ++---------------------------------+ +| database.rs | +| - backend_from_url_or_path |-- DbBackend::{Sqlite,Postgres} +| - resolve_single_db_setup |-- SingleDbSetup +| - resolve_token_db_setup |-- TokenDbSetup (engine+erc20+erc721+erc1155) +| - validate_uniform_backends | ++---------------------------------+ + | + v ++---------------------------------+ +| sink.rs | +| - initialize_sink |-- EventBus + SinkContext +| - initialize_sink_with_ |-- + CommandBus with user handlers +| command_handlers | +| - drop_postgres_schemas |-- schema reset for -synth bins ++---------------------------------+ + | + v ++---------------------------------+ +| token_support.rs | +| - InstalledTokenSupport |-- which token sinks to install +| - resolve_installed_token_... | ++---------------------------------+ + | + v + torii::ToriiConfigBuilder::build() → torii::run(...) +``` -## Example +## Deep Dive -```rust -use torii_runtime_common::database::resolve_token_db_setup; +### Public API -let db_setup = resolve_token_db_setup( - std::path::Path::new("./torii-data"), - config.database_url.as_deref(), - config.storage_database_url.as_deref(), -)?; -``` +| Item | File | Line | Purpose | +|---|---|---|---| +| `backend_from_url_or_path` | `database.rs` | 7 | Classify URL as `DbBackend::Postgres` or `DbBackend::Sqlite` | +| `validate_uniform_backends` | `database.rs` | 15 | Reject mixed Postgres/SQLite targets in one binary | +| `SingleDbSetup` | `database.rs` | 40 | `{ storage_url, engine_url, database_root }` | +| `resolve_single_db_setup` | `database.rs` | 46 | Derive engine path next to storage DB when not explicit | +| `TokenDbSetup` | `database.rs` | 71 | Engine + erc20 + erc721 + erc1155 URLs + each backend | +| `resolve_token_db_setup` | `database.rs` | 82 | Multi-DB resolver; rejects Postgres-engine + SQLite-storage mixes | +| `DEFAULT_SQLITE_MAX_CONNECTIONS` | `database.rs` | 5 | 500 — used by token sinks for connection pools | +| `initialize_sink` | `sink.rs` | 18 | `EventBus` + default `SinkContext`, drops its command bus immediately | +| `initialize_sink_with_command_handlers` | `sink.rs` | 24 | Keeps the `CommandBus` alive; returns `InitializedSinkRuntime` for shutdown | +| `drop_postgres_schemas` | `sink.rs` | 46 | `DROP SCHEMA ... CASCADE` helper for synthetic profilers | +| `InstalledTokenSupport` | `token_support.rs` | 1 | `{erc20, erc721, erc1155}` flags plus `any()` | +| `resolve_installed_token_support` | `token_support.rs` | 14 | Compose explicit targets with `--include-external-contracts` | + +### Internal Modules + +- `database` — URL classification + multi-target DB resolution with cross-backend validation. +- `sink` — builds a short-lived `EventBus`/`CommandBus` for `Sink::initialize` so binaries don’t duplicate that plumbing. +- `token_support` — little helper deciding which of the three token sinks the binary installs. + +### Interactions + +- **Upstream (consumers)**: `bins/torii-arcade`, `bins/torii-introspect-bin`, `bins/torii-tokens`, the three `-synth` profilers, `crates/torii-controllers-sink`, `crates/torii-ecs-sink` (all need `initialize_sink` or DB-setup helpers). +- **Downstream deps**: `torii`, `torii-sql` (for `DbBackend`), `tokio-postgres` (schema reset), `anyhow`, `tracing`. +- **Workspace deps**: `torii`, `torii-sql`. + +### Extension Points + +- New shared DB layout → add a `resolve_*_db_setup` returning a typed struct. Keep `validate_uniform_backends` as the one place that enforces "don’t mix backends". +- New global runtime chore (e.g., temp-dir prep, seccomp hook) belongs here, not in individual binaries. diff --git a/crates/torii-runtime-common/src/database.rs b/crates/torii-runtime-common/src/database.rs index ef7aeec0..ef409403 100644 --- a/crates/torii-runtime-common/src/database.rs +++ b/crates/torii-runtime-common/src/database.rs @@ -1,26 +1,21 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Result as AnyResult}; use std::path::{Path, PathBuf}; +use torii_sql::connection::DbBackend; pub const DEFAULT_SQLITE_MAX_CONNECTIONS: u32 = 500; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DatabaseBackend { - Postgres, - Sqlite, -} - -pub fn backend_from_url_or_path(value: &str) -> DatabaseBackend { +pub fn backend_from_url_or_path(value: &str) -> DbBackend { if value.starts_with("postgres://") || value.starts_with("postgresql://") { - DatabaseBackend::Postgres + DbBackend::Postgres } else { - DatabaseBackend::Sqlite + DbBackend::Sqlite } } pub fn validate_uniform_backends( named_urls: &[(&str, &str)], mixed_backend_message: &str, -) -> Result { +) -> AnyResult { let Some((_, first_url)) = named_urls.first() else { bail!("at least one database URL must be provided"); }; @@ -78,17 +73,17 @@ pub struct TokenDbSetup { pub erc20_url: String, pub erc721_url: String, pub erc1155_url: String, - pub engine_backend: DatabaseBackend, - pub erc20_backend: DatabaseBackend, - pub erc721_backend: DatabaseBackend, - pub erc1155_backend: DatabaseBackend, + pub engine_backend: DbBackend, + pub erc20_backend: DbBackend, + pub erc721_backend: DbBackend, + pub erc1155_backend: DbBackend, } pub fn resolve_token_db_setup( db_dir: &Path, engine_database_url: Option<&str>, storage_database_url: Option<&str>, -) -> Result { +) -> AnyResult { let engine_url = engine_database_url.map_or_else( || db_dir.join("engine.db").to_string_lossy().to_string(), ToOwned::to_owned, @@ -119,10 +114,10 @@ pub fn resolve_token_db_setup( if engine_database_url .map(backend_from_url_or_path) - .is_some_and(|backend| backend == DatabaseBackend::Postgres) - && (erc20_backend != DatabaseBackend::Postgres - || erc721_backend != DatabaseBackend::Postgres - || erc1155_backend != DatabaseBackend::Postgres) + .is_some_and(|backend| backend == DbBackend::Postgres) + && (erc20_backend != DbBackend::Postgres + || erc721_backend != DbBackend::Postgres + || erc1155_backend != DbBackend::Postgres) { bail!( "Engine is configured for Postgres but one or more token storages resolved to SQLite. Set --storage-database-url to the same Postgres URL." @@ -161,8 +156,8 @@ mod tests { fn resolves_sqlite_defaults() { let db_dir = Path::new("./torii-data"); let setup = resolve_token_db_setup(db_dir, None, None).unwrap(); - assert_eq!(setup.engine_backend, DatabaseBackend::Sqlite); - assert_eq!(setup.erc20_backend, DatabaseBackend::Sqlite); + assert_eq!(setup.engine_backend, DbBackend::Sqlite); + assert_eq!(setup.erc20_backend, DbBackend::Sqlite); assert!(setup.engine_url.ends_with("engine.db")); assert!(setup.erc20_url.ends_with("erc20.db")); } @@ -174,8 +169,9 @@ mod tests { db_dir, Some("postgres://localhost/torii"), Some("./torii-data"), - ) - .expect_err("expected mixed backend validation error"); + ); + println!("{err:?}"); + let err = err.expect_err("expected mixed backend validation error"); assert!(err .to_string() .contains("Engine is configured for Postgres")); @@ -190,8 +186,8 @@ mod tests { Some("postgres://localhost/torii"), ) .unwrap(); - assert_eq!(setup.engine_backend, DatabaseBackend::Postgres); - assert_eq!(setup.erc721_backend, DatabaseBackend::Postgres); + assert_eq!(setup.engine_backend, DbBackend::Postgres); + assert_eq!(setup.erc721_backend, DbBackend::Postgres); } #[test] @@ -204,7 +200,7 @@ mod tests { "mixed backends", ) .unwrap(); - assert_eq!(backend, DatabaseBackend::Postgres); + assert_eq!(backend, DbBackend::Postgres); } #[test] diff --git a/crates/torii-sql-sink/Cargo.toml b/crates/torii-sql-sink/Cargo.toml index 97443de8..5343a2ab 100644 --- a/crates/torii-sql-sink/Cargo.toml +++ b/crates/torii-sql-sink/Cargo.toml @@ -5,38 +5,43 @@ edition = "2021" [dependencies] # Torii core -torii = { path = "../.." } +torii.workspace = true # Async -tokio = { version = "1.35", features = ["full"] } -async-trait = "0.1" +tokio.workspace = true +async-trait.workspace = true # Database -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "postgres", "any"] } +sqlx = { workspace = true, features = [ + "runtime-tokio-rustls", + "sqlite", + "postgres", + "any", +] } # gRPC -tonic = "0.12" -tonic-web = "0.12" -prost = "0.13" -prost-types = "0.13" -async-stream = "0.3" -futures = "0.3" +tonic.workspace = true +tonic-web.workspace = true +prost.workspace = true +prost-types.workspace = true +async-stream.workspace = true +futures.workspace = true # Web framework -axum = { version = "0.7", features = ["json"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +axum = { workspace = true, features = ["json"] } +serde.workspace = true +serde_json.workspace = true # Blockchain -starknet = "0.17" +starknet-types-raw.workspace = true # Utilities -anyhow = "1.0" -tracing = "0.1" -chrono = "0.4" +anyhow.workspace = true +tracing.workspace = true +chrono.workspace = true [build-dependencies] -tonic-build = "0.12" +tonic-build.workspace = true [lints] workspace = true diff --git a/crates/torii-sql-sink/README.md b/crates/torii-sql-sink/README.md index 1dac68ed..ca859fb2 100644 --- a/crates/torii-sql-sink/README.md +++ b/crates/torii-sql-sink/README.md @@ -1,217 +1,147 @@ -# Torii SQL Sink +# torii-sql-sink -A demonstration sink for Torii that processes SQL operations and stores them in SQLite or PostgreSQL. +The reference sink. Persists decoded envelopes as SQL operations into SQLite +or PostgreSQL, publishes updates on the EventBus, serves a +`torii.sinks.sql.SqlSink` gRPC service, and exposes `/sql/query` and +`/sql/events` REST endpoints. Every new sink author should read this one +first — it is the canonical implementation of the three-extension-point +pattern. -## Features +## Role in Torii -This sink demonstrates all three Torii extension points: +Torii's core library (`src/etl/sink/mod.rs:74`) defines the `Sink` trait but +does not persist anything. `torii-sql-sink` is the first real consumer: it +receives `Envelope`s decoded by a paired `SqlDecoder`, writes a row per +envelope, and fans the write out to three downstream channels (EventBus, +sink-specific gRPC broadcast, SQL-queryable table). It is used as the +primary demo sink in `examples/simple_sql_sink` and `examples/multi_sink_example`. -1. **EventBus** - Publishes to central topic-based subscriptions (via `torii.Torii/Subscribe`) -2. **gRPC Service** - Provides `torii.sinks.sql.SqlSink` service with: - - `Query` - Execute SQL queries - - `StreamQuery` - Stream large result sets - - `GetSchema` - Get database schema - - `Subscribe` - Real-time operation updates -3. **REST HTTP** - Exposes: - - `POST /sql/query` - Execute SQL queries - - `GET /sql/events` - List all SQL operations - -## Usage - -```rust -use std::sync::Arc; -use torii::{ToriiConfig, run}; -use torii_sql_sink::{SqlDecoder, SqlSink}; -use tonic::transport::Server; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // 1. Create sink - let sql_sink = SqlSink::new("postgres://torii:torii@localhost:5432/torii").await?; - - // 2. Get the gRPC service implementation - let sql_grpc_service = sql_sink.get_grpc_service_impl(); - - // 3. Build gRPC router with SQL sink service - let grpc_router = { - use torii_sql_sink::proto::sql_sink_server::SqlSinkServer; - - Server::builder() - .accept_http1(true) - .add_service(tonic_web::enable(SqlSinkServer::new((*sql_grpc_service).clone()))) - }; - - // 4. Create decoder - let sql_decoder = Arc::new(SqlDecoder::new(Vec::new())); // No filters - - // 5. Get sample events - let sample_events = SqlSink::generate_sample_events(); - - // 6. Configure and run Torii - let config = ToriiConfig::builder() - .port(8080) - .add_sink_boxed(Box::new(sql_sink)) - .add_decoder(sql_decoder) - .with_grpc_router(grpc_router) - .with_sample_events(sample_events) - .build(); +## Architecture - run(config).await -} +```text ++----------------------------------------------------------------+ +| SqlSink | +| | +| +-------------+ +---------------+ +------------------+ | +| | SqlDecoder |--->| Envelope |--->| Sink::process | | +| | (sql.insert, | sql.insert/ | | INSERT INTO | | +| | sql.update) | sql.update | | sql_operation | | +| +-------------+ +---------------+ | (SQLite/Postgres)| | +| +------------------+ | +| | | +| +---------------------------+----------+ | +| | | | | +| v v v | +| EventBus.publish_ grpc_service HTTP| +| protobuf("sql", ...) .update_tx.send() api| +| with matches_filters (broadcast chan) ↓ | +| | | /sql/ *| +| v v | +| gRPC clients subscribed SqlSinkService | +| to torii.Torii/Subscribe (tonic) | ++----------------------------------------------------------------+ ``` -## Protobuf Code Generation - -The gRPC service definitions are in `proto/sql.proto`. The Rust code is automatically generated during build. - -### Generated Files - -- **Location**: `src/generated/torii.sinks.sql.rs` -- **Generated by**: `build.rs` (runs automatically with `cargo build`) -- **Includes**: Service trait, message types, client/server stubs - -### How to Regenerate - -The protobuf code is automatically regenerated when: - -1. **Building the crate**: `cargo build -p torii-sql-sink` -2. **Proto file changes**: Any modification to `proto/sql.proto` triggers regeneration - -### Manual Regeneration - -If you need to force regeneration: - -```bash -# Clean and rebuild -cargo clean -p torii-sql-sink -cargo build -p torii-sql-sink - -# Or touch the proto file to trigger rebuild -touch crates/torii-sql-sink/proto/sql.proto -cargo build -p torii-sql-sink +## Deep Dive + +### Public API + +| Item | File | Line | Purpose | +|---|---|---|---| +| `SqlSink` | `src/lib.rs` | 44 | The `Sink` impl — holds `Arc>`, the gRPC service, and the EventBus handle | +| `SqlSink::new(database_url)` | `src/lib.rs` | 94 | Detects SQLite vs Postgres, runs the embedded schema | +| `SqlSink::get_grpc_service_impl` | `src/lib.rs` | 83 | Returns `Arc` for registration via `with_grpc_router` | +| `SqlSink::generate_sample_events` | `src/lib.rs` | 54 | Test fixture events | +| `SqlDecoder` / `SqlInsert` / `SqlUpdate` | `src/decoder.rs` | — | Pair decoder; emits `sql.insert` / `sql.update` envelopes | +| `SqlSinkService` | `src/grpc_service.rs` | 18 | Implements `Query`, `StreamQuery`, `GetSchema`, `Subscribe` | +| `ProtoSqlOperation` / `SqlOperationUpdate` | `src/proto` (generated) | — | Wire types from `proto/sql.proto` | +| `FILE_DESCRIPTOR_SET` | `src/lib.rs` | 12 | Re-exported bytes for reflection composition | + +### Internal Modules + +- `lib.rs` — `Sink` impl. `process` handles both envelope types, broadcasts to EventBus + gRPC, writes the row. +- `decoder.rs` — `SqlDecoder`, `SqlInsert`, `SqlUpdate` + the `matches_filters` helper used by `EventBus::publish_protobuf`. +- `grpc_service.rs` — tonic service with a `tokio::sync::broadcast::Sender` fanning out live updates to subscribed RPC clients. +- `api.rs` — Axum handlers for `/sql/query` (arbitrary SQL) and `/sql/events` (recent rows). +- `samples.rs` — sample events for tutorials. +- `generated/` — `torii.sinks.sql.rs` + `sql_descriptor.bin` produced by `build.rs` from `proto/sql.proto`. + +### Sink trait wiring + +| Method | Behavior | +|---|---| +| `name` | `"sql"` | +| `interested_types` | `[TypeId::new("sql.insert"), TypeId::new("sql.update")]` | +| `process(envelopes, batch)` | For each envelope: run an `INSERT INTO {sql_operation}` via `QueryBuilder`, then encode to `ProtoSqlOperation`, publish on EventBus topic `"sql"` with filter fn `matches_filters`, and send on the gRPC broadcast channel | +| `topics` | One `TopicInfo { name: "sql", available_filters: [table, operation, value_gt, value_lt, value_gte, value_lte, value_eq] }` | +| `build_routes` | Axum router: `POST /sql/query`, `GET /sql/events` | +| `initialize(event_bus, ctx)` | Stores the `event_bus` handle so subsequent `process` calls can publish | + +### Storage + +Single table, same shape on both backends: + +```sql +CREATE TABLE sql_operation ( + id INTEGER PRIMARY KEY AUTOINCREMENT, -- SERIAL on Postgres + table_name TEXT NOT NULL, + operation TEXT NOT NULL, + value INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); ``` -### Build Process +Postgres uses the `sql_sink.sql_operation` schema-qualified name; SQLite uses +the bare table name. `SqlSink::table_name` (`src/lib.rs:87`) picks between +them. -The `build.rs` script: +### Filtering (EventBus topic `"sql"`) -1. Creates `src/generated/` directory if it doesn't exist -2. Configures `tonic-build` to generate server code (no client) -3. Compiles `proto/sql.proto` into Rust code -4. Outputs to `src/generated/torii.sinks.sql.rs` +| Filter key | Semantics | +|---|---| +| `table` | Exact match on `table_name` | +| `operation` | Exact match on `"insert"` / `"update"` | +| `value_gt`, `value_lt`, `value_gte`, `value_lte`, `value_eq` | Numeric comparisons on the integer `value` | -See `build.rs` for implementation details. - -## Architecture - -``` -┌─────────────────────────────────────────────────┐ -│ SqlSink │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌────────────────┐ ┌─────────────────┐ │ -│ │ SqlDecoder │─────▶│ SqlOperation │ │ -│ │ │ │ (SQLite) │ │ -│ │ Events → SQL │ │ Database │ │ -│ └────────────────┘ └─────────────────┘ │ -│ │ │ -│ ┌─────────────┼──────────┐ │ -│ │ │ │ │ -│ ▼ ▼ ▼ │ -│ ┌──────────────┐ ┌────────┐ ┌────┐│ -│ │ EventBus │ │ gRPC │ │HTTP││ -│ │ (central) │ │ APIs │ │API ││ -│ └──────────────┘ └────────┘ └────┘│ -│ │ -└─────────────────────────────────────────────────┘ -``` +Filter logic lives in `SqlSink::matches_filters` (`src/lib.rs:~200`) and is +passed by reference to `EventBus::publish_protobuf` so the central bus can +decide per-client routing without re-decoding the protobuf for each filter +evaluation. -## Storage - -- **Database**: Stores SQL operations (insert, update, etc.) in SQLite or PostgreSQL. -- **Schema**: - ```sql - CREATE TABLE sql_operation ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - table_name TEXT NOT NULL, - operation TEXT NOT NULL, - value INTEGER NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ``` -- **Thread-Safe**: Uses `Arc>` for concurrent access - -## Filtering - -The SqlSink supports filtering via EventBus subscriptions: - -- `table` - Filter by table name (e.g., "user", "order") -- `operation` - Filter by operation type (e.g., "insert", "update") -- `value_gt` - Value greater than -- `value_lt` - Value less than -- `value_gte` - Value greater than or equal -- `value_lte` - Value less than or equal -- `value_eq` - Value equal to - -Example subscription: -```json -{ - "topics": ["sql"], - "filters": { - "sql": { - "table": "user", - "operation": "insert", - "value_gt": "100" - } - } -} -``` +### Interactions -## gRPC Service Registration +- **Upstream (consumers)**: `examples/simple_sql_sink`, `examples/multi_sink_example`. +- **Downstream deps**: `torii`, `sqlx` (sqlite/postgres/any), `tonic`, `tonic-web`, `prost`, `prost-types`, `axum`, `chrono`, `hex`, `anyhow`, `tracing`. +- **Workspace deps**: `torii` only (this sink pre-dates `torii-sql`; it uses raw `sqlx::Any`). -Due to Rust's type system limitations, gRPC services must be registered by the user: +### gRPC registration pattern ```rust -// User code (e.g., main.rs or examples/) +use torii::{ToriiConfig, run}; +use torii_sql_sink::{SqlDecoder, SqlSink}; use torii_sql_sink::proto::sql_sink_server::SqlSinkServer; use tonic::transport::Server; +use std::sync::Arc; -let sql_sink = SqlSink::new("postgres://torii:torii@localhost:5432/torii").await?; -let service = sql_sink.get_grpc_service_impl(); +let sink = SqlSink::new("postgres://torii:torii@localhost:5432/torii").await?; +let service = sink.get_grpc_service_impl(); let grpc_router = Server::builder() .accept_http1(true) .add_service(tonic_web::enable(SqlSinkServer::new((*service).clone()))); -// Pass router to Torii let config = ToriiConfig::builder() - .add_sink_boxed(Box::new(sql_sink)) + .add_sink_boxed(Box::new(sink)) + .add_decoder(Arc::new(SqlDecoder::new(Vec::new()))) .with_grpc_router(grpc_router) .build(); -``` - -This pattern keeps all gRPC services on the same port while maintaining type safety. - -## Testing - -```bash -# Build -cargo build -p torii-sql-sink - -# Run tests -cargo test -p torii-sql-sink - -# Test gRPC (with server running) -grpcurl -plaintext localhost:8080 torii.sinks.sql.SqlSink/Query -grpcurl -plaintext localhost:8080 torii.sinks.sql.SqlSink/GetSchema -# Test HTTP -curl -X POST http://localhost:8080/sql/query -d '{"query":"SELECT * FROM sql_operation"}' -curl http://localhost:8080/sql/events +run(config).await?; ``` -## See Also +### Extension Points -- **Example**: `examples/simple_sql_sink` - Basic SqlSink usage -- **Example**: `examples/multi_sink_example` - Multi-sink usage (SqlSink + LogSink) -- **Log Sink**: `crates/torii-log-sink` - Similar architecture with in-memory storage +- New envelope types → widen `interested_types` + add a branch to `process` + add a new `TypeId::new("sql.*")`. +- New filter keys → add a key to `TopicInfo.available_filters` and extend `matches_filters`. +- New REST endpoints → add routes in `build_routes` (state stays in `api::SqlSinkState`). +- **Testing**: `grpcurl -plaintext localhost:8080 torii.sinks.sql.SqlSink/Query` and `curl http://localhost:8080/sql/events`. diff --git a/crates/torii-sql-sink/src/decoder.rs b/crates/torii-sql-sink/src/decoder.rs index ba03c262..6a50db92 100644 --- a/crates/torii-sql-sink/src/decoder.rs +++ b/crates/torii-sql-sink/src/decoder.rs @@ -2,15 +2,17 @@ //! //! It demonstrate how to decode Starknet events into envelopes based on the event content. use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; -use starknet::core::utils::parse_cairo_short_string; -use starknet::macros::selector; +use starknet_types_raw::event::EmittedEvent; +use starknet_types_raw::Felt; use std::any::Any; use std::collections::HashMap; use torii::etl::decoder::Decoder; use torii::etl::envelope::{Envelope, TypeId, TypedBody}; +use torii::etl::EventContext; +const INSERT_SELECTOR: Felt = Felt::selector("insert"); +const UPDATE_SELECTOR: Felt = Felt::selector("update"); /// SqlInsert event type - represents a SQL insert operation. /// By deriving TypedBody, it allows the envelope to be downcast to this type. #[derive(Debug, Clone)] @@ -88,35 +90,18 @@ impl SqlDecoder { self.contract_filters.contains(&event.from_address) } -} -/// Implementation of the Decoder trait for generic usage (not specific to the SQL sink). -#[async_trait] -impl Decoder for SqlDecoder { - fn decoder_name(&self) -> &'static str { - "sql" - } - - async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result> { + pub async fn decode(&self, event: &EmittedEvent) -> anyhow::Result> { if !self.is_interested(event) { return Ok(Vec::new()); } - let insert_selector = selector!("insert"); - let update_selector = selector!("update"); - - // We could add additional checks for example length of keys etc.. - // In this case, we're going to assume they are present already. let selector = match event.keys.first() { Some(s) => s, None => return Ok(Vec::new()), }; - let table_name = match event - .keys - .get(1) - .and_then(|k| parse_cairo_short_string(k).ok()) - { + let table_name = match event.keys.get(1).and_then(|k| k.as_short_ascii_str().ok()) { Some(name) => name, None => return Ok(Vec::new()), }; @@ -126,18 +111,18 @@ impl Decoder for SqlDecoder { None => return Ok(Vec::new()), }; - let (body, operation): (Box, &str) = if *selector == insert_selector { + let (body, operation): (Box, &str) = if *selector == INSERT_SELECTOR { ( Box::new(SqlInsert { - table: table_name.clone(), + table: table_name.to_string(), value, }), "insert", ) - } else if *selector == update_selector { + } else if *selector == UPDATE_SELECTOR { ( Box::new(SqlUpdate { - table: table_name.clone(), + table: table_name.to_string(), value, }), "update", @@ -151,12 +136,10 @@ impl Decoder for SqlDecoder { return Ok(Vec::new()); }; - // Create metadata, they are optional, but currently they can give more context to the envelope - // without adding this information to the envelope body. let mut metadata = HashMap::new(); metadata.insert("source".to_string(), "starknet".to_string()); metadata.insert("operation".to_string(), operation.to_string()); - metadata.insert("table".to_string(), table_name.clone()); + metadata.insert("table".to_string(), table_name.to_string()); metadata.insert("value".to_string(), value.to_string()); metadata.insert( "from_address".to_string(), @@ -170,3 +153,29 @@ impl Decoder for SqlDecoder { )]) } } + +/// Implementation of the Decoder trait for generic usage (not specific to the SQL sink). +#[async_trait] +impl Decoder for SqlDecoder { + fn decoder_name(&self) -> &'static str { + "sql" + } + + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> anyhow::Result> { + let event = EmittedEvent { + from_address: context.from_address, + keys: keys.to_vec(), + data: data.to_vec(), + block_hash: None, + block_number: Some(context.block_number), + transaction_hash: context.transaction_hash, + }; + + SqlDecoder::decode(self, &event).await + } +} diff --git a/crates/torii-sql-sink/src/lib.rs b/crates/torii-sql-sink/src/lib.rs index 32739823..e166b980 100644 --- a/crates/torii-sql-sink/src/lib.rs +++ b/crates/torii-sql-sink/src/lib.rs @@ -12,21 +12,17 @@ pub mod proto { pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("generated/sql_descriptor.bin"); use async_trait::async_trait; -use axum::{ - routing::{get, post}, - Router, -}; +use axum::routing::{get, post}; +use axum::Router; use prost::Message; use prost_types::Any as ProtoAny; -use sqlx::{any::AnyPoolOptions, Any as SqlxAny, QueryBuilder}; +use sqlx::any::AnyPoolOptions; +use sqlx::{Any as SqlxAny, QueryBuilder}; use std::sync::Arc; - -use starknet::core::types::EmittedEvent; -use torii::etl::{ - envelope::{Envelope, TypeId}, - extractor::ExtractionBatch, - sink::{EventBus, Sink, TopicInfo}, -}; +use torii::etl::envelope::{Envelope, TypeId}; +use torii::etl::extractor::ExtractionBatch; +use torii::etl::sink::{EventBus, Sink, TopicInfo}; +use torii::etl::StarknetEvent; use torii::grpc::UpdateType; pub use decoder::{SqlDecoder, SqlInsert, SqlUpdate}; @@ -55,7 +51,7 @@ pub struct SqlSink { impl SqlSink { /// Generates sample events for testing the SQL sink. - pub fn generate_sample_events() -> Vec { + pub fn generate_sample_events() -> Vec { samples::generate_sample_events() } diff --git a/crates/torii-sql-sink/src/samples.rs b/crates/torii-sql-sink/src/samples.rs index 9611e129..23fc8bef 100644 --- a/crates/torii-sql-sink/src/samples.rs +++ b/crates/torii-sql-sink/src/samples.rs @@ -5,12 +5,11 @@ //! SampleExtractor to generate test data. //! The decoder is where the events are decoded into envelopes based on the event content. -use starknet::core::types::{EmittedEvent, Felt}; -use starknet::core::utils::cairo_short_string_to_felt; -use starknet::macros::{felt, selector}; +use starknet_types_raw::Felt; +use torii::etl::StarknetEvent; const DUMMY_CONTRACT_ADDRESS: Felt = - felt!("0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"); + Felt::from_hex_unchecked("0x049d36570d4e46f48e99674bd3fcc84644ddd6b96f7c741b1562b82f9e004dc7"); /// Generate sample events for testing the SQL sink /// @@ -20,52 +19,47 @@ const DUMMY_CONTRACT_ADDRESS: Felt = /// - Processed by SqlSink and inserted into the events table /// - Published to gRPC subscribers /// -/// Returns a vector of EmittedEvent objects. The remaining default fields are automatically filled by the extractor -/// if not provided. -pub fn generate_sample_events() -> Vec { +/// Returns a vector of sample Starknet events. +pub fn generate_sample_events() -> Vec { vec![ - EmittedEvent { + StarknetEvent { from_address: DUMMY_CONTRACT_ADDRESS, keys: vec![ - selector!("insert"), - cairo_short_string_to_felt("user").unwrap(), + Felt::selector("insert"), + Felt::from_short_ascii_str_unchecked("user"), ], data: vec![Felt::from(100u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: DUMMY_CONTRACT_ADDRESS, keys: vec![ - selector!("update"), - cairo_short_string_to_felt("user").unwrap(), + Felt::selector("update"), + Felt::from_short_ascii_str_unchecked("user"), ], data: vec![Felt::from(200u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: DUMMY_CONTRACT_ADDRESS, keys: vec![ - selector!("insert"), - cairo_short_string_to_felt("order").unwrap(), + Felt::selector("insert"), + Felt::from_short_ascii_str_unchecked("order"), ], data: vec![Felt::from(150u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: DUMMY_CONTRACT_ADDRESS, keys: vec![ - selector!("update"), - cairo_short_string_to_felt("order").unwrap(), + Felt::selector("update"), + Felt::from_short_ascii_str_unchecked("order"), ], data: vec![Felt::from(300u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, ] diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml new file mode 100644 index 00000000..d552c211 --- /dev/null +++ b/crates/types/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "torii-types" +version = "0.1.0" +edition = "2021" +description = "Common types for Torii" + +[dependencies] +thiserror.workspace = true +starknet-types-raw = { workspace = true, features = ["serde"] } +starknet = { workspace = true, optional = true } + +[lints] +workspace = true + +[features] +field = ["dep:starknet"] diff --git a/crates/types/README.md b/crates/types/README.md new file mode 100644 index 00000000..094355a5 --- /dev/null +++ b/crates/types/README.md @@ -0,0 +1,71 @@ +# torii-types + +The lowest-level shared-types crate. Holds the three primitives that every +decoder, extractor, and sink agrees on: `StarknetEvent`, `EventContext`, and +`BlockContext`. Nothing here is async; nothing here does I/O. + +## Role in Torii + +`StarknetEvent` is the atomic unit produced by every `Extractor` and consumed +by every `Decoder`. `EventContext` is the identity of an event +(`from_address` + `block_number` + `tx_hash`) and is what decoders receive in +`Decoder::decode(keys, data, context)`. `BlockContext` is the dedup-key +value in `ExtractionBatch.blocks`. Because every other Torii crate depends +on the root `torii` crate, this crate is implicitly on every compile path — +keeping it tiny and dependency-free is the point. + +## Architecture + +```text ++-----------------------------------------------------------+ +| torii-types | +| | +| event.rs block.rs | +| +-------------------+ +-----------------------+ | +| | StarknetEvent | | BlockContext | | +| | from_address | | number | | +| | keys | | hash | | +| | data | | parent_hash | | +| | block_number | | timestamp | | +| | transaction_hash| +-----------------------+ | +| +-------------------+ | +| | | +| v | +| EventContext <-- .context() | +| from_address | block_number | transaction_hash | +| | +| (optional) feature "field": TryFrom`, dropping failures (typically events without a block number) | +| `EventContext` | `src/event.rs` | 13 | Copy-hashable identity used as a key in decoder code | +| `MissingBlockNumber` | `src/event.rs` | 19 | Error returned when converting from `starknet::core::types::EmittedEvent` without a confirmed block | +| `BlockContext` | `src/block.rs` | 5 | Dedupe-key for `ExtractionBatch.blocks` | + +### Internal Modules + +- `event` — `StarknetEvent`, `EventContext`, `MissingBlockNumber`, optional `feature = "field"` conversion from `starknet::core::types::EmittedEvent`. +- `block` — one struct. + +### Interactions + +- **Upstream (consumers)**: the root `torii` crate re-exports both types from `src/etl/mod.rs:18` and `src/etl/extractor/mod.rs:33`. All sinks/decoders use them through that re-export. +- **Downstream deps**: `starknet-types-raw`; optional `starknet` (feature `field`) for the `EmittedEvent` conversion. +- **Feature flags**: `field` (opt-in) — adds the `TryFrom` impl. Enabled by the root crate via `torii-types = { workspace = true, features = ["field"] }`. + +### Extension Points + +- Anything cross-cutting that isn’t a `StarknetEvent` or a `BlockContext` does **not** belong here — keep it pure. Transaction / class / deployment contexts live in `src/etl/extractor/mod.rs` with the rest of the batch types. diff --git a/crates/types/src/block.rs b/crates/types/src/block.rs new file mode 100644 index 00000000..19efc047 --- /dev/null +++ b/crates/types/src/block.rs @@ -0,0 +1,10 @@ +use starknet_types_raw::Felt; + +/// Block context information +#[derive(Debug, Clone, Default)] +pub struct BlockContext { + pub number: u64, + pub hash: Felt, + pub parent_hash: Felt, + pub timestamp: u64, +} diff --git a/crates/types/src/event.rs b/crates/types/src/event.rs new file mode 100644 index 00000000..00a05ad2 --- /dev/null +++ b/crates/types/src/event.rs @@ -0,0 +1,89 @@ +use starknet_types_raw::Felt; + +#[derive(Debug, Clone)] +pub struct StarknetEvent { + pub from_address: Felt, + pub keys: Vec, + pub data: Vec, + pub block_number: u64, + pub transaction_hash: Felt, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct EventContext { + pub from_address: Felt, + pub block_number: u64, + pub transaction_hash: Felt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct MissingBlockNumber; + +impl std::fmt::Display for MissingBlockNumber { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Event is missing block number") + } +} + +impl std::error::Error for MissingBlockNumber {} + +impl std::fmt::Display for EventContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "from={:#066x} block={} tx={:#066x}", + self.from_address, self.block_number, self.transaction_hash + ) + } +} + +#[cfg(feature = "field")] +mod field { + use super::{MissingBlockNumber, StarknetEvent}; + use starknet::core::types::EmittedEvent as SnEmittedEvent; + impl TryFrom for StarknetEvent { + type Error = MissingBlockNumber; + + fn try_from(event: SnEmittedEvent) -> Result { + Ok(Self { + from_address: event.from_address.into(), + keys: event.keys.into_iter().map(Into::into).collect(), + data: event.data.into_iter().map(Into::into).collect(), + block_number: event.block_number.ok_or(MissingBlockNumber)?, + transaction_hash: event.transaction_hash.into(), + }) + } + } +} + +impl StarknetEvent { + pub fn new( + from_address: Felt, + keys: Vec, + data: Vec, + block_number: u64, + transaction_hash: Felt, + ) -> Self { + StarknetEvent { + from_address, + keys, + data, + block_number, + transaction_hash, + } + } + pub fn context(&self) -> EventContext { + EventContext { + from_address: self.from_address, + block_number: self.block_number, + transaction_hash: self.transaction_hash, + } + } + + pub fn filter_pending(events: Vec>) -> Vec { + events + .into_iter() + .filter_map(|e| e.try_into().ok()) + .collect() + } +} diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs new file mode 100644 index 00000000..b4c01961 --- /dev/null +++ b/crates/types/src/lib.rs @@ -0,0 +1,2 @@ +pub mod block; +pub mod event; diff --git a/examples/eventbus_only_sink/main.rs b/examples/eventbus_only_sink/main.rs index 49888c46..92f6d6ae 100644 --- a/examples/eventbus_only_sink/main.rs +++ b/examples/eventbus_only_sink/main.rs @@ -14,13 +14,14 @@ use anyhow::Result; use axum::Router; use prost::Message; use prost_types::Any; -use starknet::core::types::EmittedEvent; +use starknet_types_raw::Felt; use std::sync::Arc; use torii::etl::envelope::{Envelope, TypeId, TypedBody}; use torii::etl::extractor::ExtractionBatch; use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; -use torii::etl::Decoder; +use torii::etl::{Decoder, EventContext}; use torii::{async_trait, run, ToriiConfig, UpdateType}; +use torii_types::event::StarknetEvent; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // 1. DEFINE EVENT TYPE @@ -70,24 +71,20 @@ impl BroadcastSink { Self { event_bus: None } } - pub fn generate_sample_events() -> Vec { - use starknet::core::types::Felt; - + pub fn generate_sample_events() -> Vec { vec![ - EmittedEvent { + StarknetEvent { from_address: Felt::from_hex("0x1234567890abcdef").unwrap(), keys: vec![Felt::from_hex("0x1").unwrap()], data: vec![Felt::from_hex("0x100").unwrap()], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: Felt::from_hex("0xfedcba0987654321").unwrap(), keys: vec![Felt::from_hex("0x2").unwrap()], data: vec![Felt::from_hex("0x200").unwrap()], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, ] @@ -189,15 +186,20 @@ impl Decoder for BroadcastDecoder { "broadcast" } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { + async fn decode( + &self, + _keys: &[Felt], + _data: &[Felt], + context: EventContext, + ) -> Result> { let broadcast_event = BroadcastEvent { - event_id: format!("{:#x}", event.transaction_hash), - from_address: format!("{:#x}", event.from_address), - block_number: event.block_number.unwrap_or(0), + event_id: format!("{:#x}", context.transaction_hash), + from_address: format!("{:#x}", context.from_address), + block_number: context.block_number, }; Ok(vec![Envelope::new( - format!("broadcast_{:#x}", event.transaction_hash), + format!("broadcast_{:#x}", context.transaction_hash), Box::new(broadcast_event), Default::default(), )]) diff --git a/examples/http_only_sink/main.rs b/examples/http_only_sink/main.rs index 7c542cb5..7fd8c97a 100644 --- a/examples/http_only_sink/main.rs +++ b/examples/http_only_sink/main.rs @@ -12,19 +12,18 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; -use starknet::core::types::EmittedEvent; +use starknet_types_raw::Felt; use std::sync::{Arc, RwLock}; -use torii::axum::{ - extract::State, - http::StatusCode, - routing::{get, post}, - Json, Router, -}; +use torii::axum::extract::State; +use torii::axum::http::StatusCode; +use torii::axum::routing::{get, post}; +use torii::axum::{Json, Router}; use torii::etl::envelope::{Envelope, TypeId, TypedBody}; use torii::etl::extractor::ExtractionBatch; use torii::etl::sink::{EventBus, Sink, SinkContext, TopicInfo}; -use torii::etl::Decoder; +use torii::etl::{Decoder, EventContext}; use torii::{async_trait, run, ToriiConfig}; +use torii_types::event::StarknetEvent; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // 1. DEFINE EVENT TYPE @@ -109,32 +108,27 @@ impl HttpSink { } } - pub fn generate_sample_events() -> Vec { - use starknet::core::types::Felt; - + pub fn generate_sample_events() -> Vec { vec![ - EmittedEvent { + StarknetEvent { from_address: Felt::from_hex("0xaabbccdd").unwrap(), keys: vec![Felt::from_hex("0x1").unwrap()], data: vec![Felt::from_hex("0x100").unwrap()], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: Felt::from_hex("0x11223344").unwrap(), keys: vec![Felt::from_hex("0x2").unwrap()], data: vec![Felt::from_hex("0x200").unwrap()], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: Felt::from_hex("0x55667788").unwrap(), keys: vec![Felt::from_hex("0x3").unwrap()], data: vec![Felt::from_hex("0x300").unwrap()], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, ] @@ -270,15 +264,20 @@ impl Decoder for HttpDecoder { "http" } - async fn decode_event(&self, event: &EmittedEvent) -> Result> { + async fn decode( + &self, + _keys: &[Felt], + _data: &[Felt], + context: EventContext, + ) -> Result> { let id = self .counter .fetch_add(1, std::sync::atomic::Ordering::SeqCst); let stored_event = StoredEvent { id, - from_address: format!("{:#x}", event.from_address), - block_number: event.block_number.unwrap_or(0), + from_address: format!("{:#x}", context.from_address), + block_number: context.block_number, timestamp: chrono::Utc::now().timestamp(), }; diff --git a/examples/introspect/restart.rs b/examples/introspect/restart.rs index 1221bd2d..3387bd23 100644 --- a/examples/introspect/restart.rs +++ b/examples/introspect/restart.rs @@ -1,32 +1,69 @@ use itertools::Itertools; -use sqlx::{postgres::PgPoolOptions, PgPool}; -use std::sync::Arc; +use starknet::core::types::Felt; +use torii::etl::decoder::Decoder; use torii_dojo::decoder::DojoDecoder; -use torii_dojo::store::postgres::PgStore; -use torii_dojo::DojoToriiError; -use torii_introspect_postgres_sink::IntrospectPgDb; -use torii_test_utils::{resolve_path_like, EventIterator, FakeProvider}; +use torii_dojo::store::DojoStoreTrait; +use torii_introspect::events::{IntrospectBody, IntrospectMsg}; +use torii_introspect_sql_sink::IntrospectDb; +use torii_sql::{DbPool, PoolConfig}; +use torii_test_utils::{resolve_path_like, FakeProvider, MultiContractEventIterator}; +use torii_types::event::StarknetEvent; -const DB_URL: &str = "postgres://torii:torii@localhost:5432/torii"; -// const CHAIN_DATA_PATH: &str = "~/tc-tests/pistols-2"; -// const SCHEMA_NAME: &str = "pistols"; -const CHAIN_DATA_PATH: &str = "~/tc-tests/blob-arena-2"; -const SCHEMA_NAME: &str = "blob_arena"; -const BATCH_SIZE: usize = 10000; +// const DB_URL: &str = "postgres://torii:torii@localhost:5432/torii"; +const DB_URL: &str = "sqlite://sqlite-data.db?mode=rwc"; +const EVENT_PATHS: [&str; 2] = ["~/tc-tests/blob-arena/events", "~/tc-tests/pistols/events"]; +const MODEL_CONTRACTS_PATH: &str = "~/tc-tests/model-contracts"; +const BATCH_SIZE: usize = 2000; +const PISTOLS_ADDRESS: Felt = + Felt::from_hex_unchecked("08b4838140a3cbd36ebe64d4b5aaf56a30cc3753c928a79338bf56c53f506c5"); +const BLOB_ARENA_ADDRESS: Felt = + Felt::from_hex_unchecked("2d26295d6c541d64740e1ae56abc079b82b22c35ab83985ef8bd15dc0f9edfb"); + +// const SCHEMA_MAP: [(Felt, &str); 2] = [ +// (PISTOLS_ADDRESS, "pistols"), +// (BLOB_ARENA_ADDRESS, "blob_arena"), +// ]; + +const ADDRESSES: [Felt; 2] = [PISTOLS_ADDRESS, BLOB_ARENA_ADDRESS]; + +fn clone_introspect_msg(msg: &IntrospectMsg) -> IntrospectMsg { + match msg { + IntrospectMsg::CreateTable(value) => IntrospectMsg::CreateTable(value.clone()), + IntrospectMsg::UpdateTable(value) => IntrospectMsg::UpdateTable(value.clone()), + IntrospectMsg::RenameTable(value) => IntrospectMsg::RenameTable(value.clone()), + IntrospectMsg::RenamePrimary(value) => IntrospectMsg::RenamePrimary(value.clone()), + IntrospectMsg::RetypePrimary(value) => IntrospectMsg::RetypePrimary(value.clone()), + IntrospectMsg::RenameColumns(value) => IntrospectMsg::RenameColumns(value.clone()), + IntrospectMsg::RetypeColumns(value) => IntrospectMsg::RetypeColumns(value.clone()), + IntrospectMsg::AddColumns(value) => IntrospectMsg::AddColumns(value.clone()), + IntrospectMsg::DropTable(value) => IntrospectMsg::DropTable(value.clone()), + IntrospectMsg::DropColumns(value) => IntrospectMsg::DropColumns(value.clone()), + IntrospectMsg::InsertsFields(value) => IntrospectMsg::InsertsFields(value.clone()), + IntrospectMsg::DeleteRecords(value) => IntrospectMsg::DeleteRecords(value.clone()), + IntrospectMsg::DeletesFields(value) => IntrospectMsg::DeletesFields(value.clone()), + } +} async fn run_events( - events: &mut EventIterator, + events: &mut MultiContractEventIterator, provider: FakeProvider, - pool: Arc, + pool: DbPool, end: Option, event_n: &mut u32, success: &mut u32, ) -> bool { - let decoder = DojoDecoder::, _>::new(pool.clone(), provider); - let db = IntrospectPgDb::new(pool.clone(), SCHEMA_NAME); - decoder.store.initialize().await.unwrap(); + println!("Starting event processing run"); + let decoder = DojoDecoder::new(pool.clone(), provider); + let db = IntrospectDb::new(pool, ADDRESSES); + decoder.initialize().await.unwrap(); decoder.load_tables(&[]).await.unwrap(); - db.initialize_introspect_pg_sink().await.unwrap(); + let errors = db.initialize_introspect_sql_sink().await.unwrap(); + if !errors.is_empty() { + for err in errors { + println!("Error loading table: {err}"); + } + panic!(""); + } let mut running = true; let mut this_run = 0; while running { @@ -38,18 +75,38 @@ async fn run_events( }; *event_n += 1; this_run += 1; - match decoder.decode_raw_event(&event).await { - Ok(msg) => { - msgs.push(msg); - } - Err(DojoToriiError::UnknownDojoEventSelector(_)) => { - println!("Unknown event selector, skipping event"); + let event = StarknetEvent { + from_address: event.from_address, + keys: event.keys, + data: event.data, + block_number: event.block_number.unwrap_or_default(), + transaction_hash: event.transaction_hash, + }; + match decoder.decode_event(&event).await { + Ok(envelopes) => { + for envelope in envelopes { + let Some(body) = envelope.downcast_ref::() else { + continue; + }; + let metadata = body.context; + match &body.msg { + IntrospectMsg::CreateTable(msg) => { + let mut msg = msg.clone(); + msg.append_only = true; + msgs.push((IntrospectMsg::CreateTable(msg), metadata).into()); + } + other => { + msgs.push((clone_introspect_msg(other), metadata).into()); + } + } + } } Err(err) => { println!("Failed to decode event: {err:?}"); } }; } + let msgs_ref = msgs.iter().collect_vec(); for res in db.process_messages(msgs_ref).await.unwrap() { match res { @@ -72,19 +129,21 @@ async fn run_events( #[tokio::main] async fn main() { - let chain_path = resolve_path_like(CHAIN_DATA_PATH); - let events_path = chain_path.join("events"); - let contracts_path = chain_path.join("model-contracts"); - let provider = FakeProvider::new(contracts_path); - let mut event_iterator = EventIterator::new(events_path); - let pool = Arc::new(PgPoolOptions::new().connect(DB_URL).await.unwrap()); + let event_paths = EVENT_PATHS.map(resolve_path_like).to_vec(); + let provider = FakeProvider::new(resolve_path_like(MODEL_CONTRACTS_PATH)); + let mut event_iterator = MultiContractEventIterator::new(event_paths); + let pool = PoolConfig::new(DB_URL.to_string()) + .max_connections(5) + .connect_any() + .await + .unwrap(); let mut event_n = 0; let mut success = 0; while run_events( &mut event_iterator, provider.clone(), pool.clone(), - Some(500000), + Some(20000), &mut event_n, &mut success, ) diff --git a/examples/introspect/simple.rs b/examples/introspect/simple.rs deleted file mode 100644 index cbe2b205..00000000 --- a/examples/introspect/simple.rs +++ /dev/null @@ -1,65 +0,0 @@ -use itertools::Itertools; -use sqlx::postgres::PgPoolOptions; -use std::sync::Arc; -use torii_dojo::decoder::DojoDecoder; -use torii_dojo::store::postgres::PgStore; -use torii_dojo::DojoToriiError; -use torii_introspect_postgres_sink::IntrospectPgDb; -use torii_test_utils::{resolve_path_like, EventIterator, FakeProvider}; - -const DB_URL: &str = "postgres://torii:torii@localhost:5432/torii"; -const CHAIN_DATA_PATH: &str = "~/tc-tests/pistols"; -const SCHEMA_NAME: &str = "pistols"; -// const CHAIN_DATA_PATH: &str = "~/tc-tests/blob-arena"; -// const SCHEMA_NAME: &str = "blob_arena"; -const BATCH_SIZE: usize = 1000; - -#[tokio::main] -async fn main() { - let chain_path = resolve_path_like(CHAIN_DATA_PATH); - let events_path = chain_path.join("events"); - let contracts_path = chain_path.join("model-contracts"); - let provider = FakeProvider::new(contracts_path); - let mut event_iterator = EventIterator::new(events_path); - - let pool = Arc::new(PgPoolOptions::new().connect(DB_URL).await.unwrap()); - let decoder = DojoDecoder::, _>::new(pool.clone(), provider); - let db = IntrospectPgDb::new(pool.clone(), SCHEMA_NAME); - decoder.store.initialize().await.unwrap(); - db.initialize_introspect_pg_sink().await.unwrap(); - - let mut event_n = 0; - let mut success = 0; - let mut running = true; - while running { - let mut msgs = Vec::with_capacity(BATCH_SIZE); - for _ in 0..BATCH_SIZE { - let Some(event) = event_iterator.next() else { - running = false; - break; - }; - event_n += 1; - match decoder.decode_raw_event(&event).await { - Ok(msg) => { - msgs.push(msg); - } - Err(DojoToriiError::UnknownDojoEventSelector(_)) => { - println!("Unknown event selector, skipping event"); - } - Err(err) => { - println!("Failed to decode event: {err:?}"); - } - }; - } - let msgs_ref = msgs.iter().collect_vec(); - for res in db.process_messages(msgs_ref).await.unwrap() { - match res { - Err(err) => println!("Failed to process message: {err:?}"), - Ok(()) => success += 1, - } - } - println!( - "Processed batch of events, total events processed: {event_n}, successful: {success}" - ); - } -} diff --git a/examples/pathfinder/main.rs b/examples/pathfinder/main.rs index 57bf5370..11b0c826 100644 --- a/examples/pathfinder/main.rs +++ b/examples/pathfinder/main.rs @@ -9,7 +9,7 @@ fn main() { let mut current_block = 6000000; for _ in 0..100 { let (blocks, events) = conn - .get_emitted_events_with_context(current_block, current_block + BATCH_SIZE - 1) + .get_events_with_context(current_block, current_block + BATCH_SIZE - 1) .expect("failed to fetch events with context"); println!( "Fetched {} blocks and {} events for blocks {} to {}", diff --git a/sqlite-data.db-journal b/sqlite-data.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..51827734ed64f383af6c49499eb36b648a9c2fa3 GIT binary patch literal 1642112 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≫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

pu9cEv>5RK6op4Y__Vh``{P1 zw5p=};6JNY(!6t(tDLP;Ezf?qW-;I{?v$ON2&iF&dw~t*ucKX=j;Cq8t4}Nbj)&Fqt{=I+fy{vbyUQxZLI<-2Y zys~^jd6(kn#oLOF;_&Wgy3gs}vGbG8n`SSXebnqtXRe%i;mlp9e=+^`>652#F!lMV z=S|&t@@JE8om`!~-o&RSo;9&DtO4xTf9wq=j4Of4Spt0snAu1gGV?(8s}OKHF1rEc z^j9>QA!WcRGE^_HV+np04in@)BN=dfAq=Q~!#PIo9j=O4Cya~& z1T16Fwvh*+gGt7CSJC;=-_#e?z*Of)FWbiwh%X3JKvSl7z}v#(5L46P03Qt87L1I7 zFu;|yLnnfGXAIT6wtVmKAJ#1yloD*L={%vyWB?twg32c9GMzVwU?ncSKv+wJ8{qkM zioQUQYQIWK8aGZeg~hcugUbEWvO3gJ^^bjf%lS} zB@wtlw~9nzr1zjJsMW(+Wgv{%6+Win0dt$>_ea#wtcfL^tmrgoe#7iREYTM=!;%JI z#A+b&Gj%xn`!G_wwY($E9YWoFSw3?VoHjI7l+O(Fd|0q+XsUeX$bZEWz!P*A_Jp^L z_X_Vb9KjJ4WfPYG`hdvP{E$5|#0AA((M07MiDaSZzWJy1MKv(hee)0Z`sXal$U`p0 zE*vryL9Fa6gSDXUOsY~b=Uj%8lR=Kr1fos?U-ekV6FSM(orm-?a_ znCd@h#1|hf$~&btfyMbf%Wo;W+3z*GD5t^64ng-ceE=q+X7At zT9Y-BTv*)RFbDw~` z^rpe{FnfC695H&mr%1p?M>_Nf3=0?!l|IdF+Qm^NM;Q)r-;RSfK+G~g%!>d4M1WW> zCMIrFUsMBA6B7rEna)XfIg5h*4}%J555AUU?vU;kW(kr2G$}p|40|RU-irU7E` zf!0Ey6pwuR-mYmwOOLF%O>WRECMR+u6BCv)pn`~6;QhcZ(8i&CWs^cIWI_fpI(yPK zM*5=bf6gD)UQ-iOW9QWO6K10-o|zyf9`PP@L`=`okq?;!Jc>KO=FV^}2woCLnP>{r z3D!VpxPShyYOe{&1P)lY)Ls)d*?Z9^1BeXJ)YWY+A?z=l6_I!3Pn4hVY0&HaGh_AZ zg(1Wra5nP>L=Pf44jG?33ie>hm`~U|$R{qA=wj{WGPD5z$jv4#iU*ItG(KG;QxAS+ z{f+ZahSV&DQU@_ltDJ6cU`(!n;MW7E6K7!vIKRr=&dqL|gYXho`tR@WV@bCHCxQ<_ zgv~T*C(r#ZF12E^9C$RYm;{a9j~hL6e#A=9q6__vV`AyLExt>89h)+UQV2go4uDDx zu|k+b7Ia>b_3@gpLbBtih8YiSv;hhOeoyhpH`c#h151w_$zSJ%Yk~Vp0KhngZbod( z0LL@zLp-(OQ8|jfMCMdv92p^AU6^? zxPL>I2jSoi|d#UI=|7jj>INza}rOxx8RR40u)FAsBgGXj<0R;!C z8qEdA8FbC;Kx+#-*%Vh76m^$%y;u=jhDUC8zOv6A07mk8_9dtZQl-Gg6e1LB-b#e#2x4-8TP zQ;n4umyf6Y_l@Guhcf}v@qgY-cmQvne(3ap#XXDe)D=^wPwkxim&sR7{_f=1#NSLj z)^vfdT{m6eox|w@pBp@PaL4|?_TNNbaC`5v-qU&u)ps&!p!@djX7}*Ur#o8i|KVSI z?-WdIxVE84JU1Oi9t{p)ejXc>FGB=e@WvLIZj4U|MV=d7Mv-Zj_?P|dPklp6rh40- z`j;)4s)wr)T23P5K`El8`KTdE+6azsEPV`QwPumfw`C>}wwOFskHh=FeOb{zS^ zmP}PUj(m4Zrpg^hj z^pqA%aF%tgNB)~ef`kRnG6e^EDnwA#N~AS_iHd0iayhb)lwA|$wjYeT3&ZEze^bS_ zAAF>~KO3xS+YjDZe?CGHZgvB@)S(monzov0?=-hmG?hnE?v~1`ttai2tZRxyoB?~H zIPi{>^+h!>b>JOFXtum5oKNgnDW@wNhgNKdszms~LR7F^1F7d3Hxnj3oV>z0$%{;Q zT$!(C@Aq%@=WAf9oW0-GEtx82?>FkiG`+cvzC>a%P3Vj#F{uL9c953}L8Zeq5 zq7)6EjG^{MU1?-_^c@rhcZOoWvv=?@cW=p5b?|LQJ<>d1gH@FW-{ws%t*SWqHs{ng zXoFQ9e4D4$7KH$;m8gBRbB(r?%rU~!NI71*DxBO>|Axqq3U-7G<_M6eK5io12{ilt z%SQlpud0cuUjOpfwzR6MfB9=#GFA33ujfcj9)BS5?4dWYqke5Q zF;z|Ms9!csOqCM{{;K`~8>C0Y#DUkS1KhY%uKaSMdS&OfoijVbNr1=xhe?31+B*sG z|2j|bj+r&syS(?b?)5vL>6}^Jum6y0P=26%Y(= ziUYUbdar9~xT*uU-?FwSttmbIT}!$*dv)S)#L%kkGeyBQ93~g%?drTdfqsyE-HT+USNDY z5sci500UsyI_096pe7cno~Sfd6R#PC3BDgb<$jwgCtmZTmR40vyyiRgSJhxu6R-Jd zeNi4gGf|0Bs3FpgoI~r1@**U&YGEWM5W=i0M#O}`j`Y^_!*c4Nk?YK>YGA6E zI%uouKRWUIb(X6Gw=d1!xbuy$baDVwXpJ6bpcwAk$afMEleMe8?jcSSrD20c>GY7V z0W%pYxEMeSGE&UE?=iKv-o(_*`%bC7^^~h=8LK*xdQr%i*lBetU(;5^LDp^>4uv(w zS+!EFO4nBXmrGONtPZ&00d-RwP%?&!(jGP?Q+O=D$ee6}p68*)Nd7Bgj#^vMf-$*N zXhDY54=k#|jZ{yEzH0wX9emIC*A~^}DEG#X8d1L*?y9MSo>g0v3D*Q%$gZRXZg?o( zA_Ng&7kMBS zlBr^L<_7z`s{LQp?DQx(%1%efRZG+*B0Z*5?dVBo^#^e}G&QyCa)d-3YIuNgd>bgA zS~9%dTyOHi5qqVfscQ1VQJCJ)R5^L!pV!}dg9j`oFMMJBtv4_=dEwLRi=rkUkm zQ|}oKk7#JBn0n8l^|#(&Ra5U74J^*0>~lDo1ydMkL#9tmsF3!T_6%(-P)_AIEnN5_ zxD;}Jun$m>3zTwpZT7~>eO^@qQ~HHBtM7#drpmGMrY)H&#>yMl-+F^pjg=$mis4%i z^ar-e%yJ%<-&HB3^R0xn&dc$cs+@ZK zuUj%zOg(-JWR`8U{|`9z`0vyg6$wnTlvGzpjni;Pq=S^yNz8Nwpc#=VT$RAoThtp8 zo00DH1}VN(6O%t~$y7NpS&!DC2=;qb#l++fWvky9!iK%72Bsz^M|6yAg*3e&A9n(i zkziH*1hW>g5t)4VJwy+!Y$dE->7=u+{Z06Fje-xxlJA5l1_2- z;S>E!J3G7GPXAB(3%!5oy{h+s-emRR>Ph=x{BJn*`PrL7`Tw`cf1A8ua((i!iBC_Q zIr#p>9man${-*JV4o;fAXne=m#|N+NcE_GEwremm`>5*Z@>}K0%lm-KzQ6dR;>hmT zyD#b9qk}*2f2jlfzx@$p%-%EajZ*P;Bkj!KclZv7N&?of-Sh)>EaXN8pM!2iHc=pD zT3r4fz=ZDc=2CU_eMZ`ZOwq}t4$X0zpejKkzbt_C&x*mKuTAPMveCe8##T|fQx3$< zhD{(LrIZ{qT0+E6id%lT_QLgkz*n^3 zT}J{?qk#ZyRi+^=O=_YcLL_uv`$W5)s;cUqm$e8`1V6B+T(}m5Ah*ksf`6fCL|b|f z6&;xl0Y~5}&{Ud7QBMa;h|o6K&1F|y7$M#pv@os2?IYooSfYC#1Yzkj%RG-hC`Z5Pl~}=JJL!EN!tYpd%A+1oGNt4EXp2gjRfguWXq6 z*(sK&Y#bThN)+ z=SE=3p(P#n91X&vG^6BGwlC<1j}{d(a2u2xjUm~!vqL6aehV$Tx(tI`o^wEb7es9x z#!BtM#RxzQIq2Cy#lVgfv83kU*PJ}nC{qQ4c>{AIQSaB4H`)>%2SzrO7d#9g*up7p zqfJmsmE;Dp5$?SvTvW)NNLcAP0girl=7JL=FJC-Dn0q?9N@Oi*hyad&vC!rPL|fAM zS0bctne~Ehsu|kv@~DV6=k-x#ou9AHd3){YRu(pGlc?uFjq}LMgs(tGNQILGwaKWl zLT^U~4w3}=i~em8s3Nn?)#?KutexK#s4kfac~&Df!D+M@4ZL-3fp{rjgDFEoEE-<` z6CK5`z`D&y1r=Ocf6@qXmxogaabSQ11$J}ub{&zadCM_V0mO2slD`0omp&fdE+A%F zyT<7NV=tDwzPr!EMRQcOao3P(VJEI4AYr~l^BYc4&zc4AOrQhqYqJ|o=N^JzoUhJ3 zZ68Yuq`{=7IhjUacp_{n;-LN#XDP@&G$CW$+;(bCQCi-NgebMF<=!V2>z0tPF!7~v z83AN!iA`hA_~3x)z7f%DdgGhX`W83f5rqo`4!R6l+&%MiE|BAMTjxEgk&rp!d^R(m zk<33hc;waM^y#gs7^=e1k@j-(r;ZbKS8r6;r(0W*nGt)|(IU{5+yFctNFL${&@Q++ z4Yo`%;ZB3|claP7O?9w!Lr2M-Bdp?{=8Fbr)X>175376ig2*3WT&I)uMZ7T z=+vM-z|*VtkWO8ts6KecP$ulUXX8?%P91e))GN*TfFX!e%el(|pn4`;%JPW~pPY=;aSlt_6bc#!3wdWX7L(S7IO1uBY- z^Xr3~kC5CfA#_nCk0`XyY;+BwPI~+sfUZIt5dK z;Dwg+VtPxbI>VB^72|#f)#jxH1*%?f%W44AFX{mICOL> zTPW@AH1wrL88pKULBFJ0m*5LDS((M>QAo*5uvO)kh0W>%!A4!Hh3`Gl%64O9dLLx0 zo#zJ6h!FRYb8X9u4OQ|zl*m^JpQ0F_piPTPSgdYoa%UGpr3{cKAY5*t5{bRC9q-(H* z_y)s~jXDls!4kRx(i9H5W>wiVU+6biO>eq+V zEzKDch5;hVKn-*E`0$kV{H3`b4z=dDcWpFL1pb{B5domGu`GN=`O70~mNFX!1|PCO z5(3ie6fUH8=rVzU0KgaApx6jIlrDw@QN1P!wePrDKK>=O*eMm*42cWQH>?2r3DXp6 z-fLq~4cN_>=QTCPeK7QXtK5%a2h}exsV`}WYfm1@uXtq+I|RKiWuOCOT%N<_4#Gum zUyI1Aq8(&X$1b?J&gcK2ZppcKT1@kC@LWaU$^dL`Hm_C{F+-hPR+0UO2~dyD8S7qP`?Tzlm}MLx#u* zjOMtQpidncFP`K(3WXy{_EV4zh@BuBb8~^_a`!gB-p3N6A?B1*;3CYJL@YibEl{Zo zhSP@+S6W$=y#jf)eRN^T!5#zL zI8EZ}N?tku&Ml$<8m{#&l+PTpO!h_uI7&dj*t7Bg4mQ;s+Vl8pA`Ibq=*&SzxzKfK z)X?}R-p0aKmW#LUb9^(eMozA!hi3_@BCbEgOP@3gp(iJzbG1MFY!hq-q>t$k8RHMJDE~w<-68 zYOZ^`ZfEez?X&Ng-7|ae%w;oAp1D=`cGF*;e*W|wrhYK>s;T=-btd0Fxi)#Axd2a? zxW)Jv$DcdCGWNZ(SB%|r@aw_b2CIYZ{Y&~!=-;CE#olwx82DcGit1hz2QMtw$^(m! z7EdT{(fwlgtpB%k|Nr+tU0nHsy)`TpZjqc$9-kYhj>){fAauoE3WXanjSI#JqRyRE z5~z|MH1{k^2;H#kJm=}1>h3dpKd*bj&XT!OlU6Nr>fAPB?VuB-&0)*hHZCofN2HG# zG>|JXFcRGQYUkXScFHG?gxgq?z>us>ETdqb@dJW!&Ol}>iCfoQm9dH)!0v2+NxPU5 z6CH%r2`M^1`+TSS^p}pF6=B=u!U2-dlm!T|%|3A5!X3IW9Rw8i(7puSO4MRp7A0q* zLl^u?QJ(mW&iDhLSu>}WA_q~T;B^!c7G1GOtL|*o2)Dkp){)_Km(Chg;!4eZVa2{r#-n*+Z)Rmt@*&;K$*69- zy;FST!mZU|sA2S}9GH>77KfM*DhoNWq{S@LkzwaVxr*s;1ZXN33qq}OI~p_22`=~@yd&@=!}ou ztF}14M}0~ZL8F0Nm2CmdxG`i-DgnaaRUI3y2wqSIZ1f3Vs)>_nP`mo&TfW;VZZIN> z4RaHzj(oyS((3ly0GTOy8zE(r*pAA(fWY!R^j!(LUk{KWV>JDw`wypdI%j=$?>)s@ zGSTdLVJ+gL zasD|w78C)Q(o_TA!8yh-L@UF*42#ULCIk7T1=%rW=gVhz%41$wGbj32Sr<7`*+6s8 zv9=>@e9qprB_0usy19{Cz^qii@=D#PLjIjqaqhP})wv^8*?C;NO!6~3Arr#Hl-`e= z!8{VLNqppPkujRhX}^R`Cb5_>B>q*RI|rl>49XR^9XGvlQ;p@X_}x9?PEFN|K@y6ivW0MC%W zF009ARJ2l;R$fDMr%O|GZ+d#C`?^PNEe=^#l~fudu-@bosk4w9$<{_uM17V%VJ^$x z$Q(-eAxtdaSDGO-Fg;;OU1eCnM;R_Z(~EoXWEOx zv(yT+-kUHeaA#Tm=A=&f@=wC@I>q)z zG8T|3_;-HtyiWI)Tj+fc#3`!jfxQ!lG1V#?Hlz4*e$`N&R}ELs949Pa06np>Lw$A! z^g4e|Q229OU~2_)N(7FVdyImKB4*%7=)qAglC97`mTN>}#<NS3`c8FJ;9Fc|(o~l}~>W{L33^n;FJepobl1S;r!eYO>@|sSy zT05~43O6BL1vBJ8IWk@Vv@NATPD$hq7^xJ`HONWHCasFE^#iWS7ARsb+2Vx^MWKPWkDP7N`MzhKo7R z;V)u{n;*KdAVI=Hq*TJaYvLyv5Sip1yB>_I6VSUVl%Lkha^|j`{>fXBou7`Rk=uzQ zlQJpDBa$yh-72}IBdjBkn@~y6XOQlCWs*b{R28GL+x=>%n*PMr!%FZnOHIfc2Bl$T z!aMdAb%mQAR4yF-e3dIhej`JDffyHMC@Lju7=N;z4C~lk0UlAO1znT%=SG9UKVDUs}m-_k+4ClQe}?a@-o%mPj!0JYUYe`O9_N0mk!a3_gjS@0@b9#4hgX!EeDgjY0)UPNS?)$iYzja zokcOV(dmA?=8Yu|G%vUg*h#N(&g>{zh2pe89jH`SLz&EM!0a_?R6=^+$0TM~#I44^P7mOOv0sn9 z(?9gvfBp6i{Pqp}_6_{@4gB^E{Pqp}_6_`JZ(u8QX%>H9^YE?kQ7|fXlAx!2O>0D4 zoOZp=aq{Dc(J8qpjerfTQZ9%Xtu~AdR^BE1e%i-cGF5b+Hi9FF;G3+f`?S&c&!Hu~ z0Ig7EY$2sYNn;&w4^9e1WeHeDU3Fn?mujbig1!^5gGyJxU~8*Yaool}&$s`k%Hp`S zmP{4Jaig|&!v`#m`<>dNXz*&K;tYy|wdm+2$_(X`vIY&dbnj%yB7bQj$va(OJzwp3 z!^jurtK!$Axhodc#8g@QdaR{Y6~(V>=|iJc6~8X(i&9e_;+b|Tx5DV@*sGEt;=-BW zqoqVj8$y+Cmnx+yjn1j=qJ&?|wJbZ=Z^=|qb|RH@twK)FrwvxsIjp`YB{GME{&3VM z6w~!oV62Iis4dCA(%JBYx*SUAgmG1~l9Vga@7B3_#uh2uq8gYgif8Qe{hFA1Mpb`4 zm^AsQ0+pnh=u9LMi6rWU!?118Sj}W~tspIxw1VaMnu@AA+Vo3<^6(KcCtJONseXC* zZCYAYuRQ#ymP}RU;kRzdR9PNAV%ar(S4DaFE$e%|!D}xMA8~vvs!8!_&|Q1pQwIO^ zvsBws2UzO-Sv7NK+5m5ve%SOuQy(8@|5r`EX7YiP)BWr9KH2-z;+4gJ@BG>9yT^_g zTse5b#3d6?p85Nk=NE&CW5&NV{<86VjhAEZ89Q}wm;N>V3;Jsi0Y9qVP(8RhpuDX7 zlkQ`>@9*Bc^W}1(_>P%_H~P{5#bvruAEf>AX_rT}5twXUG6+@{+A z$F1ZCeFo9us@JpCzgbmFJnwq7%@E8UG#Uv!un<*5(1^GJWg+z+RXcSsmx98j9vN?> z|Eju+SD-q*r>ySsla@>s)m=vNu^uqJPolq&{+?3>aYF?zn(z8KU}L)7{tJH~^&ka5 z$W-!nuuUZce(20BPOq9r39%NkeT7SgLL^c2_ma(b%477R78X{ zNO1uGmQ?L7s#nQE9&_MEG@Zkd~Y=@kvl??}}5KXWfr%CLfkE0q5 z%m_*r`EDYydP~}|dwR#*qa{n#F?VapQhCg-mMj&=%#Uyn4swI{bWGi;2U`NTtfdat zB|zivFagxlQ9RU(H95oBH`^C9H8^aohA44|s-Piz@@W25yk`SbMS1dkUQ{q_4Na9N zA5(J(8e1EHZWh0!CcmsnrC<$atSc#C18Rf5)wc(6)+o^z(D&0y(gf5$ugbeVz9my- zdDqcshn&3zt18O7jz&8)G*#a9k800Hr_z_#NQD>#(V^Mu&KIi!078<2*{0VD#R4@2 z8kJUwNH9%q-J`NR_K22D73Hxvt#5@Ut2*{3wMFUJ8G;Spt-GQZh53vwBXEVjLHf}- z0yfed-HiUPhE5RjA>HV+Z?2T(5&znfsiHjMC$;Bmw5lUU6jwVT=pnsI^4n5)aDRxS zXew&oX%reIqq_`K3?>N30goMc2+S1KBWT;IbM9#NRYOx{=iJfsvWBLL&bgnhzp4fg z*g3a`8$sX7m|RU!=%<}Xayswo9^ln_yfRhpO$aD=+okmeYl$v~9u^F~o2Dr4c64n~ zjZEF`HnDWw-3My56MqhLMz0-*&t(Ucp~FeY5OE>OO;|6;&pxq(r+cmoEHX_uzdCG3 zOP0#RhEVAi)>Iri8i8ORGgtf_&iUgyP6YH3wf=f(AVrpc72@*t-&A#ZEnRCA>{rDhwVHDyQ)O|;M{D1& zQC?J?cQ{_aTtGY%-92~#hjq%+%9|HoF8-{zt2qH@ZOsX|Zbra&Ctr2#jDYT8*G?0t z?o(A0j~mVlIA{F0v40j1TUkF*J7-3me~Iv3{79>kfj9=KjLZU_xAGI#^ z>x*hoMCccXe7riZbK-ToO5Ix#%b(swGps>f}%I6K$ND-~nr^f*cH@SwId($$iiRk$M_OXDjCI z;i5*m6QdPy7roZT6p$j0!us}$^J-Z;Q0@Mk>J{hRqNP<;#d$Yx$y8aKHxhx0TWIir zMRDGU#n#l+c{i;+UsTPb+nkm!+2YuDr6!?~>V0 zz3y#mP)SAC{Wn#0Z(HjdH8NFpZ`*6>0gLW!x8(HK8?CB)+j5^pDGQ^?!?nZaN2=8% zKlA~dRtcLsI#UYg(Rm5nt72&!<_cV<*0Z)zbuOxP>nv*jO_iOCYW4j_ri#u*2iN?1 zlT~#tISkb`|o}tK5cnp)>MHC2aAoB?~mwV-z z_1iR(J^O2_D$i_dRb_c*y^!8yRYiH`NM(jwXkw~7b6f4LBgzZQ43U$LBmJo)Y6?Nd zW2M0e&j~#Aw{ebWnJh!yQU;PAkYK=7Snd_uYIUX6CZ?)l+tvS@z55Q6tSs}!UvkRl@}EUg^yJkgj%rIhKM7WK~H&1qd-@qpq2~_JKz&Y zTV$^h;$SmS1ksq^X^(2jL4`DjkYU$NWywKLFKJY1a?sOCGL<9;J+-*%6HG8UsL*ya zQ4mqDTys@nev`QCwaPVLEts0bHO{i*irqJpm`bzbK3CGHlI*z8$^%?enZCz!=D6Fn z{M^cx`_7&>{rkp!{NoLO-LL{TtiTN`aKj4RumU%%!2b_c;8_Qpeb(c5Q!F`c?zDOT z=kL|S)}a~`6gzZ2$`Wb@NB9LwjyD8-9?mXVmB85nAV9=HA4EJQuZ=uVOTYF~S{#Zo z(XM(Du2PJ-^a9s2^ZM9>;t#{tVz5gE>6|Qm@Uev3BSC{x55E%vDtHhD!9_C&WqB2H z40)QqkxWE;uS5cd2&@vc389bl-7A@+uf-gO@eSY|4U2|;$QU!2ao%Vd#ule*I&!Va=jWgDtjGCOjm=NhnD|rO@TDgHQvdx!9d!0tkKN5dT0c?aGo^F`SxWZ+5^@!w zTnP%zkFI4LDsqil&~^CJ21mz<3;>s7u)b(MLGywYU?JLxRl5bi*mZ&+2hvm5HhK=E z?P zn5aI32{#CuFuhK8ss_!I5Isag209=7Earjk1o#!HQ&9XOM00{HkC7o~gY3XKM{pIn z#1KIOLB#*zRJi~j<6|{8KUSQo2|v~i-)i!2_219cp=Y19_P?F0cq(w~B1=X?f|mpW zJGf{yo1g-~`Go!eUrNyQNHe3dg#S`T42d+Xg*MXSk|L_QFGh_%b`6QWpl`Kzq7y$T zn&Gx_nIV`VvIIq^G=L`yK@eUsq(^=>;+vB&d`bu6W(b-`*afU@Xi*@kyB;Ym{?S=a z*uaB@9?DCg*9rv?gq<@32>XBgTn(Eip_O*FT~5KXf`8Rv1yc$}?_jf6cMi&N1V1$9 zkgA~G2|iZ*$FLF}_HOd|Z5yxEEARW5-pRuq4}SdPPR@U{SEaJ|jg$Gn161mjHpF?1QWK$q$%$?945vzdZe{>2~8sx&iJ5 z1#sb1Z)#iqiTs4w#{9dh*H+J|Us-=%{r0t=)m~ToyJ{u-TlTJOHQPP?M0#R+K=pOi z`;_2TB@Y}c!Vw(DKVy5@R4zijfOyw1pSyd5RF{|lx{VOH!B?x;hv>-vu(4$GqQsw8 zA6lt?XfuHuFz24qzFi0*7|j9t1QuZT`v|L0&Z3t>#v18C-pW!?Yx||(uLt98Hg8+G z^1w>d-+LqWqkw3WcLCmx&2Ifp@5`fp=+CUB1a5M!#o8g8T z+`9U$i!13PD%p$9iKN>)G)!Nk{jFgT?f{N5pc3|fFs&i~*8&Gb=eEU=7ia`E;?^*3 zWVK*C&b?;ZziXv&%T2^Dz7ZV%$~K^-Mu@vU$hHSSsAzFu{hbA*HXV>tJYT58(6a?~ zI1&$1j!WtGPvc4&j3MQkdVGj#mnc0$*Kbl9604BNrRY0C##TEaC1 z+0b^aR9|>o#=f>WHqSTi``{mId;V~Y3_&!btQdrxI`8mN18W-jN>F^NYWX8-HE67Q zs|TiQXH}|KJ$syl3(66sQaDDiSw}xz2>0NT;YP~`>o!3ghFgA}mW9)b64iF{=7TMB}@2jD~GeazOp!c?ieKJ}XbWm1891@*hXr{MS ze)O42wfEUgb5tm>=>?T7+C5Bb1Q$?|;9kLpiVRg7t-VX!Ml~F!MTZ<{F;e#)($U4y z{*{mIRjDp+wnbr3!pwtm5RW1*Om2}@!3YE}ztGyoeWP;)-x>%kx+grTmJDkOIxwyW z&Xtcppi=qHCeUsH6&*#Re23N4a{~gK# zgGTLwO5+X}Y%bkI$N!#{Z(LQWzIC&TI@xVft6cb_O67x_JxQD=tIq$!Bo(xZAeVwD zd_LeeA{E5v-q3JX(QNSzZDx#Q9?xDdPcGiQa_u#h^pWdjlk4u1iOlVOrov6|JRvz7(j;4sMJpP_n43=x!CYqtK@FBR@u!?is1L&^zlNOr@+H{B6DZ!hBXR_Z)s_*!6W$LT9 z+B7%OwYiWy`{kA7uR&~h-L($z6-+@|)xd={QPC_EJ#M723UT>va^qi1kcd>$h5u6kvxkgkPv z#*wH8jpO5%u0H-=6|c_b(k(8DQqbDrecodUq5K$s2=RF0^WY|^l12|>LKK6!M4cZf6D8xbi zlVN)+vGu0aSK{Wq*&yDpI9|}>7Tl-ReHf~iSk3)po{B&X<)>y35n8Z&x(8QCY3K?- z@+<*Euw{8EdGIGI*jD`!~0EHE!8VK_+{&`i9OL-x%^HEpe3nx`R}Y`A9~x^TGuD&2zPaJS#GSRf>^m6Oun2Zy^~xW;V*R~sCZ2p@*k+G zaiM#Rm%B;j@)uRIm!E2f%q84&OjaI&a7B8WHA&V67rw2sV3$%9U+_fvA&}v?@uIiU z-Q@E|V#UAJuUwe?$CgU=;^-l}?wd|z?y1Sge_cs-Ke-%h$0QfED%JPDpd4#AOWt*5 zC3|z|0Bu|&x(N_7Yjwe}#ybR_kr>1D#aZ#Bp(ONDTR8764G5xZ;U_D~ZLgFQcPEn9A&{DF6z50+K`w777;^wGUa@`|&|v385J{)|fUf=`Z*mO~Ad2-Mw!A)Xrwfd0LE4A(SF2~wY)d#$> z(zyKx%CUBv>e1h2sp!0LLfetJ!slr;$pd6o<}&R_!RKC2&aLS zEh+?IOTQh@X|Htpmz75CjhogcS-0uR)j#$?|J|mwNyfHsdeb8@N^fKB`iM|yCjrT~ zMP%yh12{qZ5cP@79}Zqwc^!p3ZTyAaUBQK5QP#i%&LgfbPs0OT5^>ygeqNeOloFPhx5JbjMH28B>z(*nb=fPZU|FJ8t^(qstl89_fxhlw)a2y5s%jSgNNx z9#f8`xpc={%ds?@?)c|&EX|}lUR{o*>2$|S%dyl*cbr;|rKxnsKb2!CPj?h9|4EMU z&C;j6yqq!JBz;;*E50q=@uPCav^CxF^>QrjmhSjUIhN+r9iJ=5(v8z6?N*MZ8>LU0 zDaX>D>5~GWf4y5W$FKB5zyH^lp`& z_U3Xd-7-DxndMm8J3Z|m%dvEe^l9goV`;DSX|EewLZHvRlMxda&>)~{tIVZFu}W{E zDdj8-;0*F@i6{uGA};`$P4$KrI;OWnHaGc1mXh?~-;KSMi7cfD|8R41lT-kb>I)Zn zh~J8o*u=Gj%^|61fU@YQqqIq4V7koI!*Wmg{M zPH9A*N%B4<>xjb1M~T%(GH)_fGB*%2Aq*zbt&B<67k8$tw*WkJJJb&a&$oHd=F||q z6D4a!+X`v}J%e;q)R)NHQh}0$7o#hm1m*scEI(}x#R~jDi@LN2jpV9-FUQhUa@A&R z9^~`8Jf=LkDx}`7yLl(FR8OvYUpba)$yFDXV<}6ndQ&-;(&VbwmSZVNu6osYgHN>L z$yKl1+~5)*G#hlYN}+a~x;Pw*ZZJI;k~P9zTE{5yU2qOcC*)8l-KtX3LmJ&_@6D-A za#v>QA))qH!ZV(wJEHx)1WReU<96j(s--&~b#i`nKWe7e@dRFw%-(tC*E2Vo-&Y>s zMO*GZ_s8m))w@=HU%6oB{Fz>QRC252FUduS%^siapMEnvD}P=7fV@%vaQ#F%giEGBpZ~ae#U#0a za^`>A^vt8mv2>gC%+PQs;p-lnp1D$vr9;v)mo_a;k|sVNU3>ocfKQZgP7i-?IYZhr z9TXzuNhXI(>l5XS371wOu{p^@pH^S?pXH2cuXIp|{3cnao2P>>l{2QBrGw9xW9g>p zprmhilXOtXOii**H%~5J3cQ41%-svzn!bDqJ4v9wz{cyc+G z=F`FB%CWR1J@y{uSlS~U6iN(}?D5^x!Hdfo(^Pu+*9(P#Nd}dthkv=8LDkd4zgUi? zT6%aPtvJc-Zk-;teRE8cB;IbFp7xW%l276pB-NK)z3GZgVo5TgoZ0P{p7xD$EUhOf zOS$T1(_;(%_$0evCOx*hIlD`^=+fZZ!S*>$3poHXhp8ZK{=DoxdS}U_Mj-X#FYm z+tz+idsXc|wOaQ6?1|Zd>9^Cf)1#BWCGVN~WU`j*Q@yhK!s^{Bf2dsO$4&BAIScTx z^z<*3W9gyk=_SQB|B#;k*>cA8ko5FVm1F6_>FFiKTMtT4-$aK_6eyc>Hg`d!9-tz&wju3)I-a$bl>#Ueaf+PpY+sV)GA?if0v#*RgR^5r>Fe6981Th zr~I-UOZQ4oDe1vKCOxI3c=_yYr$I{)>Q%Z_(@0y-+TsdR9OM1$q%dvDsddffW1dhHA=P(IaGO33{3D!5~MQb`;04(UlHZKvC(C*7xDrIW8B-@++XPHZU>8>4w0pnk^)t8~gNt*niUAWn#1QSu00#ut zD*RFC{>TWUYzuN2oHYcJDY0ogLA|wr27?B+CIEbrSOt4`ErD1T9Tu})1ZsHW(3qjn zr2DWy-)v>IkGzc-=;Dg*mmnW(qwmn3Lxvvn(JE4%-d$rx(>P$)*+tE00UIcqpy4v@ zSCll1Bya~!R2A4RgNkMmV}rIGOgJchaIzra!tfWID=SEGmTyr4_u!JYe@sEzXcjTA zVc8949l||2gO(P9br+=|izgv(h7zKP2y%zj*#1=l9xj*_LN->CXLZ?wq;5pPQQf!0Z!dZ#{F>%$YMsO#gcN zt5O_n%Oft*i3>L(8&tW!`^aS+=go`}Z!(*5!Hsh_Y;bE$`n(I&+(TTVb!Gyu_M` z?h1?KDi*4R-qI@dbsT!jZM3nhwW-nfkOAqkM(GypKwUgt`pG(3dY9jnWGYSXGS(3{ zzez@wq<8s6VW}oDmEPsrO-uS$aSh?5TgDHDU(ZR@@`+e?MI$MKMx=CI>`5rmknZAM z8zG2l;Um*J+U{pp-lwds@W*`ZZDrZ|L%#OfvTXf6Uwdg;wtkndJ+mxZzs=W5yS9Fl zuN_y`xPG0lm39UGDqp)>S>yVzeC=js+4^O^cB8Uv{bzps@5-|Ei~RT>mSyYO{P=H` zW$Wkp@fVh5>u34#Tg$R_O@4en{O}eP5bwJ`w3-@ z>pS`OVOh4moo|15S+>5F_y46VTi?w4rR|Atfz}F(mZ*8^7v#wc>gnM7uAld-8}nJ5YY{A5Fb> z>V8vG`G@io{HR_1YhxJ^xg+9TTtE%MpGP~~mONU)ibyI2oi^^2QI}hb$XzaEp|c>G zARdk4cPSDlOO=o2+do{Etq&aa;r^{!auK)^y;V0nMi+> zV5zLheLO$@_k|%XDjFc$?vklnrIN6)jF_8>4EFx!Xw+%Tfzk^htwA0MPyEUvI`N=` zQ!E*c(!Gx@$x^cS9W)=$2LYJ{pJYsXcQ(>#Zqgx*2x{RY{B#)e7qHkNjqf0*M{10I ztVu?^r{Y7eg=!Q6`EGA%skcIu2FreuJ@xe)nX3s+Wlw!|VNk2M+lVg^hf{5$G=U1e zv)UPSh)}FxlP2v(|3DF^-=PSxj7OY&$p~*-2&|>mC;qi0Q%UuS(b%+}4mH0?Mpb>{ z9}AWgv8dHFG4~TiFwa(hIHUzc%?@Qdot##`kN%C+(Gq4s!WJmmqyrI4FU!_D@;hHpmaTuu?|f8Qw$9J*TrJDi+w(i!rYu`;%kOyqvTVII zzr!_U*?LQUhteMCH|Mt>=W`}?0ME;BUz#&{ZhretS?l$t{Px?*vh~mT{-rrIZ_M|9 zV_D-mC*S{tW!ZW|zW>w9vi16W|8Z`6(*5>X$RYvwI8kC@pu{psmbrk5H&oxh}UPUC@%nW>LXoiue={)7Bg`F--N{;%~X z)c2ozSM8g%m(=c&{W*L0>{qkx*)7w{(=*b$B)?7mC0VVUH+Nielj;{Mzo;Cv8+Z+RLMo0Fh={1sGg3HIR$S_IXh>qN8RdFfo(+*oDc{>pYUb- zqxxL%wu~*Qehx`RQF#OCAX3~XgV^c!NrRBWph~A;NA%Lu-dS!^ha2KQKy8);Yn zbg!K(?fUc@KYCHo(((#r4XT~wtLP*RDA@Rm;9pcyc}LVJC{z+0>SLKz0QGrRpl>W- zyI+6hBZ`)2bI@^06z$35 zb!v|I#zP9#{v>Qp=#o(&?ZUgj7Z9goC1i|fDq&Rav|(mI3&{U2E-fWXmlU@F>AQu+ zWkqRHHX#?eL?bc=Ks=I;4{gllCe1uJBz~=r?v?ZCXj~FF{DMIjCJXRr^a2 zlWtn-_}X@`RG0i#tJ5W7NDGYU6a_N!h+UIv5l^#R?VubciWzEMJu0Zl4?nq+B|N|I zS9DZEo*2imzajfoASc--W46p0q&s7-ZJJ*3*_s{y%AntC<{SCNg+=0MMBuE~tbg&h8!Sf=1OQSwD%6h2h|w_& zp=OG%5M$(uY-e(FxRibPe&|(d%L@54@`=mc<=Uf)%cQPPK&U;WLJaWKrf|ueaIxvU zbMO~U10HK(0Vs<0B);rnGeo?6%TBj`lFIqWm?4+O{W>o%naKSl_bwp-Jx9U9dbyfK`72Z?J3`u zuimm^%XDA*_2Z@rJ5$i7O7Ek|d!nQ6wU$=t=21a%G6rqpd~{@&Xu*+_hJ$htS2}d4 zXwxmU+DUB`ynf?BO<<}vdTw!0-9@Le+3pNO1T6;U&=4UG; z-7}!qM)fP1`oKv$4{Dc9O?}|_q9x~g6#q|okwT~S1u&$=NHMiXJDK445Na`$3W@)P z03pF3a)8UAG5y*b?pvH5W^gBzgeoI0O3*s{Xar9R)1f8H=&ABfty(BS zdVevgJ>hf3K}}$)_JofVuLD|SH0$W-_P}_^a*=@cWU_SNdRvx z;<)Qvl(JGDba0-5m(+vSq8k{3?$Qu(h~z}##w7+xb8!V`*5wcqzTKpDMDZP-#8mBw z(~8@m=~475SVW3`gOGJ2*Xq@yBDBcia;bh1l9f*aTY|-1Cd7-)-X#>ey`8~h z2ZbR8S$|U!&>!XY(X@?HQP1t)U-1Z%ne|114WYwyS2*$^;hA$ZxTUvyq!ofMb6 zWk7430O=}%!=Afk@}AsGmmAen*Dz(dfu|7A5y~pm&sSTLd)=gQ*$az+3hZ_P%r2w$)1N72Q`VQJzG0j zqS8$WUXs?G*z){!n^KuWo-1iVLI(1e9>Bh>?a;YRjf!ICa1qUW^_ZK83ho^e54HiZ zw+<*aX<}0Gp+*b$3>qkJhb|tNycP15WR6{jgwpB6@)mlO8VH6jrnl)CQ(;)4_zj3n zXRsZLYTN{Z3Dg()jc)TMCzj@d$Ajobd&J+TLQZ;=@+J)8Oz$ZLOOp?4<8BFI&=Kc| z-hGE4gk#p`tu6{1xD#9}`D+(3kCa!#&6BQ#;T{fvpjVcrKl<9@#VK1!64s^0dlKw` z*n`eqm*pF}^t^^6Y28&yAx^=nZ$LyiT<_)f^lQ&69KBZGbxS67*+VIND-X-nC&9`G z#MQsp?>E7832&>j5;7zfAs!;EO8&Feer&6-H$mRLvg8b@TQ0Ao&;iq_T`Y8Z6p!Uq zWwJ-h{H%p#@r3*^b=cM_eezMOm*hV^ps?VRn96^;chM3Dlb__;AiBIPi{2xX%X8#u zF86rVoEUqTCai-*@E2g;*eRY9QeCOt?qNkso=px8w=7g?2c8qE8dMxxngGc4@yPTr zgy*ozNQbWh*t|p!mOnV0I_A4YODhJYKcY_>1DM?=mv)Zt1HUQ{?}_%cxx-vZa-6&s z>fVA>svgZj?Tb74J`3K$ww(hyYgJnV=Vmc<0jZF4Gl_^(iKRt9MGzOz)cfKDl7(w%5h{+4j6ve}Dan=@s<@Yu~P& zUAt%Y*X-gAM#0LjlEJ#Rz#rx=oEs)Lo4fh!muH_dd#9OS&Ae^qQ8Rl^e^xf3-S}za z4b{u8&n7%2yJh8VyOar77YkfJNu9Q3Cw^{MbwoFwQ*J5QRk6OH%{LiF*h&k%vlKq~ z3SW;)$aQl!J8H=p58e12ED2UzRqlbdBjmGKK~W2}UM|-tBv(cqF}oMa)w=%7w;`0* z#asrXnO>izbs`Z;79)Nz3G*(od(KQm_##^YqjO0gSTcc_DYf91J>g{ZArzzwTug-M zlk~L8#_68SQhMrN3zKtoNk$MJUKxy3S$JA<{;OUKG33&60D<+aSj)IT#9yG;-mv~^ zll=5x`m!sEmiWtRC7gf0k!Y&D9GAO3jCQP#I)j%~U4`7TGabDeG+kF$BXVgiPk-Ug z1xv)V7YN(?<8*V?_LkPQaoj^PFC7&O`I}LQ$h#W-!|GyR;3QaG>2;U8)0h5q(-J(n zGwmYMNf3hAJbp^CB7eTt3g>3H*rQag2BJGgeCbng?I=w1Ejyj+6UR4BEclA3K_u-9 zJ2A3840VZVd(GXwaczQtb95s~AO;iF5>%q% z5-(b-KfT{U8?Qx7F8)k+$i0uBOQhG-G*wA!k=S;Hdajq+ayX8(tB*QXNLqJ7WxLx+ z8h?02VNf1RyLM$D|7H!GJp$V;DU=SSSgE+4rYPDo{7BcHsGM->K#-^Pi#Buhb$2aV z3N`xBE9ZZPz^Y>^bZ9nPRPL7ry}HQkdbfeB*OCl_>*VI1>a1-}FO;65yNph@ zBiCtqhZlNWYUj@NL_1wb0ekNubr&qTwFcs8ucynoxs=pT|5|ZS6PT*6^Nw!Zna)%k z;PvfIjNF}Pd9NEF_o}r@89iP{+Re&?9za{4uRV})TxvFMUpOv$dV~rcIhyA3XwY8w zXe3rv7C6Y#5VWwV^>Vw3(#Lbpw_1kb>h%Y*C<{}6xvV%l%2e?l3Nr0ScN4#SS#G24 zA{Waq_B9mR(JPXv#w|i!reoFNAvzh22L#d4^|M$5h8w+Fj0}n~~>YRo%s0 z8qtttrdg)J%1h){==R*S`49iDI3|uKF>0r?W1e)kJM_ecJz1FP_ycBmwSl?}P zz*aoZQ={S$79sF4&B*742=Mv{T3XJ$3*2Nz5;@j3-Z5HfZe9MUv*#M7NnSnrA2-L; z@p^cHx@{e3Hj$krG?1Z@5th}AHbTnUeuUJ3XK>&t54~|mSb}b$)_TuQmY^Br5{B_e zxq61&r=FCv;A^zXwGh6@R#+PzUnJ>6=6zjhO^82rLU8MDoVx{QNX0=oLIzxZ!);6K zS;V+5%ccuGkW7QRB8G1{cOM#69X;S-rFtLpXHG!$x?=RnJP#~8Q$*Yno5rcY2B@nD< zOPn3K+$DIBWlx4&^D=F7lJs$Ta{L!sS?!mPEy+|``{nkMOeM8n7WdS`>aI_)_R9!M z$BQoQvmlM%m5B^Tzs<|^rB}sE>P|!9L*zo79xlD7kf8a3?lrEIs#f)^yYFOavFq6v z(RuW&XCLO(@_T1AbMf?ha(4q|mdL8~nOJc`mn1}5{$%CP|4-3UXKAR9w!^pK%kk|* z)mox`tAR=g4~0wN1P!^cssvpPn{IX)v4Oj%(F`|ZJ&VJW=1Fn}G$-5OLubYV$Y&n3 z`rSy_b+wv-1Vw+@lHkcxV}KQPU`W?n(#w%8>ZZET8fU~ zR?x335~=+EF7Wgyk(PEt{HRyYtnSEC!x5=!#Qi4wI3*l~Uq zBE`W}XIW~qw^BV}w6mqAl1GoHAyX=wpmWc~s5}Z`Mpm@l@9~M{@if}DH9-o?wXk#6 z2g8-*`KK0_yzP!{dZv4$NOZZ@sZlPpR@FW{3hFP-$ba(~rD;T>;Vmcy+91N0Bz?~t zb{f>Kn@ZpFvZAGyCx1m2PKJFjl*@3(E_nbrb8>&YQBSz|E}U{jA1}ZCu@Z?nXDLtS z3zlRhWNnoIno_@Z^r}w<2nJ0kckVIOPHOFe^15OWmE-tH9@Bo0D_=X{(4r-pobqgZ zND+eQCel?tajz}=B_dtos6@8ICGX)`6`FXZqx`VIJ;=X&>!Kw&RX}yYz+z9#B><3a zmw8q+i;WzaloipKh{3^DzOd{gE{815>O%E#!8o{at;pl6DM#H+mDx8mY`(2PRI1uc zmX>7M7aj5epqucNHo}3<*{Kx!z z=8vDh<(98)dBK*$=YBo+wz)^n?Vj9c_Or96&MwdVZ04V59x^jG{juqjrf=K$e&dym zV;jlTdy`j9J$7o}{L1{y{BHF>)Gw?L>o>1mRy)0R`|Lln^OCH3Y4vf{{VU(Bob^BC z0ZeN>p4P1zMY@fD)=tjz87g>M)h@?QwNvrK9_J>+#m7?{inUf`?73vhWe!;6c!!;| zdexXCDp4g-w3NFvsAt6a~p>*ChD_CK1saL;pE6s2wLd3c14OU+4<#xO6u}z zbTce1h_T=n1@Qb^2f8+W(xoMt%BD|xPf4cI>60$n*q|l|q9Cx8CR_3ZNIZ`afAm(= z9!71c6yBerFW!&{se~tyED@~A;CpqtH#+va#X+Ir5XU&o5iLn2^QT0S?<~GNO|7 zuEUN(SU&)^md$@6^|jPV_dMq-#X&hTQ9+IyS5)nQc+MX#OJ*=~=|!DCJhX|4W=9@0} z>)XzKT}h^D+s-|^BvaY8bI&ZvRJ!fl%_6d`Fu__S+s-|uFsj&7QCN2sbl_#&cj5)l zvH~eI@uK>(P(mCN!~^RkuAa;mPee{=A=&c<<0m;z-~^`je8JMrg9?9Xl|b_fe|%xl zCSMSuiPls<28%ElJ;f(`kEgui`E&^f{egz2ZdQBs+eaO@Tbs(tbjPIOp^Og=-+pLMZ8z!IC2JAlv=m zpO<4P-TmMnlw&E`{owBuUWW;0xBJ2GD}FmPD#~gAHN#aI>7E)yo5R4+<>Z;ee1TgkfjT|m1HVS7d8Dgt#eGt!1&mhu{wBx5F$&8DIoGLWiESDPD4I`LL?q(Uu_H z-0jxp&fIB!6PU{8&fEkiSgHw3rE_O~yQB#wb7y|Ixat#(YVORp76;Xm)9y=5DgSVv zRF6ew4vkzD_d_z*C9K4t7O5=h+elV`T5&ssV`+WAVM(Tv`hKg$`AsmY`hJgOX?%G$ z*IR8CghUBJy$ERpt>>Xm+5630pwrx0>eth=A6b&AT6*@wOEQ(EXFsGQQ)znk-cGVwNr;e^|oPC*|fEr6RHi6D?G)hOVXDXoM;C_a{zA{c+L51j^ysY$!_DLBl zWxrvsw&$gDnvP#i2*i?a%xP?kWH3%7?SO{ySD?uu$&9g^e z2ZP*HXi~rep^=nordz*u^2U9{5`-y89n49v;YrMZX@Ff0TZ&e)xTBVuu`GaGZ6Gt@ zPI2(t>n~Kx(Oj+;S0cR<%G|tvnGp6vRG1TBbU7XDEV{~tWz?VA5+)_6Ihz7 zhnKoBBq>cP$F7{X@>07c=R9m9aZ@Mr?%0U|Q*w~d->EN1p7BD%=aG(Sr&}8Vpe?~t zvbFK=g~?4YrmcMhHftat>J$Xkw#PBF3Yun!Lo!rSLY zIwZO)WEbnn_y1OorCRd+u}T|Hb%HTv$@j;4l#^LXlkW#?<@!@M$(WMl`xh2I-2}5s zzCV_Go6Mn4_k7>Db~l-&WY71#zc9H8f{Z=i7tp1R^^ONjO`MA!kTnHKI5n+(Q9Q!1 z>mU0=IDg1%PK$hi9W>PK55g7q7UNYe!yyg(gM*2zU5tVBWSe*d0uEPWe;Bm zSx;^#eb{*R)#hyCOzQwtWJ$)@(OFR7(pAFYbp^lkxO%R|LG-n1(h`Nx`m`CZ~R zFjrW@b;Oa|QnAW!h+%=AyY=}u-00-|>hmkvj@wLEUs}0c<)Z5Jv!`$0_+{fQjYl+g zpZZMYqN!7+mhx-zH|7t{=jtD?pIpCP?T5A3%-*?nzgnJsFndyVaP^+)chgr?{*oS> zrpcvP1NX0fbE9WqyX{uSzV8g#_jPvX z%7=6X`hm}Y%!4*ib|DrKod+trH3w)tJ=)wi*!BM@|v^KTJA=6*1936Yb&*X+l4)kEhfsG^^!cBA#w8X2D4YW~0 zA%PfjtD{k9X!1NsVHBv$@+~6D+E}L!n2%N}SjT}nDQFDvi!M3P{bkKSz3BFVyRejj zQvvjA_9UA@4O?7<{JhOh3JHWLPstg;pV4H1Cxu#F7zP9w96B(w`fHTgqvHocQ6DEP zqmsLB%Ei_EC0{6xX+_;uRCk>d($xS3Knl77NQkva&b6ai(H)@73po!v?Dz+g+S<0$ zk9=q+OX@577Xh0g|GNT3UGC9C2j(-tSD@hlGSz}xq(K1TbdAU@`Qg2Ows@#}=Q?zo z*#S2$GeNXKf&?fX1{~-hE`3-GMI8gvWFhk|B3Dod=o_+i=z*oyQ`T9#qRY*fgW)$d zzZ0G2?Fh)NO>}2i`BhjgkV!L%s3;I!a!uglT6>p4e^#oejH?M@N%P-QKN?;^EEk}~ zbhiOuOD=&m$p!$P`j79hFlu^-WYf@$2`z#`v-0b47g$)5mI65%$Z`eu0)&FLT$~P& zzc55lNUNaSOUfrQ7q}V%BTQUH5I5|vE?iMuZ@t9 zYB2KSkggF+P-F=Vmh~Q0-}joLCEc1xC%ms3X6&|s8yvLDnn(+Ra|r)s-<@Xl=;+7P z!=*J8x~E2O74 zPMu&6C`E*4Qw3c`^b0jL4}o)Q+ml&yo7nLdstH~wi31rJkaJUSWj|>bEuky%L4Yj- zR=VDk;h4q3t|(>&t^j&c!wP)57AF-TTqL@Eb+#|A97A&$IrIc>DAqk@Cprs$kS;d?Nt0#}9rw(pN3+7NS zs(z@rb!ztxEZTT>pqfij`*AzlBia=-0=S5mbTT>GL#?DPoZtx1U<$(I_^zjh<*M72 z-@U77i8~1Qq1+7SC+7`j8zYlA8M*BSnz5sQ-P5Dh9~Oc~N#+8g&r{J%vv==g35f|L zlixRX7Io0Tsmi#>hb`#Au#12vq8k*5BHV&_>WCsO4Tsgew-qgE1od<1Mxd$b>r%3> zM{dgnElnEU!RyzFacFK>>$X3pPi9r~IIvBtcJwoLvLs&)GAl%sqsPSohL4)g(*ZN7 zqbY8QMVnOZHF!I$zj#yIl-7iH{Z1b(TC%_GJrYRLf8d))5c<)21N{z3r4!I6kET^D z6wzG^S_=d3CG%W$lJEVfXlYRkQP3U02e}uxLAto0FQds@zXyoA9&|-JIeK6nA)Z=l zzo~u1sji&8i3bkhdvw3A<%$7a)YE~o3d*URRk+pJ#HjuYc3>NM|2aB3Y|jP7UWbG5ohByYw$Gl02f z28PDJfy8%gTj~l3k_T=9pU89U_~4$5j4(Gv#|EICuKLybOTSro?H~`MR*YL&RIkt8 zo31JW6>gV#Zfs246M+)ecI7p3(Jg`N;RS*%KIV~yA$i$s5}p#ouBMHycGZqw3glfg z`sy4qm|pwTl^GNcQ9p4*__Ir``Xe?w791{43>qF_a2L4*fG!x72@UwscNmym`@&^v zjUn{h!aTXY8;ggw&`GcPcBN8%!u2okI&TygOH(Yqj3Ad8`2?yy*P10CxN_C}-aaUw(@>NcP`9(5Sb=#+^-F#h62q^YGjnx9AcpUrem+SJ1S;4jN*sz5Zt;tQ?#mflR{@e81h}Y zyTNG1lW0rq$AhU?MH`KBLPt{$H8&Tre{it5e+Dnc7%ug*KNaS-Y|o-fk%~bx5a9-f`Oce6nv!W~9Xnit`5Ij&1Ub8cG+9sSI4-*)svmFmc$1Cn^Y4eqh@|xlB z;p9fcl37RP&3@$gddHNoggk@n#{|q7NtBQ`b~qIO)bH1bX=Yx_SE#0a6{Vo zJ+Phej72VePeAk^*!-Hf{YMzHQhoGNVN5O%H!gS8$D%AK)hE~x5O|WBNP{8!yK!_} zejGG?hcYkG<<|4F+UZaJqiD%v4SMX6lHT-vH5BxsLw2B7SZ6JIIoC?~QC>duu*)`< zMq=-UHBS#M-UK?&1%=8St`bRfy~4hK5De%P6=7-A31S5|4a-dnFKrpgc4PwWe*VeL zmPo6hM?3n%^&*H&d}(cPlCJi`k|JMF#<+8Xxn`Azj1@<-dBpdc0Y{6KQ+EpOSZX7^ ziJl^V64a6}7DP$TRS+X-p<`ZNppR%Jd{++;XOy2fT&|ycDLlX{DveX$G(Ge4%AwUm zD$xE9UPt>sW%@Ra?}_Kv=5Mv-@+~h|$Ng8QE}44F)IM8|NZyz{D4D5#qZ%Q(i)lT|#Nv6`;Nxv$| zR8l+XzcvoE?>EV^*G?KoXx?F-ldxP?SEMdB({k(NqHSpg*^}2r(gnP5Dejbz6Xj8& zVY!YLaiRM5b>BSlgT~|@k&Zz|;2PH~ZR@G8MNfcd(=X@=RozqG2tPp^vAj^%-OYE9 zO3LvVU{KQ=D*i``@;3hs9+x=FST>gZ@9-qWinq2<4l1wGZ<&P;Gmq|vITpmPq8&|M| zfxc`(r;V#S&uynpC#4)2y9JF8(Wa`eDP(q$DkzxHYmJ5)4|$e6EDl}?meS;5*Op@` zNgnp&!m}{J?2?Clukh`pYD6WGRtb(KtAL2!6{9DSr=zJ{8@w+06#?g^J;_!%#`aBP03ej%K zR}pne?7_Sv;pL?Z=~|0P_S{<+1~rMP?76ontW+ewMvFmJDx0o(xEWanekxBIuL@=n z2?72g|JHHm3QE+-O3FzIjI#9FFO*~|O|SiQNv4wY+9*VCe7{L1m|pwd!k~nP`jRje zxN}in@Oo`Nt+zaPt<5~ob^j?+i@Ghv`{D1SY1Dex>DIEhZno+v(NAD1%ielbNux@$ zw~i?kZ1qV-m1J-ILSfY>F_pb_-0*HtfIptRjs&#kf22;e}bTsN3zhaRWEpbNv5*u z1urkjR9d~@#U+_asuu+AapQ(@UGDN)70d+bqfYmzp@p)uqyfGY?YuT}q_`z<)g1+M zd~5_L5=jzAUIs<5;Y$6n#T9oQiJmzd!fIJN{zjm)v1th4!GZ8!y2qfjg;)}Xvb75+ z3!8E%xDsmhJKm?TOp}<(>UTV*BvWbqj(0D~R8qfVKutG3-Xs&O-|=?x0B2O{4?lT2 z`**s5Pp_u8oVrQ=h5VU$XL|p}HySUVe!_Ju0W&|IdF{;oX6n=LZyY)G$Egda`t_gH z-&lW8eWv!2?BzHFp1;WV|8-1lV`22~y9^bCxJIz*m_wABm7|p>MwmjOYL+mbs=RSPqOusN3`GJ}f}tv0 zzmXofSdyvf^vLm-pKw$&>5=0`l?hGlk$!&MFgT&9-P6yHyZk0JwJrVp_~M<=)Yf$P zfs(%9Zs~BGv6*mG^XYJWhcl%~H^_aK5X_)yA!<33{cb(Y_v%R2X5xggWQ2P8d00R04Q8L(x z>JT#Y>S&*mmTF5n9Pj%v1g z++KxIA%=^zG3^wIaeln~qkmG62hs`A{Y$yAy> z>Bj|AlWc}8yW*hY<2;G2G`nJ)OPFwvCfVh`E!Th51clDyi-@PQp!iG0bHrlod+MW+xQA_DSyJne2q8 zot*!)QkmZJ%;{{Z(yBbWGIg){i|7Awey=T;ZF%;V?%dDj&YAoB*#l?3IrHL~yG{RY z`uyofP4C|LRO4xl+fDso>XrH5@{980@|)McRDV{zQ@f^i%3NdiL$gm}=u6d`Rfp9- zRo|I>D>*B+bp5zm;X{Zu#1UW!buGzV`OAY~3YadvjT~j>y+e zFU!{9`PyU4vUN~?)L6QC(!F};d~Ho)mOZ!F8!q4`m-E6dg)`B7(!=3giC?7gvki4tfl_*??qLEg)#!sHQ zapEVmlr(jVy`Yt9rAEX z`Pc4Uw4^gdRarlC0Lio^t>}Y6`P-oC%ntHTbwN8=%4T5C=_DO%D&>4_Z#KnFZ0q?LfJGzsfm>$dWHjC;1i(jYWEafE51 zA_t?-R?GehXeqR~4;jtd=%~nZfNZ0$!ZaUkTpE)!r04%SzoW2FAr+`~N@Jo=tn(Y1 zUrWA5W|RwBvl;HYjj-0&Dt`{cnL( zC=-N4jQ#^{N~nLALh3{F@^IxFg{{eu4z?gRh@MYfTRKOL%Y-!~X^9E7QPEjnhcLjD zu2k`ypP{XJu$&~1{^86oucMiWF@eN&qPaC%CD-9_hGa{TPXm*stmn&PEYON*gHj&> zkqhLg9;m^}ai1w}&4~u}=%6ZGhcm6OliXeqx^#gmG^A^aM5e9qufYyXt?Q)dp|TgW z)H*UiyyzH~Eu#*p?2__aSckAr z8+~TvRtD*J4=#Q?JpTa8mQ=sJeo63Qsu8{e&Nf?97Xh?!h_gjkBK8DeH&AHnW>Xw; zuMMPmOb(ZY-wttEpV-+0Nd%?VL>`T7+UN9VQTozNtYcHpmv%w%b|}{`*N+&>GKV2e zVQvF*V-~50FNg4*Q>XvTanV4%;3^?cJ6Lv|XktK7K%k>T#Y^vKm4X^UZ>r{G z{}t`ba92TaqgMp9!Cdzu=hV556c8?cE{II@qM**k-?bmPleTG~X>k%_J%Xg|=1 z_7TtMLG?Qr?zAc!uG^R?a?H^M?!7_Cq1{lE zY9D!vy&Aoo*|F<=KGC4OZb?@wrE481I#iqPFjOPxqi{liZ1`0v7z*eCb|FrtE!cMk z^27YU(5~*-t+-Ao!6nAMb5Q=lq=>k}J~jb&5PASTNV<)*uIpR|GImA4UTUQytFc^v zZjkVezn#t`L~yv@^i=A0fjaUqYDc$Du(S4=vuClQeV){bK5?B*$PD%6H?yygf&DQ% zxch)Ktz(>Bzrjh4ltiW;+9c>%La`3L1XX%1wR#;f>fqUcMPKezo}U&jI*5Mo=mkH= zU^J)GTS8xgQUTKe;RwUDw|#xP)_v!C2LQrI;4^nRN%ae3t*P}v?YgPz7hX_YD047D$uFmk#+;? zE((!wQ_`#|C8rG)sEN!e4j^qdp)3OSg{xt8q58ekik9?EFSl{rFN7>IriOTd+HrUw z(R#r>L4u5%5uY-8mScROr^=P+W`8}UXemf4G&AEMa83G?n3KGW_)5^?gs^Gob%t-C zyTh@@eWANw?hw`IsQ%24?_`NCJ?15QM2}_=eDraT0I@aU;c;0w#Hc-V;(6z~=DT2f zxnIa`4F=hB$3Tksc0tJ;dY_nSoUZjAX9tbKP$)sUq$oZ*&xlplA65OZK7tRJ5e+ z-ur>!Mhmddg8M+{qKyd$_XVlK}(>UPATz^9m;39tM zumau^nk0B0lrBMHqoW7e1X2_DME9jOFgzlmFKegyBn!Ra^lO6ddwj20h9EHtYVSn= zKH|{c>;=0^AGy~;kE8{ZO~#?(_R(2J$jnuzd8#-4*8_`|tZWGCd9W9-4D>_uHDprp zP@zrmRJy+KtKeDTD%$)W8=Zu{r2{ejtLGIyUTF1_65*z54YEnSTF9}ze*D;I!RJvq zKJjW|kszB0WlYVTj0w`4WcsaR{HDD*iK*$gzPB(vE%(?UNFM5v=d#CJ0NzOFIz*)L zBXRP`EM^D73O}f2wd+!HID$Zh(m5{&kwUO>x%n(Qz6w-gNG4EA zg}QPZN6J#;?bF3EY0mPQILtmF5)X7$!W^2g$RxN`=7u6S_{lq1rJQw*gm#`Gy9;ZF zj457XOcd{sazt-g$cu6O93X)Y5-OoZ*Db<)(1wVHL^H^6ymnOkDCTz3*Zx~!OkR#) z%?J)PPB7UoWP(ABi(Y`tH7O55dRU;S`w0`dFm}CL0;v)Dk@~0q;b=kX_`IMAL&_(# z!*;`8A@PSUgkK$`SZ)Z?ZoH&8Kd-KL-tPyu3cW()uHh=X=Ymtq z2nal5STR7Mhdy>HkouU>1Zr_@yx`Y(0+UMP*~d;-?@~Fq@{U@1^!z@_-;(zvYso&< zE2}S@`Sq3~XP-Co_SrknJbGr&$~&e%H~q}%Zsm84Ya8b_9@^MC^{J_6Ott3ritd5@ z>iiA)1M`{sN9#}7a^>8g=H6MqZS4nhd(VFDe~6%cB= z@yi?sGVPuaFM~h=^D<^IJ{r;VWz;R(O#W8?P&!|_%|$`w!%e!!>Tf( zqK=hHIOOeLMn%N?#tPxk?`D;;L7)u|mHOV7S;pTwB#9i8GRU{K3xF9uoG z$zY?p*YIX*ZLk+f)j02%%xrWi0}_GU`~KqeEsy@?c%y_ZNN7h=$BPfk$wp1Ve{+L) z+Kq|xNO=pHl;{{tqKh7ca#Xx<3(G5wpO53DbxRs~IVo}`&P{Z3QY?1Ggn!GbfHnkA zZ5n=e5H8{tL&QVimp73g^}@nRd6u>11Tk1}M~kO5zKUX8BtgJKlN{lRed@G%RYXT5 zNHG}&J3_CUe0%#&mN38L88auoIgbuW5%$DEa4X10U>5V5I+3nt>>Y?ysNXp0E{=s^ z?G=|7Es0mrSh=#As7)+H5N&g0_zS__r!~x*0#VX-o%ya2Q^`DVC%MwuM;3OnPl zdP{YWVOisSgdUIc98aZ?Xl1~iK!4>hde&Tx2&T-#JK1=0y=W<<5oMnAxuYK7gSa1j zo=A7#Y;tXMthoC@T#q4!9wr^IXl{juY1J=Vnf+RF-yn*NZ|8&yN#l8yqFBFIQ03rz zi9Czv7+s1GUPg$-Xi&8Wm!-iWw`vU!N;V$FFeHAAC(h+1l;ZI7qhkDhcNS)Tz7Q8s z>$fKrgSp5|?x~6N37OF_$sah5N+vXw|NUQ!>lB2Z?p#OLt3{(C2+Cl5LNb=Os5MAq znKyj>ym3&S@gO$G6veUq*tmz*n0P-ZA%o3NND+*I8vi5X(u$5(Jr^=2C`1R+TcWQZ zY>S+L_Y9roV(sE@7sk}Hg#1W>9Tnp^(H<62TyQ-i+zE-^(9>O~3k;o0N01jKOo&Lf zanhIyW_?VWg*%1CS#!dnBO8(2UievI8 ziK2`11Rk+ME)bm~G9r>fNP@UT(l#1PgXVqF1F3;RN>9qGwOIXGrC>?KZcoc>pnX&1 zH+>jwMVo=mdlEGSUT-ox&K8zNgQrpaB)h&JZf#%gWJm zYg`M9$e6m>)nn$-7!$@UJ57tf=qgw%Y@ne4C)|+8LTfq=!U)GBHk_=5l!2TrpJjEm zdg!=2f8CNhT%@-w6~ar2TX%&2%^M=&X34RG*|{-dcc!~kwpOUWz6Py)v{!Lh4!n)44=Z639 z!w&f4dbjOV>A5(2TBYQV^5!^Pj&-u71f=M$L=mXOiU^`9-UQytmm| zsW1O|BQF?ZaxTOnLIyWWsI<%5hj1@)s*#&S@1lux|I5aXvl_ zIgI{AZDQ-3PjY)+e-wajYYFd=BI8ZO;}m?4@^JPRHa3qsnyKV3bH&Nw>~ps%G06Y< zI>=LPh9E==>O`1HQN3qazFQH%T!R}~PEMw;JE|Q#4&7o*=n`;V3H*5Of;`OJc=tHg zQcDKnw=A+i1Cv0g`36U+d_%y7+Wqv#4=IkxgN>WlDG~8YTu0)>N4FdCD`V&6Y{-9V z@C>G_aDnCaMIDlDiwo7i>{(oJ_biS}PDF58xzcGVIj4H)uuM2~k_o5-#o4hNbQn9m zI5$x2qDVWjcoSHUnAoZyOBTdM(p=A(QJ9r5MB5}G>!`So{D#+F$Scx_j2il$%B$a6 zd^ibLb1^tGGBb;DT~H+emuhh?Wav>rQRiN#fR>{1gz`b%6mmWu_9Xk%n9<9Mdjoda zRQ9RjBSp3fBWG|p=$`iD99hw8&{GpKM$jxphp-QqVRRR2pLP4X(t>lRIjrtc+?j3* zBp)_M5FlycrisqCKr1cKxvK^qbH7aA7}rTjUVYcA}j@aVjK} zB$RrM%x=8lJz`$2TD~ih?Vtve1Heq{XZuZx`*gvMJ>uEAIh7E{g`zkuydvzg5p%xru*T-*6hnzyIV!d%5c)77zMouvig=^;((Lq3Kjt>II1(L1{$}@ zB3{JyN-{I|^TLu(VrpjY!h$9H%-412NYJ=3j3y{2Wg)OF2`cE21>+`$0Hu#$l11Sw zpQZw0NvA&ZV!_`%ulIdTO)Zbh6V(rzjr=iPk8*D^2NKLK10DyT)(gss@?nV zvop>t#ivtD;;mzG<`LqBwpzh!-t_W|D`m)$>`3N$~Sz$><#vE_)l-^{&z z?$L95&VF|G)Y+w(pJqR)?_T?~e`Qp?`OG;p51g5v{_yk((+4;Hz45Z-rO8p%KUd$? zIA-c^Qx{L&D!&}#;PI8q(gTxkO&y;9ztFDt_FW`j&ywCw%22h~*&pbO=K z_ch{akC;D$(~`uR-O%%rrrYlQx1y;DMpfVAzBew(RBey@?pZV?tPAE*)VC@rec2wq zPL$AP8Qp$p7C1S0_Z<(n7N^R#jd#M(!Ct6s`@69ocMNI*Q`xq^n=Ot?R>7mH{v{>gm03j_9UNAwzW90hq23h1 zM^tON)YhWzDytjs=wLO?4%qDaGpY$pCD{RE9@?1T1g5eBKDyKVxF^9F)(av=gdUlYj^)dHzz}4mAs71lfLmULXBh!-?i?Xs}l#Dt*h%@A^;83Uamu%8&OlB&pzhtMa zFv0xNJ?^`;_yH#{mF#if`J$-_K44P&$X|-r!UU#jAGy~1^Zj+s2&)bOM8GB3s3k`| zH<3ID5_lOuUW_bMbD8VJVU&pr5@d<99`+7}u3>%l+|4ff$xPK|&%LI&R1=IUn?3gj zoBiB|C7sb~+kH9M}@lLQk@*QP=qcTXr#@#}hS7kFe#}|6ZjgI|HaoHy@ zb)#cvir!~olP86F8}YDJw55~0l2LE$ zET#3qCU-%s)UKOK>Vt8d7B|cUrs{()7w3nU(VH%~fNMcJDbFW;EH)d#l_2a^?hw4g z6%c+=s0w9Oi8GP1pe@<@>whgS`UIx7{`xID4a!K!X|4o^xwMCkBuXJnA?G7H<6Uv< z1%yIF<=g&B$KALdj!3H1(@Sq&lBrsH=}z7dHKko0RhC}5^R>6@rqcA%nUW@$q?e8> z!0YeU1f#k%D-KHDPSQs9Y#SJ=vsjUxxT5*2Cy(%FdCASFeQ8OaHr~M?&9@%o)38*exR@PLW zGh|yav@3AqD^9aNy|W}!N%p4;iu0RbRN0^Yr8p>O&ONQ>1&RiaLY9HU?*degme-Qa zk)KsuR!;+cPz{R&yjp_1%u=oP!qZDKmDOH&N=c^D+6zxE$y8E%;S-CiKFM0WaLl_L zTV1A2{x>+J1AQ3)B~;A#;X(eSTvS;h_3Sk2J=kiwdm?@%9%;wna^;Mj?w1Kdh-BNn ze|t=EeiPW*cJEKa1H80SJK~uMujsI<;Joqocbb3K{ObJfTRySn#4QKRT{-u>xs};# zW?wsd@0q{NylZB4W{>HAn||{2L5;6BUQqp2;|^0lpL)a8{if3VJ(vP+TK`=A8TDrE z`?Z(X?wS25yCCajd!(OCPf8C-zMZ@zx$A!q{%`n||640if8dxZMEcVW;z9|^_SMJ< z!vwlET{ALpa>L3(Y8`3|{Ea~EN??Kyqk&duQD>_D>^qg(8{TYRUZ-a&87AKwuwseR zf#kVj2VA=vj_g}xb^AdS8bVY7{F8Z+EsV3}>FM;+ZQr`Kk|pohNWno~a$)&B$|9U` zwMJF+mU@HQ2Omh6gwmLNs2T%g0$@1WxS*4hgH$d^U;Nri2F_*liw_zD1_*W$`huz&Se^X`Z3D4M^Ctlyqvic%m0Bj;~ zo-07m;5Ct9lIv!T-b&DslzUqw>?+i-dAUXQ6Xb_?OFxybhG15v`V;gl&OeG>Hkp~z@% zLK{sIAk#{VQ7e@zgzf-B>xJk9sn7mWEqPicyT^+P##A7oN5Jx6cIZjLjS+{86ulaU z;*~&&<6xbD-{HuxJ9~3cK|&lZZT-`GE6HsS+cXyXk_6XO($$hA_#<|dg;OOT&|iua zG)i4ra*7Cn+2WGPiUg^-jL`HgkF8W*c)v|!5SFmL5C_mC()@5YFcj*MQOi?EmL&)H zx1$Z%rc)*c?1nf}0vRsMExOr9n>!6H00bw96kv)&uOmZA0)oI!rk$ni82~EHGy~AS z4iBjbQRTc4G=tJi(_eqRQom}rIW)g2U{+{%Vicc1NF%CLgk)t=6G>l91LhlyYS0fA zQNpI=dnnux5vu>>{7UVK-yCmS@`NBL01rU61E&E-fILHNWs0}vMcN6XGk_}`93p~& z1yj#MyRAK}o<9D`mHdZ+%fIeOac%*vX*5y!0}4>o^^g2nw-N+9+;Bjh=@NxFn?gH! zHy;e5FYwrlY5nU@uVf2%+O!tBtWsCXQO*g(tu=ve)bl~{&5w7lkTeg-gDn_{b@}A* zJYkvvPPV>oze@V$(4)J4Y%1?~KUJy}?G^&hxGeh|Jqh-O6q)i!Q#qes&X0phh-0Oe zPUTaXESp(7yplfmzMIypnV=Uam*$^@w&paYcUukc5;{l{4nEsOq(lclra06RAyZhC zU+ga?=_mHCO#OPaX>HN@2%wV&yFiDiXCcxSBUl@S_#j3NcWg^J+z7OTesRMdcfbplQwV5`kj@esq&An)sbhJki0wf@sMZD^0{*~N0#>M>A{yG10HQKwTO~8^zNk_;_VR)` zn+l~8oIKeYTbG;aBPiH9W-djKmM1Fo6^OK|Z;^ulH3&RTBvi8cj}|KVsYh*m2$PM# z5r6@PaG-_3Bo&|)f0tI>L7_+tx1)5XOv+g{M5riXr8~jIlj_`wm8ssB3WIZLyJ`YH zLVX8_-}r1!&NYo(P$F3>=^T__4u+?I64Sa!o#h_1TFJ~&2UfD*KBr*LA4dld?G#84 zSb2|!?Wf31IL|o?Obt6p45|Le!*x(<3bS86{){SNE3 z5_NDDKD8B)C-`X*fLj~5#cfKZSZUcrK#T1}Z9vwh@%3j^>bEF<*L7H;%Nk-Amc*e^ z=k_D0S4NA7S4tTaOv&smuSZ{Vhujs!GniL0*ZX57ed*f@gNw@LdJmcp*@^xgjd8#Y z2=NI)#1kQiUx%K9(rH9eRYkwe-2fo(ZaewvO8r4ME?BeKjh^SnOLDl?KK)VE&-Hdv z&wejb@^Tn0y^3N1LtE9U%Q)dm?Oiq=e`hedNZF1496`#^8 zD%%Ml{fpIeAkx8;I{HP%Gv}?SDkVkrxx1fEab1 zIvtbi#b$3dk8aqT{q`A^{1eA*4vuuaNDweX_8-zDnhw37D&ipRoSjfkRSgePp6I9Y z?E2<}ZA0U4shP}O_UKCf>@f^QLn^me;u3E(ugyyeo9ABUh(!-d^h-IeU>9zPXu=>} z78jY5A41)W*&fYCB{_LVVRSTr0jp>wYD+>N>=6qOC200RC!>uPt+E#cT{Q?l`AKh> zvY~3b>Qg#@!FMaw$L~`x$CUw9hh$_i>au;<2CyP0H9$okdM|g|3hKe(Dh5)JJ}dcN zn|US84mzun{rZ&!a~gny_{|BUQ|N8+Mj3?$21zM>ilNW!vWE)O5eBLXV-VOZ_)(`X z+4AZ?R+4MRbr5S8>HFwUg8v6*guD#TFB+4UW2^c-_-hY_<^xUu|IC%=qrh|eNlE>i zFRwK2n-@mMqi+)wr9>Bi^8FM^7~jSdq06M6fBdhN z##_d{Mn(s*?Ds8CNOt;D=*9yU0p6wEnj7UQN=KDdAIHMGH5N$u2~Wa>+=s?>jY zeqnSxLC_WYV}VlC#teGsUhk7-Rh8m0!SM(p3?_Ooq>eQJyRL+89Id4B>a*|z?1*P^ zIzOV)t~@VKUXk1@sZ=kX`t#JgYNw@7u6;f|bR7xs$oh?H%h@&AIoSj2znvOQ-8{c6 zKRugCKQjC2`CHE1e!Jjdz01{WETSNcUNWOnVK!qYU#sAkvpE3B34UhEJZ9xmwsn7S)k zd@Xm71~^w`bfD9LcWEoG3~U_BMeGiNgJApRA2+CV?>6SA@dn08ubQ|C#L|mFF+gA% zU5IvO$aurz^P9zqdfe#)z^2KCw3}J7d`Y7mQ%SNsF4Gv4a8--Bg0z=f!>|sS;Mn9k zYhU&^TD8%4C8&tbfDpXr`kOjf4 zptg-D>WV5zFe_%o4442xoMzb|HWDP~oHG){2$;Zth?w>MMzL4*?ECC{?sJ&)&&@fH zj=u5*iBBPyz71 zg|5mEUQnt^;J{-e^}f~jmt-pKTm6TUOx5~U|F$GkN#E*wmxJnJyDFLd>Te=TT*3ye zC6g!SwnC8w>|5RA?(Oc+M}Gd&bq~KIH|a%=a&6tiZ_IrkAO$B%leN6QkQo*axX=Ed z7J`tR#(n}o0`Z`XZ2!7GbpT2pM4#>WijmKG%ytR6&iFpAyMk!MFrtrW&+^~|Q3CuR zfo9MT&8*#KMnnbtFHNK%P3ln#`7bLlocfB9lS?v{t{BO=rJ-g2h$pGGV&rQjjVf6& zazxIQD^^;FQ-eV`kO#7q^3PiE^=`m>>)RFhX+ZMqSv+0+cm?FDw2;otB!j2DJhuyq zm>N87_uQalv}gE(qA)GlI=MKH5p74rqW5@F@;Q;q7bCzUj+ zdUe-czja(L1)Socb=JCu3pldP!Ml4+Y%!<;rW%8H_aOLTN{$LrOIBYH16bCr^hok| zTYV5$h+iOu)L(hDL2VVlhXBU}CBwSAA9qtpvtFz2`t`i20;5XS-93LBOMO6ALAge< zH^e(t1t7Lu6_Q)WP( zd{}+3@94hW`Le(c9Kpqh6vR(HC|;uEY~HVbGx`vh)N(5d1BcCXfY*4*eHtcSW9Fk0sUkH_3!#0 zq<{o_1NOOF6bk-l&o`oUvAF3Y)+4GP_=cg?{(0}s0!3u(*8F-kRMe6Q?>p# z{<0)fN&g#fUk==+)z}|-d1}2)_sWe)Do;Cs5==K?qlsQSmuy48*OW*307@fXzKD_~ zYGIAV4~hv~-Kn{Hve&muGLHfwf5WI~uo?}R(pbn4^#AJl-Q8X9ee$)V>L4;FUA|#+?*d&3~#y6PC&w2qmB~RIPDaJ->bhMwK*fOY-Yiz*OV5N`6rCWWsNLqgEbyJN;Tx-y%qjN@|qB zgC@PF*;7ftMCl@s_&0S?>aExI{b@<2(%Qa1F3D7_w(p%KnM!K=-jSd60&`W{_jZ;_ z@Dyqrw&u#Qlx)~qm49d+x%aYRE1Ν!$n*!v6f)sZVMt(EzJkd_pn+U3zw+N# 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 z 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�JQ4abVV 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�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 literal 0 HcmV?d00001 diff --git a/src/README.md b/src/README.md new file mode 100644 index 00000000..f4987833 --- /dev/null +++ b/src/README.md @@ -0,0 +1,108 @@ +# torii (root library) + +The `torii` library is the heart of the indexer. It wires a three-stage ETL +pipeline (Extract → Decode → Sink) to an EventBus, a CommandBus, a gRPC +service, and an HTTP server, and exposes a single `run(ToriiConfig)` +orchestrator that binaries call. + +## Role in Torii + +Every binary in `bins/` builds a `ToriiConfig`, registers its own sinks, +decoders, and extractor, and hands the config to `torii::run`. This crate +owns the event loop, the cursor-commit contract, contract auto-identification, +graceful shutdown, and the gRPC/HTTP surface. Sinks and decoders plug in via +traits defined under `src/etl/`. + +## Architecture + +```text + +----------------------+ + | ToriiConfigBuilder | src/lib.rs (builder) + +-----------+----------+ + | + v + +---------------- torii::run(ToriiConfig) ----------------+ src/lib.rs:613 + | | + | tokio task #1 (producer) | + | Extractor.extract(cursor, engine_db) ----> mpsc --- | + | | | + | tokio task #2 (identifier, optional) | | + | ContractIdentifier.identify_contracts <------+ | + | | + | tokio task #3 (consumer loop) | + | DecoderContext.decode_events --> Vec | + | MultiSink.process(envelopes, batch) | + | | | | | + | v v v | + | EventBus gRPC svc HTTP routes (per sink) | + | | | | | + | +---> SubscriptionManager -> connected clients | + | | + | CommandBus: async fire-and-forget side channel | + | | + | EngineDb: cursor + stats + contract→decoder cache | + +----------------------------------------------------------+ + | + axum + tonic on a single TCP port (+ optional TLS) +``` + +## Deep Dive + +### Public API + +| Type / Trait | File | Line | Purpose | +|---|---|---|---| +| `ToriiConfig` / `ToriiConfigBuilder` | `src/lib.rs` | 96 / 233 | All server config: sinks, decoders, extractor, filters, TLS, concurrency | +| `ToriiTlsConfig` | `src/lib.rs` | 202 | Optional TLS acceptor config with ALPN | +| `EtlConcurrencyConfig` | `src/lib.rs` | 78 | Prefetch batch cap (`max_prefetch_batches`) | +| `run(config)` | `src/lib.rs` | 613 | Single entry point for every binary | +| `Sink` (trait) | `src/etl/sink/mod.rs` | 74 | `name` / `interested_types` / `process` / `topics` / `build_routes` / `initialize` | +| `Decoder` (trait) | `src/etl/decoder/mod.rs` | 96 | `decoder_name` + `decode(keys, data, context)` | +| `Extractor` (trait) | `src/etl/extractor/mod.rs` | 299 | `extract(cursor, engine_db)` + `commit_cursor` | +| `ContractIdentifier` (trait) | `src/etl/identification/registry.rs` | 32 | Batch ABI lookup + shared cache | +| `IdentificationRule` (trait) | `src/etl/identification/rule.rs` | 52 | ABI pattern → `Vec` | +| `EventBus` | `src/etl/sink/mod.rs` | 188 | Topic pub/sub via `SubscriptionManager` | +| `CommandBus` / `CommandHandler` | `src/command.rs` | 148 / 35 | Sinks dispatch fire-and-forget commands | +| `SubscriptionManager` | `src/grpc.rs` | 42 | Client registration + filter routing | +| `GrpcState` / `ToriiService` | `src/grpc.rs` | 134 / 156 | Core `torii.Torii` service | +| `EngineDb` / `EngineStats` | `src/etl/engine_db.rs` | 33 / (below) | Cursor + head + contract-decoder persistence | +| `Envelope` / `TypedBody` / `EventMsg` | `src/etl/envelope.rs` | 58 / 30 / 118 | Envelope model flowing through the pipeline | +| `DecoderContext` | `src/etl/decoder/context.rs` | 32 | Router from event → decoders (mapping > registry > all) | +| `MultiSink` | `src/etl/sink/multi.rs` | 16 | Runs sinks concurrently via `join_all`, aggregates topics & routes | +| `ContractFilter` / `DecoderId` | `src/etl/decoder/mod.rs` | 236 / 179 | Whitelist + blacklist + deterministic decoder IDs | + +### Internal Modules + +- `command.rs` — `Command`, `CommandHandler`, `CommandBus`, `CommandBusSender`; a bounded-queue MPSC that routes typed commands to matching handlers. +- `grpc.rs` — core `torii.Torii` service (`GetVersion`, `ListTopics`, `SubscribeToTopics[Stream]`) + `SubscriptionManager` + `ClientSubscription`. +- `http.rs` — minimal Axum router with `/health` and `/metrics`. Sinks merge their routes onto this via `MultiSink::build_routes`. +- `metrics.rs` — Prometheus recorder bootstrapped from `TORII_METRICS_ENABLED`; rendered at `/metrics`. +- `etl/mod.rs` — public re-exports (`Decoder`, `Extractor`, `Sink`, `MultiSink`, `EngineDb`, `Envelope`, `StarknetEvent`, `EventContext`, …). +- `etl/envelope.rs` — pipeline data unit: `Envelope { id, type_id, body: Box, metadata, timestamp }`. `EventMsg` is the sugar trait decoders most often implement via `to_envelope(ctx)`. +- `etl/engine_db.rs` — `sqlx::Pool` wrapper with embedded SQLite + PostgreSQL schema (see `sql/engine_schema*.sql`). Persists head, cursor, stats, and contract→decoder mappings. +- `etl/decoder/` — `Decoder` trait, `DecoderId`, `ContractFilter`, `DecoderContext` (routing logic). +- `etl/extractor/` — `Extractor` trait + several implementations: `SampleExtractor`, `BlockRangeExtractor`, `EventExtractor`, `GlobalEventExtractor`, `CompositeExtractor`, `SyntheticExtractorAdapter`, `SyntheticErc20Extractor`, plus `RetryPolicy` and starknet RPC helpers. +- `etl/identification/` — `IdentificationRule` trait, `ContractRegistry` (`ContractIdentifier` impl) with bounded positive/negative caches, batched JSON-RPC fetches (`MAX_BATCH_SIZE = 500`). +- `etl/sink/` — `Sink` trait, `EventBus`, `SinkContext`, `TopicInfo`; `multi.rs` drives per-sink concurrency with `futures::future::join_all`. + +### Interactions + +- **Upstream** (binaries): every `bins/*` calls `torii::run` with its own `ToriiConfigBuilder` assembly. +- **Downstream**: every `torii-*-sink`, `torii-dojo`, `torii-introspect`, `torii-erc20/721/1155` depends on this crate for the `Sink`, `Decoder`, `Extractor`, `Envelope`, `EventBus`, and `ContractIdentifier` types. +- **Workspace deps**: `torii-common` (indirect), `torii-types` (re-exports `StarknetEvent` / `EventContext` / `BlockContext`), `torii-sql` (not a direct dep — `engine_db` uses raw `sqlx::Any`). + +### Key Invariants + +- **Cursor safety**: the cursor is committed (`extractor.commit_cursor`) *only after* `multi_sink.process` returns `Ok` — see `src/lib.rs:1113-1127`. A crash between extract and sink processing replays the last batch; it never loses it. +- **Decoder determinism**: `DecoderContext::decoder_ids` sorts `DecoderId` values so the decode order is reproducible across restarts (`src/etl/decoder/context.rs:197`). +- **Decoder priority**: blacklist → explicit mapping → registry cache → fallback to all decoders. Stale cache entries pointing at unknown `DecoderId`s are evicted automatically (`src/etl/decoder/context.rs:341-360`). +- **Multi-sink isolation**: one sink failing logs & increments `torii_sink_failures_total` but does *not* abort the batch. Other sinks still run (`src/etl/sink/multi.rs:51-66`). +- **Shutdown**: SIGINT/SIGTERM cancels a `CancellationToken`. The HTTP/gRPC server has 15 s to drain; the ETL loop gets `config.shutdown_timeout` seconds (default 30 s) to finish its current batch. + +### Extension Points + +- Add a sink → implement `Sink` (see `crates/torii-sql-sink/src/lib.rs` as the reference). +- Add a decoder → implement `Decoder` (see `crates/torii-erc20/src/decoder.rs`). +- Add a custom extractor → implement `Extractor` (RPC-backed: `etl/extractor/event.rs`; deterministic: `SyntheticExtractor` under `etl/extractor/synthetic.rs`). +- Add auto-identification → implement `IdentificationRule` and register it on a `ContractRegistry`, then plug the registry in via `ToriiConfigBuilder::with_contract_identifier`. +- Add a background worker → implement `CommandHandler` and register via `ToriiConfigBuilder::with_command_handler`. diff --git a/src/etl/decoder/context.rs b/src/etl/decoder/context.rs index 8f850c38..9550e6d7 100644 --- a/src/etl/decoder/context.rs +++ b/src/etl/decoder/context.rs @@ -11,23 +11,16 @@ //! - Unmapped contracts with no registry fall back to all decoders //! - Deterministic ordering: decoders are always called in sorted DecoderId order +use super::{ContractFilter, Decoder, DecoderId}; +use crate::etl::decoder::EventContext; +use crate::etl::engine_db::EngineDb; +use crate::etl::envelope::Envelope; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; -use super::{ContractFilter, Decoder, DecoderId}; -use crate::etl::engine_db::EngineDb; -use crate::etl::envelope::Envelope; - -fn event_preview(event: &EmittedEvent) -> String { - format!( - "contract={:#x} tx={:#x}", - event.from_address, event.transaction_hash - ) -} - /// DecoderContext manages multiple decoders with contract filtering. /// /// Routes events to decoders based on: @@ -215,39 +208,38 @@ impl DecoderContext { /// Decode an event using specific decoders async fn decode_with_decoders( &self, - event: &EmittedEvent, + keys: &[Felt], + data: &[Felt], + context: EventContext, decoder_ids: &[DecoderId], ) -> anyhow::Result> { let mut all_envelopes = Vec::new(); for decoder_id in decoder_ids { if let Some(decoder) = self.decoders.get(decoder_id) { - match decoder.decode_event(event).await { + match decoder.decode(keys, data, context).await { Ok(envelopes) => { if !envelopes.is_empty() { tracing::trace!( target: "torii::etl::decoder_context", "Decoder '{}' decoded event from {:#x} into {} envelope(s)", decoder.decoder_name(), - event.from_address, + context.from_address, envelopes.len() ); } all_envelopes.extend(envelopes); } Err(e) => { - let selector = event - .keys + let selector = keys .first() .map_or_else(|| "".to_string(), |felt| format!("{felt:#x}")); - let preview = event_preview(event); tracing::warn!( target: "torii::etl::decoder_context", - contract = %format!("{:#x}", event.from_address), + contract = %format!("{:#x}", context.from_address), selector = %selector, - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number, - event = %preview, + tx_hash = %format!("{:#x}", context.transaction_hash), + block_number = context.block_number, "Decoder '{}' failed: {}", decoder.decoder_name(), e @@ -259,7 +251,7 @@ impl DecoderContext { target: "torii::etl::decoder_context", "Decoder ID {:?} not found for contract {:#x}", decoder_id, - event.from_address + context.from_address ); } } @@ -270,37 +262,36 @@ impl DecoderContext { /// Decode an event using all registered decoders (fallback) async fn decode_with_all_decoders( &self, - event: &EmittedEvent, + keys: &[Felt], + data: &[Felt], + context: EventContext, ) -> anyhow::Result> { let mut all_envelopes = Vec::new(); for decoder in self.decoders.values() { - match decoder.decode_event(event).await { + match decoder.decode(keys, data, context).await { Ok(envelopes) => { if !envelopes.is_empty() { tracing::trace!( target: "torii::etl::decoder_context", "Decoder '{}' decoded event from {:#x} into {} envelope(s)", decoder.decoder_name(), - event.from_address, + context.from_address, envelopes.len() ); } all_envelopes.extend(envelopes); } Err(e) => { - let selector = event - .keys + let selector = keys .first() .map_or_else(|| "".to_string(), |felt| format!("{felt:#x}")); - let preview = event_preview(event); tracing::warn!( target: "torii::etl::decoder_context", - contract = %format!("{:#x}", event.from_address), + contract = %format!("{:#x}", context.from_address), selector = %selector, - tx_hash = %format!("{:#x}", event.transaction_hash), - block_number = event.block_number, - event = %preview, + tx_hash = %format!("{:#x}", context.transaction_hash), + block_number = context.block_number, "Decoder '{}' failed: {}", decoder.decoder_name(), e @@ -319,21 +310,28 @@ impl Decoder for DecoderContext { "context" } - async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result> { + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> anyhow::Result> { // 1. Check blacklist first - if !self.contract_filter.allows(event.from_address) { + if !self.contract_filter.allows(context.from_address) { return Ok(Vec::new()); } // 2. Check explicit mappings (highest priority) - if let Some(decoder_ids) = self.contract_filter.get_decoders(event.from_address) { - return self.decode_with_decoders(event, decoder_ids).await; + if let Some(decoder_ids) = self.contract_filter.get_decoders(context.from_address) { + return self + .decode_with_decoders(keys, data, context, decoder_ids) + .await; } // 3. Check registry cache (if registry is configured) if self.has_registry { let cache = self.registry_cache.read().await; - if let Some(decoder_ids) = cache.get(&event.from_address) { + if let Some(decoder_ids) = cache.get(&context.from_address) { if decoder_ids.is_empty() { // Contract was identified but no decoders match - skip silently return Ok(Vec::new()); @@ -350,55 +348,37 @@ impl Decoder for DecoderContext { drop(cache); { let mut cache = self.registry_cache.write().await; - cache.remove(&event.from_address); + cache.remove(&context.from_address); } tracing::debug!( target: "torii::etl::decoder_context", - contract = %format!("{:#x}", event.from_address), + contract = %format!("{:#x}", context.from_address), invalid_decoder_ids = ?invalid_ids, "Evicted stale decoder mapping from registry cache; falling back to all decoders" ); - return self.decode_with_all_decoders(event).await; + return self.decode_with_all_decoders(keys, data, context).await; } // Clone to release lock before async decode let decoder_ids = decoder_ids.clone(); drop(cache); - return self.decode_with_decoders(event, &decoder_ids).await; + return self + .decode_with_decoders(keys, data, context, &decoder_ids) + .await; } // Not in registry cache = not yet identified, try all decoders // This enables auto-discovery: decoders can identify events they understand tracing::trace!( target: "torii::etl::decoder_context", - contract = %format!("{:#x}", event.from_address), + contract = %format!("{:#x}", context.from_address), "Contract not in registry cache, trying all decoders" ); drop(cache); - return self.decode_with_all_decoders(event).await; + return self.decode_with_all_decoders(keys, data, context).await; } // 4. No registry: try all decoders (fallback for non-block-range extractors) - self.decode_with_all_decoders(event).await - } - - async fn decode(&self, events: &[EmittedEvent]) -> anyhow::Result> { - let mut all_envelopes = Vec::new(); - - for event in events { - let envelopes = self.decode_event(event).await?; - - all_envelopes.extend(envelopes); - } - - tracing::debug!( - target: "torii::etl::decoder_context", - "Decoded {} events into {} envelopes across {} decoders", - events.len(), - all_envelopes.len(), - self.decoders.len(), - ); - - Ok(all_envelopes) + self.decode_with_all_decoders(keys, data, context).await } } @@ -439,15 +419,20 @@ mod tests { "ordered_decoder" } - async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result> { - if event.from_address != self.contract { + async fn decode( + &self, + _keys: &[Felt], + _data: &[Felt], + context: EventContext, + ) -> anyhow::Result> { + if context.from_address != self.contract { return Ok(Vec::new()); } Ok(vec![Envelope::new( - format!("evt-{}", event.block_number.unwrap_or_default()), + format!("evt-{}", context.block_number), Box::new(TestBody { - seq: event.block_number.unwrap_or_default(), + seq: context.block_number, }), HashMap::new(), )]) @@ -472,17 +457,16 @@ mod tests { let context = DecoderContext::new(vec![decoder], engine_db, ContractFilter::new()); let events = (0..600_u64) - .map(|seq| EmittedEvent { + .map(|seq| crate::etl::StarknetEvent { from_address: contract, keys: Vec::new(), data: Vec::new(), - block_hash: None, - block_number: Some(seq), + block_number: seq, transaction_hash: Felt::from(seq + 1), }) .collect::>(); - let envelopes = Decoder::decode(&context, &events).await.unwrap(); + let envelopes = Decoder::decode_events(&context, &events).await.unwrap(); let actual = envelopes .iter() .map(|envelope| envelope.downcast_ref::().unwrap().seq) diff --git a/src/etl/decoder/mod.rs b/src/etl/decoder/mod.rs index e6b74268..07f8a73f 100644 --- a/src/etl/decoder/mod.rs +++ b/src/etl/decoder/mod.rs @@ -1,11 +1,12 @@ pub mod context; -use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; -use std::collections::{hash_map::DefaultHasher, HashMap, HashSet}; -use std::hash::{Hash, Hasher}; - use super::envelope::Envelope; +use async_trait::async_trait; +use starknet_types_raw::Felt; +use std::collections::{HashMap, HashSet}; +use std::hash::Hash; +use torii_types::event::{EventContext, StarknetEvent}; +use xxhash_rust::const_xxh3::xxh3_64; pub use context::DecoderContext; @@ -43,16 +44,17 @@ pub use context::DecoderContext; /// ```rust,ignore /// use crate::etl::decoder::Decoder; /// use crate::etl::envelope::{Envelope, TypeId, TypedBody}; -/// use starknet::core::types::EmittedEvent; +/// use crate::etl::decoder::StarknetEvent; /// use async_trait::async_trait; /// use std::collections::HashMap; +/// use starknet_types_raw::Felt; /// /// pub struct MyDecoder { -/// contract_filters: Vec, +/// contract_filters: Vec, /// } /// /// impl MyDecoder { -/// fn is_interested(&self, event: &EmittedEvent) -> bool { +/// fn is_interested(&self, event: &StarknetEvent) -> bool { /// // Filter logic here /// true /// } @@ -64,7 +66,7 @@ pub use context::DecoderContext; /// "my_decoder" /// } /// -/// async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result> { +/// async fn decode_event(&self, event: &StarknetEvent) -> anyhow::Result> { /// if !self.is_interested(event) { /// return Ok(Vec::new()); /// } @@ -86,7 +88,7 @@ pub use context::DecoderContext; /// /// # Performance /// -/// **Zero-copy filtering**: Decoders receive `&EmittedEvent` (reference), allowing: +/// **Zero-copy filtering**: Decoders receive `&StarknetEvent` (reference), allowing: /// - Process events without cloning. /// - Only extract data for events the decoder is interested in. /// - Multiple decoders process the same events without memory duplication. @@ -109,17 +111,38 @@ pub trait Decoder: Send + Sync { /// ``` fn decoder_name(&self) -> &str; - /// Decode a single event into typed envelopes + /// Decode event data into typed envelopes /// /// This is the primary method that decoders should implement. /// Returns an empty Vec if the decoder is not interested in this event. /// /// # Arguments + /// * `keys` - Event keys (selectors and other identifiers). + /// * `data` - Event data fields. + /// * `context` - from_address, block_number, transaction_hash + /// + /// # Returns + /// Vector of envelopes produced from this event (empty if not interested). + async fn decode( + &self, + keys: &[Felt], + data: &[Felt], + context: EventContext, + ) -> anyhow::Result>; + + /// Decode a single event into envelopes (convenience method) + /// + /// Default implementation calls `decode()` with event fields. + /// Override only if you want to change the input type or add pre-processing. + /// + /// # Arguments /// * `event` - Reference to the event to decode. /// /// # Returns /// Vector of envelopes produced from this event (empty if not interested). - async fn decode_event(&self, event: &EmittedEvent) -> anyhow::Result>; + async fn decode_event(&self, event: &StarknetEvent) -> anyhow::Result> { + self.decode(&event.keys, &event.data, event.context()).await + } /// Decode multiple events into typed envelopes (convenience method) /// @@ -131,11 +154,10 @@ pub trait Decoder: Send + Sync { /// /// # Returns /// Vector of all envelopes produced from all events. - async fn decode(&self, events: &[EmittedEvent]) -> anyhow::Result> { + async fn decode_events(&self, events: &[StarknetEvent]) -> anyhow::Result> { let mut all_envelopes = Vec::new(); for event in events { - let envelopes = self.decode_event(event).await?; - all_envelopes.extend(envelopes); + all_envelopes.extend(self.decode_event(event).await?); } Ok(all_envelopes) } @@ -150,18 +172,16 @@ pub trait Decoder: Send + Sync { /// # Example /// /// ```rust,ignore -/// let erc20_decoder_id = DecoderId::new("erc20"); -/// let erc721_decoder_id = DecoderId::new("erc721"); +/// const ERC20_DECODER_ID = DecoderId::new("erc20"); +/// const ERC721_DECODER_ID = DecoderId::new("erc721"); /// ``` #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct DecoderId(u64); impl DecoderId { /// Creates a DecoderId from a decoder name (deterministic) - pub fn new(name: &str) -> Self { - let mut hasher = DefaultHasher::new(); - name.hash(&mut hasher); - DecoderId(hasher.finish()) + pub const fn new(type_name: &str) -> Self { + Self(xxh3_64(type_name.as_bytes())) } /// Creates a DecoderId from a u64 value (for deserialization) diff --git a/src/etl/engine_db.rs b/src/etl/engine_db.rs index 94215a7d..b86e1271 100644 --- a/src/etl/engine_db.rs +++ b/src/etl/engine_db.rs @@ -4,8 +4,10 @@ //! This will be enhanced with actual Torii features in the future. use anyhow::{Context, Result}; -use sqlx::{any::AnyPoolOptions, sqlite::SqliteConnectOptions, Any, ConnectOptions, Pool, Row}; -use starknet::core::types::Felt; +use sqlx::any::AnyPoolOptions; +use sqlx::sqlite::SqliteConnectOptions; +use sqlx::{Any, ConnectOptions, Pool, Row}; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::str::FromStr; diff --git a/src/etl/envelope.rs b/src/etl/envelope.rs index 9ca2df8b..2fac8d3b 100644 --- a/src/etl/envelope.rs +++ b/src/etl/envelope.rs @@ -1,8 +1,8 @@ //! This module contains the envelope for the ETL pipeline. -use starknet::core::types::{EmittedEvent, Felt}; use std::any::Any; use std::collections::HashMap; +use torii_types::event::EventContext; use xxhash_rust::const_xxh3::xxh3_64; /// Type identifier based on a string hash @@ -109,67 +109,61 @@ impl std::fmt::Debug for Envelope { } } -#[derive(Debug, Clone)] -pub struct MetaData { - pub block_number: Option, - pub transaction_hash: Felt, - pub from_address: Felt, -} - #[derive(Debug, Clone)] pub struct EventBody { - pub metadata: MetaData, + pub context: EventContext, pub msg: T, } pub trait EventMsg: Send + Sync + 'static { fn event_id(&self) -> String; fn envelope_type_id(&self) -> TypeId; - fn to_body(self, raw: &EmittedEvent) -> EventBody + fn to_body(self, context: EventContext) -> EventBody where Self: Sized, { - EventBody { - metadata: raw.into(), - msg: self, - } + EventBody { context, msg: self } + } + fn to_envelope(self, context: EventContext) -> Envelope + where + Self: Sized, + { + Envelope::new( + self.event_id(), + Box::new(self.to_body(context)), + HashMap::new(), + ) + } + fn to_envelopes(self, context: EventContext) -> Vec + where + Self: Sized, + { + vec![self.to_envelope(context)] } - fn to_envelope(self, raw: &EmittedEvent) -> Envelope + fn to_ok_envelopes(self, context: EventContext) -> Result, E> where Self: Sized, { - Envelope::new(self.event_id(), Box::new(self.to_body(raw)), HashMap::new()) + Ok(self.to_envelopes(context)) } } -impl From> for (T, MetaData) { +impl From> for (T, EventContext) { fn from(value: EventBody) -> Self { - (value.msg, value.metadata) + (value.msg, value.context) } } -impl From<(T, MetaData)> for EventBody { - fn from(value: (T, MetaData)) -> Self { - let (msg, meta_data) = value; - EventBody { - msg, - metadata: meta_data, - } +impl From<(T, EventContext)> for EventBody { + fn from(value: (T, EventContext)) -> Self { + let (msg, context) = value; + EventBody { context, msg } } } -impl<'a, T> From<&'a EventBody> for (&'a T, &'a MetaData) { +impl<'a, T> From<&'a EventBody> for (&'a T, &'a EventContext) { fn from(value: &'a EventBody) -> Self { - (&value.msg, &value.metadata) - } -} -impl From<&EmittedEvent> for MetaData { - fn from(value: &EmittedEvent) -> Self { - MetaData { - block_number: value.block_number, - transaction_hash: value.transaction_hash, - from_address: value.from_address, - } + (&value.msg, &value.context) } } diff --git a/src/etl/event.rs b/src/etl/event.rs deleted file mode 100644 index a75b8088..00000000 --- a/src/etl/event.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::collections::HashMap; - -use starknet::core::types::{EmittedEvent, Felt}; - -pub trait EmittedEventExt { - fn metadata(&self) -> HashMap; - fn split_keys(&self) -> Option<(&Felt, &[Felt])>; -} - -impl EmittedEventExt for EmittedEvent { - fn metadata(&self) -> HashMap { - let mut metadata = HashMap::new(); - metadata.insert( - "from_address".to_string(), - self.from_address.to_fixed_hex_string(), - ); - metadata.insert( - "tx_hash".to_string(), - self.transaction_hash.to_fixed_hex_string(), - ); - if let Some(block_hash) = self.block_hash { - metadata.insert("block_hash".to_string(), block_hash.to_fixed_hex_string()); - } - if let Some(block_number) = self.block_number { - metadata.insert("block_number".to_string(), block_number.to_string()); - } - metadata - } - fn split_keys(&self) -> Option<(&Felt, &[Felt])> { - self.keys.split_first() - } -} diff --git a/src/etl/extractor/composite.rs b/src/etl/extractor/composite.rs index 9ad93d84..da5bf920 100644 --- a/src/etl/extractor/composite.rs +++ b/src/etl/extractor/composite.rs @@ -167,15 +167,15 @@ impl Extractor for CompositeExtractor { mod tests { use super::*; use crate::etl::extractor::SampleExtractor; - use starknet::core::types::{EmittedEvent, Felt}; + use starknet_types_raw::Felt; + use torii_types::event::StarknetEvent; fn make_sample_extractor() -> SampleExtractor { - let events = vec![EmittedEvent { + let events = vec![StarknetEvent { from_address: Felt::from(1u64), keys: vec![Felt::from(100u64)], data: vec![Felt::from(1000u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }]; SampleExtractor::new(events, 1) diff --git a/src/etl/extractor/event.rs b/src/etl/extractor/event.rs index 5f8d8511..c4976b42 100644 --- a/src/etl/extractor/event.rs +++ b/src/etl/extractor/event.rs @@ -46,20 +46,18 @@ //! .build(); //! ``` +use crate::etl::engine_db::EngineDb; +use crate::etl::extractor::{event_common, ExtractionBatch, Extractor, RetryPolicy}; use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{ - requests::GetEventsRequest, BlockId, EmittedEvent, EventFilter, EventFilterWithPage, Felt, - ResultPageRequest, -}; +use starknet::core::types::requests::GetEventsRequest; +use starknet::core::types::{BlockId, EventFilter, EventFilterWithPage, ResultPageRequest}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet}; use std::sync::Arc; - -use crate::etl::engine_db::EngineDb; -use crate::etl::extractor::event_common; -use crate::etl::extractor::{ExtractionBatch, Extractor, RetryPolicy}; +use torii_types::event::StarknetEvent; const EXTRACTOR_TYPE: &str = "event"; @@ -471,7 +469,7 @@ impl EventExtractor { event_filter: EventFilter { from_block: Some(BlockId::Number(state.current_block.max(self.start_block))), to_block: Some(BlockId::Number(range_end)), - address: Some(state.address), + address: Some(state.address.into()), keys: None, }, result_page_request: ResultPageRequest { @@ -500,16 +498,16 @@ impl EventExtractor { } fn filter_events_by_tx_hashes( - events: Vec, + events: Vec, successful_transaction_hashes: &HashSet, - ) -> Vec { + ) -> Vec { event_common::filter_events_by_tx_hashes(events, successful_transaction_hashes) } /// Build ExtractionBatch from events with block context. async fn build_batch( &self, - events: Vec, + events: Vec, engine_db: &EngineDb, ) -> Result { event_common::build_batch( @@ -622,7 +620,7 @@ impl Extractor for EventExtractor { ); // Process responses and update state - let mut all_events = Vec::new(); + let mut all_events: Vec = Vec::new(); let mut any_advanced = false; for (address, response) in addresses.iter().zip(responses) { @@ -633,7 +631,7 @@ impl Extractor for EventExtractor { if let ProviderResponseData::GetEvents(events_page) = response { let event_count = events_page.events.len(); - all_events.extend(events_page.events); + all_events.extend(StarknetEvent::filter_pending(events_page.events)); tracing::debug!( target: "torii::etl::event", @@ -903,20 +901,18 @@ mod tests { let keep = Felt::from(1u64); let drop = Felt::from(2u64); let events = vec![ - EmittedEvent { + StarknetEvent { from_address: Felt::ZERO, keys: Vec::new(), data: Vec::new(), - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: keep, }, - EmittedEvent { + StarknetEvent { from_address: Felt::ZERO, keys: Vec::new(), data: Vec::new(), - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: drop, }, ]; @@ -948,12 +944,11 @@ mod tests { .unwrap(); let tx_hash = Felt::from(42u64); - let event = EmittedEvent { + let event = StarknetEvent { from_address: Felt::from(7u64), keys: Vec::new(), data: Vec::new(), - block_hash: None, - block_number: Some(123), + block_number: 123, transaction_hash: tx_hash, }; diff --git a/src/etl/extractor/event_common.rs b/src/etl/extractor/event_common.rs index 87d28871..1fc6028b 100644 --- a/src/etl/extractor/event_common.rs +++ b/src/etl/extractor/event_common.rs @@ -1,17 +1,16 @@ +use crate::etl::engine_db::EngineDb; +use crate::etl::extractor::{BlockContext, ExtractionBatch, RetryPolicy, TransactionContext}; +use crate::etl::StarknetEvent; use anyhow::{Context, Result}; use futures::stream::{self, StreamExt}; -use starknet::core::types::{ - requests::{GetBlockWithTxHashesRequest, GetTransactionReceiptRequest}, - BlockId, EmittedEvent, ExecutionResult, Felt, MaybePreConfirmedBlockWithTxHashes, -}; +use starknet::core::types::requests::{GetBlockWithTxHashesRequest, GetTransactionReceiptRequest}; +use starknet::core::types::{BlockId, ExecutionResult, MaybePreConfirmedBlockWithTxHashes}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use crate::etl::engine_db::EngineDb; -use crate::etl::extractor::{BlockContext, ExtractionBatch, RetryPolicy, TransactionContext}; - const RECEIPT_LOOKUP_BATCH_SIZE: usize = 200; pub(crate) fn resolved_rpc_parallelism(configured: usize) -> usize { @@ -153,7 +152,7 @@ pub(crate) async fn fetch_successful_transaction_hashes( .iter() .map(|tx_hash| { ProviderRequestData::GetTransactionReceipt(GetTransactionReceiptRequest { - transaction_hash: *tx_hash, + transaction_hash: tx_hash.into(), }) }) .collect(); @@ -235,9 +234,9 @@ pub(crate) async fn fetch_successful_transaction_hashes( } pub(crate) fn filter_events_by_tx_hashes( - events: Vec, + events: Vec, successful_transaction_hashes: &HashSet, -) -> Vec { +) -> Vec { events .into_iter() .filter(|event| successful_transaction_hashes.contains(&event.transaction_hash)) @@ -248,15 +247,15 @@ pub(crate) async fn build_batch( provider: Arc>, retry_policy: &RetryPolicy, rpc_parallelism: usize, - events: Vec, + events: Vec, engine_db: &EngineDb, ) -> Result { let block_numbers: Vec = events .iter() - .filter_map(|e| e.block_number) + .map(|e| e.block_number) .collect::>() .into_iter() - .collect(); + .collect::>(); let timestamps = fetch_block_timestamps( provider, @@ -283,18 +282,16 @@ pub(crate) async fn build_batch( let mut transactions = HashMap::new(); for event in &events { - if let Some(block_number) = event.block_number { - transactions - .entry(event.transaction_hash) - .or_insert_with(|| { - Arc::new(TransactionContext { - hash: event.transaction_hash, - block_number, - sender_address: None, - calldata: Vec::new(), - }) - }); - } + transactions + .entry(event.transaction_hash) + .or_insert_with(|| { + Arc::new(TransactionContext { + hash: event.transaction_hash, + block_number: event.block_number, + sender_address: None, + calldata: Vec::new(), + }) + }); } Ok(ExtractionBatch { diff --git a/src/etl/extractor/global_event.rs b/src/etl/extractor/global_event.rs index d9b115bd..8e20c7cc 100644 --- a/src/etl/extractor/global_event.rs +++ b/src/etl/extractor/global_event.rs @@ -5,13 +5,14 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{ - requests::GetEventsRequest, BlockId, EventFilter, EventFilterWithPage, Felt, ResultPageRequest, -}; +use starknet::core::types::requests::GetEventsRequest; +use starknet::core::types::{BlockId, EventFilter, EventFilterWithPage, ResultPageRequest}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::collections::HashSet; use std::sync::Arc; +use torii_types::event::StarknetEvent; use crate::etl::engine_db::EngineDb; use crate::etl::extractor::event_common::{ @@ -313,7 +314,7 @@ impl Extractor for GlobalEventExtractor { _ => anyhow::bail!("Unexpected response type for global event request"), }; - let mut all_events = events_page.events; + let mut all_events: Vec = StarknetEvent::filter_pending(events_page.events); let mut any_advanced = false; if let Some(token) = events_page.continuation_token { diff --git a/src/etl/extractor/mod.rs b/src/etl/extractor/mod.rs index 541fab4a..624a6521 100644 --- a/src/etl/extractor/mod.rs +++ b/src/etl/extractor/mod.rs @@ -13,9 +13,10 @@ pub mod synthetic_adapter; pub mod synthetic_erc20; use crate::etl::engine_db::EngineDb; +use crate::etl::StarknetEvent; use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::sync::Arc; @@ -29,16 +30,7 @@ pub use starknet_helpers::ContractAbi; pub use synthetic::SyntheticExtractor; pub use synthetic_adapter::SyntheticExtractorAdapter; pub use synthetic_erc20::{SyntheticErc20Config, SyntheticErc20Extractor}; - -/// Block context information -#[derive(Debug, Clone, Default)] -pub struct BlockContext { - pub number: u64, - pub hash: Felt, - pub parent_hash: Felt, - pub timestamp: u64, -} - +pub use torii_types::block::BlockContext; /// Transaction context information #[derive(Debug, Clone, Default)] pub struct TransactionContext { @@ -48,13 +40,6 @@ pub struct TransactionContext { pub calldata: Vec, } -#[derive(Debug, Clone, Default)] -pub struct EventContext { - pub from_address: Felt, - pub transaction: Arc, - pub block: Arc, -} - /// Declared class information #[derive(Debug, Clone)] pub struct DeclaredClass { @@ -76,7 +61,7 @@ pub struct DeployedContract { pub struct BlockData { pub block_context: BlockContext, pub transactions: Vec, - pub events: Vec, + pub events: Vec, pub declared_classes: Vec, pub deployed_contracts: Vec, } @@ -110,7 +95,7 @@ pub struct BlockData { #[derive(Debug, Clone)] pub struct ExtractionBatch { /// Events extracted (may contain duplicates from same block/tx) - pub events: Vec, + pub events: Vec, /// Block context (deduplicated by block_number for memory efficiency) pub blocks: HashMap>, @@ -240,29 +225,28 @@ impl ExtractionBatch { ); } // Add an event to the batch - pub fn add_event(&mut self, event: EmittedEvent) { + pub fn add_event(&mut self, event: StarknetEvent) { self.events.push(event); } // Add an event with transaction context (block_number and sender_address) to the batch // Returns None if block_number is missing from event and does not updated, since we need it to add transaction context pub fn add_event_with_tx_context( &mut self, - event: EmittedEvent, + event: StarknetEvent, sender_address: Option, calldata: Vec, - ) -> Option<()> { + ) { self.add_transaction_context( event.transaction_hash, - event.block_number?, + event.block_number, sender_address, calldata, ); self.events.push(event); - Some(()) } // Add multiple events to the batch - pub fn add_events(&mut self, events: Vec) { + pub fn add_events(&mut self, events: Vec) { self.events.extend(events); } // add a declared class to the batch @@ -308,17 +292,6 @@ impl ExtractionBatch { pub fn remove_chain_head(&mut self) { self.chain_head = None; } - - pub fn get_event_context(&self, tx_hash: &Felt, from_address: Felt) -> Option { - let transaction = self.transactions.get(tx_hash)?.clone(); - let block = self.blocks.get(&transaction.block_number)?.clone(); - - Some(EventContext { - from_address, - transaction, - block, - }) - } } /// Extractor trait for fetching enriched event batches diff --git a/src/etl/extractor/sample.rs b/src/etl/extractor/sample.rs index 54797cb5..ae98e592 100644 --- a/src/etl/extractor/sample.rs +++ b/src/etl/extractor/sample.rs @@ -5,17 +5,19 @@ use anyhow::Result; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; -use std::{collections::HashMap, sync::Arc}; +use starknet_types_raw::Felt; +use std::collections::HashMap; +use std::sync::Arc; use crate::etl::engine_db::EngineDb; +use crate::etl::StarknetEvent; use super::{BlockContext, ExtractionBatch, Extractor, TransactionContext}; /// Simple extractor that cycles through predefined events pub struct SampleExtractor { /// Sample events to cycle through - events: Vec, + events: Vec, /// Current index in the events array current_index: usize, /// Number of events to return per extraction @@ -30,7 +32,7 @@ impl SampleExtractor { /// # Arguments /// * `events` - Predefined events to cycle through /// * `batch_size` - Number of events to return per extraction - pub fn new(events: Vec, batch_size: usize) -> Self { + pub fn new(events: Vec, batch_size: usize) -> Self { Self { events, current_index: 0, @@ -40,7 +42,7 @@ impl SampleExtractor { } /// Generate the next batch of events (cycling through the predefined list) - fn next_batch(&mut self) -> Vec { + fn next_batch(&mut self) -> Vec { if self.events.is_empty() { return Vec::new(); } @@ -52,8 +54,7 @@ impl SampleExtractor { let mut event = self.events[self.current_index].clone(); // Update block number to current block - event.block_number = Some(self.current_block); - event.block_hash = Some(Felt::from(self.current_block)); + event.block_number = self.current_block; // Generate unique transaction hash based on position event.transaction_hash = Felt::from(2000 + self.current_index as u64); @@ -101,20 +102,18 @@ impl Extractor for SampleExtractor { // Create block context (deduplicated) let mut blocks = HashMap::new(); for event in &events { - if let Some(block_num) = event.block_number { - blocks.entry(block_num).or_insert_with(|| { - Arc::new(BlockContext { - number: block_num, - hash: event.block_hash.unwrap_or(Felt::ZERO), - parent_hash: if block_num > 0 { - Felt::from(block_num - 1) - } else { - Felt::ZERO - }, - timestamp: 1700000000 + block_num, // Realistic timestamp - }) - }); - } + blocks.entry(event.block_number).or_insert_with(|| { + Arc::new(BlockContext { + number: event.block_number, + hash: event.block_number.into(), + parent_hash: if event.block_number > 0 { + Felt::from(event.block_number - 1) + } else { + Felt::ZERO + }, + timestamp: 1700000000 + event.block_number, // Realistic timestamp + }) + }); } // Create transaction context (deduplicated) @@ -125,7 +124,7 @@ impl Extractor for SampleExtractor { .or_insert_with(|| { Arc::new(TransactionContext { hash: event.transaction_hash, - block_number: event.block_number.unwrap_or(0), + block_number: event.block_number, sender_address: Some( Felt::from_hex( "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", @@ -133,7 +132,7 @@ impl Extractor for SampleExtractor { .unwrap(), ), calldata: vec![ - Felt::from(1), // selector + Felt::from(0), // selector Felt::from(self.current_block), // param1 Felt::from(42), // param2 ], @@ -169,20 +168,18 @@ mod tests { async fn test_sample_extractor_cycling() { // Create sample events let events = vec![ - EmittedEvent { + StarknetEvent { from_address: Felt::from(1u64), keys: vec![Felt::from(100u64)], data: vec![Felt::from(1000u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, - EmittedEvent { + StarknetEvent { from_address: Felt::from(2u64), keys: vec![Felt::from(200u64)], data: vec![Felt::from(2000u64)], - block_hash: None, - block_number: None, + block_number: 0, transaction_hash: Felt::ZERO, }, ]; diff --git a/src/etl/extractor/starknet_helpers.rs b/src/etl/extractor/starknet_helpers.rs index a2dd9696..1132a86a 100644 --- a/src/etl/extractor/starknet_helpers.rs +++ b/src/etl/extractor/starknet_helpers.rs @@ -1,17 +1,18 @@ +use crate::etl::StarknetEvent; + +use super::{BlockContext, BlockData, DeclaredClass, DeployedContract, TransactionContext}; use anyhow::{Context, Result}; use starknet::core::types::contract::{AbiEntry, TypedAbiEvent}; -use starknet::core::types::requests::GetClassAtRequest; -use starknet::core::types::LegacyContractAbiEntry; +use starknet::core::types::requests::{GetBlockWithReceiptsRequest, GetClassAtRequest}; use starknet::core::types::{ - requests::GetBlockWithReceiptsRequest, BlockId, ContractClass, DeclareTransactionContent, - DeployAccountTransactionContent, EmittedEvent, ExecutionResult, Felt, InvokeTransactionContent, + BlockId, ContractClass, DeclareTransactionContent, DeployAccountTransactionContent, + ExecutionResult, InvokeTransactionContent, LegacyContractAbiEntry, MaybePreConfirmedBlockWithReceipts, TransactionContent, TransactionReceipt, }; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use std::collections::HashSet; -use super::{BlockContext, BlockData, DeclaredClass, DeployedContract, TransactionContext}; - #[inline] fn is_execution_succeeded(execution_result: &ExecutionResult) -> bool { matches!(execution_result, ExecutionResult::Succeeded) @@ -88,7 +89,7 @@ where .map(|&class_hash| { ProviderRequestData::GetClassAt(GetClassAtRequest { block_id: BlockId::Tag(starknet::core::types::BlockTag::Latest), - contract_address: class_hash, + contract_address: class_hash.into(), }) }) .collect(); @@ -153,8 +154,8 @@ pub fn block_into_contexts(block: MaybePreConfirmedBlockWithReceipts) -> Result< let block_context = BlockContext { number: block_with_receipts.block_number, - hash: block_with_receipts.block_hash, - parent_hash: block_with_receipts.parent_hash, + hash: block_with_receipts.block_hash.into(), + parent_hash: block_with_receipts.parent_hash.into(), timestamp: block_with_receipts.timestamp, }; @@ -178,7 +179,8 @@ pub fn block_into_contexts(block: MaybePreConfirmedBlockWithReceipts) -> Result< TransactionReceipt::Declare(r) => r.transaction_hash, TransactionReceipt::Deploy(r) => r.transaction_hash, TransactionReceipt::DeployAccount(r) => r.transaction_hash, - }; + } + .into(); if !is_receipt_succeeded(&receipt) { skipped_reverted += 1; @@ -242,20 +244,20 @@ pub fn block_into_contexts(block: MaybePreConfirmedBlockWithReceipts) -> Result< } }, }; - + let sender_address = sender_address.map(Into::into); // Build transaction context transaction_contexts.push(TransactionContext { hash: tx_hash, block_number: block_with_receipts.block_number, sender_address, - calldata, + calldata: calldata.into_iter().map(Into::into).collect(), }); // Extract declared classes from Declare transactions if let Some((class_hash, compiled_class_hash)) = declare_info { declared_classes.push(DeclaredClass { - class_hash, - compiled_class_hash, + class_hash: class_hash.into(), + compiled_class_hash: compiled_class_hash.map(Into::into), transaction_hash: tx_hash, }); } @@ -264,15 +266,15 @@ pub fn block_into_contexts(block: MaybePreConfirmedBlockWithReceipts) -> Result< match &receipt { TransactionReceipt::Deploy(deploy_receipt) => { deployed_contracts.push(DeployedContract { - contract_address: deploy_receipt.contract_address, + contract_address: deploy_receipt.contract_address.into(), class_hash: Felt::ZERO, // Old Deploy doesn't have class_hash accessible easily transaction_hash: tx_hash, }); } TransactionReceipt::DeployAccount(deploy_account_receipt) => { deployed_contracts.push(DeployedContract { - contract_address: deploy_account_receipt.contract_address, - class_hash: deploy_account_class.unwrap_or(Felt::ZERO), + contract_address: deploy_account_receipt.contract_address.into(), + class_hash: deploy_account_class.map_or(Felt::ZERO, Into::into), transaction_hash: tx_hash, }); } @@ -288,17 +290,15 @@ pub fn block_into_contexts(block: MaybePreConfirmedBlockWithReceipts) -> Result< TransactionReceipt::DeployAccount(r) => r.events, }; - // Convert to EmittedEvent format - move ownership to avoid cloning - for event in events { - all_events.push(EmittedEvent { - from_address: event.from_address, - keys: event.keys, - data: event.data, - block_hash: Some(block_with_receipts.block_hash), - block_number: Some(block_with_receipts.block_number), - transaction_hash: tx_hash, - }); - } + all_events.extend(events.into_iter().map(|e| { + StarknetEvent::new( + e.from_address.into(), + e.keys.into_iter().map(Into::into).collect(), + e.data.into_iter().map(Into::into).collect(), + block_with_receipts.block_number, + tx_hash, + ) + })); } tracing::debug!( diff --git a/src/etl/extractor/synthetic_adapter.rs b/src/etl/extractor/synthetic_adapter.rs index 5d8dca21..0cae363f 100644 --- a/src/etl/extractor/synthetic_adapter.rs +++ b/src/etl/extractor/synthetic_adapter.rs @@ -155,8 +155,8 @@ mod tests { batch.set_cursor(Self::make_cursor(block)); batch.add_block_context( block, - starknet::core::types::Felt::ZERO, - starknet::core::types::Felt::ZERO, + starknet::core::types::Felt::ZERO.into(), + starknet::core::types::Felt::ZERO.into(), 0, ); Ok(batch) diff --git a/src/etl/extractor/synthetic_erc20.rs b/src/etl/extractor/synthetic_erc20.rs index b418ff7d..2673fae2 100644 --- a/src/etl/extractor/synthetic_erc20.rs +++ b/src/etl/extractor/synthetic_erc20.rs @@ -6,12 +6,12 @@ use anyhow::{Context, Result}; use async_trait::async_trait; -use starknet::core::types::{EmittedEvent, Felt}; -use starknet::macros::selector; +use starknet_types_raw::Felt; use std::collections::HashMap; use std::sync::Arc; use crate::etl::engine_db::EngineDb; +use crate::etl::StarknetEvent; use super::{BlockContext, ExtractionBatch, Extractor, TransactionContext}; @@ -210,21 +210,19 @@ impl SyntheticErc20Extractor { let amount_low = self.amount_low_for(block_number, tx_index); let event = if self.is_approval(tx_index) { - EmittedEvent { + StarknetEvent { from_address: token, - keys: vec![selector!("Approval"), from, to], + keys: vec![Felt::selector("Approval"), from, to], data: vec![amount_low, Felt::ZERO], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), + block_number, transaction_hash: tx_hash, } } else { - EmittedEvent { + StarknetEvent { from_address: token, - keys: vec![selector!("Transfer"), from, to], + keys: vec![Felt::selector("Transfer"), from, to], data: vec![amount_low, Felt::ZERO], - block_hash: Some(Felt::from(0x0300_0000_u64 + block_number)), - block_number: Some(block_number), + block_number, transaction_hash: tx_hash, } }; diff --git a/src/etl/identification/registry.rs b/src/etl/identification/registry.rs index a7856810..07d57b1c 100644 --- a/src/etl/identification/registry.rs +++ b/src/etl/identification/registry.rs @@ -10,20 +10,20 @@ use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; const MAX_BATCH_SIZE: usize = 500; use std::sync::Arc; +use super::IdentificationRule; +use crate::etl::decoder::DecoderId; +use crate::etl::engine_db::EngineDb; +use crate::etl::extractor::ContractAbi; use anyhow::{Context, Result}; use async_trait::async_trait; use futures::stream::{self, StreamExt}; use starknet::core::types::requests::{GetClassHashAtRequest, GetClassRequest}; -use starknet::core::types::{BlockId, BlockTag, Felt}; +use starknet::core::types::{BlockId, BlockTag}; use starknet::providers::jsonrpc::{HttpTransport, JsonRpcClient}; use starknet::providers::{Provider, ProviderRequestData, ProviderResponseData}; +use starknet_types_raw::Felt; use tokio::sync::RwLock; -use super::IdentificationRule; -use crate::etl::decoder::DecoderId; -use crate::etl::engine_db::EngineDb; -use crate::etl::extractor::ContractAbi; - /// Trait for contract identification (object-safe). /// /// This trait allows type-erased contract identification, so that `ToriiConfig` @@ -297,7 +297,7 @@ impl ContractRegistry { .map(|&addr| { ProviderRequestData::GetClassHashAt(GetClassHashAtRequest { block_id: BlockId::Tag(BlockTag::Latest), - contract_address: addr, + contract_address: addr.into(), }) }) .collect(); @@ -332,7 +332,7 @@ impl ContractRegistry { // Map contract → class_hash, track failures for (addr, response) in chunk_addresses.iter().zip(class_hash_responses) { if let ProviderResponseData::GetClassHashAt(class_hash) = response { - contract_to_class.insert(*addr, class_hash); + contract_to_class.insert(*addr, class_hash.into()); } else { tracing::debug!( target: "torii::etl::identification", @@ -375,7 +375,7 @@ impl ContractRegistry { .map(|&class_hash| { ProviderRequestData::GetClass(GetClassRequest { block_id: BlockId::Tag(BlockTag::Latest), - class_hash, + class_hash: class_hash.into(), }) }) .collect(); diff --git a/src/etl/identification/rule.rs b/src/etl/identification/rule.rs index 07dbc8ae..87f7b1bf 100644 --- a/src/etl/identification/rule.rs +++ b/src/etl/identification/rule.rs @@ -4,7 +4,7 @@ //! can be identified by inspecting the ABI. use anyhow::Result; -use starknet::core::types::Felt; +use starknet_types_raw::Felt; use crate::etl::decoder::DecoderId; use crate::etl::extractor::ContractAbi; diff --git a/src/etl/mod.rs b/src/etl/mod.rs index 0bc9ca41..89c8db8f 100644 --- a/src/etl/mod.rs +++ b/src/etl/mod.rs @@ -1,18 +1,18 @@ pub mod decoder; pub mod engine_db; pub mod envelope; -pub mod event; pub mod extractor; pub mod identification; pub mod sink; pub use decoder::{Decoder, DecoderContext}; pub use engine_db::{EngineDb, EngineStats}; -pub use envelope::{Envelope, EventBody, EventMsg, MetaData, TypeId, TypedBody}; +pub use envelope::{Envelope, EventBody, EventMsg, TypeId, TypedBody}; pub use extractor::{ - BlockContext, ContractAbi, EventContext, ExtractionBatch, Extractor, SampleExtractor, - SyntheticErc20Config, SyntheticErc20Extractor, SyntheticExtractor, SyntheticExtractorAdapter, - TransactionContext, + BlockContext, ContractAbi, ExtractionBatch, Extractor, SampleExtractor, SyntheticErc20Config, + SyntheticErc20Extractor, SyntheticExtractor, SyntheticExtractorAdapter, TransactionContext, }; pub use identification::{ContractRegistry, IdentificationRule}; pub use sink::{MultiSink, Sink}; + +pub use torii_types::event::{EventContext, StarknetEvent}; diff --git a/src/lib.rs b/src/lib.rs index 5173bdaa..1a96f3b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,10 +17,10 @@ pub mod proto { } // Re-export commonly used types for external sink authors +use crate::etl::StarknetEvent; pub use async_trait::async_trait; -pub use axum; -pub use tokio; -pub use tonic; + +pub use {axum, tokio, tonic}; // Re-export UpdateType for sink implementations pub use grpc::UpdateType; @@ -47,6 +47,7 @@ use etl::sink::{EventBus, Sink}; use etl::{Decoder, DecoderContext, MultiSink, SampleExtractor}; use grpc::{create_grpc_service, GrpcState, SubscriptionManager}; use http::create_http_router; +use starknet_types_raw::Felt; // Include the file descriptor set generated at build time. // This is also exported publicly so external sink authors can use it for reflection. @@ -126,7 +127,7 @@ pub struct ToriiConfig { pub events_per_cycle: usize, /// Sample events for testing (provided by sinks). - pub sample_events: Vec, + pub sample_events: Vec, /// Extractor for fetching blockchain events. /// @@ -163,13 +164,8 @@ pub struct ToriiConfig { /// contract→decoder mappings for contracts not in explicit mappings. /// The cache is typically populated by a ContractRegistry running batch /// identification before decoding. - pub registry_cache: Option< - Arc< - tokio::sync::RwLock< - std::collections::HashMap>, - >, - >, - >, + pub registry_cache: + Option>>>>, /// Optional contract identifier for runtime identification. /// @@ -243,19 +239,14 @@ pub struct ToriiConfigBuilder { custom_reflection: bool, cycle_interval: Option, events_per_cycle: Option, - sample_events: Vec, + sample_events: Vec, extractor: Option>, database_root: Option, engine_database_url: Option, contract_filter: Option, identification_rules: Vec>, - registry_cache: Option< - Arc< - tokio::sync::RwLock< - std::collections::HashMap>, - >, - >, - >, + registry_cache: + Option>>>>, contract_identifier: Option>, shutdown_timeout: Option, etl_concurrency: Option, @@ -364,7 +355,7 @@ impl ToriiConfigBuilder { } /// Adds sample events for testing. - pub fn with_sample_events(mut self, events: Vec) -> Self { + pub fn with_sample_events(mut self, events: Vec) -> Self { self.sample_events.extend(events); self } @@ -419,11 +410,7 @@ impl ToriiConfigBuilder { /// /// Events from this contract will ONLY be tried with the specified decoders. /// This provides O(k) performance where k is the number of mapped decoders. - pub fn map_contract( - mut self, - contract: starknet::core::types::Felt, - decoder_ids: Vec, - ) -> Self { + pub fn map_contract(mut self, contract: Felt, decoder_ids: Vec) -> Self { self.contract_filter .get_or_insert_with(ContractFilter::new) .mappings @@ -434,7 +421,7 @@ impl ToriiConfigBuilder { /// Add contract to blacklist (fast discard). /// /// Events from this contract will be discarded immediately (O(1) check). - pub fn blacklist_contract(mut self, contract: starknet::core::types::Felt) -> Self { + pub fn blacklist_contract(mut self, contract: Felt) -> Self { self.contract_filter .get_or_insert_with(ContractFilter::new) .blacklist @@ -445,7 +432,7 @@ impl ToriiConfigBuilder { /// Add multiple contracts to blacklist. /// /// Events from these contracts will be discarded immediately (O(1) check). - pub fn blacklist_contracts(mut self, contracts: Vec) -> Self { + pub fn blacklist_contracts(mut self, contracts: Vec) -> Self { self.contract_filter .get_or_insert_with(ContractFilter::new) .blacklist @@ -504,11 +491,7 @@ impl ToriiConfigBuilder { /// ``` pub fn with_registry_cache( mut self, - cache: Arc< - tokio::sync::RwLock< - std::collections::HashMap>, - >, - >, + cache: Arc>>>, ) -> Self { self.registry_cache = Some(cache); self @@ -839,9 +822,8 @@ pub async fn run(config: ToriiConfig) -> Result<(), Box> let queue_depth = Arc::new(AtomicUsize::new(0)); let (identify_tx, identify_handle) = if let Some(identifier) = contract_identifier.clone() { - let (tx, mut rx) = tokio::sync::mpsc::channel::>( - prefetch_capacity.saturating_mul(2).max(8), - ); + let (tx, mut rx) = + tokio::sync::mpsc::channel::>(prefetch_capacity.saturating_mul(2).max(8)); let handle = tokio::spawn(async move { while let Some(contract_addresses) = rx.recv().await { if contract_addresses.is_empty() { @@ -903,7 +885,7 @@ pub async fn run(config: ToriiConfig) -> Result<(), Box> let new_cursor = batch.cursor.clone(); if let Some(ref identify_tx) = producer_identify_tx { - let contract_addresses: Vec = batch + let contract_addresses: Vec = batch .events .iter() .map(|event| event.from_address) @@ -1095,7 +1077,7 @@ pub async fn run(config: ToriiConfig) -> Result<(), Box> } // Transform the events into envelopes. - let envelopes = match etl_decoder_context.decode(&batch.events).await { + let envelopes = match etl_decoder_context.decode_events(&batch.events).await { Ok(envelopes) => envelopes, Err(e) => { tracing::error!(target: "torii::etl", "Decode failed: {}", e);

1%E@L!-ldpx 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