From f0d3b064df32665a10ca364c14040e540accd521 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Fri, 24 May 2024 22:17:38 +0200 Subject: [PATCH 01/50] Start backend written in Rust --- backend-rust/.env | 2 + backend-rust/Cargo.lock | 3962 +++++++++++++++++ backend-rust/Cargo.toml | 20 + backend-rust/README.md | 19 + backend-rust/database.odb | Bin 0 -> 2272 bytes backend-rust/docker-compose.yml | 12 + .../migrations/20240508183938_initialize.sql | 33 + backend-rust/rustfmt.toml | 1 + backend-rust/src/graphql_api.rs | 2094 +++++++++ backend-rust/src/indexer.rs | 189 + backend-rust/src/main.rs | 89 + 11 files changed, 6421 insertions(+) create mode 100644 backend-rust/.env create mode 100644 backend-rust/Cargo.lock create mode 100644 backend-rust/Cargo.toml create mode 100644 backend-rust/README.md create mode 100644 backend-rust/database.odb create mode 100644 backend-rust/docker-compose.yml create mode 100644 backend-rust/migrations/20240508183938_initialize.sql create mode 100644 backend-rust/rustfmt.toml create mode 100644 backend-rust/src/graphql_api.rs create mode 100644 backend-rust/src/indexer.rs create mode 100644 backend-rust/src/main.rs diff --git a/backend-rust/.env b/backend-rust/.env new file mode 100644 index 00000000..1cb082f1 --- /dev/null +++ b/backend-rust/.env @@ -0,0 +1,2 @@ +# Postgres +DATABASE_URL=postgres://postgres:example@localhost/ccd-scan \ No newline at end of file diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock new file mode 100644 index 00000000..69f59fb9 --- /dev/null +++ b/backend-rust/Cargo.lock @@ -0,0 +1,3962 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261fa27d5bff5afdf7beff291b3bc73f99d1529804c70e51b0fbc51e70b1c6a9" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-stream", + "async-trait", + "base64 0.21.7", + "bytes", + "chrono", + "fast_chemail", + "fnv", + "futures-util", + "handlebars", + "http 1.1.0", + "indexmap 2.2.6", + "mime", + "multer", + "num-traits", + "once_cell", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror", +] + +[[package]] +name = "async-graphql-axum" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93605d26b9da33b4cf6541906a9eb9e74396f1accbbc0f066e06f3b0869b84fc" +dependencies = [ + "async-graphql", + "async-trait", + "axum 0.7.5", + "bytes", + "futures-util", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", +] + +[[package]] +name = "async-graphql-derive" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3188809947798ea6db736715a60cf645ba3b87ea031c710130e1476b48e45967" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling", + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "strum", + "syn 2.0.61", + "thiserror", +] + +[[package]] +name = "async-graphql-parser" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e65a0b83027f35b2a5d9728a098bc66ac394caa8191d2c65ed9eb2985cf3d8" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e40849c29a39012d38bff87bfed431f1ed6c53fbec493294c1045d61a7ae75" +dependencies = [ + "bytes", + "indexmap 2.2.6", + "serde", + "serde_json", +] + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "async-trait" +version = "0.1.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "axum" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf" +dependencies = [ + "async-trait", + "axum-core 0.3.4", + "bitflags 1.3.2", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper 0.1.2", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6c9af12842a67734c9a2e355436e5d03b22383ed60cf13cd0c18fbfe3dcbcf" +dependencies = [ + "async-trait", + "axum-core 0.4.3", + "base64 0.21.7", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.3.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper 1.0.1", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "mime", + "rustversion", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 0.1.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backend-rust" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-graphql", + "async-graphql-axum", + "axum 0.7.5", + "chrono", + "clap", + "concordium-rust-sdk", + "dotenv", + "futures", + "sqlx", + "thiserror", + "tokio", +] + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe5b10e214954177fb1dc9fbd20a1a2608fe99e6c832033bdc7cea287a20d77" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a8646f94ab393e43e8b35a2558b1624bed28b97ee09c5d15456e3c9463f46d" +dependencies = [ + "once_cell", + "proc-macro-crate 3.1.0", + "proc-macro2", + "quote", + "syn 2.0.61", + "syn_derive", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "sha2", + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +dependencies = [ + "serde", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.5", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "concordium-contracts-common" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dfda3a8d06d2d72e74aad650757680a82df4e528c18c66c49efe9f60b646c5" +dependencies = [ + "base64 0.21.7", + "bs58", + "chrono", + "concordium-contracts-common-derive", + "fnv", + "hashbrown 0.11.2", + "hex", + "num-bigint", + "num-integer", + "num-traits", + "rust_decimal", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "concordium-contracts-common-derive" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3482ffacf3c18133be976c1b874b6e87e018ac0316e9385888b43df07fa39c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "concordium-rust-sdk" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d0798ed24edd3a8212ba2f3d6c771635cb3561c327dc03463efa09fa9bd6083" +dependencies = [ + "aes-gcm", + "anyhow", + "chrono", + "concordium-smart-contract-engine", + "concordium_base", + "derive_more", + "ed25519-dalek", + "futures", + "hex", + "http 0.2.12", + "num", + "num-bigint", + "num-traits", + "prost", + "rand", + "rust_decimal", + "semver", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tracing", +] + +[[package]] +name = "concordium-smart-contract-engine" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec03be6a4a56c0501cf9225d135aaa6c155228039efde27d6551814bc6776db1" +dependencies = [ + "anyhow", + "byteorder", + "concordium-contracts-common", + "concordium-wasm", + "derive_more", + "ed25519-zebra", + "futures", + "libc", + "num_enum", + "rand", + "secp256k1", + "serde", + "sha2", + "sha3", + "slab", + "thiserror", + "tinyvec", +] + +[[package]] +name = "concordium-wasm" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26a581ef6ae1b23e149eaf5ed1e43c1da877ded9ae0de4989cd71b76d25c67d7" +dependencies = [ + "anyhow", + "concordium-contracts-common", + "derive_more", + "leb128", + "num_enum", +] + +[[package]] +name = "concordium_base" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3dd5a345b61f667a8cb6a361f3e88268bf15b68eabab7f78e27ce513b20175" +dependencies = [ + "aes", + "anyhow", + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", + "base64 0.21.7", + "bs58", + "byteorder", + "cbc", + "chrono", + "concordium-contracts-common", + "concordium_base_derive", + "curve25519-dalek", + "derive_more", + "ed25519-dalek", + "either", + "ff", + "hex", + "hmac", + "itertools 0.10.5", + "leb128", + "libc", + "nom", + "num", + "num-bigint", + "num-traits", + "pbkdf2", + "rand", + "rayon", + "rust_decimal", + "serde", + "serde_json", + "serde_with", + "sha2", + "sha3", + "subtle", + "thiserror", + "zeroize", +] + +[[package]] +name = "concordium_base_derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9a154e9a64d58d3e9f890c603df847b8685cfd0bac3fadd18498af539650bed" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a677b8922c94e01bdbb12126b0bc852f00447528dee1782229af9c720c3f348" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "group", + "platforms", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.61", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "data-encoding" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" + +[[package]] +name = "der" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "serde", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "ed25519-zebra" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9ce6874da5d4415896cd45ffbc4d1cfc0c4f9c079427bd870742c30f2f65a9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "hashbrown 0.14.5", + "hex", + "rand_core", + "serde", + "sha2", + "zeroize", +] + +[[package]] +name = "either" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "ff" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded41244b729663b1e574f1b4fb731469f69f79c17667b5d776b16cda0479449" +dependencies = [ + "bitvec", + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38793c55593b33412e3ae40c2c9781ffaa6f438f6f8c10f24e71846fbd7ae01e" + +[[package]] +name = "finl_unicode" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdc7a0362c9f4444381a9e697c79d435fe65b52a37466fc2c1184cee9edc6" + +[[package]] +name = "flume" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.2.6", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "handlebars" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa67bab9ff362228eb3d00bd024a4965d8231bbb7921167f0cfa66c6626b225" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash 0.8.11", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.11", + "allocator-api2", +] + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0475f8b2ac86659c21b64320d5d653f9efe42acd2a4e560073ec61a155a34f1d" +dependencies = [ + "bytes", + "futures-core", + "http 1.1.0", + "http-body 1.0.0", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper 0.14.28", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + +[[package]] +name = "hyper-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.3.1", + "pin-project-lite", + "socket2", + "tokio", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "inout" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "libc" +version = "0.2.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" + +[[package]] +name = "libm" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.1.0", + "httparse", + "memchr", + "mime", + "spin 0.9.8", + "version_check", +] + +[[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 = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c165a9ab64cf766f73521c0dd2cfdff64f488b8f0b3e621face3462d3db536d7" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.1", + "smallvec", + "windows-targets 0.52.5", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "pest_meta" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "platforms" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23d408679286588f4d4644f965003d056e3dd5abcaaa938116871d7ce2fee7" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d37c51ca738a55da99dc0c4a34860fd675453b8b36209178c2249bb13651284" +dependencies = [ + "toml_edit 0.21.1", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f5d036824e4761737860779c906171497f6d55681139d8312388f8fe398922" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9554e3ab233f0a932403704f1a1d08c30d5ccd931adfdfa1e8b5a19b52c1d55a" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +dependencies = [ + "bitflags 2.5.0", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cba464629b3394fc4dbc6f940ff8f5b4ff5c7aef40f29166fd4ad12acbc99c0" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dddfff8de25e6f62b9d64e6e432bf1c6736c57d20323e15ee10435fbda7c65" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1790d1c4c0ca81211399e0e0af16333276f375209e71a37b67698a373db5b47a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustversion" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092474d1a01ea8278f69e6a358998405fae5b8b963ddaeb2b0b04a128bf1dfb0" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "secp256k1" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "295642060261c80709ac034f52fca8e5a9fa2c7d341ded5cdb164b7c33768b2a" +dependencies = [ + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152e20a0fd0519390fc43ab404663af8a0b794273d2a91d60ad4a39f13ffe110" +dependencies = [ + "cc", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.200" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "serde_json" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad483d2ab0149d5a5ebcd9972a3852711e0153d863bf5a5d0391d28883c4a20" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.2.6", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65569b702f41443e8bc8bbb1c5779bd0450bbe723b56198980e80ec45780bce2" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce81b7bd7c4493975347ef60d8c7e8b742d4694f4c49f93e0a12ea263938176c" +dependencies = [ + "itertools 0.12.1", + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash 0.8.11", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.2.6", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.5.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags 2.5.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "stringprep" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb41d74e231a107a1b4ee36bd1214b11285b77768d2e3824aedafa988fd36ee6" +dependencies = [ + "finl_unicode", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.61", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c993ed8ccba56ae856363b1845da7266a7cb78e1d146c8a32d54b45a8b831fc9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow", +] + +[[package]] +name = "toml_edit" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8534fd7f78b5405e860340ad6575217ce99f38d4d5c8f2442cb5ecb50090e1" +dependencies = [ + "indexmap 2.2.6", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tonic" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d560933a0de61cf715926b9cac824d4c883c2c43142f787595e48280c40a1d0e" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.6.20", + "base64 0.21.7", + "bytes", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.28", + "hyper-timeout", + "percent-encoding", + "pin-project", + "prost", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.1.0", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.61", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "whoami" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44ab49fad634e88f55bf8f9bb3abd2f27d7204172a112c7c9987e01c1c94ea9" +dependencies = [ + "redox_syscall 0.4.1", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + +[[package]] +name = "zeroize" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml new file mode 100644 index 00000000..f331ef21 --- /dev/null +++ b/backend-rust/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "backend-rust" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1" +async-graphql = {version = "7.0", features = ["chrono"] } +async-graphql-axum = "7.0" +axum = "0.7" +chrono = "0.4" +clap = { version = "4.5", features = ["derive", "env"] } +concordium-rust-sdk = "4.3" +dotenv = "0.15" +futures = "0.3" +sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } +thiserror = "1.0" +tokio = { version = "1.37", features = ["full"] } diff --git a/backend-rust/README.md b/backend-rust/README.md new file mode 100644 index 00000000..b8f4c603 --- /dev/null +++ b/backend-rust/README.md @@ -0,0 +1,19 @@ + + +## Setup + +Install PostgreSQL server 16 or run `docker-compose up`. + +Install `sqlx-cli` + +Create database using connection defined in `.env`: + +``` +sqlx database create +``` + +Setup tables: + +``` +sqlx migrate run +``` diff --git a/backend-rust/database.odb b/backend-rust/database.odb new file mode 100644 index 0000000000000000000000000000000000000000..1f88191369f3c0fb19d21f65adc73c3edc73c8fe GIT binary patch literal 2272 zcmaJ?2{@E_9R6H0vyMo_x^+0Rj@&|`atwyh4h_jM#;`MknQ>J}*jC7jwn`?b>f zQ>Fkz5dZ-8!+Hf^&=@2kECA^d5bzV)%Y%T%Vbz1N-fB1xJQ}Zt3qWGMab83W5=&6? z^uQzSw>E=7AX}TU`2Q9kYvqN*64=O4%ug9`Tl}D|Pz?j_`_>94bV!Bti;H#Dgorzp zkfSFEi4swetGi%&hRJXP4xLuX=@|Evd@=i{+RF90hFm-uu1G)OQrcNDZCjBNhj?I9 z-pSltAzkV?F3k)rLFQL>#7YlrgRN12>PNJ5%Qv(D7v|l29lm%_(+c`zALn*vo!2enk6bA(_+;+ z(|INE_}bdp<`pLMp81~;9v!c6>(3WA76eV&?(9X@X*Ep59;W#uzN0>u@RBFB=|rHFg~QKXdl#;3cp-P_&gz&=k_pwqsaf`hJLr;M-Xq%DO8TeL z-lQ2$dHJ}H)w%RbRs9ZjsIKg?wLW|#-7`6T6lL{cXFqZWW3`!Y;7L~!QSWit za90%RR6WkTp7bGWp05m@ktFvjzem$mYAU!!`nAejz|b0@%1>&QS^+}w^_|Kz3BzVP znl=%#C)9tiSp+d%)QgGScKuA@&UEj{iT+#d2>!5;EW5ZzwZmQv?dTj)*J_@<*ZG1a z@&XK>+84vhNe|o<|P#vslfZ1HOWRU56`;Ioo&61(w-jivbG>sykba}-9;bF+%c8)%I z`PKu~W2mCvN_!C-14^)m<-e@g$4|~{RBP`Me0UAaSwS?)m|yigBsd{&N>s&bdcXrM zCM^^L6Qh(uY0h(^1(8#ih>f+3$fF{gWL0cU0POYI+t1D~zu%lG{w@9h%_u`(j9Xue zp1NGTfxnv1aaOO=#W%rv5c>W22USZ8hnYByr*iNU5}+;@MU4ena*(k9ted227ipR| zx1@kOn^74rCmxNOd#V7JD1zF@xe9x%IW|avM%SYtWjvu12-);(Z;7}mmu}ytu|vsL z;*QV6Bc)osq1KM82j3Jl+vSTVkW-XDGG*G!JiAb1oXSOiq>#$I?s6_UA+8A7@O5Xy zEqs5cH1_X#Kz_wR!|J;5>yy;en%ix;+X9B;;UZR1qH~|xrD#ntwFP&4i^*>Il<~x& z!(BvwMXkz(C&u*&VBQWtGX)3XwC^q&^JdDubndgWU5+u!Ke8w?WJFZ!sWWvS90a9T z)J^pzyypKboLd*Rw&x^+GIWipJ+;&%b6@LosT31zZw-c&NE;8m=K_EwegOD88Mb5Q z-&kNR*fA4?48R2u@ao@239(QoaabQTiWsyNLwL=9&|ga%)`0*XNg$xHC_FoE(o$Zf zL~HQJ&8~~s%sRLlTUsoA*jFTI53Qq^jV2#hTN%VOd7xIJo++#{H>?IX=V|4w0^t=g zvCg?9OF-c4{&cguwXnhreVOAcPJO*m1ugv2_Yp_+C-=gtrCi>3rFDsUPM!JHXIkqy z*N+sX=bSK2Q99J{_n9}*yb(v~g#LQ!9*%p3g$vS)4vVrnBO6b}CX1nMfl}RpHyuV= zI)7--er$VNz6ev=qeR80U-X|}H_0O2o78ktOucsg9d0)CbGH-X`!O-;hmWork7)9* z7P zQp4`k=tjWd)sXWP*fT$_xwc|z@2kqGxSOsq3GF?FPC1XwHNqkK<*8{D@g6xn-crYq zyj}=MwFhbB`Xg|YOs;{NsO}4?>OFYkMgdGrP}Px!=+R2nckG;$oWl^95M{=hlIM_1V zR-HKsZ~(yLEtxquMSyS5O_q+kYI};>TmQ}4mVUQE*aLE>Y+1`)<8qt$E&uEgQQW)T z^yMXYPyoR8(@rI_SpRj`mzUk$Wo&=#)C(4Sx7+^jw(Kt4sZO4qp8WE(-D0!5a;Fwq YoUit%y*1C)?qC26q_X~#E|^XF3+), + #[error("Invalid ID format: {0}")] + InvalidIdInt(std::num::ParseIntError), + #[error("Invalid ID format: {0}")] + InvalidIdIntSize(std::num::TryFromIntError), + #[error("The period cannot be converted")] + DurationOutOfRange(Arc>), + #[error("The \"first\" and \"last\" parameters cannot exist at the same time")] + QueryConnectionFirstLast, + #[error("The \"first\" parameter must be a non-negative number")] + QueryConnectionNegativeFirst, + #[error("The \"last\" parameter must be a non-negative number")] + QueryConnectionNegativeLast, +} + +type ApiResult = Result; + +fn get_pool<'a>(ctx: &Context<'a>) -> ApiResult<&'a PgPool> { + ctx.data::().map_err(ApiError::NoDatabasePool) +} + +fn check_connection_query(first: &Option, last: &Option) -> ApiResult<()> { + if first.is_some() && last.is_some() { + return Err(ApiError::QueryConnectionFirstLast); + } + if let Some(first) = first { + if first < &0 { + return Err(ApiError::QueryConnectionNegativeFirst); + } + }; + if let Some(last) = last { + if last < &0 { + return Err(ApiError::QueryConnectionNegativeLast); + } + }; + Ok(()) +} + +#[Object] +impl Query { + async fn versions(&self) -> Versions { + Versions { + backend_versions: VERSION.to_string(), + } + } + async fn block<'a>(&self, ctx: &Context<'a>, height_id: types::ID) -> ApiResult { + let height: i64 = height_id + .clone() + .try_into() + .map_err(ApiError::InvalidIdInt)?; + let block = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", height) + .fetch_optional(get_pool(ctx)?) + .await + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? + .ok_or(ApiError::NotFound)?; + Ok(BlockWithId { + id: height_id, + inner: block, + }) + } + async fn block_by_block_hash<'a>( + &self, + ctx: &Context<'a>, + block_hash: BlockHash, + ) -> ApiResult { + let block = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE hash=$1", block_hash) + .fetch_optional(get_pool(ctx)?) + .await + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? + .ok_or(ApiError::NotFound)?; + Ok(BlockWithId { + id: block.height.into(), + inner: block, + }) + } + + async fn blocks<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: Option, + ) -> ApiResult> { + check_connection_query(&first, &last)?; + + let mut builder = + sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM blocks"); + + match (after, before) { + (None, None) => {} + (None, Some(before)) => { + builder + .push(" WHERE height < ") + .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); + } + (Some(after), None) => { + builder + .push(" WHERE height > ") + .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); + } + (Some(after), Some(before)) => { + builder + .push(" WHERE height > ") + .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) + .push(" AND height < ") + .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); + } + } + + match (first, &last) { + (None, None) => { + builder.push(" ORDER BY height ASC)"); + } + (None, Some(last)) => { + builder + .push(" ORDER BY height DESC LIMIT ") + .push_bind(last) + .push(") ORDER BY height ASC "); + } + (Some(first), None) => { + builder + .push(" ORDER BY height ASC LIMIT ") + .push_bind(first) + .push(")"); + } + (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), + } + + let mut block_stream = builder.build_query_as::().fetch(get_pool(ctx)?); + + let mut connection = connection::Connection::new(true, true); + while let Some(block) = block_stream + .try_next() + .await + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? + { + connection + .edges + .push(connection::Edge::new(block.height.to_string(), block)); + } + if last.is_some() { + if let Some(edge) = connection.edges.last() { + connection.has_previous_page = edge.node.height != 0; + } + } else { + if let Some(edge) = connection.edges.first() { + connection.has_previous_page = edge.node.height != 0; + } + } + + Ok(connection) + } + + async fn transaction<'a>( + &self, + ctx: &Context<'a>, + id: types::ID, + ) -> ApiResult { + let pool = get_pool(ctx)?; + let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; + + let transaction = sqlx::query_as!( + Transaction, + "SELECT * FROM transactions WHERE index=$1", + index + ) + .fetch_optional(pool) + .await + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? + .ok_or(ApiError::NotFound)?; + + Ok(TransactionWithId { + id, + inner: transaction, + }) + } + async fn transaction_by_transaction_hash( + &self, + _transaction_hash: TransactionHash, + ) -> Transaction { + todo!() + } + async fn transactions( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + async fn account(&self, _id: types::ID) -> Account { + todo!() + } + async fn account_by_address(&self, _account_address: String) -> Account { + todo!() + } + async fn accounts( + &self, + #[graphql(default)] _sort: AccountSort, + _filter: AccountFilterInput, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + async fn baker(&self, _id: types::ID) -> Baker { + todo!() + } + async fn baker_by_baker_id(&self, _id: BakerId) -> Baker { + todo!() + } + + async fn bakers( + &self, + #[graphql(default)] _sort: BakerSort, + _filter: BakerFilterInput, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn search(&self, query: String) -> SearchResult { + todo!() + } + async fn block_metrics<'a>( + &self, + ctx: &Context<'a>, + period: MetricsPeriod, + ) -> ApiResult { + let pool = get_pool(ctx)?; + + let queried_period: Duration = match period { + MetricsPeriod::LastHour => Duration::hours(1), + MetricsPeriod::Last24Hours => Duration::hours(24), + MetricsPeriod::Last7Days => Duration::days(7), + MetricsPeriod::Last30Days => Duration::days(30), + MetricsPeriod::LastYear => Duration::days(364), + }; + + let interval: PgInterval = queried_period + .try_into() + .map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; + let rec = sqlx::query!( + "SELECT +MAX(height) as last_block_height, +COUNT(1) as blocks_added, +(MAX(slot_time) - MIN(slot_time)) / (COUNT(1) - 1) as avg_block_time +FROM blocks +WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", + interval + ) + .fetch_one(pool) + .await + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))?; + + Ok(BlockMetrics { + last_block_height: rec.last_block_height.unwrap_or(0), + blocks_added: rec.blocks_added.unwrap_or(0), + avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64), // TODO check what format this is expected to be in. + }) + } + + // accountsMetrics(period: MetricsPeriod!): AccountsMetrics + // transactionMetrics(period: MetricsPeriod!): TransactionMetrics + // bakerMetrics(period: MetricsPeriod!): BakerMetrics! + // rewardMetrics(period: MetricsPeriod!): RewardMetrics! + // rewardMetricsForAccount(accountId: ID! period: MetricsPeriod!): RewardMetrics! + // poolRewardMetricsForPassiveDelegation(period: MetricsPeriod!): PoolRewardMetrics! + // poolRewardMetricsForBakerPool(bakerId: ID! period: MetricsPeriod!): PoolRewardMetrics! + // passiveDelegation: PassiveDelegation + // paydayStatus: PaydayStatus + // latestChainParameters: ChainParameters + // importState: ImportState + // nodeStatuses(sortField: NodeSortField! sortDirection: NodeSortDirection! "Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): NodeStatusesConnection + // nodeStatus(id: ID!): NodeStatus + // tokens("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): TokensConnection + // token(contractIndex: UnsignedLong! contractSubIndex: UnsignedLong! tokenId: String!): Token! + // contract(contractAddressIndex: UnsignedLong! contractAddressSubIndex: UnsignedLong!): Contract + // contracts("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): ContractsConnection + // moduleReferenceEvent(moduleReference: String!): ModuleReferenceEvent +} + +/// The UnsignedLong scalar type represents a unsigned 64-bit numeric non-fractional value greater than or equal to 0. +struct UnsignedLong(u64); +#[Scalar] +impl ScalarType for UnsignedLong { + fn parse(value: Value) -> InputValueResult { + let Value::Number(number) = &value else { + return Err(InputValueError::expected_type(value)); + }; + if let Some(v) = number.as_u64() { + Ok(Self(v)) + } else { + Err(InputValueError::expected_type(value)) + } + } + + fn to_value(&self) -> Value { + Value::Number(self.0.into()) + } +} + +/// The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1. +struct Long(i64); +#[Scalar] +impl ScalarType for Long { + fn parse(value: Value) -> InputValueResult { + let Value::Number(number) = &value else { + return Err(InputValueError::expected_type(value)); + }; + if let Some(v) = number.as_i64() { + Ok(Self(v)) + } else { + Err(InputValueError::expected_type(value)) + } + } + + fn to_value(&self) -> Value { + Value::Number(self.0.into()) + } +} +struct Byte(u8); +#[Scalar] +impl ScalarType for Byte { + fn parse(value: Value) -> InputValueResult { + let Value::Number(number) = &value else { + return Err(InputValueError::expected_type(value)); + }; + let Some(v) = number.as_u64() else { + return Err(InputValueError::expected_type(value)); + }; + + if let Ok(v) = u8::try_from(v) { + Ok(Self(v)) + } else { + Err(InputValueError::expected_type(value)) + } + } + + fn to_value(&self) -> Value { + Value::Number(self.0.into()) + } +} + +struct Decimal(f64); +#[Scalar] +impl ScalarType for Decimal { + fn parse(value: Value) -> InputValueResult { + let Value::Number(number) = &value else { + return Err(InputValueError::expected_type(value)); + }; + if let Some(v) = number.as_f64() { + Ok(Self(v)) + } else { + Err(InputValueError::expected_type(value)) + } + } + + fn to_value(&self) -> Value { + let number = Number::from_f64(self.0).unwrap(); + Value::Number(number) + } +} + +/// The `TimeSpan` scalar represents an ISO-8601 compliant duration type. +struct TimeSpan(Duration); +#[Scalar] +impl ScalarType for TimeSpan { + fn parse(value: Value) -> InputValueResult { + todo!() + } + + fn to_value(&self) -> Value { + todo!() + } +} + +type BlockHeight = i64; +type BlockHash = String; +type TransactionHash = String; +type BakerId = i64; +type AccountIndex = i64; +type Amount = i64; // TODO: should be UnsignedLong in graphQL +type Energy = i64; // TODO: should be UnsignedLong in graphQL +type DateTime = chrono::NaiveDateTime; // TODO check format matches. +type ContractIndex = UnsignedLong; // TODO check format. +type BigInteger = u64; // TODO check format. + +#[derive(SimpleObject)] +struct Versions { + backend_versions: String, +} + +#[derive(SimpleObject, sqlx::FromRow)] +#[graphql(complex)] +struct Block { + #[graphql(name = "blockHash")] + hash: BlockHash, + #[graphql(name = "blockHeight")] + height: BlockHeight, + #[graphql(name = "blockSlotTime")] + slot_time: DateTime, + baker_id: Option, + finalized: bool, + // transaction_count: i32, + // chain_parameters: ChainParameters, + // balance_statistics: BalanceStatistics, + // block_statistics: BlockStatistics, +} + +#[derive(SimpleObject)] +struct BlockWithId { + id: types::ID, + #[graphql(flatten)] + inner: Block, +} + +#[ComplexObject] +impl Block { + async fn special_events( + &self, + #[graphql( + desc = "Filter special events by special event type. Set to null to return all special events (no filtering)." + )] + include_filters: Option>, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: Option, + ) -> ApiResult> { + todo!() + } + async fn transactions( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: Option, + ) -> ApiResult> { + todo!() + } +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +enum SpecialEventTypeFilter { + Mint, + FinalizationRewards, + BlockRewards, + BakingRewards, + PaydayAccountReward, + BlockAccrueReward, + PaydayFoundationReward, + PaydayPoolReward, +} + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct SpecialEventsConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct TransactionsConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountAddressAmountConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountReleaseScheduleItemConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountTokenConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountTransactionRelationConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountStatementEntryConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountRewardConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct AccountsConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct BakersConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct ContractsConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct NodeStatusesConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct ModulesConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// A connection to a list of items. +// #[derive(SimpleObject)] +// struct TokensConnection { +// /// Information to aid in pagination. +// page_info: connection::PageInfo, +// /// A list of edges. +// edges: Option>, +// /// A flattened list of the nodes. +// nodes: Option>, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct SpecialEventsEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: SpecialEvent, +// } + +// /// An edge in a connection." +// #[derive(SimpleObject)] +// struct TransactionsEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Transaction, +// } + +// /// An edge in a connection." +// #[derive(SimpleObject)] +// struct BlocksEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Block, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountAddressAmountEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountAddressAmount, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountReleaseScheduleItemEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountReleaseScheduleItem, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountTokenEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountToken, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountTransactionRelationEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountTransactionRelation, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountStatementEntryEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountStatementEntry, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountRewardEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: AccountReward, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct AccountsEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Account, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct BakersEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Baker, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct ContractsEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Contract, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct NodeStatusesEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: NodeStatus, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct ModulesEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: ModuleReferenceEvent, +// } + +// /// An edge in a connection. +// #[derive(SimpleObject)] +// struct TokensEdge { +// /// A cursor for use in pagination. +// cursor: String, +// /// The item at the end of the edge. +// node: Token, +// } + +#[derive(SimpleObject)] +#[graphql(complex)] +struct Contract { + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + contract_address: String, + creator: AccountAddress, + block_height: BlockHeight, + transaction_hash: String, + block_slot_time: DateTime, + snapshot: ContractSnapshot, +} +#[ComplexObject] +impl Contract { + async fn contract_events(&self, skip: i32, take: i32) -> ContractEventsCollectionSegment { + todo!() + } + async fn contract_reject_events( + &self, + _skip: i32, + _take: i32, + ) -> ContractRejectEventsCollectionSegment { + todo!() + } + async fn tokens(&self, skip: i32, take: i32) -> TokensCollectionSegment { + todo!() + } +} + +/// A segment of a collection. +#[derive(SimpleObject)] +struct TokensCollectionSegment { + /// Information to aid in pagination. + page_info: CollectionSegmentInfo, + /// A flattened list of the items. + items: Vec, + total_count: i32, +} + +/// A segment of a collection. +#[derive(SimpleObject)] +struct ContractRejectEventsCollectionSegment { + /// Information to aid in pagination. + page_info: CollectionSegmentInfo, + /// A flattened list of the items. + items: Vec, + total_count: i32, +} + +#[derive(SimpleObject)] +struct ContractRejectEvent { + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + sender: AccountAddress, + rejected_event: TransactionRejectReason, + block_height: BlockHeight, + transaction_hash: TransactionHash, + block_slot_time: DateTime, +} + +// union TransactionRejectReason = ModuleNotWf | ModuleHashAlreadyExists | InvalidAccountReference | InvalidInitMethod | InvalidReceiveMethod | InvalidModuleReference | InvalidContractAddress | RuntimeFailure | AmountTooLarge | SerializationFailure | OutOfEnergy | RejectedInit | RejectedReceive | NonExistentRewardAccount | InvalidProof | AlreadyABaker | NotABaker | InsufficientBalanceForBakerStake | StakeUnderMinimumThresholdForBaking | BakerInCooldown | DuplicateAggregationKey | NonExistentCredentialId | KeyIndexAlreadyInUse | InvalidAccountThreshold | InvalidCredentialKeySignThreshold | InvalidEncryptedAmountTransferProof | InvalidTransferToPublicProof | EncryptedAmountSelfTransfer | InvalidIndexOnEncryptedTransfer | ZeroScheduledAmount | NonIncreasingSchedule | FirstScheduledReleaseExpired | ScheduledSelfTransfer | InvalidCredentials | DuplicateCredIds | NonExistentCredIds | RemoveFirstCredential | CredentialHolderDidNotSign | NotAllowedMultipleCredentials | NotAllowedToReceiveEncrypted | NotAllowedToHandleEncrypted | MissingBakerAddParameters | FinalizationRewardCommissionNotInRange | BakingRewardCommissionNotInRange | TransactionFeeCommissionNotInRange | AlreadyADelegator | InsufficientBalanceForDelegationStake | MissingDelegationAddParameters | InsufficientDelegationStake | DelegatorInCooldown | NotADelegator | DelegationTargetNotABaker | StakeOverMaximumThresholdForPool | PoolWouldBecomeOverDelegated | PoolClosed +#[derive(Union)] +enum TransactionRejectReason { + PoolClosed(PoolClosed), +} + +#[derive(SimpleObject)] +struct PoolClosed { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject)] +struct ContractSnapshot { + block_height: BlockHeight, + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + contract_name: String, + module_reference: String, + amount: Amount, +} + +/// A segment of a collection. +#[derive(SimpleObject)] +struct ContractEventsCollectionSegment { + /// Information to aid in pagination. + page_info: CollectionSegmentInfo, + /// A flattened list of the items. + items: Option>, + total_count: i32, +} + +#[derive(SimpleObject)] +struct ContractEvent { + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + sender: AccountAddress, + event: Event, + block_height: BlockHeight, + transaction_hash: String, + block_slot_time: DateTime, +} + +/// Information about the offset pagination. +#[derive(SimpleObject)] +struct CollectionSegmentInfo { + /// Indicates whether more items exist following the set defined by the clients arguments. + has_next_page: bool, + /// Indicates whether more items exist prior the set defined by the clients arguments. + has_previous_page: bool, +} + +#[derive(SimpleObject)] +struct AccountReward { + block: Block, + id: types::ID, + timestamp: DateTime, + reward_type: RewardType, + amount: Amount, +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +enum RewardType { + FinalizationReward, + FoundationReward, + BakerReward, + TransactionFeeReward, +} + +#[derive(SimpleObject)] +struct AccountStatementEntry { + reference: BlockOrTransaction, + id: types::ID, + timestamp: DateTime, + entry_type: AccountStatementEntryType, + amount: i64, + account_balance: Amount, +} + +#[derive(SimpleObject)] +struct AccountTransactionRelation { + transaction: Transaction, +} + +#[derive(SimpleObject)] +struct AccountAddressAmount { + account_address: AccountAddress, + amount: Amount, +} + +#[derive(SimpleObject)] +struct AccountReleaseScheduleItem { + transaction: Transaction, + timestamp: DateTime, + amount: Amount, +} + +#[derive(SimpleObject)] +struct AccountToken { + contract_index: ContractIndex, + contract_sub_index: ContractIndex, + token_id: String, + balance: BigInteger, + token: Token, + account_id: i64, + account: Account, +} + +#[derive(SimpleObject)] +struct Token { + initial_transaction: Transaction, + contract_index: ContractIndex, + contract_sub_index: ContractIndex, + token_id: String, + metadata_url: String, + total_supply: BigInteger, + contract_address_formatted: String, + token_address: String, + // TODO accounts(skip: Int take: Int): AccountsCollectionSegment + // TODO tokenEvents(skip: Int take: Int): TokenEventsCollectionSegment +} + +#[derive(Union)] +enum SpecialEvent { + MintSpecialEvent(MintSpecialEvent), + FinalizationRewardsSpecialEvent(FinalizationRewardsSpecialEvent), + BlockRewardsSpecialEvent(BlockRewardsSpecialEvent), + BakingRewardsSpecialEvent(BakingRewardsSpecialEvent), + PaydayAccountRewardSpecialEvent(PaydayAccountRewardSpecialEvent), + BlockAccrueRewardSpecialEvent(BlockAccrueRewardSpecialEvent), + PaydayFoundationRewardSpecialEvent(PaydayFoundationRewardSpecialEvent), + PaydayPoolRewardSpecialEvent(PaydayPoolRewardSpecialEvent), +} + +#[derive(SimpleObject)] +struct MintSpecialEvent { + baking_reward: Amount, + finalization_reward: Amount, + platform_development_charge: Amount, + foundation_account_address: AccountAddress, + id: types::ID, +} + +#[derive(SimpleObject)] +#[graphql(complex)] +struct FinalizationRewardsSpecialEvent { + remainder: Amount, + id: types::ID, +} + +#[ComplexObject] +impl FinalizationRewardsSpecialEvent { + async fn finalization_rewards( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +struct BlockRewardsSpecialEvent { + transaction_fees: Amount, + old_gas_account: Amount, + new_gas_account: Amount, + baker_reward: Amount, + foundation_charge: Amount, + baker_account_address: AccountAddress, + foundation_account_address: AccountAddress, + id: types::ID, +} + +#[derive(SimpleObject)] +#[graphql(complex)] +struct BakingRewardsSpecialEvent { + remainder: Amount, + id: types::ID, +} +#[ComplexObject] +impl BakingRewardsSpecialEvent { + async fn baking_rewards( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +struct PaydayAccountRewardSpecialEvent { + /// The account that got rewarded. + account: AccountAddress, + /// The transaction fee reward at payday to the account. + transaction_fees: Amount, + /// The baking reward at payday to the account. + baker_reward: Amount, + /// The finalization reward at payday to the account. + finalization_reward: Amount, + id: types::ID, +} + +#[derive(SimpleObject)] +struct BlockAccrueRewardSpecialEvent { + /// The total fees paid for transactions in the block. + transaction_fees: Amount, + /// The old balance of the GAS account. + old_gas_account: Amount, + /// The new balance of the GAS account. + new_gas_account: Amount, + /// The amount awarded to the baker. + baker_reward: Amount, + /// The amount awarded to the passive delegators. + passive_reward: Amount, + /// The amount awarded to the foundation. + foundation_charge: Amount, + /// The baker of the block, who will receive the award. + baker_id: BakerId, + id: types::ID, +} + +#[derive(SimpleObject)] +struct PaydayFoundationRewardSpecialEvent { + foundation_account: AccountAddress, + development_charge: Amount, + id: types::ID, +} + +#[derive(SimpleObject)] +struct PaydayPoolRewardSpecialEvent { + /// The pool awarded. + pool: PoolRewardTarget, + /// Accrued transaction fees for pool. + transaction_fees: Amount, + /// Accrued baking rewards for pool. + baker_reward: Amount, + /// Accrued finalization rewards for pool. + finalization_reward: Amount, + id: types::ID, +} + +#[derive(Union)] +enum PoolRewardTarget { + PassiveDelegationPoolRewardTarget(PassiveDelegationPoolRewardTarget), + BakerPoolRewardTarget(BakerPoolRewardTarget), +} + +#[derive(SimpleObject)] +struct PassiveDelegationPoolRewardTarget { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject)] +struct PassiveDelegationTarget { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject)] +struct BakerPoolRewardTarget { + baker_id: BakerId, +} + +#[derive(SimpleObject)] +struct BakerDelegationTarget { + baker_id: BakerId, +} + +#[derive(SimpleObject)] +struct BalanceStatistics { + /// The total CCD in existence + total_amount: Amount, + /// The total CCD Released. This is total CCD supply not counting the balances of non circulating accounts. + total_amount_released: Amount, + /// The total CCD Unlocked according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule. + total_amount_unlocked: Amount, + /// The total CCD in encrypted balances. + total_amount_encrypted: Amount, + /// The total CCD locked in release schedules (from transfers with schedule). + total_amount_locked_in_release_schedules: Amount, + /// The total CCD staked. + total_amount_staked: Amount, + /// The amount in the baking reward account. + baking_reward_account: Amount, + /// The amount in the finalization reward account. + finalization_reward_account: Amount, + /// The amount in the GAS account. + gas_account: Amount, +} + +#[derive(SimpleObject)] +struct BlockStatistics { + block_time: f32, + finalization_time: f32, +} + +#[derive(Interface)] +#[graphql( + field(name = "euro_per_energy", ty = "&ExchangeRate"), + field(name = "micro_ccd_per_euro", ty = "&ExchangeRate"), + field(name = "account_creation_limit", ty = "&i32"), + field(name = "foundation_account_address", ty = "&AccountAddress") +)] +enum ChainParameters { + ChainParametersV0(ChainParametersV0), + ChainParametersV1(ChainParametersV1), + ChainParametersV2(ChainParametersV2), +} + +#[derive(SimpleObject)] +struct ChainParametersV0 { + // TODO + // electionDifficulty: Decimal! + // bakerCooldownEpochs: UnsignedLong! + // rewardParameters: RewardParametersV0! + // minimumThresholdForBaking: UnsignedLong! + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, + foundation_account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct ChainParametersV1 { + // TODO + // electionDifficulty: Decimal! + // poolOwnerCooldown: UnsignedLong! + // delegatorCooldown: UnsignedLong! + // rewardPeriodLength: UnsignedLong! + // mintPerPayday: Decimal! + // rewardParameters: RewardParametersV1! + // passiveFinalizationCommission: Decimal! + // passiveBakingCommission: Decimal! + // passiveTransactionCommission: Decimal! + // finalizationCommissionRange: CommissionRange! + // bakingCommissionRange: CommissionRange! + // transactionCommissionRange: CommissionRange! + // minimumEquityCapital: UnsignedLong! + // capitalBound: Decimal! + // leverageBound: LeverageFactor! + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, + foundation_account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct ChainParametersV2 { + // TODO + // poolOwnerCooldown: UnsignedLong! + // delegatorCooldown: UnsignedLong! + // rewardPeriodLength: UnsignedLong! + // mintPerPayday: Decimal! + // rewardParameters: RewardParametersV2! + // passiveFinalizationCommission: Decimal! + // passiveBakingCommission: Decimal! + // passiveTransactionCommission: Decimal! + // finalizationCommissionRange: CommissionRange! + // bakingCommissionRange: CommissionRange! + // transactionCommissionRange: CommissionRange! + // minimumEquityCapital: UnsignedLong! + // capitalBound: Decimal! + // leverageBound: LeverageFactor! + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, + foundation_account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct ExchangeRate { + numerator: u64, + denominator: u64, +} + +#[derive(SimpleObject, Clone)] +struct AccountAddress { + as_string: String, +} + +#[derive(SimpleObject)] +struct TransactionWithId { + id: types::ID, + #[graphql(flatten)] + inner: Transaction, +} +#[derive(SimpleObject)] +#[graphql(complex)] +struct Transaction { + #[graphql(skip)] + block: BlockHeight, + #[graphql(name = "transactionIndex")] + index: i64, + #[graphql(name = "transactionHash")] + hash: TransactionHash, + ccd_cost: Amount, + energy_cost: Energy, + #[graphql(skip)] + sender: Option, + // transaction_type: TransactionType, + // result: TransactionResult, +} +#[ComplexObject] +impl Transaction { + async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { + sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", self.block) + .fetch_one(get_pool(ctx)?) + .await + .map(|block| BlockWithId { + id: self.block.to_string().into(), + inner: block, + }) + .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err))) + } + async fn sender_account_address<'a>( + &self, + _ctx: &Context<'a>, + ) -> ApiResult> { + let Some(_account_index) = self.sender else { + return Ok(None); + }; + todo!() + } +} + +#[derive(SimpleObject)] +// TODO union TransactionType = AccountTransaction | CredentialDeploymentTransaction | UpdateTransaction +struct TransactionType { + dummy: i32, +} + +#[derive(SimpleObject)] +// TODO union TransactionResult = Success | Rejected +struct TransactionResult { + dummy: i32, +} + +#[derive(SimpleObject)] +#[graphql(complex)] +struct Account { + release_schedule: AccountReleaseSchedule, + baker: Baker, + id: types::ID, + address: AccountAddress, + amount: Amount, + transaction_count: i32, + created_at: DateTime, + delegation: Delegation, +} + +#[ComplexObject] +impl Account { + async fn tokens( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } + async fn transactions( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } + async fn account_statement( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } + async fn rewards( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +#[graphql(complex)] +struct AccountReleaseSchedule { + total_amount: Amount, +} +#[ComplexObject] +impl AccountReleaseSchedule { + async fn schedule( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +struct Baker { + account: Box, + id: types::ID, + baker_id: BakerId, + state: BakerState, + // /// Get the transactions that have affected the baker. + // transactions("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): BakerTransactionRelationConnection +} + +#[derive(Union)] +enum BakerState { + ActiveBakerState(ActiveBakerState), + RemovedBakerState(RemovedBakerState), +} + +#[derive(SimpleObject)] +struct ActiveBakerState { + /// The status of the bakers node. Will be null if no status for the node exists. + node_status: NodeStatus, + staked_amount: Amount, + restake_earnings: bool, + pool: BakerPool, + pending_change: PendingBakerChange, +} + +#[derive(Union)] +enum PendingBakerChange { + PendingBakerRemoval(PendingBakerRemoval), + PendingBakerReduceStake(PendingBakerReduceStake), +} + +#[derive(SimpleObject)] +struct PendingBakerRemoval { + effective_time: DateTime, +} + +#[derive(SimpleObject)] +struct PendingBakerReduceStake { + new_staked_amount: Amount, + effective_time: DateTime, +} + +#[derive(SimpleObject)] +struct RemovedBakerState { + removed_at: DateTime, +} + +#[derive(SimpleObject)] +struct NodeStatus { + // TODO: add below fields + // peersList: [PeerReference!]! + // nodeName: String + // nodeId: String! + // peerType: String! + // uptime: UnsignedLong! + // clientVersion: String! + // averagePing: Float + // peersCount: UnsignedLong! + // bestBlock: String! + // bestBlockHeight: UnsignedLong! + // bestBlockBakerId: UnsignedLong + // bestArrivedTime: DateTime + // blockArrivePeriodEma: Float + // blockArrivePeriodEmsd: Float + // blockArriveLatencyEma: Float + // blockArriveLatencyEmsd: Float + // blockReceivePeriodEma: Float + // blockReceivePeriodEmsd: Float + // blockReceiveLatencyEma: Float + // blockReceiveLatencyEmsd: Float + // finalizedBlock: String! + // finalizedBlockHeight: UnsignedLong! + // finalizedTime: DateTime + // finalizationPeriodEma: Float + // finalizationPeriodEmsd: Float + // packetsSent: UnsignedLong! + // packetsReceived: UnsignedLong! + // consensusRunning: Boolean! + // bakingCommitteeMember: String! + // consensusBakerId: UnsignedLong + // finalizationCommitteeMember: Boolean! + // transactionsPerBlockEma: Float + // transactionsPerBlockEmsd: Float + // bestBlockTransactionsSize: UnsignedLong + // bestBlockTotalEncryptedAmount: UnsignedLong + // bestBlockTotalAmount: UnsignedLong + // bestBlockTransactionCount: UnsignedLong + // bestBlockTransactionEnergyCost: UnsignedLong + // bestBlockExecutionCost: UnsignedLong + // bestBlockCentralBankAmount: UnsignedLong + // blocksReceivedCount: UnsignedLong + // blocksVerifiedCount: UnsignedLong + // genesisBlock: String! + // finalizationCount: UnsignedLong + // finalizedBlockParent: String! + // averageBytesPerSecondIn: Float! + // averageBytesPerSecondOut: Float! + id: types::ID, +} + +#[derive(SimpleObject)] +struct BakerPool { + /// Total stake of the baker pool as a percentage of all CCDs in existence. Value may be null for brand new bakers where statistics have not been calculated yet. This should be rare and only a temporary condition. + total_stake_percentage: Decimal, + lottery_power: Decimal, + payday_commission_rates: CommissionRates, + open_status: BakerPoolOpenStatus, + commission_rates: CommissionRates, + metadata_url: String, + /// The total amount staked by delegation to this baker pool. + delegated_stake: Amount, + /// The maximum amount that may be delegated to the pool, accounting for leverage and stake limits. + delegated_stake_cap: Amount, + /// The total amount staked in this baker pool. Includes both baker stake and delegated stake. + total_stake: Amount, + delegator_count: i32, + /// Ranking of the baker pool by total staked amount. Value may be null for brand new bakers where statistics have not been calculated yet. This should be rare and only a temporary condition. + ranking_by_total_stake: Ranking, + // TODO: apy(period: ApyPeriod!): PoolApy! + // TODO: delegators("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): DelegatorsConnection + // TODO: poolRewards("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): PaydayPoolRewardConnection +} + +#[derive(SimpleObject)] +struct CommissionRates { + transaction_commission: Decimal, + finalization_commission: Decimal, + baking_commission: Decimal, +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +enum BakerPoolOpenStatus { + OpenForAll, + ClosedForNew, + ClosedForAll, +} + +#[derive(SimpleObject)] +struct Ranking { + rank: i32, + total: i32, +} + +#[derive(SimpleObject)] +struct Delegation { + delegator_id: i64, + staked_amount: Amount, + restake_earnings: bool, + delegation_target: DelegationTarget, + pending_change: PendingDelegationChange, +} + +#[derive(Union)] +enum DelegationTarget { + PassiveDelegationTarget(PassiveDelegationTarget), + BakerDelegationTarget(BakerDelegationTarget), +} + +#[derive(Union)] +enum PendingDelegationChange { + PendingDelegationRemoval(PendingDelegationRemoval), + PendingDelegationReduceStake(PendingDelegationReduceStake), +} + +#[derive(SimpleObject)] +struct PendingDelegationRemoval { + effective_time: DateTime, +} + +#[derive(SimpleObject)] +struct PendingDelegationReduceStake { + new_staked_amount: Amount, + effective_time: DateTime, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq)] +enum AccountStatementEntryType { + TransferIn, + TransferOut, + AmountDecrypted, + AmountEncrypted, + TransactionFee, + FinalizationReward, + FoundationReward, + BakerReward, + TransactionFeeReward, +} + +#[derive(Union)] +enum BlockOrTransaction { + Transaction(Transaction), + Block(Block), +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq, Default)] +enum AccountSort { + AgeAsc, + #[default] + AgeDesc, + AmountAsc, + AmountDesc, + TransactionCountAsc, + TransactionCountDesc, + DelegatedStakeAsc, + DelegatedStakeDesc, +} + +#[derive(InputObject)] +struct AccountFilterInput { + is_delegator: bool, +} + +#[derive(InputObject)] +struct BakerFilterInput { + open_status_filter: BakerPoolOpenStatus, + include_removed: bool, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq, Default)] +enum BakerSort { + #[default] + BakerIdAsc, + BakerIdDesc, + BakerStakedAmountAsc, + BakerStakedAmountDesc, + TotalStakedAmountAsc, + TotalStakedAmountDesc, + DelegatorCountAsc, + DelegatorCountDesc, + BakerApy30DaysDesc, + DelegatorApy30DaysDesc, + BlockCommissionsAsc, + BlockCommissionsDesc, +} + +struct SearchResult; + +#[Object] +impl SearchResult { + async fn contracts( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + // async fn modules( + // &self, + // #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + // #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + // _after: Option, + // #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + // #[graphql( + // desc = "Returns the elements in the list that come before the specified cursor." + // )] + // _before: Option, + // ) -> ApiResult> { + // todo!() + // } + + async fn blocks( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn transactions( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn tokens( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn accounts( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn bakers( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } + + async fn node_statuses( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + _after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + _before: Option, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +struct ContractAddress { + index: ContractIndex, + sub_index: ContractIndex, + as_string: String, +} + +#[derive(Union)] +enum Address { + ContractAddress(ContractAddress), + AccountAddress(AccountAddress), +} + +#[derive(Union)] +enum Event { + Transferred(Transferred), + AccountCreated(AccountCreated), + AmountAddedByDecryption(AmountAddedByDecryption), + BakerAdded(BakerAdded), + BakerKeysUpdated(BakerKeysUpdated), + BakerRemoved(BakerRemoved), + BakerSetRestakeEarnings(BakerSetRestakeEarnings), + BakerStakeDecreased(BakerStakeDecreased), + BakerStakeIncreased(BakerStakeIncreased), + ContractInitialized(ContractInitialized), + ContractModuleDeployed(ContractModuleDeployed), + ContractUpdated(ContractUpdated), + ContractCall(ContractCall), + CredentialDeployed(CredentialDeployed), + CredentialKeysUpdated(CredentialKeysUpdated), + CredentialsUpdated(CredentialsUpdated), + DataRegistered(DataRegistered), + EncryptedAmountsRemoved(EncryptedAmountsRemoved), + EncryptedSelfAmountAdded(EncryptedSelfAmountAdded), + NewEncryptedAmount(NewEncryptedAmount), + TransferMemo(TransferMemo), + TransferredWithSchedule(TransferredWithSchedule), + // TODO: + // ChainUpdateEnqueued(ChainUpdateEnqueued), + // ContractInterrupted(ContractInterrupted), + // ContractResumed(ContractResumed), + // ContractUpgraded(ContractUpgraded), + // BakerSetOpenStatus(BakerSetOpenStatus), + // BakerSetMetadataURL(BakerSetMetadataURL), + // BakerSetTransactionFeeCommission(BakerSetTransactionFeeCommission), + // BakerSetBakingRewardCommission(BakerSetBakingRewardCommission), + // BakerSetFinalizationRewardCommission(BakerSetFinalizationRewardCommission), + // DelegationAdded(DelegationAdded), + // DelegationRemoved(DelegationRemoved), + // DelegationStakeIncreased(DelegationStakeIncreased), + // DelegationStakeDecreased(DelegationStakeDecreased), + // DelegationSetRestakeEarnings(DelegationSetRestakeEarnings), + // DelegationSetDelegationTarget(DelegationSetDelegationTarget), +} + +#[derive(SimpleObject)] +struct Transferred { + amount: Amount, + from: Address, + to: Address, +} + +#[derive(SimpleObject)] +struct AccountCreated { + account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct AmountAddedByDecryption { + amount: Amount, + account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct BakerAdded { + staked_amount: Amount, + restake_earnings: bool, + baker_id: BakerId, + account_address: AccountAddress, + sign_key: String, + election_key: String, + aggregation_key: String, +} + +#[derive(SimpleObject)] +struct BakerKeysUpdated { + baker_id: BakerId, + account_address: AccountAddress, + sign_key: String, + election_key: String, + aggregation_key: String, +} + +#[derive(SimpleObject)] +struct BakerRemoved { + baker_id: BakerId, + account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct BakerSetRestakeEarnings { + baker_id: BakerId, + account_address: AccountAddress, + restake_earnings: bool, +} + +#[derive(SimpleObject)] +struct BakerStakeDecreased { + baker_id: BakerId, + account_address: AccountAddress, + new_staked_amount: Amount, +} + +#[derive(SimpleObject)] +struct BakerStakeIncreased { + baker_id: BakerId, + account_address: AccountAddress, + new_staked_amount: Amount, +} + +#[derive(SimpleObject)] +struct ContractInitialized { + module_ref: String, + contract_address: ContractAddress, + amount: Amount, + init_name: String, + version: ContractVersion, + // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection + // TODO: events("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +enum ContractVersion { + V0, + V1, +} + +#[derive(SimpleObject)] +struct ContractModuleDeployed { + module_ref: String, +} + +#[derive(SimpleObject)] +struct ContractUpdated { + contract_address: ContractAddress, + instigator: Address, + amount: Amount, + message_as_hex: String, + receive_name: String, + version: ContractVersion, + message: String, + // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection + // TODO: events("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection +} + +#[derive(SimpleObject)] +struct ContractCall { + contract_updated: ContractUpdated, +} + +#[derive(SimpleObject)] +struct CredentialDeployed { + reg_id: String, + account_address: AccountAddress, +} + +#[derive(SimpleObject)] +struct CredentialKeysUpdated { + cred_id: String, +} + +#[derive(SimpleObject)] +struct CredentialsUpdated { + account_address: AccountAddress, + new_cred_ids: Vec, + removed_cred_ids: Vec, + new_threshold: Byte, +} + +#[derive(SimpleObject)] +struct DataRegistered { + decoded: DecodedText, + data_as_hex: String, +} + +#[derive(SimpleObject)] +struct DecodedText { + text: String, + decode_type: TextDecodeType, +} + +#[derive(Enum, Copy, Clone, PartialEq, Eq)] +enum TextDecodeType { + Cbor, + Hex, +} + +#[derive(SimpleObject)] +struct EncryptedAmountsRemoved { + account_address: AccountAddress, + new_encrypted_amount: String, + input_amount: String, + up_to_index: u64, +} + +#[derive(SimpleObject)] +struct EncryptedSelfAmountAdded { + account_address: AccountAddress, + new_encrypted_amount: String, + amount: Amount, +} + +#[derive(SimpleObject)] +struct NewEncryptedAmount { + account_address: AccountAddress, + new_index: u64, + encrypted_amount: String, +} + +#[derive(SimpleObject)] +struct TransferMemo { + decoded: DecodedText, + raw_hex: String, +} + +#[derive(SimpleObject)] +struct TransferredWithSchedule { + from_account_address: AccountAddress, + to_account_address: AccountAddress, + total_amount: Amount, + // TODO: amountsSchedule("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): AmountsScheduleConnection +} + +#[derive(SimpleObject)] +struct ModuleReferenceEvent { + module_reference: String, + sender: AccountAddress, + block_height: BlockHeight, + transaction_hash: String, + block_slot_time: DateTime, + display_schema: String, + // TODO: + // moduleReferenceRejectEvents(skip: Int take: Int): ModuleReferenceRejectEventsCollectionSegment + // moduleReferenceContractLinkEvents(skip: Int take: Int): ModuleReferenceContractLinkEventsCollectionSegment + // linkedContracts(skip: Int take: Int): LinkedContractsCollectionSegment +} + +#[derive(SimpleObject)] +struct BlockMetrics { + /// The most recent block height. Equals the total length of the chain minus one (genesis block is at height zero). + last_block_height: BlockHeight, + /// Total number of blocks added in requested period. + blocks_added: i64, + /// The average block time (slot-time difference between two adjacent blocks) in the requested period. Will be null if no blocks have been added in the requested period. + avg_block_time: Option, + // /// The average finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the requested period. Will be null if no blocks have been finalized in the requested period. + // avg_finalization_time: Option, + // /// The current total amount of CCD in existence. + // last_total_micro_ccd: Amount, + // /// The total CCD Released. This is total CCD supply not counting the balances of non circulating accounts. + // last_total_micro_ccd_released: Option, + // /// The current total CCD released according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule. + // last_total_micro_ccd_unlocked: Option, + // /// The current total amount of CCD in encrypted balances. + // last_total_micro_ccd_encrypted: Long, + // /// The current total amount of CCD staked. + // last_total_micro_ccd_staked: Long, + // /// The current percentage of CCD released (of total CCD in existence) according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule." + // last_total_percentage_released: Option, + // /// The current percentage of CCD encrypted (of total CCD in existence). + // last_total_percentage_encrypted: f32, + // /// The current percentage of CCD staked (of total CCD in existence). + // last_total_percentage_staked: f32, + // buckets: BlockMetricsBuckets, +} + +#[derive(SimpleObject)] +struct BlockMetricsBuckets { + /// The width (time interval) of each bucket. + bucket_width: TimeSpan, + /// Start of the bucket time period. Intended x-axis value. + #[graphql(name = "x_Time")] + x_time: Vec, + /// Number of blocks added within the bucket time period. Intended y-axis value. + #[graphql(name = "y_BlocksAdded")] + y_blocks_added: Vec, + /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_BlockTimeMin")] + y_block_time_min: Vec, + /// The average block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_BlockTimeAvg")] + y_block_time_avg: Vec, + /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_BlockTimeMax")] + y_block_time_max: Vec, + /// The minimum finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + #[graphql(name = "y_FinalizationTimeMin")] + y_finalization_time_min: Vec, + /// The average finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + #[graphql(name = "y_FinalizationTimeAvg")] + y_finalization_time_avg: Vec, + /// The maximum finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + #[graphql(name = "y_FinalizationTimeMax")] + y_finalization_time_max: Vec, + /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis value. + #[graphql(name = "y_LastTotalMicroCcd")] + y_last_total_micro_ccd: Vec, + /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_MinTotalMicroCcdEncrypted")] + y_min_total_micro_ccd_encrypted: Vec, + /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_MaxTotalMicroCcdEncrypted")] + y_max_total_micro_ccd_encrypted: Vec, + /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended y-axis value. + #[graphql(name = "y_LastTotalMicroCcdEncrypted")] + y_last_total_micro_ccd_encrypted: Vec, + /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_MinTotalMicroCcdStaked")] + y_min_total_micro_ccd_staked: Vec, + /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + #[graphql(name = "y_MaxTotalMicroCcdStaked")] + y_max_total_micro_ccd_staked: Vec, + /// The total amount of CCD staked at the end of the bucket period. Intended y-axis value. + #[graphql(name = "y_LastTotalMicroCcdStaked")] + y_last_total_micro_ccd_staked: Vec, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq)] +enum MetricsPeriod { + LastHour, + Last24Hours, + Last7Days, + Last30Days, + LastYear, +} diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs new file mode 100644 index 00000000..fd113f9e --- /dev/null +++ b/backend-rust/src/indexer.rs @@ -0,0 +1,189 @@ +use anyhow::Context; +use concordium_rust_sdk::{ + indexer::{async_trait, Indexer, TraverseConfig, TraverseError}, + types::{queries::BlockInfo, BlockItemSummary, BlockItemSummaryDetails}, + v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult}, +}; +use futures::{StreamExt, TryStreamExt}; +use sqlx::PgPool; +use tokio::sync::mpsc; + +pub async fn traverse_chain( + endpoints: Vec, + pool: PgPool, + sender: mpsc::Sender, +) -> anyhow::Result<()> { + let rec = sqlx::query!( + r#" +SELECT MAX(height) as start_height FROM blocks +"# + ) + .fetch_one(&pool) + .await?; + let start_height = rec + .start_height + .map_or(0, |height| u64::try_from(height).unwrap() + 1u64) + .into(); + + let config = + TraverseConfig::new(endpoints, start_height).context("No gRPC endpoints provided")?; + let indexer = BlockIndexer; + + println!("Indexing from {}", start_height); + config + .traverse(indexer, sender) + .await + .context("Failed traversing the blocks in the chain") +} + +pub async fn save_blocks( + mut receiver: mpsc::Receiver, + pool: PgPool, +) -> anyhow::Result<()> { + while let Some(res) = receiver.recv().await { + println!( + "Saving {}:{}", + res.finalized_block_info.height, res.finalized_block_info.block_hash + ); + res.save_to_database(&pool) + .await + .expect("Failed saving block") + } + Ok(()) +} + +struct BlockIndexer; + +#[async_trait] +impl Indexer for BlockIndexer { + type Context = (); + type Data = BlockData; + + async fn on_connect<'a>( + &mut self, + _endpoint: v2::Endpoint, + _client: &'a mut v2::Client, + ) -> QueryResult { + println!("Indexer connection"); + Ok(()) + } + + async fn on_finalized<'a>( + &self, + mut client: v2::Client, + _ctx: &'a Self::Context, + fbi: FinalizedBlockInfo, + ) -> QueryResult { + let block_info = client.get_block_info(fbi.height).await?.response; + let events: Vec<_> = client + .get_block_transaction_events(fbi.height) + .await? + .response + .try_collect() + .await?; + let chain_parameters = client + .get_block_chain_parameters(fbi.height) + .await? + .response; + + Ok(BlockData { + finalized_block_info: fbi, + block_info, + events, + chain_parameters, + }) + } + + async fn on_failure( + &mut self, + _ep: v2::Endpoint, + _successive_failures: u64, + _err: TraverseError, + ) -> bool { + true + } +} + +pub struct BlockData { + finalized_block_info: FinalizedBlockInfo, + block_info: BlockInfo, + events: Vec, + chain_parameters: ChainParameters, +} + +impl BlockData { + // Relies on blocks being stored sequencially. + async fn save_to_database(self, pool: &PgPool) -> anyhow::Result<()> { + let mut tx = pool + .begin() + .await + .context("Failed to create SQL transaction")?; + + let height = i64::try_from(self.finalized_block_info.height.height)?; + let block_hash = self.finalized_block_info.block_hash.to_string(); + let slot_time = self.block_info.block_slot_time.naive_utc(); + let baker_id = if let Some(index) = self.block_info.block_baker { + Some(i64::try_from(index.id.index)?) + } else { + None + }; + + sqlx::query!( + r#"INSERT INTO blocks (height, hash, slot_time, finalized, baker_id) VALUES ($1, $2, $3, $4, $5);"#, + height, + block_hash, + slot_time, + self.block_info.finalized, + baker_id + ) + .execute(&mut *tx) + .await?; + + for block_item in self.events { + let block_index = i64::try_from(block_item.index.index).unwrap(); + let tx_hash = block_item.hash.to_string(); + let ccd_cost = i64::try_from( + self.chain_parameters + .ccd_cost(block_item.energy_cost) + .micro_ccd, + ) + .unwrap(); + let energy_cost = i64::try_from(block_item.energy_cost.energy).unwrap(); + let sender = block_item.sender_account().map(|a| a.to_string()); + + sqlx::query!( + r#"INSERT INTO transactions (index, hash, ccd_cost, energy_cost, block, sender) +VALUES ($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6));"#, + block_index, + tx_hash, + ccd_cost, + energy_cost, + height, + sender + ) + .execute(&mut *tx) + .await?; + + match block_item.details { + BlockItemSummaryDetails::AccountCreation(details) => { + let account_address = details.address.to_string(); + sqlx::query!( + r#"INSERT INTO accounts (index, address, created_block, created_index) +VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3)"#, + account_address, + height, + block_index + ) + .execute(&mut *tx) + .await?; + } + _ => {} + } + } + + tx.commit() + .await + .context("Failed to commit SQL transaction")?; + Ok(()) + } +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs new file mode 100644 index 00000000..b1e922cd --- /dev/null +++ b/backend-rust/src/main.rs @@ -0,0 +1,89 @@ +use anyhow::Context; +use async_graphql::futures_util::lock::Mutex; +use async_graphql::http::GraphiQLSource; +use async_graphql::Schema; +use async_graphql_axum::GraphQL; +use axum::response::{self, IntoResponse}; +use axum::{routing::get, Router}; +use clap::Parser; +use concordium_rust_sdk::v2; +use dotenv::dotenv; +use sqlx::PgPool; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio::sync::mpsc; + +mod graphql_api; +mod indexer; + +pub async fn graphiql() -> impl IntoResponse { + response::Html(GraphiQLSource::build().endpoint("/").finish()) +} + +#[derive(Parser)] +struct Cli { + /// The url used for the database, something of the form "postgres://postgres:example@localhost/ccd-scan" + #[arg(long, env = "DATABASE_URL")] + database_url: String, + + /// GRPC interface of the node. + #[arg(long, default_value = "http://localhost:20000")] + node: Vec, + + /// Whether to run the indexer. + #[arg(long)] + indexer: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv().ok(); + let cli = Cli::parse(); + + let pool = PgPool::connect(&cli.database_url) + .await + .context("Failed constructin database connection pool")?; + + if cli.indexer { + println!("Starting indexer"); + let (sender, receiver) = mpsc::channel(10); + { + let pool = pool.clone(); + tokio::spawn(async move { + indexer::traverse_chain(cli.node, pool, sender) + .await + .expect("failed") + }); + } + { + let pool = pool.clone(); + tokio::spawn( + async move { indexer::save_blocks(receiver, pool).await.expect("failed") }, + ); + } + } + + let schema = Schema::build( + graphql_api::Query, + async_graphql::EmptyMutation, + async_graphql::EmptySubscription, + ) + .data(pool) + .finish(); + + println!("Schema: \n{}", schema.sdl()); + + let app = Router::new().route("/", get(graphiql).post_service(GraphQL::new(schema))); + + println!("Server is running at http://localhost:8000"); + axum::serve( + TcpListener::bind("127.0.0.1:8000") + .await + .context("Parsing TCP listener address failed")?, + app, + ) + .await + .context("Server failed")?; + + Ok(()) +} From b987f2fa1181eee705c793ce4d7ee1619b12628f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Sun, 26 May 2024 22:21:12 +0200 Subject: [PATCH 02/50] Add more of the API --- backend-rust/Cargo.lock | 105 +++ backend-rust/Cargo.toml | 4 +- .../migrations/20240508183938_initialize.sql | 1 + backend-rust/src/graphql_api.rs | 619 +++++++----------- backend-rust/src/indexer.rs | 8 +- backend-rust/src/main.rs | 5 + 6 files changed, 358 insertions(+), 384 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 69f59fb9..8b846460 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -333,6 +333,8 @@ dependencies = [ "static_assertions_next", "tempfile", "thiserror", + "tracing", + "tracing-futures", ] [[package]] @@ -561,6 +563,8 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -2055,6 +2059,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num" version = "0.4.3" @@ -2204,6 +2218,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.2" @@ -2876,6 +2896,15 @@ dependencies = [ "keccak", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -3301,6 +3330,16 @@ dependencies = [ "syn 2.0.61", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -3541,6 +3580,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3658,6 +3735,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -3755,6 +3838,28 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.52.0" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index f331ef21..2b5c973e 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] anyhow = "1" -async-graphql = {version = "7.0", features = ["chrono"] } +async-graphql = {version = "7.0", features = ["chrono", "tracing"] } async-graphql-axum = "7.0" axum = "0.7" chrono = "0.4" @@ -18,3 +18,5 @@ futures = "0.3" sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } thiserror = "1.0" tokio = { version = "1.37", features = ["full"] } +tracing = "0.1" +tracing-subscriber = "0.3" diff --git a/backend-rust/migrations/20240508183938_initialize.sql b/backend-rust/migrations/20240508183938_initialize.sql index 6fd79bb4..fbb3a53a 100644 --- a/backend-rust/migrations/20240508183938_initialize.sql +++ b/backend-rust/migrations/20240508183938_initialize.sql @@ -22,6 +22,7 @@ CREATE TABLE accounts( address CHAR(50) UNIQUE NOT NULL, created_block BIGINT NOT NULL, created_index BIGINT NOT NULL, + amount BIGINT NOT NULL, -- credential_registration_id FOREIGN KEY (created_block, created_index) REFERENCES transactions(block, index) ); diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index d28661cf..13d66f07 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -16,14 +16,16 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); enum ApiError { #[error("Could not find resource")] NotFound, - #[error("Internal error")] + #[error("Internal error: {}", .0.message)] NoDatabasePool(async_graphql::Error), - #[error("Internal error")] + #[error("Internal error: {0}")] FailedDatabaseQuery(Arc), #[error("Invalid ID format: {0}")] InvalidIdInt(std::num::ParseIntError), #[error("Invalid ID format: {0}")] InvalidIdIntSize(std::num::TryFromIntError), + #[error("Invalid ID for transaction, must be of the format 'block:index'")] + InvalidIdTransaction, #[error("The period cannot be converted")] DurationOutOfRange(Arc>), #[error("The \"first\" and \"last\" parameters cannot exist at the same time")] @@ -34,6 +36,12 @@ enum ApiError { QueryConnectionNegativeLast, } +impl From for ApiError { + fn from(value: sqlx::Error) -> Self { + ApiError::FailedDatabaseQuery(Arc::new(value)) + } +} + type ApiResult = Result; fn get_pool<'a>(ctx: &Context<'a>) -> ApiResult<&'a PgPool> { @@ -64,35 +72,25 @@ impl Query { backend_versions: VERSION.to_string(), } } - async fn block<'a>(&self, ctx: &Context<'a>, height_id: types::ID) -> ApiResult { + async fn block<'a>(&self, ctx: &Context<'a>, height_id: types::ID) -> ApiResult { let height: i64 = height_id .clone() .try_into() .map_err(ApiError::InvalidIdInt)?; - let block = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", height) + sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", height) .fetch_optional(get_pool(ctx)?) - .await - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? - .ok_or(ApiError::NotFound)?; - Ok(BlockWithId { - id: height_id, - inner: block, - }) + .await? + .ok_or(ApiError::NotFound) } async fn block_by_block_hash<'a>( &self, ctx: &Context<'a>, block_hash: BlockHash, - ) -> ApiResult { - let block = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE hash=$1", block_hash) + ) -> ApiResult { + sqlx::query_as!(Block, "SELECT * FROM blocks WHERE hash=$1", block_hash) .fetch_optional(get_pool(ctx)?) - .await - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? - .ok_or(ApiError::NotFound)?; - Ok(BlockWithId { - id: block.height.into(), - inner: block, - }) + .await? + .ok_or(ApiError::NotFound) } async fn blocks<'a>( @@ -155,11 +153,7 @@ impl Query { let mut block_stream = builder.build_query_as::().fetch(get_pool(ctx)?); let mut connection = connection::Connection::new(true, true); - while let Some(block) = block_stream - .try_next() - .await - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? - { + while let Some(block) = block_stream.try_next().await? { connection .edges .push(connection::Edge::new(block.height.to_string(), block)); @@ -177,34 +171,31 @@ impl Query { Ok(connection) } - async fn transaction<'a>( - &self, - ctx: &Context<'a>, - id: types::ID, - ) -> ApiResult { - let pool = get_pool(ctx)?; - let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; - - let transaction = sqlx::query_as!( + async fn transaction<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { + let id = IdTransaction::try_from(id)?; + sqlx::query_as!( Transaction, - "SELECT * FROM transactions WHERE index=$1", - index + "SELECT * FROM transactions WHERE block=$1 AND index=$2", + id.block, + id.index ) - .fetch_optional(pool) - .await - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))? - .ok_or(ApiError::NotFound)?; - - Ok(TransactionWithId { - id, - inner: transaction, - }) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } - async fn transaction_by_transaction_hash( + async fn transaction_by_transaction_hash<'a>( &self, - _transaction_hash: TransactionHash, - ) -> Transaction { - todo!() + ctx: &Context<'a>, + transaction_hash: TransactionHash, + ) -> ApiResult { + sqlx::query_as!( + Transaction, + "SELECT * FROM transactions WHERE hash=$1", + transaction_hash + ) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } async fn transactions( &self, @@ -219,26 +210,105 @@ impl Query { ) -> ApiResult> { todo!() } - async fn account(&self, _id: types::ID) -> Account { - todo!() + async fn account<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { + let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; + sqlx::query_as("SELECT * FROM accounts WHERE index=$1") + .bind(index) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } - async fn account_by_address(&self, _account_address: String) -> Account { - todo!() + async fn account_by_address<'a>( + &self, + ctx: &Context<'a>, + account_address: String, + ) -> ApiResult { + sqlx::query_as("SELECT * FROM accounts WHERE address=$1") + .bind(account_address) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } - async fn accounts( + async fn accounts<'a>( &self, + ctx: &Context<'a>, #[graphql(default)] _sort: AccountSort, - _filter: AccountFilterInput, - #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + _filter: Option, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] - _after: Option, - #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, #[graphql( desc = "Returns the elements in the list that come before the specified cursor." )] - _before: Option, + before: Option, ) -> ApiResult> { - todo!() + check_connection_query(&first, &last)?; + + let mut builder = + sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM accounts"); + + // TODO: include sort and filter + + match (after, before) { + (None, None) => {} + (None, Some(before)) => { + builder + .push(" WHERE index < ") + .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); + } + (Some(after), None) => { + builder + .push(" WHERE index > ") + .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); + } + (Some(after), Some(before)) => { + builder + .push(" WHERE index > ") + .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) + .push(" AND index < ") + .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); + } + } + + match (first, &last) { + (None, None) => { + builder.push(" ORDER BY index ASC)"); + } + (None, Some(last)) => { + builder + .push(" ORDER BY index DESC LIMIT ") + .push_bind(last) + .push(") ORDER BY index ASC "); + } + (Some(first), None) => { + builder + .push(" ORDER BY index ASC LIMIT ") + .push_bind(first) + .push(")"); + } + (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), + } + + let mut row_stream = builder.build_query_as::().fetch(get_pool(ctx)?); + + let mut connection = connection::Connection::new(true, true); + while let Some(row) = row_stream.try_next().await? { + connection + .edges + .push(connection::Edge::new(row.index.to_string(), row)); + } + if last.is_some() { + if let Some(edge) = connection.edges.last() { + connection.has_previous_page = edge.node.index != 0; + } + } else { + if let Some(edge) = connection.edges.first() { + connection.has_previous_page = edge.node.index != 0; + } + } + + Ok(connection) } async fn baker(&self, _id: types::ID) -> Baker { todo!() @@ -294,8 +364,7 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", interval ) .fetch_one(pool) - .await - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err)))?; + .await?; Ok(BlockMetrics { last_block_height: rec.last_block_height.unwrap_or(0), @@ -424,6 +493,7 @@ type BlockHash = String; type TransactionHash = String; type BakerId = i64; type AccountIndex = i64; +type TransactionIndex = i64; type Amount = i64; // TODO: should be UnsignedLong in graphQL type Energy = i64; // TODO: should be UnsignedLong in graphQL type DateTime = chrono::NaiveDateTime; // TODO check format matches. @@ -442,25 +512,34 @@ struct Block { hash: BlockHash, #[graphql(name = "blockHeight")] height: BlockHeight, + /// Time of the block being baked. #[graphql(name = "blockSlotTime")] slot_time: DateTime, baker_id: Option, finalized: bool, - // transaction_count: i32, // chain_parameters: ChainParameters, // balance_statistics: BalanceStatistics, // block_statistics: BlockStatistics, } -#[derive(SimpleObject)] -struct BlockWithId { - id: types::ID, - #[graphql(flatten)] - inner: Block, -} - #[ComplexObject] impl Block { + /// Absolute block height. + async fn id(&self) -> types::ID { + types::ID::from(self.height) + } + + /// Number of transactions included in this block. + async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { + let result = sqlx::query!( + "SELECT COUNT(*) FROM transactions WHERE block=$1", + self.height + ) + .fetch_one(get_pool(ctx)?) + .await?; + Ok(result.count.unwrap_or(0)) + } + async fn special_events( &self, #[graphql( @@ -505,294 +584,6 @@ enum SpecialEventTypeFilter { PaydayPoolReward, } -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct SpecialEventsConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct TransactionsConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountAddressAmountConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountReleaseScheduleItemConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountTokenConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountTransactionRelationConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountStatementEntryConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountRewardConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct AccountsConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct BakersConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct ContractsConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct NodeStatusesConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct ModulesConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// A connection to a list of items. -// #[derive(SimpleObject)] -// struct TokensConnection { -// /// Information to aid in pagination. -// page_info: connection::PageInfo, -// /// A list of edges. -// edges: Option>, -// /// A flattened list of the nodes. -// nodes: Option>, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct SpecialEventsEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: SpecialEvent, -// } - -// /// An edge in a connection." -// #[derive(SimpleObject)] -// struct TransactionsEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Transaction, -// } - -// /// An edge in a connection." -// #[derive(SimpleObject)] -// struct BlocksEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Block, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountAddressAmountEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountAddressAmount, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountReleaseScheduleItemEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountReleaseScheduleItem, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountTokenEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountToken, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountTransactionRelationEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountTransactionRelation, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountStatementEntryEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountStatementEntry, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountRewardEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: AccountReward, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct AccountsEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Account, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct BakersEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Baker, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct ContractsEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Contract, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct NodeStatusesEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: NodeStatus, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct ModulesEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: ModuleReferenceEvent, -// } - -// /// An edge in a connection. -// #[derive(SimpleObject)] -// struct TokensEdge { -// /// A cursor for use in pagination. -// cursor: String, -// /// The item at the end of the edge. -// node: Token, -// } - #[derive(SimpleObject)] #[graphql(complex)] struct Contract { @@ -1256,12 +1047,36 @@ struct AccountAddress { as_string: String, } -#[derive(SimpleObject)] -struct TransactionWithId { - id: types::ID, - #[graphql(flatten)] - inner: Transaction, +impl From for AccountAddress { + fn from(as_string: String) -> Self { + Self { as_string } + } } + +struct IdTransaction { + block: BlockHeight, + index: TransactionIndex, +} + +impl TryFrom for IdTransaction { + type Error = ApiError; + fn try_from(value: types::ID) -> Result { + let (height_str, index_str) = value + .as_str() + .split_once(':') + .ok_or(ApiError::InvalidIdTransaction)?; + Ok(IdTransaction { + block: height_str.parse().map_err(ApiError::InvalidIdInt)?, + index: index_str.parse().map_err(ApiError::InvalidIdInt)?, + }) + } +} +impl From for types::ID { + fn from(value: IdTransaction) -> Self { + types::ID::from(format!("{}:{}", value.block, value.index)) + } +} + #[derive(SimpleObject)] #[graphql(complex)] struct Transaction { @@ -1280,24 +1095,33 @@ struct Transaction { } #[ComplexObject] impl Transaction { - async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { - sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", self.block) + /// Transaction query ID, formatted as ":". + async fn id(&self) -> types::ID { + IdTransaction { + block: self.block, + index: self.index, + } + .into() + } + + async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { + let result = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", self.block) .fetch_one(get_pool(ctx)?) - .await - .map(|block| BlockWithId { - id: self.block.to_string().into(), - inner: block, - }) - .map_err(|err| ApiError::FailedDatabaseQuery(Arc::new(err))) + .await?; + Ok(result) } + async fn sender_account_address<'a>( &self, - _ctx: &Context<'a>, + ctx: &Context<'a>, ) -> ApiResult> { - let Some(_account_index) = self.sender else { + let Some(account_index) = self.sender else { return Ok(None); }; - todo!() + let result = sqlx::query!("SELECT address FROM accounts WHERE index=$1", account_index) + .fetch_one(get_pool(ctx)?) + .await?; + Ok(Some(result.address.into())) } } @@ -1313,21 +1137,54 @@ struct TransactionResult { dummy: i32, } -#[derive(SimpleObject)] +#[derive(SimpleObject, sqlx::FromRow)] #[graphql(complex)] struct Account { - release_schedule: AccountReleaseSchedule, - baker: Baker, - id: types::ID, + // release_schedule: AccountReleaseSchedule, + #[graphql(skip)] + index: i64, + #[graphql(skip)] + created_block: BlockHeight, + #[graphql(skip)] + created_index: TransactionIndex, + /// The address of the account in Base58Check. + #[sqlx(try_from = "String")] address: AccountAddress, + /// The total amount of CCD hold by the account. amount: Amount, - transaction_count: i32, - created_at: DateTime, - delegation: Delegation, + // Get baker information if this account is baking. + // baker: Option, + // delegation: Option, } #[ComplexObject] impl Account { + async fn id(&self) -> types::ID { + types::ID::from(self.index) + } + + /// Timestamp of the block where this account was created. + async fn created_at<'a>(&self, ctx: &Context<'a>) -> ApiResult { + let rec = sqlx::query!( + "SELECT slot_time FROM blocks WHERE height=$1", + self.created_block + ) + .fetch_one(get_pool(ctx)?) + .await?; + Ok(rec.slot_time) + } + + /// Number of transactions where this account is used as sender. + async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { + let rec = sqlx::query!( + "SELECT COUNT(*) FROM transactions WHERE sender=$1", + self.index + ) + .fetch_one(get_pool(ctx)?) + .await?; + Ok(rec.count.unwrap_or(0)) + } + async fn tokens( &self, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index fd113f9e..b5594cad 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1,3 +1,7 @@ +//! TODO: +//! - Insert genesis accounts +//! - + use anyhow::Context; use concordium_rust_sdk::{ indexer::{async_trait, Indexer, TraverseConfig, TraverseError}, @@ -168,8 +172,8 @@ VALUES ($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6));"#, BlockItemSummaryDetails::AccountCreation(details) => { let account_address = details.address.to_string(); sqlx::query!( - r#"INSERT INTO accounts (index, address, created_block, created_index) -VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3)"#, + r#"INSERT INTO accounts (index, address, created_block, created_index, amount) +VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, account_address, height, block_index diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index b1e922cd..52789db4 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -40,6 +40,10 @@ async fn main() -> anyhow::Result<()> { dotenv().ok(); let cli = Cli::parse(); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .init(); + let pool = PgPool::connect(&cli.database_url) .await .context("Failed constructin database connection pool")?; @@ -68,6 +72,7 @@ async fn main() -> anyhow::Result<()> { async_graphql::EmptyMutation, async_graphql::EmptySubscription, ) + .extension(async_graphql::extensions::Tracing) .data(pool) .finish(); From 3328f151a39ea2aaefb45f1e691db4e17c38a5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Sun, 9 Jun 2024 22:16:25 +0200 Subject: [PATCH 03/50] Index most of the events in a transaction --- backend-rust/Cargo.lock | 53 +- backend-rust/Cargo.toml | 7 +- .../migrations/20240508183938_initialize.sql | 187 ++- backend-rust/rustfmt.toml | 16 +- backend-rust/src/graphql_api.rs | 1353 ++++++++++++++--- backend-rust/src/indexer.rs | 169 +- backend-rust/src/main.rs | 27 +- 7 files changed, 1568 insertions(+), 244 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 8b846460..861dc336 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -555,11 +555,16 @@ dependencies = [ "async-graphql", "async-graphql-axum", "axum 0.7.5", + "cbor", "chrono", "clap", "concordium-rust-sdk", + "derive_more", "dotenv", "futures", + "hex", + "serde", + "serde_json", "sqlx", "thiserror", "tokio", @@ -707,6 +712,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "byteorder" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc10e8cc6b2580fda3f36eb6dc5316657f812a3df879a44a66fc9f0fdbc4855" + [[package]] name = "byteorder" version = "1.5.0" @@ -731,6 +742,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "cbor" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e56053652b4b5c0ded5ae6183c7cd547ad2dd6bcce149658bef052a4995533bd" +dependencies = [ + "byteorder 0.5.3", + "rustc-serialize", +] + [[package]] name = "cc" version = "1.0.97" @@ -893,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec03be6a4a56c0501cf9225d135aaa6c155228039efde27d6551814bc6776db1" dependencies = [ "anyhow", - "byteorder", + "byteorder 1.5.0", "concordium-contracts-common", "concordium-wasm", "derive_more", @@ -939,7 +960,7 @@ dependencies = [ "ark-std", "base64 0.21.7", "bs58", - "byteorder", + "byteorder 1.5.0", "cbc", "chrono", "concordium-contracts-common", @@ -2099,7 +2120,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ - "byteorder", + "byteorder 1.5.0", "lazy_static", "libm", "num-integer", @@ -2711,6 +2732,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-serialize" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe834bc780604f4674073badbad26d7219cadfb4a2275802db12cbae17498401" + [[package]] name = "rustc_version" version = "0.4.0" @@ -2783,18 +2810,18 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" -version = "1.0.200" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.200" +version = "1.0.203" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", @@ -2803,9 +2830,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.116" +version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e17db7126d17feb94eb3fad46bf1a96b034e8aacbc2e775fe81505f8b0b2813" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" dependencies = [ "itoa", "ryu", @@ -3012,7 +3039,7 @@ checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" dependencies = [ "ahash 0.8.11", "atoi", - "byteorder", + "byteorder 1.5.0", "bytes", "chrono", "crc", @@ -3092,7 +3119,7 @@ dependencies = [ "atoi", "base64 0.21.7", "bitflags 2.5.0", - "byteorder", + "byteorder 1.5.0", "bytes", "chrono", "crc", @@ -3135,7 +3162,7 @@ dependencies = [ "atoi", "base64 0.21.7", "bitflags 2.5.0", - "byteorder", + "byteorder 1.5.0", "chrono", "crc", "dotenvy", @@ -3632,7 +3659,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ - "byteorder", + "byteorder 1.5.0", "bytes", "data-encoding", "http 1.1.0", diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 2b5c973e..fab64bab 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -10,11 +10,16 @@ anyhow = "1" async-graphql = {version = "7.0", features = ["chrono", "tracing"] } async-graphql-axum = "7.0" axum = "0.7" -chrono = "0.4" +cbor = "0.4" +chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } concordium-rust-sdk = "4.3" +derive_more = "0.99" dotenv = "0.15" futures = "0.3" +hex = "0.4" +serde = "1.0" +serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } thiserror = "1.0" tokio = { version = "1.37", features = ["full"] } diff --git a/backend-rust/migrations/20240508183938_initialize.sql b/backend-rust/migrations/20240508183938_initialize.sql index fbb3a53a..4879118a 100644 --- a/backend-rust/migrations/20240508183938_initialize.sql +++ b/backend-rust/migrations/20240508183938_initialize.sql @@ -1,34 +1,185 @@ +CREATE TYPE account_transaction_type AS ENUM ( + 'InitializeSmartContractInstance', + 'UpdateSmartContractInstance', + 'SimpleTransfer', + 'EncryptedTransfer', + 'SimpleTransferWithMemo', + 'EncryptedTransferWithMemo', + 'TransferWithScheduleWithMemo', + 'DeployModule', + 'AddBaker', + 'RemoveBaker', + 'UpdateBakerStake', + 'UpdateBakerRestakeEarnings', + 'UpdateBakerKeys', + 'UpdateCredentialKeys', + 'TransferToEncrypted', + 'TransferToPublic', + 'TransferWithSchedule', + 'UpdateCredentials', + 'RegisterData', + 'ConfigureBaker', + 'ConfigureDelegation' +); + +CREATE TYPE credential_deployment_transaction_type AS ENUM ( + 'Initial', + 'Normal' +); + +CREATE TYPE update_transaction_type AS ENUM ( + 'UpdateProtocol', + 'UpdateElectionDifficulty', + 'UpdateEuroPerEnergy', + 'UpdateMicroGtuPerEuro', + 'UpdateFoundationAccount', + 'UpdateMintDistribution', + 'UpdateTransactionFeeDistribution', + 'UpdateGasRewards', + 'UpdateBakerStakeThreshold', + 'UpdateAddAnonymityRevoker', + 'UpdateAddIdentityProvider', + 'UpdateRootKeys', + 'UpdateLevel1Keys', + 'UpdateLevel2Keys', + 'UpdatePoolParameters', + 'UpdateCooldownParameters', + 'UpdateTimeParameters', + 'MintDistributionCpv1Update', + 'GasRewardsCpv2Update', + 'TimeoutParametersUpdate', + 'MinBlockTimeUpdate', + 'BlockEnergyLimitUpdate', + 'FinalizationCommitteeParametersUpdate' +); + +CREATE TYPE transaction_type AS ENUM ( + 'Account', + 'CredentialDeployment', + 'Update' +); + + +-- Every block on chain. CREATE TABLE blocks( - height BIGINT PRIMARY KEY NOT NULL, - hash CHAR(64) UNIQUE NOT NULL, - slot_time TIMESTAMP NOT NULL, - finalized BOOLEAN NOT NULL, - baker_id BIGINT -- For non-genesis blocks this should always be defined + -- The absolute height of the block. + height + BIGINT + PRIMARY KEY + NOT NULL, + -- Block hash encoded using HEX. + hash + CHAR(64) + UNIQUE + NOT NULL, + -- Timestamp for when the block was baked. + slot_time + TIMESTAMP + NOT NULL, + -- Whether the block is finalized. + finalized + BOOLEAN + NOT NULL, + -- Index of the account which baked the block. + -- For non-genesis blocks this should always be defined. + -- Foreign key constraint added later, since account table is not defined yet. + baker_id + BIGINT ); +-- Every transaction on chain. CREATE TABLE transactions( - index BIGINT NOT NULL, - block BIGINT REFERENCES blocks(height) NOT NULL, - hash CHAR(64) UNIQUE NOT NULL, - ccd_cost BIGINT NOT NULL, - energy_cost BIGINT NOT NULL, - -- transaction_type: TransactionType, - sender BIGINT, -- NULL for chain update and account creation transactions. Reference added later. + -- Index of the transaction within the block. + index + BIGINT + NOT NULL, + -- Absolute height of the block containing the transaction. + block + BIGINT + REFERENCES blocks(height) + NOT NULL, + -- Transaction hash encoded using HEX. + hash + CHAR(64) + UNIQUE + NOT NULL, + -- The cost of the transaction in terms of CCD. + ccd_cost + BIGINT + NOT NULL, + -- The energy cost of the transaction. + energy_cost + BIGINT + NOT NULL, + -- The account used for sending of the transaction. + -- NULL for chain update and account creation transactions. + -- Foreign key constraint added later, since account table is not defined yet. + sender + BIGINT, + -- The type of transaction. + type + transaction_type + NOT NULL, + -- NULL if the transaction type is not account or the account transaction have no effect on chain. + type_account + account_transaction_type, + -- NULL if the transaction type is not credential deployment. + type_credential_deployment + credential_deployment_transaction_type, + -- NULL if the transaction type is not update. + type_update + update_transaction_type, + -- Whether the transaction was accepted or rejected. + success + BOOLEAN + NOT NULL, + -- Transaction details. Events if success otherwise the reject reason. + details + JSONB, + + -- Make the block height and transaction index the primary key. PRIMARY KEY (block, index) + -- transaction_type: TransactionType, ); +-- Every account on chain. CREATE TABLE accounts( - index BIGINT PRIMARY KEY NOT NULL, - address CHAR(50) UNIQUE NOT NULL, - created_block BIGINT NOT NULL, - created_index BIGINT NOT NULL, - amount BIGINT NOT NULL, - -- credential_registration_id + -- Index of the account. + index + BIGINT + PRIMARY KEY + NOT NULL, + -- Account address bytes encoded using base58check. + address + CHAR(50) + UNIQUE + NOT NULL, + -- Block height where this account was created. + created_block + BIGINT + NOT NULL, + -- Index of the transaction in the block creating this account. + -- Only NULL for genesis accounts + created_index + BIGINT, + -- The total balance of this account. + amount + BIGINT + NOT NULL, + -- Connect the account with the transaction creating it. FOREIGN KEY (created_block, created_index) REFERENCES transactions(block, index) + -- credential_registration_id ); +-- Add foreign key constraint now that the account table is created. ALTER TABLE transactions ADD CONSTRAINT fk_transaction_sender FOREIGN KEY (sender) REFERENCES accounts(index); +-- Add foreign key constraint now that the account table is created. +ALTER TABLE blocks + ADD CONSTRAINT fk_block_baker_id + FOREIGN KEY (baker_id) + REFERENCES accounts(index); + diff --git a/backend-rust/rustfmt.toml b/backend-rust/rustfmt.toml index 36c419bb..87395aaa 100644 --- a/backend-rust/rustfmt.toml +++ b/backend-rust/rustfmt.toml @@ -1 +1,15 @@ -edition = "2021" \ No newline at end of file +edition = "2021" +version = "Two" +imports_granularity = "Crate" +group_imports = "One" +imports_layout = "Vertical" +wrap_comments = true +comment_width = 100 +condense_wildcard_suffixes = true +force_multiline_blocks = true +format_code_in_doc_comments = true +format_generated_files = false +normalize_doc_attributes = true +match_block_trailing_comma = true +normalize_comments = true +use_field_init_shorthand = true diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 13d66f07..9dce80d5 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -1,14 +1,35 @@ +use anyhow::Context as _; use async_graphql::{ - types::{self, connection}, - ComplexObject, Context, Enum, InputObject, InputValueError, InputValueResult, Interface, - Number, Object, Scalar, ScalarType, SimpleObject, Union, Value, + types::{ + self, + connection, + }, + ComplexObject, + Context, + Enum, + InputObject, + InputValueError, + InputValueResult, + Interface, + Number, + Object, + Scalar, + ScalarType, + SimpleObject, + Union, + Value, }; use chrono::Duration; use futures::prelude::*; -use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; -use std::{error::Error, sync::Arc}; - -pub struct Query; +use sqlx::{ + postgres::types::PgInterval, + PgPool, + Postgres, +}; +use std::{ + error::Error, + sync::Arc, +}; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -34,6 +55,10 @@ enum ApiError { QueryConnectionNegativeFirst, #[error("The \"last\" parameter must be a non-negative number")] QueryConnectionNegativeLast, + #[error("Internal error: {0}")] + InternalError(String), + #[error("Invalid integer: {0}")] + InvalidInt(#[from] std::num::TryFromIntError), } impl From for ApiError { @@ -65,6 +90,7 @@ fn check_connection_query(first: &Option, last: &Option) -> ApiResult< Ok(()) } +pub struct Query; #[Object] impl Query { async fn versions(&self) -> Versions { @@ -111,42 +137,42 @@ impl Query { sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM blocks"); match (after, before) { - (None, None) => {} + (None, None) => {}, (None, Some(before)) => { builder .push(" WHERE height < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, (Some(after), None) => { builder .push(" WHERE height > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, (Some(after), Some(before)) => { builder .push(" WHERE height > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) .push(" AND height < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, } match (first, &last) { (None, None) => { builder.push(" ORDER BY height ASC)"); - } + }, (None, Some(last)) => { builder .push(" ORDER BY height DESC LIMIT ") .push_bind(last) .push(") ORDER BY height ASC "); - } + }, (Some(first), None) => { builder .push(" ORDER BY height ASC LIMIT ") .push_bind(first) .push(")"); - } + }, (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } @@ -173,29 +199,23 @@ impl Query { async fn transaction<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { let id = IdTransaction::try_from(id)?; - sqlx::query_as!( - Transaction, - "SELECT * FROM transactions WHERE block=$1 AND index=$2", - id.block, - id.index - ) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + sqlx::query_as("SELECT * FROM transactions WHERE block=$1 AND index=$2") + .bind(id.block) + .bind(id.index) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } async fn transaction_by_transaction_hash<'a>( &self, ctx: &Context<'a>, transaction_hash: TransactionHash, ) -> ApiResult { - sqlx::query_as!( - Transaction, - "SELECT * FROM transactions WHERE hash=$1", - transaction_hash - ) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + sqlx::query_as("SELECT * FROM transactions WHERE hash=$1") + .bind(transaction_hash) + .fetch_optional(get_pool(ctx)?) + .await? + .ok_or(ApiError::NotFound) } async fn transactions( &self, @@ -251,42 +271,42 @@ impl Query { // TODO: include sort and filter match (after, before) { - (None, None) => {} + (None, None) => {}, (None, Some(before)) => { builder .push(" WHERE index < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, (Some(after), None) => { builder .push(" WHERE index > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, (Some(after), Some(before)) => { builder .push(" WHERE index > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) .push(" AND index < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } + }, } match (first, &last) { (None, None) => { builder.push(" ORDER BY index ASC)"); - } + }, (None, Some(last)) => { builder .push(" ORDER BY index DESC LIMIT ") .push_bind(last) .push(") ORDER BY index ASC "); - } + }, (Some(first), None) => { builder .push(" ORDER BY index ASC LIMIT ") .push_bind(first) .push(")"); - } + }, (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } @@ -334,7 +354,7 @@ impl Query { } async fn search(&self, query: String) -> SearchResult { - todo!() + SearchResult { _query: query } } async fn block_metrics<'a>( &self, @@ -369,7 +389,8 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", Ok(BlockMetrics { last_block_height: rec.last_block_height.unwrap_or(0), blocks_added: rec.blocks_added.unwrap_or(0), - avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64), // TODO check what format this is expected to be in. + avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64), + // TODO check what format this is expected to be in. }) } @@ -384,16 +405,29 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", // paydayStatus: PaydayStatus // latestChainParameters: ChainParameters // importState: ImportState - // nodeStatuses(sortField: NodeSortField! sortDirection: NodeSortDirection! "Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): NodeStatusesConnection - // nodeStatus(id: ID!): NodeStatus - // tokens("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): TokensConnection - // token(contractIndex: UnsignedLong! contractSubIndex: UnsignedLong! tokenId: String!): Token! - // contract(contractAddressIndex: UnsignedLong! contractAddressSubIndex: UnsignedLong!): Contract - // contracts("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): ContractsConnection - // moduleReferenceEvent(moduleReference: String!): ModuleReferenceEvent -} - -/// The UnsignedLong scalar type represents a unsigned 64-bit numeric non-fractional value greater than or equal to 0. + // nodeStatuses(sortField: NodeSortField! sortDirection: NodeSortDirection! "Returns the first + // _n_ elements from the list." first: Int "Returns the elements in the list that come after the + // specified cursor." after: String "Returns the last _n_ elements from the list." last: Int + // "Returns the elements in the list that come before the specified cursor." before: String): + // NodeStatusesConnection nodeStatus(id: ID!): NodeStatus + // tokens("Returns the first _n_ elements from the list." first: Int "Returns the elements in + // the list that come after the specified cursor." after: String "Returns the last _n_ elements + // from the list." last: Int "Returns the elements in the list that come before the specified + // cursor." before: String): TokensConnection token(contractIndex: UnsignedLong! + // contractSubIndex: UnsignedLong! tokenId: String!): Token! contract(contractAddressIndex: + // UnsignedLong! contractAddressSubIndex: UnsignedLong!): Contract contracts("Returns the + // first _n_ elements from the list." first: Int "Returns the elements in the list that come + // after the specified cursor." after: String "Returns the last _n_ elements from the list." + // last: Int "Returns the elements in the list that come before the specified cursor." before: + // String): ContractsConnection moduleReferenceEvent(moduleReference: String!): + // ModuleReferenceEvent +} + +/// The UnsignedLong scalar type represents a unsigned 64-bit numeric non-fractional value greater +/// than or equal to 0. +#[derive(serde::Serialize, serde::Deserialize, derive_more::From)] +#[repr(transparent)] +#[serde(transparent)] struct UnsignedLong(u64); #[Scalar] impl ScalarType for UnsignedLong { @@ -413,7 +447,11 @@ impl ScalarType for UnsignedLong { } } -/// The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1. +/// The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can +/// represent values between -(2^63) and 2^63 - 1. +#[derive(serde::Serialize, serde::Deserialize, derive_more::From)] +#[repr(transparent)] +#[serde(transparent)] struct Long(i64); #[Scalar] impl ScalarType for Long { @@ -432,6 +470,9 @@ impl ScalarType for Long { Value::Number(self.0.into()) } } +#[derive(serde::Serialize, serde::Deserialize, derive_more::From)] +#[repr(transparent)] +#[serde(transparent)] struct Byte(u8); #[Scalar] impl ScalarType for Byte { @@ -455,6 +496,9 @@ impl ScalarType for Byte { } } +#[derive(serde::Serialize, serde::Deserialize)] +#[repr(transparent)] +#[serde(transparent)] struct Decimal(f64); #[Scalar] impl ScalarType for Decimal { @@ -476,7 +520,10 @@ impl ScalarType for Decimal { } /// The `TimeSpan` scalar represents an ISO-8601 compliant duration type. -struct TimeSpan(Duration); +#[derive(serde::Serialize, serde::Deserialize)] +#[repr(transparent)] +#[serde(transparent)] +struct TimeSpan(String); #[Scalar] impl ScalarType for TimeSpan { fn parse(value: Value) -> InputValueResult { @@ -644,13 +691,29 @@ struct ContractRejectEvent { block_slot_time: DateTime, } -// union TransactionRejectReason = ModuleNotWf | ModuleHashAlreadyExists | InvalidAccountReference | InvalidInitMethod | InvalidReceiveMethod | InvalidModuleReference | InvalidContractAddress | RuntimeFailure | AmountTooLarge | SerializationFailure | OutOfEnergy | RejectedInit | RejectedReceive | NonExistentRewardAccount | InvalidProof | AlreadyABaker | NotABaker | InsufficientBalanceForBakerStake | StakeUnderMinimumThresholdForBaking | BakerInCooldown | DuplicateAggregationKey | NonExistentCredentialId | KeyIndexAlreadyInUse | InvalidAccountThreshold | InvalidCredentialKeySignThreshold | InvalidEncryptedAmountTransferProof | InvalidTransferToPublicProof | EncryptedAmountSelfTransfer | InvalidIndexOnEncryptedTransfer | ZeroScheduledAmount | NonIncreasingSchedule | FirstScheduledReleaseExpired | ScheduledSelfTransfer | InvalidCredentials | DuplicateCredIds | NonExistentCredIds | RemoveFirstCredential | CredentialHolderDidNotSign | NotAllowedMultipleCredentials | NotAllowedToReceiveEncrypted | NotAllowedToHandleEncrypted | MissingBakerAddParameters | FinalizationRewardCommissionNotInRange | BakingRewardCommissionNotInRange | TransactionFeeCommissionNotInRange | AlreadyADelegator | InsufficientBalanceForDelegationStake | MissingDelegationAddParameters | InsufficientDelegationStake | DelegatorInCooldown | NotADelegator | DelegationTargetNotABaker | StakeOverMaximumThresholdForPool | PoolWouldBecomeOverDelegated | PoolClosed -#[derive(Union)] +// union TransactionRejectReason = ModuleNotWf | ModuleHashAlreadyExists | InvalidAccountReference | +// InvalidInitMethod | InvalidReceiveMethod | InvalidModuleReference | InvalidContractAddress | +// RuntimeFailure | AmountTooLarge | SerializationFailure | OutOfEnergy | RejectedInit | +// RejectedReceive | NonExistentRewardAccount | InvalidProof | AlreadyABaker | NotABaker | +// InsufficientBalanceForBakerStake | StakeUnderMinimumThresholdForBaking | BakerInCooldown | +// DuplicateAggregationKey | NonExistentCredentialId | KeyIndexAlreadyInUse | +// InvalidAccountThreshold | InvalidCredentialKeySignThreshold | InvalidEncryptedAmountTransferProof +// | InvalidTransferToPublicProof | EncryptedAmountSelfTransfer | InvalidIndexOnEncryptedTransfer | +// ZeroScheduledAmount | NonIncreasingSchedule | FirstScheduledReleaseExpired | +// ScheduledSelfTransfer | InvalidCredentials | DuplicateCredIds | NonExistentCredIds | +// RemoveFirstCredential | CredentialHolderDidNotSign | NotAllowedMultipleCredentials | +// NotAllowedToReceiveEncrypted | NotAllowedToHandleEncrypted | MissingBakerAddParameters | +// FinalizationRewardCommissionNotInRange | BakingRewardCommissionNotInRange | +// TransactionFeeCommissionNotInRange | AlreadyADelegator | InsufficientBalanceForDelegationStake | +// MissingDelegationAddParameters | InsufficientDelegationStake | DelegatorInCooldown | +// NotADelegator | DelegationTargetNotABaker | StakeOverMaximumThresholdForPool | +// PoolWouldBecomeOverDelegated | PoolClosed +#[derive(Union, serde::Serialize, serde::Deserialize)] enum TransactionRejectReason { PoolClosed(PoolClosed), } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct PoolClosed { #[graphql( name = "_", @@ -916,7 +979,7 @@ struct PassiveDelegationPoolRewardTarget { dummy: bool, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct PassiveDelegationTarget { #[graphql( name = "_", @@ -925,12 +988,12 @@ struct PassiveDelegationTarget { dummy: bool, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct BakerPoolRewardTarget { baker_id: BakerId, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct BakerDelegationTarget { baker_id: BakerId, } @@ -939,9 +1002,12 @@ struct BakerDelegationTarget { struct BalanceStatistics { /// The total CCD in existence total_amount: Amount, - /// The total CCD Released. This is total CCD supply not counting the balances of non circulating accounts. + /// The total CCD Released. This is total CCD supply not counting the balances of non + /// circulating accounts. total_amount_released: Amount, - /// The total CCD Unlocked according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule. + /// The total CCD Unlocked according to the Concordium promise published on + /// deck.concordium.com. Will be null for blocks with slot time before the published release + /// schedule. total_amount_unlocked: Amount, /// The total CCD in encrypted balances. total_amount_encrypted: Amount, @@ -1042,11 +1108,17 @@ struct ExchangeRate { denominator: u64, } -#[derive(SimpleObject, Clone)] +#[derive(SimpleObject, Clone, serde::Serialize, serde::Deserialize)] struct AccountAddress { as_string: String, } +impl From for AccountAddress { + fn from(address: concordium_rust_sdk::common::types::AccountAddress) -> Self { + address.to_string().into() + } +} + impl From for AccountAddress { fn from(as_string: String) -> Self { Self { as_string } @@ -1077,7 +1149,7 @@ impl From for types::ID { } } -#[derive(SimpleObject)] +#[derive(SimpleObject, sqlx::FromRow)] #[graphql(complex)] struct Transaction { #[graphql(skip)] @@ -1090,8 +1162,22 @@ struct Transaction { energy_cost: Energy, #[graphql(skip)] sender: Option, - // transaction_type: TransactionType, - // result: TransactionResult, + #[graphql(skip)] + r#type: DbTransactionType, + #[graphql(skip)] + type_account: Option, + #[graphql(skip)] + type_credential_deployment: Option, + #[graphql(skip)] + type_update: Option, + #[graphql(skip)] + success: bool, + #[graphql(skip)] + #[sqlx(json)] + events: Option>, + #[graphql(skip)] + #[sqlx(json)] + reject: Option, } #[ComplexObject] impl Transaction { @@ -1123,18 +1209,227 @@ impl Transaction { .await?; Ok(Some(result.address.into())) } + + async fn transaction_type(&self) -> ApiResult { + let tt = match self.r#type { + DbTransactionType::Account => TransactionType::AccountTransaction(AccountTransaction { + account_transaction_type: self.type_account, + }), + DbTransactionType::CredentialDeployment => TransactionType::CredentialDeploymentTransaction(CredentialDeploymentTransaction { + credential_deployment_transaction_type: self.type_credential_deployment.ok_or(ApiError::InternalError("Database invariant violated, transaction type is credential deployment, but credential deployment type is null".to_string()))?, + }), + DbTransactionType::Update => TransactionType::UpdateTransaction(UpdateTransaction { + update_transaction_type: self.type_update.ok_or(ApiError::InternalError("Database invariant violated, transaction type is update, but update type is null".to_string()))?, + }), + }; + Ok(tt) + } + + async fn result(&self) -> ApiResult> { + if self.success { + let events = self.events.as_ref().ok_or(ApiError::InternalError( + "Success events is null".to_string(), + ))?; + Ok(TransactionResult::Success(Success { events })) + } else { + let reason = self.reject.as_ref().ok_or(ApiError::InternalError( + "Success events is null".to_string(), + ))?; + Ok(TransactionResult::Rejected(Rejected { reason })) + } + } +} + +#[derive(Union)] +enum TransactionType { + AccountTransaction(AccountTransaction), + CredentialDeploymentTransaction(CredentialDeploymentTransaction), + UpdateTransaction(UpdateTransaction), +} + +#[derive(SimpleObject)] +struct AccountTransaction { + account_transaction_type: Option, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "account_transaction_type")] +pub enum AccountTransactionType { + InitializeSmartContractInstance, + UpdateSmartContractInstance, + SimpleTransfer, + EncryptedTransfer, + SimpleTransferWithMemo, + EncryptedTransferWithMemo, + TransferWithScheduleWithMemo, + DeployModule, + AddBaker, + RemoveBaker, + UpdateBakerStake, + UpdateBakerRestakeEarnings, + UpdateBakerKeys, + UpdateCredentialKeys, + TransferToEncrypted, + TransferToPublic, + TransferWithSchedule, + UpdateCredentials, + RegisterData, + ConfigureBaker, + ConfigureDelegation, +} + +impl From for AccountTransactionType { + fn from(value: concordium_rust_sdk::types::TransactionType) -> Self { + use concordium_rust_sdk::types::TransactionType as TT; + use AccountTransactionType as ATT; + match value { + TT::DeployModule => ATT::DeployModule, + TT::InitContract => ATT::InitializeSmartContractInstance, + TT::Update => ATT::UpdateSmartContractInstance, + TT::Transfer => ATT::SimpleTransfer, + TT::AddBaker => ATT::AddBaker, + TT::RemoveBaker => ATT::RemoveBaker, + TT::UpdateBakerStake => ATT::UpdateBakerStake, + TT::UpdateBakerRestakeEarnings => ATT::UpdateBakerRestakeEarnings, + TT::UpdateBakerKeys => ATT::UpdateBakerKeys, + TT::UpdateCredentialKeys => ATT::UpdateCredentialKeys, + TT::EncryptedAmountTransfer => ATT::EncryptedTransfer, + TT::TransferToEncrypted => ATT::TransferToEncrypted, + TT::TransferToPublic => ATT::TransferToPublic, + TT::TransferWithSchedule => ATT::TransferWithSchedule, + TT::UpdateCredentials => ATT::UpdateCredentials, + TT::RegisterData => ATT::RegisterData, + TT::TransferWithMemo => ATT::SimpleTransferWithMemo, + TT::EncryptedAmountTransferWithMemo => ATT::EncryptedTransferWithMemo, + TT::TransferWithScheduleAndMemo => ATT::TransferWithScheduleWithMemo, + TT::ConfigureBaker => ATT::ConfigureBaker, + TT::ConfigureDelegation => ATT::ConfigureDelegation, + } + } } #[derive(SimpleObject)] -// TODO union TransactionType = AccountTransaction | CredentialDeploymentTransaction | UpdateTransaction -struct TransactionType { - dummy: i32, +struct CredentialDeploymentTransaction { + credential_deployment_transaction_type: CredentialDeploymentTransactionType, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "credential_deployment_transaction_type")] +pub enum CredentialDeploymentTransactionType { + Initial, + Normal, +} + +impl From for CredentialDeploymentTransactionType { + fn from(value: concordium_rust_sdk::types::CredentialType) -> Self { + use concordium_rust_sdk::types::CredentialType; + match value { + CredentialType::Initial => CredentialDeploymentTransactionType::Initial, + CredentialType::Normal => CredentialDeploymentTransactionType::Normal, + } + } } #[derive(SimpleObject)] -// TODO union TransactionResult = Success | Rejected -struct TransactionResult { - dummy: i32, +struct UpdateTransaction { + update_transaction_type: UpdateTransactionType, +} + +#[derive(Enum, Clone, Copy, PartialEq, Eq, sqlx::Type)] +#[sqlx(type_name = "update_transaction_type")] +pub enum UpdateTransactionType { + UpdateProtocol, + UpdateElectionDifficulty, + UpdateEuroPerEnergy, + UpdateMicroGtuPerEuro, + UpdateFoundationAccount, + UpdateMintDistribution, + UpdateTransactionFeeDistribution, + UpdateGasRewards, + UpdateBakerStakeThreshold, + UpdateAddAnonymityRevoker, + UpdateAddIdentityProvider, + UpdateRootKeys, + UpdateLevel1Keys, + UpdateLevel2Keys, + UpdatePoolParameters, + UpdateCooldownParameters, + UpdateTimeParameters, + MintDistributionCpv1Update, + GasRewardsCpv2Update, + TimeoutParametersUpdate, + MinBlockTimeUpdate, + BlockEnergyLimitUpdate, + FinalizationCommitteeParametersUpdate, +} + +impl From for UpdateTransactionType { + fn from(value: concordium_rust_sdk::types::UpdateType) -> Self { + use concordium_rust_sdk::types::UpdateType; + match value { + UpdateType::UpdateProtocol => UpdateTransactionType::UpdateProtocol, + UpdateType::UpdateElectionDifficulty => UpdateTransactionType::UpdateElectionDifficulty, + UpdateType::UpdateEuroPerEnergy => UpdateTransactionType::UpdateEuroPerEnergy, + UpdateType::UpdateMicroGTUPerEuro => UpdateTransactionType::UpdateMicroGtuPerEuro, + UpdateType::UpdateFoundationAccount => UpdateTransactionType::UpdateFoundationAccount, + UpdateType::UpdateMintDistribution => UpdateTransactionType::UpdateMintDistribution, + UpdateType::UpdateTransactionFeeDistribution => { + UpdateTransactionType::UpdateTransactionFeeDistribution + }, + UpdateType::UpdateGASRewards => UpdateTransactionType::UpdateGasRewards, + UpdateType::UpdateAddAnonymityRevoker => { + UpdateTransactionType::UpdateAddAnonymityRevoker + }, + UpdateType::UpdateAddIdentityProvider => { + UpdateTransactionType::UpdateAddIdentityProvider + }, + UpdateType::UpdateRootKeys => UpdateTransactionType::UpdateRootKeys, + UpdateType::UpdateLevel1Keys => UpdateTransactionType::UpdateLevel1Keys, + UpdateType::UpdateLevel2Keys => UpdateTransactionType::UpdateLevel2Keys, + UpdateType::UpdatePoolParameters => UpdateTransactionType::UpdatePoolParameters, + UpdateType::UpdateCooldownParameters => UpdateTransactionType::UpdateCooldownParameters, + UpdateType::UpdateTimeParameters => UpdateTransactionType::UpdateTimeParameters, + UpdateType::UpdateGASRewardsCPV2 => UpdateTransactionType::GasRewardsCpv2Update, + UpdateType::UpdateTimeoutParameters => UpdateTransactionType::TimeoutParametersUpdate, + UpdateType::UpdateMinBlockTime => UpdateTransactionType::MinBlockTimeUpdate, + UpdateType::UpdateBlockEnergyLimit => UpdateTransactionType::BlockEnergyLimitUpdate, + UpdateType::UpdateFinalizationCommitteeParameters => { + UpdateTransactionType::FinalizationCommitteeParametersUpdate + }, + } + } +} + +#[derive(Union)] +enum TransactionResult<'a> { + Success(Success<'a>), + Rejected(Rejected<'a>), +} + +struct Success<'a> { + events: &'a Vec, +} + +#[Object] +impl Success<'_> { + async fn events( + &self, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i64, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: String, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i64, + #[graphql( + desc = "Returns the elements in the list that come before the specified cursor." + )] + before: String, + ) -> ApiResult> { + todo!() + } +} + +#[derive(SimpleObject)] +struct Rejected<'a> { + reason: &'a TransactionRejectReason, } #[derive(SimpleObject, sqlx::FromRow)] @@ -1143,10 +1438,12 @@ struct Account { // release_schedule: AccountReleaseSchedule, #[graphql(skip)] index: i64, + /// Height of the block with the transaction creating this account. #[graphql(skip)] created_block: BlockHeight, + /// Index of transaction creating this account within a block. Only Null for genesis accounts. #[graphql(skip)] - created_index: TransactionIndex, + created_index: Option, /// The address of the account in Base58Check. #[sqlx(try_from = "String")] address: AccountAddress, @@ -1268,7 +1565,10 @@ struct Baker { baker_id: BakerId, state: BakerState, // /// Get the transactions that have affected the baker. - // transactions("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): BakerTransactionRelationConnection + // transactions("Returns the first _n_ elements from the list." first: Int "Returns the + // elements in the list that come after the specified cursor." after: String "Returns the last + // _n_ elements from the list." last: Int "Returns the elements in the list that come before + // the specified cursor." before: String): BakerTransactionRelationConnection } #[derive(Union)] @@ -1364,7 +1664,9 @@ struct NodeStatus { #[derive(SimpleObject)] struct BakerPool { - /// Total stake of the baker pool as a percentage of all CCDs in existence. Value may be null for brand new bakers where statistics have not been calculated yet. This should be rare and only a temporary condition. + /// Total stake of the baker pool as a percentage of all CCDs in existence. Value may be null + /// for brand new bakers where statistics have not been calculated yet. This should be rare and + /// only a temporary condition. total_stake_percentage: Decimal, lottery_power: Decimal, payday_commission_rates: CommissionRates, @@ -1373,16 +1675,25 @@ struct BakerPool { metadata_url: String, /// The total amount staked by delegation to this baker pool. delegated_stake: Amount, - /// The maximum amount that may be delegated to the pool, accounting for leverage and stake limits. + /// The maximum amount that may be delegated to the pool, accounting for leverage and stake + /// limits. delegated_stake_cap: Amount, /// The total amount staked in this baker pool. Includes both baker stake and delegated stake. total_stake: Amount, delegator_count: i32, - /// Ranking of the baker pool by total staked amount. Value may be null for brand new bakers where statistics have not been calculated yet. This should be rare and only a temporary condition. + /// Ranking of the baker pool by total staked amount. Value may be null for brand new bakers + /// where statistics have not been calculated yet. This should be rare and only a temporary + /// condition. ranking_by_total_stake: Ranking, // TODO: apy(period: ApyPeriod!): PoolApy! - // TODO: delegators("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): DelegatorsConnection - // TODO: poolRewards("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): PaydayPoolRewardConnection + // TODO: delegators("Returns the first _n_ elements from the list." first: Int "Returns the + // elements in the list that come after the specified cursor." after: String "Returns the last + // _n_ elements from the list." last: Int "Returns the elements in the list that come before + // the specified cursor." before: String): DelegatorsConnection + // TODO: poolRewards("Returns the first _n_ elements from the list." first: Int "Returns the + // elements in the list that come after the specified cursor." after: String "Returns the last + // _n_ elements from the list." last: Int "Returns the elements in the list that come before + // the specified cursor." before: String): PaydayPoolRewardConnection } #[derive(SimpleObject)] @@ -1392,13 +1703,24 @@ struct CommissionRates { baking_commission: Decimal, } -#[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] enum BakerPoolOpenStatus { OpenForAll, ClosedForNew, ClosedForAll, } +impl From for BakerPoolOpenStatus { + fn from(status: concordium_rust_sdk::types::OpenStatus) -> Self { + use concordium_rust_sdk::types::OpenStatus; + match status { + OpenStatus::OpenForAll => Self::OpenForAll, + OpenStatus::ClosedForNew => Self::ClosedForNew, + OpenStatus::ClosedForAll => Self::ClosedForAll, + } + } +} + #[derive(SimpleObject)] struct Ranking { rank: i32, @@ -1414,12 +1736,33 @@ struct Delegation { pending_change: PendingDelegationChange, } -#[derive(Union)] +#[derive(Union, serde::Serialize, serde::Deserialize)] enum DelegationTarget { PassiveDelegationTarget(PassiveDelegationTarget), BakerDelegationTarget(BakerDelegationTarget), } +impl TryFrom for DelegationTarget { + type Error = anyhow::Error; + fn try_from(target: concordium_rust_sdk::types::DelegationTarget) -> Result { + use concordium_rust_sdk::types::DelegationTarget as Target; + match target { + Target::Passive => { + Ok(DelegationTarget::PassiveDelegationTarget( + PassiveDelegationTarget { dummy: true }, + )) + }, + Target::Baker { baker_id } => { + Ok(DelegationTarget::BakerDelegationTarget( + BakerDelegationTarget { + baker_id: baker_id.id.index.try_into()?, + }, + )) + }, + } + } +} + #[derive(Union)] enum PendingDelegationChange { PendingDelegationRemoval(PendingDelegationRemoval), @@ -1497,12 +1840,15 @@ enum BakerSort { BlockCommissionsDesc, } -struct SearchResult; +struct SearchResult { + _query: String, +} #[Object] impl SearchResult { - async fn contracts( + async fn contracts<'a>( &self, + _ctx: &Context<'a>, #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, @@ -1518,8 +1864,8 @@ impl SearchResult { // async fn modules( // &self, // #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, - // #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] - // _after: Option, + // #[graphql(desc = "Returns the elements in the list that come after the specified + // cursor.")] _after: Option, // #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, // #[graphql( // desc = "Returns the elements in the list that come before the specified cursor." @@ -1614,21 +1960,31 @@ impl SearchResult { } } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct ContractAddress { index: ContractIndex, sub_index: ContractIndex, as_string: String, } -#[derive(Union)] +impl From for ContractAddress { + fn from(value: concordium_rust_sdk::types::ContractAddress) -> Self { + Self { + index: value.index.into(), + sub_index: value.subindex.into(), + as_string: value.to_string(), + } + } +} + +#[derive(Union, serde::Serialize, serde::Deserialize)] enum Address { ContractAddress(ContractAddress), AccountAddress(AccountAddress), } -#[derive(Union)] -enum Event { +#[derive(Union, serde::Serialize, serde::Deserialize)] +pub enum Event { Transferred(Transferred), AccountCreated(AccountCreated), AmountAddedByDecryption(AmountAddedByDecryption), @@ -1651,141 +2007,578 @@ enum Event { NewEncryptedAmount(NewEncryptedAmount), TransferMemo(TransferMemo), TransferredWithSchedule(TransferredWithSchedule), - // TODO: - // ChainUpdateEnqueued(ChainUpdateEnqueued), - // ContractInterrupted(ContractInterrupted), - // ContractResumed(ContractResumed), - // ContractUpgraded(ContractUpgraded), - // BakerSetOpenStatus(BakerSetOpenStatus), - // BakerSetMetadataURL(BakerSetMetadataURL), - // BakerSetTransactionFeeCommission(BakerSetTransactionFeeCommission), - // BakerSetBakingRewardCommission(BakerSetBakingRewardCommission), - // BakerSetFinalizationRewardCommission(BakerSetFinalizationRewardCommission), - // DelegationAdded(DelegationAdded), - // DelegationRemoved(DelegationRemoved), - // DelegationStakeIncreased(DelegationStakeIncreased), - // DelegationStakeDecreased(DelegationStakeDecreased), - // DelegationSetRestakeEarnings(DelegationSetRestakeEarnings), - // DelegationSetDelegationTarget(DelegationSetDelegationTarget), + ChainUpdateEnqueued(ChainUpdateEnqueued), + ContractInterrupted(ContractInterrupted), + ContractResumed(ContractResumed), + ContractUpgraded(ContractUpgraded), + BakerSetOpenStatus(BakerSetOpenStatus), + BakerSetMetadataURL(BakerSetMetadataURL), + BakerSetTransactionFeeCommission(BakerSetTransactionFeeCommission), + BakerSetBakingRewardCommission(BakerSetBakingRewardCommission), + BakerSetFinalizationRewardCommission(BakerSetFinalizationRewardCommission), + DelegationAdded(DelegationAdded), + DelegationRemoved(DelegationRemoved), + DelegationStakeIncreased(DelegationStakeIncreased), + DelegationStakeDecreased(DelegationStakeDecreased), + DelegationSetRestakeEarnings(DelegationSetRestakeEarnings), + DelegationSetDelegationTarget(DelegationSetDelegationTarget), +} + +pub fn events_from_summary( + value: concordium_rust_sdk::types::BlockItemSummaryDetails, +) -> anyhow::Result> { + use concordium_rust_sdk::types::{ + AccountTransactionEffects, + BlockItemSummaryDetails, + }; + let events = match value { + BlockItemSummaryDetails::AccountTransaction(details) => { + match details.effects { + AccountTransactionEffects::None { + transaction_type, + reject_reason, + } => todo!(), + AccountTransactionEffects::ModuleDeployed { module_ref } => { + vec![Event::ContractModuleDeployed(ContractModuleDeployed { + module_ref: module_ref.to_string(), + })] + }, + AccountTransactionEffects::ContractInitialized { data } => { + vec![Event::ContractInitialized(ContractInitialized { + module_ref: data.origin_ref.to_string(), + contract_address: data.address.into(), + amount: i64::try_from(data.amount.micro_ccd)?, + init_name: data.init_name.to_string(), + version: data.contract_version.into(), + })] + }, + AccountTransactionEffects::ContractUpdateIssued { effects } => todo!(), + AccountTransactionEffects::AccountTransfer { amount, to } => { + vec![Event::Transferred(Transferred { + amount: i64::try_from(amount.micro_ccd)?, + from: details.sender.into(), + to: to.into(), + })] + }, + AccountTransactionEffects::AccountTransferWithMemo { amount, to, memo } => { + vec![ + Event::Transferred(Transferred { + amount: i64::try_from(amount.micro_ccd)?, + from: details.sender.into(), + to: to.into(), + }), + Event::TransferMemo(memo.into()), + ] + }, + AccountTransactionEffects::BakerAdded { data } => { + vec![Event::BakerAdded(BakerAdded { + staked_amount: data.stake.micro_ccd.try_into()?, + restake_earnings: data.restake_earnings, + baker_id: data.keys_event.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.keys_event.sign_key)?, + election_key: serde_json::to_string(&data.keys_event.election_key)?, + aggregation_key: serde_json::to_string(&data.keys_event.aggregation_key)?, + })] + }, + AccountTransactionEffects::BakerRemoved { baker_id } => { + vec![Event::BakerRemoved(BakerRemoved { + baker_id: baker_id.id.index.try_into()?, + })] + }, + AccountTransactionEffects::BakerStakeUpdated { data } => { + if let Some(data) = data { + if data.increased { + vec![Event::BakerStakeIncreased(BakerStakeIncreased { + baker_id: data.baker_id.id.index.try_into()?, + new_staked_amount: data.new_stake.micro_ccd.try_into()?, + })] + } else { + vec![Event::BakerStakeDecreased(BakerStakeDecreased { + baker_id: data.baker_id.id.index.try_into()?, + new_staked_amount: data.new_stake.micro_ccd.try_into()?, + })] + } + } else { + Vec::new() + } + }, + AccountTransactionEffects::BakerRestakeEarningsUpdated { + baker_id, + restake_earnings, + } => { + vec![Event::BakerSetRestakeEarnings(BakerSetRestakeEarnings { + baker_id: baker_id.id.index.try_into()?, + restake_earnings, + })] + }, + AccountTransactionEffects::BakerKeysUpdated { data } => { + vec![Event::BakerKeysUpdated(BakerKeysUpdated { + baker_id: data.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.sign_key)?, + election_key: serde_json::to_string(&data.election_key)?, + aggregation_key: serde_json::to_string(&data.aggregation_key)?, + })] + }, + AccountTransactionEffects::EncryptedAmountTransferred { removed, added } => { + vec![ + Event::EncryptedAmountsRemoved((*removed).try_into()?), + Event::NewEncryptedAmount((*added).try_into()?), + ] + }, + AccountTransactionEffects::EncryptedAmountTransferredWithMemo { + removed, + added, + memo, + } => { + vec![ + Event::EncryptedAmountsRemoved((*removed).try_into()?), + Event::NewEncryptedAmount((*added).try_into()?), + Event::TransferMemo(memo.into()), + ] + }, + AccountTransactionEffects::TransferredToEncrypted { data } => { + vec![Event::EncryptedSelfAmountAdded(EncryptedSelfAmountAdded { + account_address: data.account.into(), + new_encrypted_amount: serde_json::to_string(&data.new_amount)?, + amount: data.amount.micro_ccd.try_into()?, + })] + }, + AccountTransactionEffects::TransferredToPublic { removed, amount } => { + vec![ + Event::EncryptedAmountsRemoved((*removed).try_into()?), + Event::AmountAddedByDecryption(AmountAddedByDecryption { + amount: amount.micro_ccd().try_into()?, + account_address: details.sender.into(), + }), + ] + }, + AccountTransactionEffects::TransferredWithSchedule { to, amount } => { + vec![Event::TransferredWithSchedule(TransferredWithSchedule { + from_account_address: details.sender.into(), + to_account_address: to.into(), + total_amount: amount + .into_iter() + .map(|(_, amount)| amount.micro_ccd()) + .sum::() + .try_into()?, + })] + }, + AccountTransactionEffects::TransferredWithScheduleAndMemo { to, amount, memo } => { + vec![ + Event::TransferredWithSchedule(TransferredWithSchedule { + from_account_address: details.sender.into(), + to_account_address: to.into(), + total_amount: amount + .into_iter() + .map(|(_, amount)| amount.micro_ccd()) + .sum::() + .try_into()?, + }), + Event::TransferMemo(memo.try_into()?), + ] + }, + AccountTransactionEffects::CredentialKeysUpdated { cred_id } => { + vec![Event::CredentialKeysUpdated(CredentialKeysUpdated { + cred_id: cred_id.to_string(), + })] + }, + AccountTransactionEffects::CredentialsUpdated { + new_cred_ids, + removed_cred_ids, + new_threshold, + } => { + vec![Event::CredentialsUpdated(CredentialsUpdated { + account_address: details.sender.into(), + new_cred_ids: new_cred_ids + .into_iter() + .map(|cred| cred.to_string()) + .collect(), + removed_cred_ids: removed_cred_ids + .into_iter() + .map(|cred| cred.to_string()) + .collect(), + new_threshold: Byte(u8::from(new_threshold)), + })] + }, + AccountTransactionEffects::DataRegistered { data } => { + vec![Event::DataRegistered(DataRegistered { + data_as_hex: hex::encode(data.as_ref()), + decoded: todo!(), + })] + }, + AccountTransactionEffects::BakerConfigured { data } => { + data.into_iter() + .map(|baker_event| { + use concordium_rust_sdk::types::BakerEvent; + match baker_event { + BakerEvent::BakerAdded { data } => { + Ok(Event::BakerAdded(BakerAdded { + staked_amount: data.stake.micro_ccd.try_into()?, + restake_earnings: data.restake_earnings, + baker_id: data.keys_event.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.keys_event.sign_key)?, + election_key: serde_json::to_string( + &data.keys_event.election_key, + )?, + aggregation_key: serde_json::to_string( + &data.keys_event.aggregation_key, + )?, + })) + }, + BakerEvent::BakerRemoved { baker_id } => { + Ok(Event::BakerRemoved(BakerRemoved { + baker_id: baker_id.id.index.try_into()?, + })) + }, + BakerEvent::BakerStakeIncreased { + baker_id, + new_stake, + } => { + Ok(Event::BakerStakeIncreased(BakerStakeIncreased { + baker_id: baker_id.id.index.try_into()?, + new_staked_amount: new_stake.micro_ccd.try_into()?, + })) + }, + BakerEvent::BakerStakeDecreased { + baker_id, + new_stake, + } => { + Ok(Event::BakerStakeDecreased(BakerStakeDecreased { + baker_id: baker_id.id.index.try_into()?, + new_staked_amount: new_stake.micro_ccd.try_into()?, + })) + }, + BakerEvent::BakerRestakeEarningsUpdated { + baker_id, + restake_earnings, + } => { + Ok(Event::BakerSetRestakeEarnings(BakerSetRestakeEarnings { + baker_id: baker_id.id.index.try_into()?, + restake_earnings, + })) + }, + BakerEvent::BakerKeysUpdated { data } => { + Ok(Event::BakerKeysUpdated(BakerKeysUpdated { + baker_id: data.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.sign_key)?, + election_key: serde_json::to_string(&data.election_key)?, + aggregation_key: serde_json::to_string( + &data.aggregation_key, + )?, + })) + }, + BakerEvent::BakerSetOpenStatus { + baker_id, + open_status, + } => { + Ok(Event::BakerSetOpenStatus(BakerSetOpenStatus { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + open_status: open_status.into(), + })) + }, + BakerEvent::BakerSetMetadataURL { + baker_id, + metadata_url, + } => { + Ok(Event::BakerSetMetadataURL(BakerSetMetadataURL { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + metadata_url: metadata_url.into(), + })) + }, + BakerEvent::BakerSetTransactionFeeCommission { + baker_id, + transaction_fee_commission, + } => { + Ok(Event::BakerSetTransactionFeeCommission( + BakerSetTransactionFeeCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + transaction_fee_commission: todo!(), + }, + )) + }, + BakerEvent::BakerSetBakingRewardCommission { + baker_id, + baking_reward_commission, + } => { + Ok(Event::BakerSetBakingRewardCommission( + BakerSetBakingRewardCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + baking_reward_commission: todo!(), + }, + )) + }, + BakerEvent::BakerSetFinalizationRewardCommission { + baker_id, + finalization_reward_commission, + } => { + Ok(Event::BakerSetFinalizationRewardCommission( + BakerSetFinalizationRewardCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + finalization_reward_commission: todo!(), + }, + )) + }, + } + }) + .collect::>>()? + }, + AccountTransactionEffects::DelegationConfigured { data } => { + use concordium_rust_sdk::types::DelegationEvent; + data.into_iter() + .map(|event| { + match event { + DelegationEvent::DelegationStakeIncreased { + delegator_id, + new_stake, + } => { + Ok(Event::DelegationStakeIncreased(DelegationStakeIncreased { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + new_staked_amount: new_stake.micro_ccd().try_into()?, + })) + }, + DelegationEvent::DelegationStakeDecreased { + delegator_id, + new_stake, + } => { + Ok(Event::DelegationStakeDecreased(DelegationStakeDecreased { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + new_staked_amount: new_stake.micro_ccd().try_into()?, + })) + }, + DelegationEvent::DelegationSetRestakeEarnings { + delegator_id, + restake_earnings, + } => { + Ok(Event::DelegationSetRestakeEarnings( + DelegationSetRestakeEarnings { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + restake_earnings, + }, + )) + }, + DelegationEvent::DelegationSetDelegationTarget { + delegator_id, + delegation_target, + } => { + Ok(Event::DelegationSetDelegationTarget( + DelegationSetDelegationTarget { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + delegation_target: delegation_target.try_into()?, + }, + )) + }, + DelegationEvent::DelegationAdded { delegator_id } => { + Ok(Event::DelegationAdded(DelegationAdded { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + })) + }, + DelegationEvent::DelegationRemoved { delegator_id } => { + Ok(Event::DelegationRemoved(DelegationRemoved { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + })) + }, + } + }) + .collect::>>()? + }, + } + }, + BlockItemSummaryDetails::AccountCreation(details) => { + vec![Event::AccountCreated(AccountCreated { + account_address: details.address.into(), + })] + }, + BlockItemSummaryDetails::Update(details) => { + vec![Event::ChainUpdateEnqueued(ChainUpdateEnqueued { + effective_time: chrono::DateTime::from_timestamp( + details.effective_time.seconds.try_into()?, + 0, + ) + .context("Failed to parse effective time")? + .naive_utc(), + payload: true, // placeholder + })] + }, + }; + Ok(events) } -#[derive(SimpleObject)] +impl From for TransferMemo { + fn from(value: concordium_rust_sdk::types::Memo) -> Self { + TransferMemo { + decoded: todo!(), + raw_hex: hex::encode(value.as_ref()), + } + } +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct Transferred { amount: Amount, - from: Address, - to: Address, + from: AccountAddress, + to: AccountAddress, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct AccountCreated { account_address: AccountAddress, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct AmountAddedByDecryption { amount: Amount, account_address: AccountAddress, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerAdded { staked_amount: Amount, restake_earnings: bool, baker_id: BakerId, - account_address: AccountAddress, sign_key: String, election_key: String, aggregation_key: String, } +#[ComplexObject] +impl BakerAdded { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } +} -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerKeysUpdated { baker_id: BakerId, - account_address: AccountAddress, sign_key: String, election_key: String, aggregation_key: String, } +#[ComplexObject] +impl BakerKeysUpdated { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } +} -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerRemoved { baker_id: BakerId, - account_address: AccountAddress, +} +#[ComplexObject] +impl BakerRemoved { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerSetRestakeEarnings { baker_id: BakerId, - account_address: AccountAddress, restake_earnings: bool, } +#[ComplexObject] +impl BakerSetRestakeEarnings { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } +} -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerStakeDecreased { baker_id: BakerId, - account_address: AccountAddress, new_staked_amount: Amount, } +#[ComplexObject] +impl BakerStakeDecreased { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } +} -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +#[graphql(complex)] struct BakerStakeIncreased { baker_id: BakerId, - account_address: AccountAddress, new_staked_amount: Amount, } +#[ComplexObject] +impl BakerStakeIncreased { + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo!() + } +} -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct ContractInitialized { module_ref: String, contract_address: ContractAddress, amount: Amount, init_name: String, version: ContractVersion, - // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection - // TODO: events("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection + // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the + // elements in the list that come after the specified cursor." after: String "Returns the last + // _n_ elements from the list." last: Int "Returns the elements in the list that come before + // the specified cursor." before: String): StringConnection TODO: events("Returns the first + // _n_ elements from the list." first: Int "Returns the elements in the list that come after + // the specified cursor." after: String "Returns the last _n_ elements from the list." last: + // Int "Returns the elements in the list that come before the specified cursor." before: + // String): StringConnection } -#[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] enum ContractVersion { V0, V1, } -#[derive(SimpleObject)] -struct ContractModuleDeployed { - module_ref: String, +impl From for ContractVersion { + fn from(value: concordium_rust_sdk::types::smart_contracts::WasmVersion) -> Self { + use concordium_rust_sdk::types::smart_contracts::WasmVersion; + match value { + WasmVersion::V0 => ContractVersion::V0, + WasmVersion::V1 => ContractVersion::V1, + } + } } -#[derive(SimpleObject)] -struct ContractUpdated { - contract_address: ContractAddress, - instigator: Address, - amount: Amount, - message_as_hex: String, - receive_name: String, - version: ContractVersion, - message: String, - // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection - // TODO: events("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): StringConnection +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ContractModuleDeployed { + module_ref: String, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct ContractCall { contract_updated: ContractUpdated, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct CredentialDeployed { reg_id: String, account_address: AccountAddress, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct CredentialKeysUpdated { cred_id: String, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct CredentialsUpdated { account_address: AccountAddress, new_cred_ids: Vec, @@ -1793,25 +2586,25 @@ struct CredentialsUpdated { new_threshold: Byte, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct DataRegistered { decoded: DecodedText, data_as_hex: String, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct DecodedText { text: String, decode_type: TextDecodeType, } -#[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] enum TextDecodeType { Cbor, Hex, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct EncryptedAmountsRemoved { account_address: AccountAddress, new_encrypted_amount: String, @@ -1819,35 +2612,67 @@ struct EncryptedAmountsRemoved { up_to_index: u64, } -#[derive(SimpleObject)] +impl TryFrom for EncryptedAmountsRemoved { + type Error = anyhow::Error; + + fn try_from( + removed: concordium_rust_sdk::types::EncryptedAmountRemovedEvent, + ) -> Result { + Ok(EncryptedAmountsRemoved { + account_address: removed.account.into(), + new_encrypted_amount: serde_json::to_string(&removed.new_amount)?, + input_amount: serde_json::to_string(&removed.input_amount)?, + up_to_index: removed.up_to_index.index, + }) + } +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct EncryptedSelfAmountAdded { account_address: AccountAddress, new_encrypted_amount: String, amount: Amount, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct NewEncryptedAmount { account_address: AccountAddress, new_index: u64, encrypted_amount: String, } -#[derive(SimpleObject)] +impl TryFrom for NewEncryptedAmount { + type Error = anyhow::Error; + + fn try_from( + added: concordium_rust_sdk::types::NewEncryptedAmountEvent, + ) -> Result { + Ok(NewEncryptedAmount { + account_address: added.receiver.into(), + new_index: added.new_index.index, + encrypted_amount: serde_json::to_string(&added.encrypted_amount)?, + }) + } +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct TransferMemo { decoded: DecodedText, raw_hex: String, } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct TransferredWithSchedule { from_account_address: AccountAddress, to_account_address: AccountAddress, total_amount: Amount, - // TODO: amountsSchedule("Returns the first _n_ elements from the list." first: Int "Returns the elements in the list that come after the specified cursor." after: String "Returns the last _n_ elements from the list." last: Int "Returns the elements in the list that come before the specified cursor." before: String): AmountsScheduleConnection + // TODO: amountsSchedule("Returns the first _n_ elements from the list." first: Int "Returns + // the elements in the list that come after the specified cursor." after: String "Returns the + // last _n_ elements from the list." last: Int "Returns the elements in the list that come + // before the specified cursor." before: String): AmountsScheduleConnection } -#[derive(SimpleObject)] +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct ModuleReferenceEvent { module_reference: String, sender: AccountAddress, @@ -1856,33 +2681,181 @@ struct ModuleReferenceEvent { block_slot_time: DateTime, display_schema: String, // TODO: - // moduleReferenceRejectEvents(skip: Int take: Int): ModuleReferenceRejectEventsCollectionSegment - // moduleReferenceContractLinkEvents(skip: Int take: Int): ModuleReferenceContractLinkEventsCollectionSegment - // linkedContracts(skip: Int take: Int): LinkedContractsCollectionSegment + // moduleReferenceRejectEvents(skip: Int take: Int): + // ModuleReferenceRejectEventsCollectionSegment moduleReferenceContractLinkEvents(skip: Int + // take: Int): ModuleReferenceContractLinkEventsCollectionSegment linkedContracts(skip: Int + // take: Int): LinkedContractsCollectionSegment +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ChainUpdateEnqueued { + effective_time: DateTime, + // effective_immediately: bool, // Not sure this makes sense. + payload: bool, // ChainUpdatePayload, +} + +// union ChainUpdatePayload = MinBlockTimeUpdate | TimeoutParametersUpdate | +// FinalizationCommitteeParametersUpdate | BlockEnergyLimitUpdate | GasRewardsCpv2Update | +// ProtocolChainUpdatePayload | ElectionDifficultyChainUpdatePayload | +// EuroPerEnergyChainUpdatePayload | MicroCcdPerEuroChainUpdatePayload | +// FoundationAccountChainUpdatePayload | MintDistributionChainUpdatePayload | +// TransactionFeeDistributionChainUpdatePayload | GasRewardsChainUpdatePayload | +// BakerStakeThresholdChainUpdatePayload | RootKeysChainUpdatePayload | Level1KeysChainUpdatePayload +// | AddAnonymityRevokerChainUpdatePayload | AddIdentityProviderChainUpdatePayload | +// CooldownParametersChainUpdatePayload | PoolParametersChainUpdatePayload | +// TimeParametersChainUpdatePayload | MintDistributionV1ChainUpdatePayload +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ChainUpdatePayload { + todo: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ContractInterrupted { + contract_address: ContractAddress, + // eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements + // in the list that come after the specified cursor." after: String "Returns the last _n_ + // elements from the list." last: Int "Returns the elements in the list that come before the + // specified cursor." before: String): StringConnection events("Returns the first _n_ + // elements from the list." first: Int "Returns the elements in the list that come after the + // specified cursor." after: String "Returns the last _n_ elements from the list." last: Int + // "Returns the elements in the list that come before the specified cursor." before: String): + // StringConnection +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ContractResumed { + contract_address: ContractAddress, + success: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ContractUpdated { + contract_address: ContractAddress, + instigator: Address, + amount: Amount, + message_as_hex: String, + receive_name: String, + version: ContractVersion, + // eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements + // in the list that come after the specified cursor." after: String "Returns the last _n_ + // elements from the list." last: Int "Returns the elements in the list that come before the + // specified cursor." before: String): StringConnection events("Returns the first _n_ + // elements from the list." first: Int "Returns the elements in the list that come after the + // specified cursor." after: String "Returns the last _n_ elements from the list." last: Int + // "Returns the elements in the list that come before the specified cursor." before: String): + // StringConnection + message: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct ContractUpgraded { + contract_address: ContractAddress, + from: String, + to: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct BakerSetBakingRewardCommission { + baker_id: BakerId, + account_address: AccountAddress, + baking_reward_commission: Decimal, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct BakerSetFinalizationRewardCommission { + baker_id: BakerId, + account_address: AccountAddress, + finalization_reward_commission: Decimal, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct BakerSetTransactionFeeCommission { + baker_id: BakerId, + account_address: AccountAddress, + transaction_fee_commission: Decimal, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct BakerSetMetadataURL { + baker_id: BakerId, + account_address: AccountAddress, + metadata_url: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct BakerSetOpenStatus { + baker_id: BakerId, + account_address: AccountAddress, + open_status: BakerPoolOpenStatus, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationAdded { + delegator_id: AccountIndex, + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationRemoved { + delegator_id: AccountIndex, + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationSetDelegationTarget { + delegator_id: AccountIndex, + account_address: AccountAddress, + delegation_target: DelegationTarget, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationSetRestakeEarnings { + delegator_id: AccountIndex, + account_address: AccountAddress, + restake_earnings: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationStakeDecreased { + delegator_id: AccountIndex, + account_address: AccountAddress, + new_staked_amount: Amount, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +struct DelegationStakeIncreased { + delegator_id: AccountIndex, + account_address: AccountAddress, + new_staked_amount: Amount, } #[derive(SimpleObject)] struct BlockMetrics { - /// The most recent block height. Equals the total length of the chain minus one (genesis block is at height zero). + /// The most recent block height. Equals the total length of the chain minus one (genesis block + /// is at height zero). last_block_height: BlockHeight, /// Total number of blocks added in requested period. blocks_added: i64, - /// The average block time (slot-time difference between two adjacent blocks) in the requested period. Will be null if no blocks have been added in the requested period. + /// The average block time (slot-time difference between two adjacent blocks) in the requested + /// period. Will be null if no blocks have been added in the requested period. avg_block_time: Option, - // /// The average finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the requested period. Will be null if no blocks have been finalized in the requested period. - // avg_finalization_time: Option, + // /// The average finalization time (slot-time difference between a given block and the block + // that holds its finalization proof) in the requested period. Will be null if no blocks have + // been finalized in the requested period. avg_finalization_time: Option, // /// The current total amount of CCD in existence. // last_total_micro_ccd: Amount, - // /// The total CCD Released. This is total CCD supply not counting the balances of non circulating accounts. - // last_total_micro_ccd_released: Option, - // /// The current total CCD released according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule. - // last_total_micro_ccd_unlocked: Option, + // /// The total CCD Released. This is total CCD supply not counting the balances of non + // circulating accounts. last_total_micro_ccd_released: Option, + // /// The current total CCD released according to the Concordium promise published on + // deck.concordium.com. Will be null for blocks with slot time before the published release + // schedule. last_total_micro_ccd_unlocked: Option, // /// The current total amount of CCD in encrypted balances. // last_total_micro_ccd_encrypted: Long, // /// The current total amount of CCD staked. // last_total_micro_ccd_staked: Long, - // /// The current percentage of CCD released (of total CCD in existence) according to the Concordium promise published on deck.concordium.com. Will be null for blocks with slot time before the published release schedule." - // last_total_percentage_released: Option, + // /// The current percentage of CCD released (of total CCD in existence) according to the + // Concordium promise published on deck.concordium.com. Will be null for blocks with slot time + // before the published release schedule." last_total_percentage_released: Option, // /// The current percentage of CCD encrypted (of total CCD in existence). // last_total_percentage_encrypted: f32, // /// The current percentage of CCD staked (of total CCD in existence). @@ -1900,40 +2873,58 @@ struct BlockMetricsBuckets { /// Number of blocks added within the bucket time period. Intended y-axis value. #[graphql(name = "y_BlocksAdded")] y_blocks_added: Vec, - /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket + /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket + /// period. #[graphql(name = "y_BlockTimeMin")] y_block_time_min: Vec, - /// The average block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The average block time (slot-time difference between two adjacent blocks) in the bucket + /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket + /// period. #[graphql(name = "y_BlockTimeAvg")] y_block_time_avg: Vec, - /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket + /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket + /// period. #[graphql(name = "y_BlockTimeMax")] y_block_time_max: Vec, - /// The minimum finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + /// The minimum finalization time (slot-time difference between a given block and the block + /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be + /// null if no blocks have been finalized in the bucket period. #[graphql(name = "y_FinalizationTimeMin")] y_finalization_time_min: Vec, - /// The average finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + /// The average finalization time (slot-time difference between a given block and the block + /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be + /// null if no blocks have been finalized in the bucket period. #[graphql(name = "y_FinalizationTimeAvg")] y_finalization_time_avg: Vec, - /// The maximum finalization time (slot-time difference between a given block and the block that holds its finalization proof) in the bucket period. Intended y-axis value. Will be null if no blocks have been finalized in the bucket period. + /// The maximum finalization time (slot-time difference between a given block and the block + /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be + /// null if no blocks have been finalized in the bucket period. #[graphql(name = "y_FinalizationTimeMax")] y_finalization_time_max: Vec, - /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis value. + /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis + /// value. #[graphql(name = "y_LastTotalMicroCcd")] y_last_total_micro_ccd: Vec, - /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis + /// value. Will be null if no blocks have been added in the bucket period. #[graphql(name = "y_MinTotalMicroCcdEncrypted")] y_min_total_micro_ccd_encrypted: Vec, - /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis + /// value. Will be null if no blocks have been added in the bucket period. #[graphql(name = "y_MaxTotalMicroCcdEncrypted")] y_max_total_micro_ccd_encrypted: Vec, - /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended y-axis value. + /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended + /// y-axis value. #[graphql(name = "y_LastTotalMicroCcdEncrypted")] y_last_total_micro_ccd_encrypted: Vec, - /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be null + /// if no blocks have been added in the bucket period. #[graphql(name = "y_MinTotalMicroCcdStaked")] y_min_total_micro_ccd_staked: Vec, - /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be null if no blocks have been added in the bucket period. + /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be null + /// if no blocks have been added in the bucket period. #[graphql(name = "y_MaxTotalMicroCcdStaked")] y_max_total_micro_ccd_staked: Vec, /// The total amount of CCD staked at the end of the bucket period. Intended y-axis value. @@ -1949,3 +2940,11 @@ enum MetricsPeriod { Last30Days, LastYear, } + +#[derive(sqlx::Type)] +#[sqlx(type_name = "transaction_type")] // only for PostgreSQL to match a type definition +pub enum DbTransactionType { + Account, + CredentialDeployment, + Update, +} diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index b5594cad..f28392a4 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1,14 +1,34 @@ //! TODO: -//! - Insert genesis accounts -//! - +//! - Check endpoints are using the same chain. +use crate::graphql_api::{ + events_from_summary, + AccountTransactionType, + CredentialDeploymentTransactionType, + DbTransactionType, + UpdateTransactionType, +}; use anyhow::Context; use concordium_rust_sdk::{ - indexer::{async_trait, Indexer, TraverseConfig, TraverseError}, - types::{queries::BlockInfo, BlockItemSummary, BlockItemSummaryDetails}, - v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult}, + indexer::{ + async_trait, + Indexer, + TraverseConfig, + TraverseError, + }, + types::{ + queries::BlockInfo, + BlockItemSummary, + BlockItemSummaryDetails, + }, + v2::{ + self, + ChainParameters, + FinalizedBlockInfo, + QueryResult, + }, }; -use futures::{StreamExt, TryStreamExt}; +use futures::TryStreamExt; use sqlx::PgPool; use tokio::sync::mpsc; @@ -19,18 +39,25 @@ pub async fn traverse_chain( ) -> anyhow::Result<()> { let rec = sqlx::query!( r#" -SELECT MAX(height) as start_height FROM blocks +SELECT MAX(height) FROM blocks "# ) .fetch_one(&pool) .await?; - let start_height = rec - .start_height - .map_or(0, |height| u64::try_from(height).unwrap() + 1u64) - .into(); + let last_height_stored = rec.max; + + if last_height_stored.is_none() { + save_genesis_data(endpoints[0].clone(), &pool).await?; + } + + let start_height = if let Some(height) = last_height_stored { + u64::try_from(height).unwrap() + 1u64 + } else { + 1 + }; - let config = - TraverseConfig::new(endpoints, start_height).context("No gRPC endpoints provided")?; + let config = TraverseConfig::new(endpoints, start_height.into()) + .context("No gRPC endpoints provided")?; let indexer = BlockIndexer; println!("Indexing from {}", start_height); @@ -154,17 +181,53 @@ impl BlockData { .unwrap(); let energy_cost = i64::try_from(block_item.energy_cost.energy).unwrap(); let sender = block_item.sender_account().map(|a| a.to_string()); + let (transaction_type, account_type, credential_type, update_type) = + match &block_item.details { + BlockItemSummaryDetails::AccountTransaction(details) => { + let account_transaction_type = + details.transaction_type().map(AccountTransactionType::from); + ( + DbTransactionType::Account, + account_transaction_type, + None, + None, + ) + }, + BlockItemSummaryDetails::AccountCreation(details) => { + let credential_type = + CredentialDeploymentTransactionType::from(details.credential_type); + ( + DbTransactionType::CredentialDeployment, + None, + Some(credential_type), + None, + ) + }, + BlockItemSummaryDetails::Update(details) => { + let update_type = UpdateTransactionType::from(details.update_type()); + (DbTransactionType::Update, None, None, Some(update_type)) + }, + }; + let success = block_item.is_success(); + let details = serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; - sqlx::query!( - r#"INSERT INTO transactions (index, hash, ccd_cost, energy_cost, block, sender) -VALUES ($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6));"#, - block_index, - tx_hash, - ccd_cost, - energy_cost, - height, - sender - ) + sqlx::query( + r#"INSERT INTO transactions +(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, details) +VALUES +($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12);"#) + .bind(block_index) + .bind(tx_hash) + .bind(ccd_cost) + .bind(energy_cost) + .bind(height) + .bind(sender) + .bind(transaction_type) + .bind(account_type) + .bind(credential_type) + .bind(update_type) + .bind(success) + .bind(details) .execute(&mut *tx) .await?; @@ -180,8 +243,8 @@ VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, ) .execute(&mut *tx) .await?; - } - _ => {} + }, + _ => {}, } } @@ -191,3 +254,59 @@ VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, Ok(()) } } + +async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { + let mut client = v2::Client::new(endpoint).await?; + let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); + + let mut tx = pool + .begin() + .await + .context("Failed to create SQL transaction")?; + + let genesis_block_info = client.get_block_info(genesis_height).await?.response; + let block_hash = genesis_block_info.block_hash.to_string(); + let slot_time = genesis_block_info.block_slot_time.naive_utc(); + let finalized = genesis_block_info.finalized; + let baker_id = if let Some(index) = genesis_block_info.block_baker { + Some(i64::try_from(index.id.index)?) + } else { + None + }; + sqlx::query!( + r#"INSERT INTO blocks (height, hash, slot_time, finalized, baker_id) VALUES ($1, $2, $3, $4, $5);"#, + 0, + block_hash, + slot_time, + finalized, + baker_id + ) + .execute(&mut *tx) + .await?; + + let mut genesis_accounts = client.get_account_list(genesis_height).await?.response; + while let Some(account) = genesis_accounts.try_next().await? { + let info = client + .get_account_info(&account.into(), genesis_height) + .await? + .response; + let index = i64::try_from(info.account_index.index)?; + let account_address = account.to_string(); + let amount = i64::try_from(info.account_amount.micro_ccd)?; + + sqlx::query!( + r#"INSERT INTO accounts (index, address, created_block, amount) + VALUES ($1, $2, $3, $4)"#, + index, + account_address, + 0, + amount + ) + .execute(&mut *tx) + .await?; + } + tx.commit() + .await + .context("Failed to commit SQL transaction")?; + Ok(()) +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index 52789db4..8497cff4 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -1,17 +1,25 @@ use anyhow::Context; -use async_graphql::futures_util::lock::Mutex; -use async_graphql::http::GraphiQLSource; -use async_graphql::Schema; +use async_graphql::{ + http::GraphiQLSource, + Schema, +}; use async_graphql_axum::GraphQL; -use axum::response::{self, IntoResponse}; -use axum::{routing::get, Router}; +use axum::{ + response::{ + self, + IntoResponse, + }, + routing::get, + Router, +}; use clap::Parser; use concordium_rust_sdk::v2; use dotenv::dotenv; use sqlx::PgPool; -use std::sync::Arc; -use tokio::net::TcpListener; -use tokio::sync::mpsc; +use tokio::{ + net::TcpListener, + sync::mpsc, +}; mod graphql_api; mod indexer; @@ -22,7 +30,8 @@ pub async fn graphiql() -> impl IntoResponse { #[derive(Parser)] struct Cli { - /// The url used for the database, something of the form "postgres://postgres:example@localhost/ccd-scan" + /// The url used for the database, something of the form + /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] database_url: String, From afede030d85613ad52b1b0f58063a726b70ee8fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Tue, 18 Jun 2024 22:44:21 +0200 Subject: [PATCH 04/50] cbor, transaction query and subscription --- backend-rust/Cargo.lock | 95 +- backend-rust/Cargo.toml | 6 +- .../migrations/20240508183938_initialize.sql | 24 +- backend-rust/src/graphql_api.rs | 1361 +++++++++++++++-- backend-rust/src/indexer.rs | 30 +- backend-rust/src/main.rs | 47 +- 6 files changed, 1364 insertions(+), 199 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 861dc336..e3f683f2 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -555,19 +555,21 @@ dependencies = [ "async-graphql", "async-graphql-axum", "axum 0.7.5", - "cbor", "chrono", + "ciborium", "clap", "concordium-rust-sdk", "derive_more", "dotenv", "futures", "hex", + "rust_decimal", "serde", "serde_json", "sqlx", "thiserror", "tokio", + "tokio-stream", "tracing", "tracing-subscriber", ] @@ -712,12 +714,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fc10e8cc6b2580fda3f36eb6dc5316657f812a3df879a44a66fc9f0fdbc4855" - [[package]] name = "byteorder" version = "1.5.0" @@ -742,16 +738,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "cbor" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56053652b4b5c0ded5ae6183c7cd547ad2dd6bcce149658bef052a4995533bd" -dependencies = [ - "byteorder 0.5.3", - "rustc-serialize", -] - [[package]] name = "cc" version = "1.0.97" @@ -785,6 +771,33 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -914,7 +927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec03be6a4a56c0501cf9225d135aaa6c155228039efde27d6551814bc6776db1" dependencies = [ "anyhow", - "byteorder 1.5.0", + "byteorder", "concordium-contracts-common", "concordium-wasm", "derive_more", @@ -960,7 +973,7 @@ dependencies = [ "ark-std", "base64 0.21.7", "bs58", - "byteorder 1.5.0", + "byteorder", "cbc", "chrono", "concordium-contracts-common", @@ -1080,6 +1093,12 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" +[[package]] +name = "crunchy" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" + [[package]] name = "crypto-common" version = "0.1.6" @@ -1570,6 +1589,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "handlebars" version = "4.5.0" @@ -2120,7 +2149,7 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" dependencies = [ - "byteorder 1.5.0", + "byteorder", "lazy_static", "libm", "num-integer", @@ -2732,12 +2761,6 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" -[[package]] -name = "rustc-serialize" -version = "0.3.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe834bc780604f4674073badbad26d7219cadfb4a2275802db12cbae17498401" - [[package]] name = "rustc_version" version = "0.4.0" @@ -2932,15 +2955,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "signal-hook-registry" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" -dependencies = [ - "libc", -] - [[package]] name = "signature" version = "2.2.0" @@ -3039,7 +3053,7 @@ checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" dependencies = [ "ahash 0.8.11", "atoi", - "byteorder 1.5.0", + "byteorder", "bytes", "chrono", "crc", @@ -3119,7 +3133,7 @@ dependencies = [ "atoi", "base64 0.21.7", "bitflags 2.5.0", - "byteorder 1.5.0", + "byteorder", "bytes", "chrono", "crc", @@ -3162,7 +3176,7 @@ dependencies = [ "atoi", "base64 0.21.7", "bitflags 2.5.0", - "byteorder 1.5.0", + "byteorder", "chrono", "crc", "dotenvy", @@ -3424,9 +3438,7 @@ dependencies = [ "libc", "mio", "num_cpus", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.48.0", @@ -3462,6 +3474,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -3659,7 +3672,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" dependencies = [ - "byteorder 1.5.0", + "byteorder", "bytes", "data-encoding", "http 1.1.0", diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index fab64bab..f31a9e87 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -10,7 +10,7 @@ anyhow = "1" async-graphql = {version = "7.0", features = ["chrono", "tracing"] } async-graphql-axum = "7.0" axum = "0.7" -cbor = "0.4" +ciborium = "0.2" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } concordium-rust-sdk = "4.3" @@ -22,6 +22,8 @@ serde = "1.0" serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } thiserror = "1.0" -tokio = { version = "1.37", features = ["full"] } +tokio = { version = "1.37", features = ["rt-multi-thread", "sync"] } +tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = "0.3" +rust_decimal = "1.35" diff --git a/backend-rust/migrations/20240508183938_initialize.sql b/backend-rust/migrations/20240508183938_initialize.sql index 4879118a..fe6613d6 100644 --- a/backend-rust/migrations/20240508183938_initialize.sql +++ b/backend-rust/migrations/20240508183938_initialize.sql @@ -133,8 +133,11 @@ CREATE TABLE transactions( success BOOLEAN NOT NULL, - -- Transaction details. Events if success otherwise the reject reason. - details + -- Transaction details. Events if success is true. + events + JSONB, + -- Transaction details. Reject reason if success is false. + reject JSONB, -- Make the block height and transaction index the primary key. @@ -183,3 +186,20 @@ ALTER TABLE blocks FOREIGN KEY (baker_id) REFERENCES accounts(index); +CREATE OR REPLACE FUNCTION notify_trigger() RETURNS trigger AS $trigger$ +DECLARE + rec blocks; + payload TEXT; +BEGIN + CASE TG_OP + WHEN 'INSERT' THEN + payload := NEW.height; + PERFORM pg_notify('block_added', payload); + END CASE; + RETURN NEW; +END; +$trigger$ LANGUAGE plpgsql; + +CREATE TRIGGER user_notify AFTER INSERT OR UPDATE OR DELETE +ON blocks +FOR EACH ROW EXECUTE PROCEDURE notify_trigger(); diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 9dce80d5..42252357 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -1,3 +1,7 @@ +//! TODO +//! - Introduce default LIMITS for connections +//! - Introduce a MAX LIMIT for connections + use anyhow::Context as _; use async_graphql::{ types::{ @@ -11,11 +15,11 @@ use async_graphql::{ InputValueError, InputValueResult, Interface, - Number, Object, Scalar, ScalarType, SimpleObject, + Subscription, Union, Value, }; @@ -28,10 +32,13 @@ use sqlx::{ }; use std::{ error::Error, + str::FromStr as _, sync::Arc, }; +use tokio::sync::broadcast; const VERSION: &str = env!("CARGO_PKG_VERSION"); +const QUERY_TRANSACTIONS_LIMIT: i64 = 100; #[derive(Debug, thiserror::Error, Clone)] enum ApiError { @@ -59,6 +66,8 @@ enum ApiError { InternalError(String), #[error("Invalid integer: {0}")] InvalidInt(#[from] std::num::TryFromIntError), + #[error("Invalid integer: {0}")] + InvalidIntString(#[from] std::num::ParseIntError), } impl From for ApiError { @@ -217,18 +226,110 @@ impl Query { .await? .ok_or(ApiError::NotFound) } - async fn transactions( + async fn transactions<'a>( &self, - #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, + ctx: &Context<'a>, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] - _after: Option, - #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, #[graphql( desc = "Returns the elements in the list that come before the specified cursor." )] - _before: Option, + before: Option, ) -> ApiResult> { - todo!() + check_connection_query(&first, &last)?; + let after_id = after.as_deref().map(IdTransaction::from_str).transpose()?; + let before_id = before.as_deref().map(IdTransaction::from_str).transpose()?; + + let mut builder = sqlx::QueryBuilder::<'_, Postgres>::new(""); + if last.is_some() { + builder.push("SELECT * FROM ("); + } + builder.push("SELECT * FROM ( + SELECT + block, index, hash, ccd_cost, energy_cost, sender, type, type_account, type_credential_deployment, + type_update, success, events, reject, + LAG(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_prev, + LEAD(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_next + FROM + transactions +)"); + match (after_id, before_id) { + (None, None) => {}, + (None, Some(before_id)) => { + builder + .push(" WHERE block < ") + .push_bind(before_id.block) + .push(" OR block = ") + .push_bind(before_id.block) + .push(" AND index < ") + .push_bind(before_id.index); + }, + (Some(after_id), None) => { + builder + .push(" WHERE block > ") + .push_bind(after_id.block) + .push(" OR block = ") + .push_bind(after_id.block) + .push(" AND index > ") + .push_bind(after_id.index); + }, + (Some(after_id), Some(before_id)) => { + builder + .push(" WHERE (block > ") + .push_bind(after_id.block) + .push(" OR block = ") + .push_bind(after_id.block) + .push(" AND index > ") + .push_bind(after_id.index) + .push(") AND (block < ") + .push_bind(before_id.block) + .push(" OR block = ") + .push_bind(before_id.block) + .push(" AND index < ") + .push_bind(before_id.index) + .push(")"); + }, + } + + match (first, last) { + (None, None) => { + builder + .push(" ORDER BY block ASC, index ASC LIMIT ") + .push_bind(QUERY_TRANSACTIONS_LIMIT); + }, + (None, Some(last)) => { + builder + .push(" ORDER BY block DESC, index DESC LIMIT ") + .push_bind(last.min(QUERY_TRANSACTIONS_LIMIT)) + .push(") ORDER BY block ASC, index ASC"); + }, + (Some(first), None) => { + builder + .push(" ORDER BY block ASC, index ASC LIMIT ") + .push_bind(first.min(QUERY_TRANSACTIONS_LIMIT)); + }, + (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), + } + let mut row_stream = builder + .build_query_as::() + .fetch(get_pool(ctx)?); + let mut connection = connection::Connection::new(true, true); + let mut first_row = true; + while let Some(row) = row_stream.try_next().await? { + if first_row { + connection.has_previous_page = row.has_prev; + first_row = false; + } + connection.edges.push(connection::Edge::new( + row.transaction.id_transaction().to_string(), + row.transaction, + )); + connection.has_next_page = row.has_next; + } + + Ok(connection) } async fn account<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; @@ -423,6 +524,64 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", // ModuleReferenceEvent } +pub struct Subscription { + pub block_added: broadcast::Receiver>, +} +pub struct SubscriptionContext { + block_added_sender: broadcast::Sender>, +} +impl Subscription { + const BLOCK_ADDED_CHANNEL: &'static str = "block_added"; + + pub fn new() -> (Self, SubscriptionContext) { + let (block_added_sender, block_added) = broadcast::channel(100); + ( + Subscription { block_added }, + SubscriptionContext { block_added_sender }, + ) + } + + pub async fn handle_notifications( + context: SubscriptionContext, + pool: PgPool, + ) -> anyhow::Result<()> { + let mut listener = sqlx::postgres::PgListener::connect_with(&pool) + .await + .context("Failed to create a postgreSQL listener")?; + listener + .listen_all([Self::BLOCK_ADDED_CHANNEL]) + .await + .context("Failed to listen to postgreSQL notifications")?; + + loop { + let notification = listener.recv().await?; + match notification.channel() { + Self::BLOCK_ADDED_CHANNEL => { + let block_height = BlockHeight::from_str(notification.payload()) + .context("Failed to parse payload of block added")?; + let block = sqlx::query_as("SELECT * FROM blocks WHERE height=$1") + .bind(block_height) + .fetch_one(&pool) + .await?; + context.block_added_sender.send(Arc::new(block))?; + }, + unknown => { + anyhow::bail!("Unknown channel {}", unknown); + }, + } + } + } +} +#[Subscription] +impl Subscription { + async fn block_added( + &self, + ) -> impl Stream, tokio_stream::wrappers::errors::BroadcastStreamRecvError>> + { + tokio_stream::wrappers::BroadcastStream::new(self.block_added.resubscribe()) + } +} + /// The UnsignedLong scalar type represents a unsigned 64-bit numeric non-fractional value greater /// than or equal to 0. #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] @@ -496,26 +655,27 @@ impl ScalarType for Byte { } } -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(serde::Serialize, serde::Deserialize, derive_more::From)] #[repr(transparent)] #[serde(transparent)] -struct Decimal(f64); +struct Decimal(rust_decimal::Decimal); #[Scalar] impl ScalarType for Decimal { fn parse(value: Value) -> InputValueResult { - let Value::Number(number) = &value else { + let Value::String(string) = value else { return Err(InputValueError::expected_type(value)); }; - if let Some(v) = number.as_f64() { - Ok(Self(v)) - } else { - Err(InputValueError::expected_type(value)) - } + Ok(Self(rust_decimal::Decimal::from_str(string.as_str())?)) } fn to_value(&self) -> Value { - let number = Number::from_f64(self.0).unwrap(); - Value::Number(number) + Value::String(self.0.to_string()) + } +} + +impl From for Decimal { + fn from(fraction: concordium_rust_sdk::types::AmountFraction) -> Self { + Self(concordium_rust_sdk::types::PartsPerHundredThousands::from(fraction).into()) } } @@ -552,9 +712,9 @@ struct Versions { backend_versions: String, } -#[derive(SimpleObject, sqlx::FromRow)] +#[derive(Debug, SimpleObject, sqlx::FromRow)] #[graphql(complex)] -struct Block { +pub struct Block { #[graphql(name = "blockHash")] hash: BlockHash, #[graphql(name = "blockHeight")] @@ -660,61 +820,515 @@ impl Contract { } } -/// A segment of a collection. -#[derive(SimpleObject)] -struct TokensCollectionSegment { - /// Information to aid in pagination. - page_info: CollectionSegmentInfo, - /// A flattened list of the items. - items: Vec, - total_count: i32, +/// A segment of a collection. +#[derive(SimpleObject)] +struct TokensCollectionSegment { + /// Information to aid in pagination. + page_info: CollectionSegmentInfo, + /// A flattened list of the items. + items: Vec, + total_count: i32, +} + +/// A segment of a collection. +#[derive(SimpleObject)] +struct ContractRejectEventsCollectionSegment { + /// Information to aid in pagination. + page_info: CollectionSegmentInfo, + /// A flattened list of the items. + items: Vec, + total_count: i32, +} + +#[derive(SimpleObject)] +struct ContractRejectEvent { + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + sender: AccountAddress, + rejected_event: TransactionRejectReason, + block_height: BlockHeight, + transaction_hash: TransactionHash, + block_slot_time: DateTime, +} + +#[derive(Union, serde::Serialize, serde::Deserialize)] +pub enum TransactionRejectReason { + ModuleNotWf(ModuleNotWf), + ModuleHashAlreadyExists(ModuleHashAlreadyExists), + InvalidAccountReference(InvalidAccountReference), + InvalidInitMethod(InvalidInitMethod), + InvalidReceiveMethod(InvalidReceiveMethod), + InvalidModuleReference(InvalidModuleReference), + InvalidContractAddress(InvalidContractAddress), + RuntimeFailure(RuntimeFailure), + AmountTooLarge(AmountTooLarge), + SerializationFailure(SerializationFailure), + OutOfEnergy(OutOfEnergy), + RejectedInit(RejectedInit), + RejectedReceive(RejectedReceive), + NonExistentRewardAccount(NonExistentRewardAccount), + InvalidProof(InvalidProof), + AlreadyABaker(AlreadyABaker), + NotABaker(NotABaker), + InsufficientBalanceForBakerStake(InsufficientBalanceForBakerStake), + StakeUnderMinimumThresholdForBaking(StakeUnderMinimumThresholdForBaking), + BakerInCooldown(BakerInCooldown), + DuplicateAggregationKey(DuplicateAggregationKey), + NonExistentCredentialId(NonExistentCredentialId), + KeyIndexAlreadyInUse(KeyIndexAlreadyInUse), + InvalidAccountThreshold(InvalidAccountThreshold), + InvalidCredentialKeySignThreshold(InvalidCredentialKeySignThreshold), + InvalidEncryptedAmountTransferProof(InvalidEncryptedAmountTransferProof), + InvalidTransferToPublicProof(InvalidTransferToPublicProof), + EncryptedAmountSelfTransfer(EncryptedAmountSelfTransfer), + InvalidIndexOnEncryptedTransfer(InvalidIndexOnEncryptedTransfer), + ZeroScheduledAmount(ZeroScheduledAmount), + NonIncreasingSchedule(NonIncreasingSchedule), + FirstScheduledReleaseExpired(FirstScheduledReleaseExpired), + ScheduledSelfTransfer(ScheduledSelfTransfer), + InvalidCredentials(InvalidCredentials), + DuplicateCredIds(DuplicateCredIds), + NonExistentCredIds(NonExistentCredIds), + RemoveFirstCredential(RemoveFirstCredential), + CredentialHolderDidNotSign(CredentialHolderDidNotSign), + NotAllowedMultipleCredentials(NotAllowedMultipleCredentials), + NotAllowedToReceiveEncrypted(NotAllowedToReceiveEncrypted), + NotAllowedToHandleEncrypted(NotAllowedToHandleEncrypted), + MissingBakerAddParameters(MissingBakerAddParameters), + FinalizationRewardCommissionNotInRange(FinalizationRewardCommissionNotInRange), + BakingRewardCommissionNotInRange(BakingRewardCommissionNotInRange), + TransactionFeeCommissionNotInRange(TransactionFeeCommissionNotInRange), + AlreadyADelegator(AlreadyADelegator), + InsufficientBalanceForDelegationStake(InsufficientBalanceForDelegationStake), + MissingDelegationAddParameters(MissingDelegationAddParameters), + InsufficientDelegationStake(InsufficientDelegationStake), + DelegatorInCooldown(DelegatorInCooldown), + NotADelegator(NotADelegator), + DelegationTargetNotABaker(DelegationTargetNotABaker), + StakeOverMaximumThresholdForPool(StakeOverMaximumThresholdForPool), + PoolWouldBecomeOverDelegated(PoolWouldBecomeOverDelegated), + PoolClosed(PoolClosed), +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct ModuleNotWf { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct ModuleHashAlreadyExists { + module_ref: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidInitMethod { + module_ref: String, + init_name: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidReceiveMethod { + module_ref: String, + receive_name: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidAccountReference { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidModuleReference { + module_ref: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidContractAddress { + contract_address: ContractAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct RuntimeFailure { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct AmountTooLarge { + address: Address, + amount: Amount, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct SerializationFailure { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct OutOfEnergy { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct RejectedInit { + reject_reason: i32, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct RejectedReceive { + reject_reason: i32, + contract_address: ContractAddress, + receive_name: String, + message_as_hex: String, + // TODO message: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NonExistentRewardAccount { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidProof { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct AlreadyABaker { + baker_id: BakerId, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NotABaker { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InsufficientBalanceForBakerStake { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InsufficientBalanceForDelegationStake { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InsufficientDelegationStake { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct StakeUnderMinimumThresholdForBaking { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct StakeOverMaximumThresholdForPool { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct BakerInCooldown { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct DuplicateAggregationKey { + aggregation_key: String, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NonExistentCredentialId { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct KeyIndexAlreadyInUse { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidAccountThreshold { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidCredentialKeySignThreshold { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidEncryptedAmountTransferProof { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidTransferToPublicProof { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct EncryptedAmountSelfTransfer { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidIndexOnEncryptedTransfer { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct ZeroScheduledAmount { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NonIncreasingSchedule { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct FirstScheduledReleaseExpired { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct ScheduledSelfTransfer { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct InvalidCredentials { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct DuplicateCredIds { + cred_ids: Vec, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NonExistentCredIds { + cred_ids: Vec, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct RemoveFirstCredential { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct CredentialHolderDidNotSign { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NotAllowedMultipleCredentials { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NotAllowedToReceiveEncrypted { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NotAllowedToHandleEncrypted { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct MissingBakerAddParameters { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct FinalizationRewardCommissionNotInRange { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct BakingRewardCommissionNotInRange { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct TransactionFeeCommissionNotInRange { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct AlreadyADelegator { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, } -/// A segment of a collection. -#[derive(SimpleObject)] -struct ContractRejectEventsCollectionSegment { - /// Information to aid in pagination. - page_info: CollectionSegmentInfo, - /// A flattened list of the items. - items: Vec, - total_count: i32, +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct MissingDelegationAddParameters { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, } -#[derive(SimpleObject)] -struct ContractRejectEvent { - contract_address_index: ContractIndex, - contract_address_sub_index: ContractIndex, - sender: AccountAddress, - rejected_event: TransactionRejectReason, - block_height: BlockHeight, - transaction_hash: TransactionHash, - block_slot_time: DateTime, +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct DelegatorInCooldown { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, } -// union TransactionRejectReason = ModuleNotWf | ModuleHashAlreadyExists | InvalidAccountReference | -// InvalidInitMethod | InvalidReceiveMethod | InvalidModuleReference | InvalidContractAddress | -// RuntimeFailure | AmountTooLarge | SerializationFailure | OutOfEnergy | RejectedInit | -// RejectedReceive | NonExistentRewardAccount | InvalidProof | AlreadyABaker | NotABaker | -// InsufficientBalanceForBakerStake | StakeUnderMinimumThresholdForBaking | BakerInCooldown | -// DuplicateAggregationKey | NonExistentCredentialId | KeyIndexAlreadyInUse | -// InvalidAccountThreshold | InvalidCredentialKeySignThreshold | InvalidEncryptedAmountTransferProof -// | InvalidTransferToPublicProof | EncryptedAmountSelfTransfer | InvalidIndexOnEncryptedTransfer | -// ZeroScheduledAmount | NonIncreasingSchedule | FirstScheduledReleaseExpired | -// ScheduledSelfTransfer | InvalidCredentials | DuplicateCredIds | NonExistentCredIds | -// RemoveFirstCredential | CredentialHolderDidNotSign | NotAllowedMultipleCredentials | -// NotAllowedToReceiveEncrypted | NotAllowedToHandleEncrypted | MissingBakerAddParameters | -// FinalizationRewardCommissionNotInRange | BakingRewardCommissionNotInRange | -// TransactionFeeCommissionNotInRange | AlreadyADelegator | InsufficientBalanceForDelegationStake | -// MissingDelegationAddParameters | InsufficientDelegationStake | DelegatorInCooldown | -// NotADelegator | DelegationTargetNotABaker | StakeOverMaximumThresholdForPool | -// PoolWouldBecomeOverDelegated | PoolClosed -#[derive(Union, serde::Serialize, serde::Deserialize)] -enum TransactionRejectReason { - PoolClosed(PoolClosed), +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct NotADelegator { + account_address: AccountAddress, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct DelegationTargetNotABaker { + baker_id: BakerId, +} + +#[derive(SimpleObject, serde::Serialize, serde::Deserialize)] +pub struct PoolWouldBecomeOverDelegated { + #[graphql( + name = "_", + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + )] + dummy: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct PoolClosed { +pub struct PoolClosed { #[graphql( name = "_", deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" @@ -1130,11 +1744,11 @@ struct IdTransaction { index: TransactionIndex, } -impl TryFrom for IdTransaction { - type Error = ApiError; - fn try_from(value: types::ID) -> Result { +impl std::str::FromStr for IdTransaction { + type Err = ApiError; + + fn from_str(value: &str) -> Result { let (height_str, index_str) = value - .as_str() .split_once(':') .ok_or(ApiError::InvalidIdTransaction)?; Ok(IdTransaction { @@ -1143,12 +1757,32 @@ impl TryFrom for IdTransaction { }) } } -impl From for types::ID { - fn from(value: IdTransaction) -> Self { - types::ID::from(format!("{}:{}", value.block, value.index)) +impl TryFrom for IdTransaction { + type Error = ApiError; + fn try_from(value: types::ID) -> Result { + value.0.parse() + } +} +// impl From for types::ID { +// fn from(value: IdTransaction) -> Self { +// types::ID::from(value.to_string()) +// } +// } + +impl std::fmt::Display for IdTransaction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.block, self.index) } } +#[derive(sqlx::FromRow)] +struct TransactionConnectionQuery { + #[sqlx(flatten)] + transaction: Transaction, + has_prev: bool, + has_next: bool, +} + #[derive(SimpleObject, sqlx::FromRow)] #[graphql(complex)] struct Transaction { @@ -1173,21 +1807,15 @@ struct Transaction { #[graphql(skip)] success: bool, #[graphql(skip)] - #[sqlx(json)] - events: Option>, + events: Option>>, #[graphql(skip)] - #[sqlx(json)] - reject: Option, + reject: Option>, } #[ComplexObject] impl Transaction { /// Transaction query ID, formatted as ":". async fn id(&self) -> types::ID { - IdTransaction { - block: self.block, - index: self.index, - } - .into() + self.id_transaction().into() } async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { @@ -1239,6 +1867,14 @@ impl Transaction { } } } +impl Transaction { + fn id_transaction(&self) -> IdTransaction { + IdTransaction { + block: self.block, + index: self.index, + } + } +} #[derive(Union)] enum TransactionType { @@ -1414,16 +2050,49 @@ struct Success<'a> { impl Success<'_> { async fn events( &self, - #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i64, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] - after: String, - #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i64, + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, #[graphql( desc = "Returns the elements in the list that come before the specified cursor." )] - before: String, - ) -> ApiResult> { - todo!() + before: Option, + ) -> ApiResult> { + check_connection_query(&first, &last)?; + + let mut start = if let Some(after) = after { + usize::from_str(after.as_str())? + } else { + 0 + }; + + let mut end = if let Some(before) = before { + usize::from_str(before.as_str())? + } else { + self.events.len() + }; + + if let Some(first) = first { + let first = usize::try_from(first)?; + end = usize::min(end, start + first); + } + + if let Some(last) = last { + let last = usize::try_from(last)?; + if let Some(new_end) = end.checked_sub(last) { + start = usize::max(start, new_end); + } + } + + let mut connection = connection::Connection::new(start == 0, end == self.events.len()); + connection.edges = self.events[start..end] + .iter() + .enumerate() + .map(|(i, event)| connection::Edge::new(i.to_string(), event)) + .collect(); + + Ok(connection) } } @@ -1983,8 +2652,20 @@ enum Address { AccountAddress(AccountAddress), } +impl From for Address { + fn from(value: concordium_rust_sdk::types::Address) -> Self { + use concordium_rust_sdk::types::Address as Addr; + match value { + Addr::Account(a) => Address::AccountAddress(a.into()), + Addr::Contract(c) => Address::ContractAddress(c.into()), + } + } +} + #[derive(Union, serde::Serialize, serde::Deserialize)] pub enum Event { + /// A transfer of CCD. Can be either from an account or a smart contract instance, but the + /// receiver in this event is always an account. Transferred(Transferred), AccountCreated(AccountCreated), AmountAddedByDecryption(AmountAddedByDecryption), @@ -2034,10 +2715,9 @@ pub fn events_from_summary( let events = match value { BlockItemSummaryDetails::AccountTransaction(details) => { match details.effects { - AccountTransactionEffects::None { - transaction_type, - reject_reason, - } => todo!(), + AccountTransactionEffects::None { .. } => { + anyhow::bail!("Transaction was rejected") + }, AccountTransactionEffects::ModuleDeployed { module_ref } => { vec![Event::ContractModuleDeployed(ContractModuleDeployed { module_ref: module_ref.to_string(), @@ -2052,11 +2732,56 @@ pub fn events_from_summary( version: data.contract_version.into(), })] }, - AccountTransactionEffects::ContractUpdateIssued { effects } => todo!(), + AccountTransactionEffects::ContractUpdateIssued { effects } => { + use concordium_rust_sdk::types::ContractTraceElement; + effects + .into_iter() + .map(|effect| { + match effect { + ContractTraceElement::Updated { data } => { + Ok(Event::ContractUpdated(ContractUpdated { + contract_address: data.address.into(), + instigator: data.instigator.into(), + amount: data.amount.micro_ccd().try_into()?, + message_as_hex: hex::encode(data.message.as_ref()), + receive_name: data.receive_name.to_string(), + version: data.contract_version.into(), + // TODO message: (), + })) + }, + ContractTraceElement::Transferred { from, amount, to } => { + Ok(Event::Transferred(Transferred { + amount: amount.micro_ccd().try_into()?, + from: Address::ContractAddress(from.into()), + to: to.into(), + })) + }, + ContractTraceElement::Interrupted { address, events } => { + Ok(Event::ContractInterrupted(ContractInterrupted { + contract_address: address.into(), + })) + }, + ContractTraceElement::Resumed { address, success } => { + Ok(Event::ContractResumed(ContractResumed { + contract_address: address.into(), + success, + })) + }, + ContractTraceElement::Upgraded { address, from, to } => { + Ok(Event::ContractUpgraded(ContractUpgraded { + contract_address: address.into(), + from: from.to_string(), + to: to.to_string(), + })) + }, + } + }) + .collect::>>()? + }, AccountTransactionEffects::AccountTransfer { amount, to } => { vec![Event::Transferred(Transferred { amount: i64::try_from(amount.micro_ccd)?, - from: details.sender.into(), + from: Address::AccountAddress(details.sender.into()), to: to.into(), })] }, @@ -2064,7 +2789,7 @@ pub fn events_from_summary( vec![ Event::Transferred(Transferred { amount: i64::try_from(amount.micro_ccd)?, - from: details.sender.into(), + from: Address::AccountAddress(details.sender.into()), to: to.into(), }), Event::TransferMemo(memo.into()), @@ -2203,7 +2928,7 @@ pub fn events_from_summary( AccountTransactionEffects::DataRegistered { data } => { vec![Event::DataRegistered(DataRegistered { data_as_hex: hex::encode(data.as_ref()), - decoded: todo!(), + decoded: DecodedText::from_bytes(data.as_ref()), })] }, AccountTransactionEffects::BakerConfigured { data } => { @@ -2295,7 +3020,8 @@ pub fn events_from_summary( BakerSetTransactionFeeCommission { baker_id: baker_id.id.index.try_into()?, account_address: details.sender.into(), - transaction_fee_commission: todo!(), + transaction_fee_commission: transaction_fee_commission + .into(), }, )) }, @@ -2307,7 +3033,8 @@ pub fn events_from_summary( BakerSetBakingRewardCommission { baker_id: baker_id.id.index.try_into()?, account_address: details.sender.into(), - baking_reward_commission: todo!(), + baking_reward_commission: baking_reward_commission + .into(), }, )) }, @@ -2319,7 +3046,8 @@ pub fn events_from_summary( BakerSetFinalizationRewardCommission { baker_id: baker_id.id.index.try_into()?, account_address: details.sender.into(), - finalization_reward_commission: todo!(), + finalization_reward_commission: + finalization_reward_commission.into(), }, )) }, @@ -2414,36 +3142,366 @@ pub fn events_from_summary( Ok(events) } +impl TryFrom for TransactionRejectReason { + type Error = anyhow::Error; + + fn try_from(reason: concordium_rust_sdk::types::RejectReason) -> Result { + use concordium_rust_sdk::types::RejectReason; + match reason { + RejectReason::ModuleNotWF => { + Ok(TransactionRejectReason::ModuleNotWf(ModuleNotWf { + dummy: true, + })) + }, + RejectReason::ModuleHashAlreadyExists { contents } => { + Ok(TransactionRejectReason::ModuleHashAlreadyExists( + ModuleHashAlreadyExists { + module_ref: contents.to_string(), + }, + )) + }, + RejectReason::InvalidAccountReference { contents } => { + Ok(TransactionRejectReason::InvalidAccountReference( + InvalidAccountReference { + account_address: contents.into(), + }, + )) + }, + RejectReason::InvalidInitMethod { contents } => { + Ok(TransactionRejectReason::InvalidInitMethod( + InvalidInitMethod { + module_ref: contents.0.to_string(), + init_name: contents.1.to_string(), + }, + )) + }, + RejectReason::InvalidReceiveMethod { contents } => { + Ok(TransactionRejectReason::InvalidReceiveMethod( + InvalidReceiveMethod { + module_ref: contents.0.to_string(), + receive_name: contents.1.to_string(), + }, + )) + }, + RejectReason::InvalidModuleReference { contents } => { + Ok(TransactionRejectReason::InvalidModuleReference( + InvalidModuleReference { + module_ref: contents.to_string(), + }, + )) + }, + RejectReason::InvalidContractAddress { contents } => { + Ok(TransactionRejectReason::InvalidContractAddress( + InvalidContractAddress { + contract_address: contents.into(), + }, + )) + }, + RejectReason::RuntimeFailure => { + Ok(TransactionRejectReason::RuntimeFailure(RuntimeFailure { + dummy: true, + })) + }, + RejectReason::AmountTooLarge { contents } => { + Ok(TransactionRejectReason::AmountTooLarge(AmountTooLarge { + address: contents.0.into(), + amount: contents.1.micro_ccd().try_into()?, + })) + }, + RejectReason::SerializationFailure => { + Ok(TransactionRejectReason::SerializationFailure( + SerializationFailure { dummy: true }, + )) + }, + RejectReason::OutOfEnergy => { + Ok(TransactionRejectReason::OutOfEnergy(OutOfEnergy { + dummy: true, + })) + }, + RejectReason::RejectedInit { reject_reason } => { + Ok(TransactionRejectReason::RejectedInit(RejectedInit { + reject_reason, + })) + }, + RejectReason::RejectedReceive { + reject_reason, + contract_address, + receive_name, + parameter, + } => { + Ok(TransactionRejectReason::RejectedReceive(RejectedReceive { + reject_reason, + contract_address: contract_address.into(), + receive_name: receive_name.to_string(), + message_as_hex: hex::encode(parameter.as_ref()), + // message: todo!(), + })) + }, + RejectReason::InvalidProof => { + Ok(TransactionRejectReason::InvalidProof(InvalidProof { + dummy: true, + })) + }, + RejectReason::AlreadyABaker { contents } => { + Ok(TransactionRejectReason::AlreadyABaker(AlreadyABaker { + baker_id: contents.id.index.try_into()?, + })) + }, + RejectReason::NotABaker { contents } => { + Ok(TransactionRejectReason::NotABaker(NotABaker { + account_address: contents.into(), + })) + }, + RejectReason::InsufficientBalanceForBakerStake => { + Ok(TransactionRejectReason::InsufficientBalanceForBakerStake( + InsufficientBalanceForBakerStake { dummy: true }, + )) + }, + RejectReason::StakeUnderMinimumThresholdForBaking => { + Ok( + TransactionRejectReason::StakeUnderMinimumThresholdForBaking( + StakeUnderMinimumThresholdForBaking { dummy: true }, + ), + ) + }, + RejectReason::BakerInCooldown => { + Ok(TransactionRejectReason::BakerInCooldown(BakerInCooldown { + dummy: true, + })) + }, + RejectReason::DuplicateAggregationKey { contents } => { + Ok(TransactionRejectReason::DuplicateAggregationKey( + DuplicateAggregationKey { + aggregation_key: serde_json::to_string(&contents)?, + }, + )) + }, + RejectReason::NonExistentCredentialID => { + Ok(TransactionRejectReason::NonExistentCredentialId( + NonExistentCredentialId { dummy: true }, + )) + }, + RejectReason::KeyIndexAlreadyInUse => { + Ok(TransactionRejectReason::KeyIndexAlreadyInUse( + KeyIndexAlreadyInUse { dummy: true }, + )) + }, + RejectReason::InvalidAccountThreshold => { + Ok(TransactionRejectReason::InvalidAccountThreshold( + InvalidAccountThreshold { dummy: true }, + )) + }, + RejectReason::InvalidCredentialKeySignThreshold => { + Ok(TransactionRejectReason::InvalidCredentialKeySignThreshold( + InvalidCredentialKeySignThreshold { dummy: true }, + )) + }, + RejectReason::InvalidEncryptedAmountTransferProof => { + Ok( + TransactionRejectReason::InvalidEncryptedAmountTransferProof( + InvalidEncryptedAmountTransferProof { dummy: true }, + ), + ) + }, + RejectReason::InvalidTransferToPublicProof => { + Ok(TransactionRejectReason::InvalidTransferToPublicProof( + InvalidTransferToPublicProof { dummy: true }, + )) + }, + RejectReason::EncryptedAmountSelfTransfer { contents } => { + Ok(TransactionRejectReason::EncryptedAmountSelfTransfer( + EncryptedAmountSelfTransfer { + account_address: contents.into(), + }, + )) + }, + RejectReason::InvalidIndexOnEncryptedTransfer => { + Ok(TransactionRejectReason::InvalidIndexOnEncryptedTransfer( + InvalidIndexOnEncryptedTransfer { dummy: true }, + )) + }, + RejectReason::ZeroScheduledAmount => { + Ok(TransactionRejectReason::ZeroScheduledAmount( + ZeroScheduledAmount { dummy: true }, + )) + }, + RejectReason::NonIncreasingSchedule => { + Ok(TransactionRejectReason::NonIncreasingSchedule( + NonIncreasingSchedule { dummy: true }, + )) + }, + RejectReason::FirstScheduledReleaseExpired => { + Ok(TransactionRejectReason::FirstScheduledReleaseExpired( + FirstScheduledReleaseExpired { dummy: true }, + )) + }, + RejectReason::ScheduledSelfTransfer { contents } => { + Ok(TransactionRejectReason::ScheduledSelfTransfer( + ScheduledSelfTransfer { + account_address: contents.into(), + }, + )) + }, + RejectReason::InvalidCredentials => { + Ok(TransactionRejectReason::InvalidCredentials( + InvalidCredentials { dummy: true }, + )) + }, + RejectReason::DuplicateCredIDs { contents } => { + Ok(TransactionRejectReason::DuplicateCredIds( + DuplicateCredIds { + cred_ids: contents + .into_iter() + .map(|cred_id| cred_id.to_string()) + .collect(), + }, + )) + }, + RejectReason::NonExistentCredIDs { contents } => { + Ok(TransactionRejectReason::NonExistentCredIds( + NonExistentCredIds { + cred_ids: contents + .into_iter() + .map(|cred_id| cred_id.to_string()) + .collect(), + }, + )) + }, + RejectReason::RemoveFirstCredential => { + Ok(TransactionRejectReason::RemoveFirstCredential( + RemoveFirstCredential { dummy: true }, + )) + }, + RejectReason::CredentialHolderDidNotSign => { + Ok(TransactionRejectReason::CredentialHolderDidNotSign( + CredentialHolderDidNotSign { dummy: true }, + )) + }, + RejectReason::NotAllowedMultipleCredentials => { + Ok(TransactionRejectReason::NotAllowedMultipleCredentials( + NotAllowedMultipleCredentials { dummy: true }, + )) + }, + RejectReason::NotAllowedToReceiveEncrypted => { + Ok(TransactionRejectReason::NotAllowedToReceiveEncrypted( + NotAllowedToReceiveEncrypted { dummy: true }, + )) + }, + RejectReason::NotAllowedToHandleEncrypted => { + Ok(TransactionRejectReason::NotAllowedToHandleEncrypted( + NotAllowedToHandleEncrypted { dummy: true }, + )) + }, + RejectReason::MissingBakerAddParameters => { + Ok(TransactionRejectReason::MissingBakerAddParameters( + MissingBakerAddParameters { dummy: true }, + )) + }, + RejectReason::FinalizationRewardCommissionNotInRange => { + Ok( + TransactionRejectReason::FinalizationRewardCommissionNotInRange( + FinalizationRewardCommissionNotInRange { dummy: true }, + ), + ) + }, + RejectReason::BakingRewardCommissionNotInRange => { + Ok(TransactionRejectReason::BakingRewardCommissionNotInRange( + BakingRewardCommissionNotInRange { dummy: true }, + )) + }, + RejectReason::TransactionFeeCommissionNotInRange => { + Ok(TransactionRejectReason::TransactionFeeCommissionNotInRange( + TransactionFeeCommissionNotInRange { dummy: true }, + )) + }, + RejectReason::AlreadyADelegator => { + Ok(TransactionRejectReason::AlreadyADelegator( + AlreadyADelegator { dummy: true }, + )) + }, + RejectReason::InsufficientBalanceForDelegationStake => { + Ok( + TransactionRejectReason::InsufficientBalanceForDelegationStake( + InsufficientBalanceForDelegationStake { dummy: true }, + ), + ) + }, + RejectReason::MissingDelegationAddParameters => { + Ok(TransactionRejectReason::MissingDelegationAddParameters( + MissingDelegationAddParameters { dummy: true }, + )) + }, + RejectReason::InsufficientDelegationStake => { + Ok(TransactionRejectReason::InsufficientDelegationStake( + InsufficientDelegationStake { dummy: true }, + )) + }, + RejectReason::DelegatorInCooldown => { + Ok(TransactionRejectReason::DelegatorInCooldown( + DelegatorInCooldown { dummy: true }, + )) + }, + RejectReason::NotADelegator { address } => { + Ok(TransactionRejectReason::NotADelegator(NotADelegator { + account_address: address.into(), + })) + }, + RejectReason::DelegationTargetNotABaker { target } => { + Ok(TransactionRejectReason::DelegationTargetNotABaker( + DelegationTargetNotABaker { + baker_id: target.id.index.try_into()?, + }, + )) + }, + RejectReason::StakeOverMaximumThresholdForPool => { + Ok(TransactionRejectReason::StakeOverMaximumThresholdForPool( + StakeOverMaximumThresholdForPool { dummy: true }, + )) + }, + RejectReason::PoolWouldBecomeOverDelegated => { + Ok(TransactionRejectReason::PoolWouldBecomeOverDelegated( + PoolWouldBecomeOverDelegated { dummy: true }, + )) + }, + RejectReason::PoolClosed => { + Ok(TransactionRejectReason::PoolClosed(PoolClosed { + dummy: true, + })) + }, + } + } +} + impl From for TransferMemo { fn from(value: concordium_rust_sdk::types::Memo) -> Self { TransferMemo { - decoded: todo!(), + decoded: DecodedText::from_bytes(value.as_ref()), raw_hex: hex::encode(value.as_ref()), } } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct Transferred { +pub struct Transferred { amount: Amount, - from: AccountAddress, + from: Address, to: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct AccountCreated { +pub struct AccountCreated { account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct AmountAddedByDecryption { +pub struct AmountAddedByDecryption { amount: Amount, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerAdded { +pub struct BakerAdded { staked_amount: Amount, restake_earnings: bool, baker_id: BakerId, @@ -2460,7 +3518,7 @@ impl BakerAdded { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerKeysUpdated { +pub struct BakerKeysUpdated { baker_id: BakerId, sign_key: String, election_key: String, @@ -2475,7 +3533,7 @@ impl BakerKeysUpdated { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerRemoved { +pub struct BakerRemoved { baker_id: BakerId, } #[ComplexObject] @@ -2487,7 +3545,7 @@ impl BakerRemoved { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerSetRestakeEarnings { +pub struct BakerSetRestakeEarnings { baker_id: BakerId, restake_earnings: bool, } @@ -2500,7 +3558,7 @@ impl BakerSetRestakeEarnings { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerStakeDecreased { +pub struct BakerStakeDecreased { baker_id: BakerId, new_staked_amount: Amount, } @@ -2513,7 +3571,7 @@ impl BakerStakeDecreased { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] -struct BakerStakeIncreased { +pub struct BakerStakeIncreased { baker_id: BakerId, new_staked_amount: Amount, } @@ -2525,7 +3583,7 @@ impl BakerStakeIncreased { } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractInitialized { +pub struct ContractInitialized { module_ref: String, contract_address: ContractAddress, amount: Amount, @@ -2542,7 +3600,7 @@ struct ContractInitialized { } #[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -enum ContractVersion { +pub enum ContractVersion { V0, V1, } @@ -2558,28 +3616,28 @@ impl From for Contract } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractModuleDeployed { +pub struct ContractModuleDeployed { module_ref: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractCall { +pub struct ContractCall { contract_updated: ContractUpdated, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct CredentialDeployed { +pub struct CredentialDeployed { reg_id: String, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct CredentialKeysUpdated { +pub struct CredentialKeysUpdated { cred_id: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct CredentialsUpdated { +pub struct CredentialsUpdated { account_address: AccountAddress, new_cred_ids: Vec, removed_cred_ids: Vec, @@ -2587,25 +3645,42 @@ struct CredentialsUpdated { } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DataRegistered { +pub struct DataRegistered { decoded: DecodedText, data_as_hex: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DecodedText { +pub struct DecodedText { text: String, decode_type: TextDecodeType, } +impl DecodedText { + /// Attempt to parse the bytes as a CBOR string otherwise use HEX to present the bytes. + fn from_bytes(bytes: &[u8]) -> Self { + if let Ok(text) = ciborium::from_reader::(bytes) { + Self { + text, + decode_type: TextDecodeType::Cbor, + } + } else { + Self { + text: hex::encode(bytes), + decode_type: TextDecodeType::Hex, + } + } + } +} + #[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -enum TextDecodeType { +pub enum TextDecodeType { Cbor, Hex, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct EncryptedAmountsRemoved { +pub struct EncryptedAmountsRemoved { account_address: AccountAddress, new_encrypted_amount: String, input_amount: String, @@ -2628,14 +3703,14 @@ impl TryFrom for Encryp } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct EncryptedSelfAmountAdded { +pub struct EncryptedSelfAmountAdded { account_address: AccountAddress, new_encrypted_amount: String, amount: Amount, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct NewEncryptedAmount { +pub struct NewEncryptedAmount { account_address: AccountAddress, new_index: u64, encrypted_amount: String, @@ -2656,13 +3731,13 @@ impl TryFrom for NewEncrypt } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct TransferMemo { +pub struct TransferMemo { decoded: DecodedText, raw_hex: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct TransferredWithSchedule { +pub struct TransferredWithSchedule { from_account_address: AccountAddress, to_account_address: AccountAddress, total_amount: Amount, @@ -2673,7 +3748,7 @@ struct TransferredWithSchedule { } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ModuleReferenceEvent { +pub struct ModuleReferenceEvent { module_reference: String, sender: AccountAddress, block_height: BlockHeight, @@ -2688,7 +3763,7 @@ struct ModuleReferenceEvent { } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ChainUpdateEnqueued { +pub struct ChainUpdateEnqueued { effective_time: DateTime, // effective_immediately: bool, // Not sure this makes sense. payload: bool, // ChainUpdatePayload, @@ -2705,12 +3780,12 @@ struct ChainUpdateEnqueued { // CooldownParametersChainUpdatePayload | PoolParametersChainUpdatePayload | // TimeParametersChainUpdatePayload | MintDistributionV1ChainUpdatePayload #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ChainUpdatePayload { +pub struct ChainUpdatePayload { todo: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractInterrupted { +pub struct ContractInterrupted { contract_address: ContractAddress, // eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements // in the list that come after the specified cursor." after: String "Returns the last _n_ @@ -2723,13 +3798,13 @@ struct ContractInterrupted { } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractResumed { +pub struct ContractResumed { contract_address: ContractAddress, success: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractUpdated { +pub struct ContractUpdated { contract_address: ContractAddress, instigator: Address, amount: Amount, @@ -2744,86 +3819,86 @@ struct ContractUpdated { // specified cursor." after: String "Returns the last _n_ elements from the list." last: Int // "Returns the elements in the list that come before the specified cursor." before: String): // StringConnection - message: String, + // TODO message: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct ContractUpgraded { +pub struct ContractUpgraded { contract_address: ContractAddress, from: String, to: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct BakerSetBakingRewardCommission { +pub struct BakerSetBakingRewardCommission { baker_id: BakerId, account_address: AccountAddress, baking_reward_commission: Decimal, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct BakerSetFinalizationRewardCommission { +pub struct BakerSetFinalizationRewardCommission { baker_id: BakerId, account_address: AccountAddress, finalization_reward_commission: Decimal, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct BakerSetTransactionFeeCommission { +pub struct BakerSetTransactionFeeCommission { baker_id: BakerId, account_address: AccountAddress, transaction_fee_commission: Decimal, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct BakerSetMetadataURL { +pub struct BakerSetMetadataURL { baker_id: BakerId, account_address: AccountAddress, metadata_url: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct BakerSetOpenStatus { +pub struct BakerSetOpenStatus { baker_id: BakerId, account_address: AccountAddress, open_status: BakerPoolOpenStatus, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationAdded { +pub struct DelegationAdded { delegator_id: AccountIndex, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationRemoved { +pub struct DelegationRemoved { delegator_id: AccountIndex, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationSetDelegationTarget { +pub struct DelegationSetDelegationTarget { delegator_id: AccountIndex, account_address: AccountAddress, delegation_target: DelegationTarget, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationSetRestakeEarnings { +pub struct DelegationSetRestakeEarnings { delegator_id: AccountIndex, account_address: AccountAddress, restake_earnings: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationStakeDecreased { +pub struct DelegationStakeDecreased { delegator_id: AccountIndex, account_address: AccountAddress, new_staked_amount: Amount, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] -struct DelegationStakeIncreased { +pub struct DelegationStakeIncreased { delegator_id: AccountIndex, account_address: AccountAddress, new_staked_amount: Amount, diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index f28392a4..63b4b31d 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -18,6 +18,8 @@ use concordium_rust_sdk::{ }, types::{ queries::BlockInfo, + AccountTransactionDetails, + AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, }, @@ -209,13 +211,32 @@ impl BlockData { }, }; let success = block_item.is_success(); - let details = serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; + let (events, reject) = if success { + let events = + serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; + (Some(events), None) + } else { + let reject = if let BlockItemSummaryDetails::AccountTransaction( + AccountTransactionDetails { + effects: AccountTransactionEffects::None { reject_reason, .. }, + .. + }, + ) = &block_item.details + { + serde_json::to_value(crate::graphql_api::TransactionRejectReason::try_from( + reject_reason.clone(), + )?)? + } else { + anyhow::bail!("Invariant violation: Failed transaction without a reject reason") + }; + (None, Some(reject)) + }; sqlx::query( r#"INSERT INTO transactions -(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, details) +(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, events, reject) VALUES -($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12);"#) +($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12, $13);"#) .bind(block_index) .bind(tx_hash) .bind(ccd_cost) @@ -227,7 +248,8 @@ VALUES .bind(credential_type) .bind(update_type) .bind(success) - .bind(details) + .bind(events) + .bind(reject) .execute(&mut *tx) .await?; diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index 8497cff4..ef6797e6 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -3,7 +3,10 @@ use async_graphql::{ http::GraphiQLSource, Schema, }; -use async_graphql_axum::GraphQL; +use async_graphql_axum::{ + GraphQL, + GraphQLSubscription, +}; use axum::{ response::{ self, @@ -16,16 +19,25 @@ use clap::Parser; use concordium_rust_sdk::v2; use dotenv::dotenv; use sqlx::PgPool; +use std::path::PathBuf; use tokio::{ net::TcpListener, - sync::mpsc, + sync::{ + broadcast, + mpsc, + }, }; mod graphql_api; mod indexer; pub async fn graphiql() -> impl IntoResponse { - response::Html(GraphiQLSource::build().endpoint("/").finish()) + response::Html( + GraphiQLSource::build() + .endpoint("/") + .subscription_endpoint("/ws") + .finish(), + ) } #[derive(Parser)] @@ -42,6 +54,9 @@ struct Cli { /// Whether to run the indexer. #[arg(long)] indexer: bool, + + #[arg(long)] + schema_out: Option, } #[tokio::main] @@ -50,7 +65,7 @@ async fn main() -> anyhow::Result<()> { let cli = Cli::parse(); tracing_subscriber::fmt() - .with_max_level(tracing::Level::DEBUG) + .with_max_level(tracing::Level::INFO) .init(); let pool = PgPool::connect(&cli.database_url) @@ -76,18 +91,36 @@ async fn main() -> anyhow::Result<()> { } } + let (subscription, subscription_context) = graphql_api::Subscription::new(); + { + let pool = pool.clone(); + tokio::spawn(async move { + graphql_api::Subscription::handle_notifications(subscription_context, pool) + .await + .expect("PostgreSQL notification task failed") + }); + } + let schema = Schema::build( graphql_api::Query, async_graphql::EmptyMutation, - async_graphql::EmptySubscription, + subscription, ) .extension(async_graphql::extensions::Tracing) .data(pool) .finish(); - println!("Schema: \n{}", schema.sdl()); + if let Some(schema_file) = cli.schema_out { + println!("Writing schema to {}", schema_file.to_string_lossy()); + std::fs::write(schema_file, schema.sdl()).context("Failed to write schema")?; + } - let app = Router::new().route("/", get(graphiql).post_service(GraphQL::new(schema))); + let app = Router::new() + .route( + "/", + get(graphiql).post_service(GraphQL::new(schema.clone())), + ) + .route_service("/ws", GraphQLSubscription::new(schema)); println!("Server is running at http://localhost:8000"); axum::serve( From 5951d47d5aec6e9cfb8d3c0ca1eb510c1354bfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Sun, 15 Sep 2024 21:37:19 +0200 Subject: [PATCH 05/50] block metrics and more --- backend-rust/Cargo.lock | 11 + backend-rust/Cargo.toml | 1 + backend-rust/README.md | 25 +- .../migrations/20240508183938_initialize.sql | 33 +- backend-rust/src/graphql_api.rs | 322 ++++++++++++------ backend-rust/src/indexer.rs | 182 +++++++--- backend-rust/src/main.rs | 40 ++- 7 files changed, 452 insertions(+), 162 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index e3f683f2..ddbe7302 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -563,6 +563,7 @@ dependencies = [ "dotenv", "futures", "hex", + "iso8601-duration", "rust_decimal", "serde", "serde_json", @@ -1926,6 +1927,16 @@ version = "1.70.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" +[[package]] +name = "iso8601-duration" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26adff60a5d3ca10dc271ad37a34ff376595d2a1e5f21d02564929ca888c511" +dependencies = [ + "chrono", + "nom", +] + [[package]] name = "itertools" version = "0.10.5" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index f31a9e87..6445b4ce 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -27,3 +27,4 @@ tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = "0.3" rust_decimal = "1.35" +iso8601-duration = { version = "0.2", features = ["chrono"] } diff --git a/backend-rust/README.md b/backend-rust/README.md index b8f4c603..6d43bca3 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -1,12 +1,26 @@ +# CCDScan Backend +This is the backend for [CCDScan](https://ccdscan.io/) Blockchain explorer for the [Concordium blockchain](https://concordium.com/). -## Setup + +## Setup for development Install PostgreSQL server 16 or run `docker-compose up`. Install `sqlx-cli` -Create database using connection defined in `.env`: +``` +cargo install sqlx-cli +``` + +Create a `.env` file in this directory: + +``` +# Postgres database connection used by sqlx-cli and this service. +DATABASE_URL=postgres://postgres:example@localhost/ccd-scan +``` + +Create the database ``` sqlx database create @@ -17,3 +31,10 @@ Setup tables: ``` sqlx migrate run ``` + + +## Run the backend + + + +TODO diff --git a/backend-rust/migrations/20240508183938_initialize.sql b/backend-rust/migrations/20240508183938_initialize.sql index fe6613d6..0ca0aa57 100644 --- a/backend-rust/migrations/20240508183938_initialize.sql +++ b/backend-rust/migrations/20240508183938_initialize.sql @@ -76,15 +76,33 @@ CREATE TABLE blocks( slot_time TIMESTAMP NOT NULL, - -- Whether the block is finalized. - finalized - BOOLEAN + -- Milliseconds between the slot_time of this block and the block below (height - 1). + -- For the genesis block it will be 0. + block_time + INTEGER NOT NULL, + -- Milliseconds between the slot_time of this block and the block above causing this block to be finalized. + -- This is NULL until the indexer have processed the block marking this a finalized. + finalization_time + INTEGER, + -- Block causing this block to become finalized. + -- This is NULL until the indexer have processed the block marking this a finalized. + finalized_by + BIGINT + REFERENCES blocks(height), -- Index of the account which baked the block. -- For non-genesis blocks this should always be defined. -- Foreign key constraint added later, since account table is not defined yet. baker_id + BIGINT, + -- The total amount of CCD in existence at the time of this block was created in micro CCD. + total_amount + BIGINT + NOT NULL, + -- The total staked amount of CCD at the time of this block was created in micro CCD. + total_staked BIGINT + NOT NULL ); -- Every transaction on chain. @@ -165,7 +183,7 @@ CREATE TABLE accounts( -- Only NULL for genesis accounts created_index BIGINT, - -- The total balance of this account. + -- The total balance of this account in micro CCD. amount BIGINT NOT NULL, @@ -192,9 +210,10 @@ DECLARE payload TEXT; BEGIN CASE TG_OP - WHEN 'INSERT' THEN - payload := NEW.height; - PERFORM pg_notify('block_added', payload); + WHEN 'INSERT' THEN + payload := NEW.height; + PERFORM pg_notify('block_added', payload); + ELSE NULL; END CASE; RETURN NEW; END; diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 42252357..70b52b89 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -38,6 +38,8 @@ use std::{ use tokio::sync::broadcast; const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The most transactions which can be queried at once. const QUERY_TRANSACTIONS_LIMIT: i64 = 100; #[derive(Debug, thiserror::Error, Clone)] @@ -50,8 +52,8 @@ enum ApiError { FailedDatabaseQuery(Arc), #[error("Invalid ID format: {0}")] InvalidIdInt(std::num::ParseIntError), - #[error("Invalid ID format: {0}")] - InvalidIdIntSize(std::num::TryFromIntError), + // #[error("Invalid ID format: {0}")] + // InvalidIdIntSize(std::num::TryFromIntError), #[error("Invalid ID for transaction, must be of the format 'block:index'")] InvalidIdTransaction, #[error("The period cannot be converted")] @@ -366,11 +368,27 @@ impl Query { ) -> ApiResult> { check_connection_query(&first, &last)?; - let mut builder = - sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM accounts"); - // TODO: include sort and filter + let mut builder = sqlx::QueryBuilder::<'_, Postgres>::new(""); + if last.is_some() { + builder.push("SELECT * FROM ("); + } + + builder.push( + "SELECT * FROM ( + SELECT + index, + created_block, + created_index, + address, + amount, + LAG(TRUE, 1, FALSE) OVER (ORDER BY index ASC) as has_prev, + LEAD(TRUE, 1, FALSE) OVER (ORDER BY index ASC) as has_next + FROM accounts +)", + ); + match (after, before) { (None, None) => {}, (None, Some(before)) => { @@ -394,7 +412,7 @@ impl Query { match (first, &last) { (None, None) => { - builder.push(" ORDER BY index ASC)"); + builder.push(" ORDER BY index ASC"); }, (None, Some(last)) => { builder @@ -403,30 +421,27 @@ impl Query { .push(") ORDER BY index ASC "); }, (Some(first), None) => { - builder - .push(" ORDER BY index ASC LIMIT ") - .push_bind(first) - .push(")"); + builder.push(" ORDER BY index ASC LIMIT ").push_bind(first); }, (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } - let mut row_stream = builder.build_query_as::().fetch(get_pool(ctx)?); + let mut row_stream = builder + .build_query_as::() + .fetch(get_pool(ctx)?); let mut connection = connection::Connection::new(true, true); + let mut first_row = true; while let Some(row) = row_stream.try_next().await? { - connection - .edges - .push(connection::Edge::new(row.index.to_string(), row)); - } - if last.is_some() { - if let Some(edge) = connection.edges.last() { - connection.has_previous_page = edge.node.index != 0; - } - } else { - if let Some(edge) = connection.edges.first() { - connection.has_previous_page = edge.node.index != 0; + if first_row { + connection.has_previous_page = row.has_prev; + first_row = false; } + connection.edges.push(connection::Edge::new( + row.account.index.to_string(), + row.account, + )); + connection.has_next_page = row.has_next; } Ok(connection) @@ -463,23 +478,16 @@ impl Query { period: MetricsPeriod, ) -> ApiResult { let pool = get_pool(ctx)?; - - let queried_period: Duration = match period { - MetricsPeriod::LastHour => Duration::hours(1), - MetricsPeriod::Last24Hours => Duration::hours(24), - MetricsPeriod::Last7Days => Duration::days(7), - MetricsPeriod::Last30Days => Duration::days(30), - MetricsPeriod::LastYear => Duration::days(364), - }; - - let interval: PgInterval = queried_period + let interval: PgInterval = period + .as_duration() .try_into() .map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; let rec = sqlx::query!( "SELECT MAX(height) as last_block_height, -COUNT(1) as blocks_added, -(MAX(slot_time) - MIN(slot_time)) / (COUNT(1) - 1) as avg_block_time +COUNT(*) as blocks_added, +(MAX(slot_time) - MIN(slot_time)) / (COUNT(*) - 1) as avg_block_time, +(MAX(total_amount)) as last_total_micro_ccd FROM blocks WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", interval @@ -487,11 +495,69 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", .fetch_one(pool) .await?; + let bucket_width = period.bucket_width(); + let bucket_interval: PgInterval = bucket_width + .try_into() + .map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; + let res = sqlx::query!( + " +WITH data AS ( + SELECT + date_bin($1::interval, slot_time, TIMESTAMP '2001-01-01') as time, + block_time, + finalization_time, + LAST_VALUE(total_staked) OVER ( + PARTITION BY date_bin($1::interval, slot_time, TIMESTAMP '2001-01-01') + ORDER BY height ASC + ) as total_staked + FROM blocks + ORDER BY height +) +SELECT + time, + COUNT(*) as y_blocks_added, + AVG(block_time)::integer as y_block_time_avg, + AVG(finalization_time)::integer as y_finalization_time_avg, + MAX(total_staked) as y_last_total_micro_ccd_staked +FROM data +GROUP BY time +LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) + bucket_interval + ) + .fetch_all(pool) + .await?; + + let mut buckets = BlockMetricsBuckets { + bucket_width: bucket_width.into(), + x_time: Vec::new(), + y_blocks_added: Vec::new(), + y_block_time_avg: Vec::new(), + y_finalization_time_avg: Vec::new(), + y_last_total_micro_ccd_staked: Vec::new(), + }; + for row in res { + buckets.x_time.push(row.time.ok_or(ApiError::InternalError( + "Unexpected missing time for bucket".to_string(), + ))?); + buckets.y_blocks_added.push(row.y_blocks_added.unwrap_or(0)); + let y_block_time_avg = row.y_block_time_avg.unwrap_or(0) as f64 / 1000.0; + buckets.y_block_time_avg.push(y_block_time_avg); + let y_finalization_time_avg = row.y_finalization_time_avg.unwrap_or(0) as f64 / 1000.0; + buckets + .y_finalization_time_avg + .push(y_finalization_time_avg); + buckets + .y_last_total_micro_ccd_staked + .push(row.y_last_total_micro_ccd_staked.unwrap_or(0)); + } + Ok(BlockMetrics { last_block_height: rec.last_block_height.unwrap_or(0), blocks_added: rec.blocks_added.unwrap_or(0), - avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64), + avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64 / 1000.0), + last_total_micro_ccd: rec.last_total_micro_ccd.unwrap_or(0), // TODO check what format this is expected to be in. + buckets, }) } @@ -680,18 +746,43 @@ impl From for Decimal { } /// The `TimeSpan` scalar represents an ISO-8601 compliant duration type. -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(serde::Serialize, serde::Deserialize, Clone)] #[repr(transparent)] -#[serde(transparent)] -struct TimeSpan(String); +#[serde(try_from = "String", into = "String")] +struct TimeSpan(Duration); #[Scalar] impl ScalarType for TimeSpan { fn parse(value: Value) -> InputValueResult { - todo!() + let Value::String(string) = value else { + return Err(InputValueError::expected_type(value)); + }; + Ok(Self::try_from(string)?) } fn to_value(&self) -> Value { - todo!() + Value::String(self.0.to_string()) + } +} +impl TryFrom for TimeSpan { + type Error = anyhow::Error; + fn try_from(value: String) -> Result { + let duration = iso8601_duration::Duration::from_str(value.as_str()) + .map_err(|err| anyhow::anyhow!("Invalid duration, expected ISO-8601"))?; + Ok(Self( + duration + .to_chrono() + .context("Failed to construct duration")?, + )) + } +} +impl From for String { + fn from(time: TimeSpan) -> Self { + time.0.to_string() + } +} +impl From for TimeSpan { + fn from(duration: Duration) -> Self { + TimeSpan(duration) } } @@ -722,9 +813,17 @@ pub struct Block { /// Time of the block being baked. #[graphql(name = "blockSlotTime")] slot_time: DateTime, + #[graphql(skip)] + block_time: i32, + #[graphql(skip)] + finalization_time: Option, + #[graphql(skip)] + finalized_by: Option, baker_id: Option, - finalized: bool, - // chain_parameters: ChainParameters, + total_amount: Amount, + #[graphql(skip)] + total_staked: Amount, + // chain_parameters: ChainParameters, // balance_statistics: BalanceStatistics, // block_statistics: BlockStatistics, } @@ -2101,6 +2200,14 @@ struct Rejected<'a> { reason: &'a TransactionRejectReason, } +#[derive(sqlx::FromRow)] +struct AccountConnectionQuery { + #[sqlx(flatten)] + account: Account, + has_prev: bool, + has_next: bool, +} + #[derive(SimpleObject, sqlx::FromRow)] #[graphql(complex)] struct Account { @@ -3915,10 +4022,11 @@ struct BlockMetrics { /// period. Will be null if no blocks have been added in the requested period. avg_block_time: Option, // /// The average finalization time (slot-time difference between a given block and the block - // that holds its finalization proof) in the requested period. Will be null if no blocks have - // been finalized in the requested period. avg_finalization_time: Option, - // /// The current total amount of CCD in existence. - // last_total_micro_ccd: Amount, + // /// that holds its finalization proof) in the requested period. Will be null if no blocks + // have /// been finalized in the requested period. + // avg_finalization_time: Option, + /// The current total amount of CCD in existence. + last_total_micro_ccd: Amount, // /// The total CCD Released. This is total CCD supply not counting the balances of non // circulating accounts. last_total_micro_ccd_released: Option, // /// The current total CCD released according to the Concordium promise published on @@ -3935,7 +4043,7 @@ struct BlockMetrics { // last_total_percentage_encrypted: f32, // /// The current percentage of CCD staked (of total CCD in existence). // last_total_percentage_staked: f32, - // buckets: BlockMetricsBuckets, + buckets: BlockMetricsBuckets, } #[derive(SimpleObject)] @@ -3947,64 +4055,64 @@ struct BlockMetricsBuckets { x_time: Vec, /// Number of blocks added within the bucket time period. Intended y-axis value. #[graphql(name = "y_BlocksAdded")] - y_blocks_added: Vec, - /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket - /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket - /// period. - #[graphql(name = "y_BlockTimeMin")] - y_block_time_min: Vec, + y_blocks_added: Vec, + // /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket + // /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket + // /// period. + // #[graphql(name = "y_BlockTimeMin")] + // y_block_time_min: Vec, /// The average block time (slot-time difference between two adjacent blocks) in the bucket /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket /// period. #[graphql(name = "y_BlockTimeAvg")] - y_block_time_avg: Vec, - /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket - /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket - /// period. - #[graphql(name = "y_BlockTimeMax")] - y_block_time_max: Vec, - /// The minimum finalization time (slot-time difference between a given block and the block - /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be - /// null if no blocks have been finalized in the bucket period. - #[graphql(name = "y_FinalizationTimeMin")] - y_finalization_time_min: Vec, + y_block_time_avg: Vec, + // /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket + // /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket + // /// period. + // #[graphql(name = "y_BlockTimeMax")] + // y_block_time_max: Vec, + // /// The minimum finalization time (slot-time difference between a given block and the block + // /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be + // /// null if no blocks have been finalized in the bucket period. + // #[graphql(name = "y_FinalizationTimeMin")] + // y_finalization_time_min: Vec, /// The average finalization time (slot-time difference between a given block and the block /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be /// null if no blocks have been finalized in the bucket period. #[graphql(name = "y_FinalizationTimeAvg")] - y_finalization_time_avg: Vec, - /// The maximum finalization time (slot-time difference between a given block and the block - /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be - /// null if no blocks have been finalized in the bucket period. - #[graphql(name = "y_FinalizationTimeMax")] - y_finalization_time_max: Vec, - /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis - /// value. - #[graphql(name = "y_LastTotalMicroCcd")] - y_last_total_micro_ccd: Vec, - /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis - /// value. Will be null if no blocks have been added in the bucket period. - #[graphql(name = "y_MinTotalMicroCcdEncrypted")] - y_min_total_micro_ccd_encrypted: Vec, - /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis - /// value. Will be null if no blocks have been added in the bucket period. - #[graphql(name = "y_MaxTotalMicroCcdEncrypted")] - y_max_total_micro_ccd_encrypted: Vec, - /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended - /// y-axis value. - #[graphql(name = "y_LastTotalMicroCcdEncrypted")] - y_last_total_micro_ccd_encrypted: Vec, - /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be null - /// if no blocks have been added in the bucket period. - #[graphql(name = "y_MinTotalMicroCcdStaked")] - y_min_total_micro_ccd_staked: Vec, - /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be null - /// if no blocks have been added in the bucket period. - #[graphql(name = "y_MaxTotalMicroCcdStaked")] - y_max_total_micro_ccd_staked: Vec, + y_finalization_time_avg: Vec, + // /// The maximum finalization time (slot-time difference between a given block and the block + // /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be + // /// null if no blocks have been finalized in the bucket period. + // #[graphql(name = "y_FinalizationTimeMax")] + // y_finalization_time_max: Vec, + // /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis + // /// value. + // #[graphql(name = "y_LastTotalMicroCcd")] + // y_last_total_micro_ccd: Vec, + // /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis + // /// value. Will be null if no blocks have been added in the bucket period. + // #[graphql(name = "y_MinTotalMicroCcdEncrypted")] + // y_min_total_micro_ccd_encrypted: Vec, + // /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis + // /// value. Will be null if no blocks have been added in the bucket period. + // #[graphql(name = "y_MaxTotalMicroCcdEncrypted")] + // y_max_total_micro_ccd_encrypted: Vec, + // /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended + // /// y-axis value. + // #[graphql(name = "y_LastTotalMicroCcdEncrypted")] + // y_last_total_micro_ccd_encrypted: Vec, + // /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be + // null /// if no blocks have been added in the bucket period. + // #[graphql(name = "y_MinTotalMicroCcdStaked")] + // y_min_total_micro_ccd_staked: Vec, + // /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be + // null /// if no blocks have been added in the bucket period. + // #[graphql(name = "y_MaxTotalMicroCcdStaked")] + // y_max_total_micro_ccd_staked: Vec, /// The total amount of CCD staked at the end of the bucket period. Intended y-axis value. #[graphql(name = "y_LastTotalMicroCcdStaked")] - y_last_total_micro_ccd_staked: Vec, + y_last_total_micro_ccd_staked: Vec, } #[derive(Enum, Clone, Copy, PartialEq, Eq)] @@ -4016,6 +4124,30 @@ enum MetricsPeriod { LastYear, } +impl MetricsPeriod { + /// The metrics period as a duration. + fn as_duration(&self) -> Duration { + match self { + MetricsPeriod::LastHour => Duration::hours(1), + MetricsPeriod::Last24Hours => Duration::hours(24), + MetricsPeriod::Last7Days => Duration::days(7), + MetricsPeriod::Last30Days => Duration::days(30), + MetricsPeriod::LastYear => Duration::days(364), + } + } + + /// Duration used per bucket for a given metrics period. + fn bucket_width(&self) -> Duration { + match self { + MetricsPeriod::LastHour => Duration::minutes(2), + MetricsPeriod::Last24Hours => Duration::hours(1), + MetricsPeriod::Last7Days => Duration::hours(6), + MetricsPeriod::Last30Days => Duration::days(1), + MetricsPeriod::LastYear => Duration::days(15), + } + } +} + #[derive(sqlx::Type)] #[sqlx(type_name = "transaction_type")] // only for PostgreSQL to match a type definition pub enum DbTransactionType { diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 63b4b31d..b32d13a8 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -10,6 +10,7 @@ use crate::graphql_api::{ }; use anyhow::Context; use concordium_rust_sdk::{ + base::hashes::BlockHash, indexer::{ async_trait, Indexer, @@ -18,10 +19,13 @@ use concordium_rust_sdk::{ }, types::{ queries::BlockInfo, + AbsoluteBlockHeight, AccountTransactionDetails, AccountTransactionEffects, + BlockHeight, BlockItemSummary, BlockItemSummaryDetails, + RewardsOverview, }, v2::{ self, @@ -36,30 +40,11 @@ use tokio::sync::mpsc; pub async fn traverse_chain( endpoints: Vec, - pool: PgPool, sender: mpsc::Sender, + start_height: AbsoluteBlockHeight, ) -> anyhow::Result<()> { - let rec = sqlx::query!( - r#" -SELECT MAX(height) FROM blocks -"# - ) - .fetch_one(&pool) - .await?; - let last_height_stored = rec.max; - - if last_height_stored.is_none() { - save_genesis_data(endpoints[0].clone(), &pool).await?; - } - - let start_height = if let Some(height) = last_height_stored { - u64::try_from(height).unwrap() + 1u64 - } else { - 1 - }; - - let config = TraverseConfig::new(endpoints, start_height.into()) - .context("No gRPC endpoints provided")?; + let config = + TraverseConfig::new(endpoints, start_height).context("No gRPC endpoints provided")?; let indexer = BlockIndexer; println!("Indexing from {}", start_height); @@ -73,14 +58,26 @@ pub async fn save_blocks( mut receiver: mpsc::Receiver, pool: PgPool, ) -> anyhow::Result<()> { + let mut context = SaveContext::load_from_database(&pool).await?; + while let Some(res) = receiver.recv().await { + // TODO: Improve this by batching blocks within some time frame into the same + // DB-transaction. + // TODO: Handle failures and probably retry a few times println!( "Saving {}:{}", res.finalized_block_info.height, res.finalized_block_info.block_hash ); - res.save_to_database(&pool) + let mut tx = pool + .begin() + .await + .context("Failed to create SQL transaction")?; + res.save_to_database(&mut context, &mut tx) + .await + .context("Failed saving block")?; + tx.commit() .await - .expect("Failed saving block") + .context("Failed to commit SQL transaction")?; } Ok(()) } @@ -118,12 +115,13 @@ impl Indexer for BlockIndexer { .get_block_chain_parameters(fbi.height) .await? .response; - + let tokenomics_info = client.get_tokenomics_info(fbi.height).await?.response; Ok(BlockData { finalized_block_info: fbi, block_info, events, chain_parameters, + tokenomics_info, }) } @@ -137,21 +135,52 @@ impl Indexer for BlockIndexer { } } +/// Information for a block which is relevant for storing it into the database. pub struct BlockData { finalized_block_info: FinalizedBlockInfo, block_info: BlockInfo, events: Vec, chain_parameters: ChainParameters, + tokenomics_info: RewardsOverview, } -impl BlockData { - // Relies on blocks being stored sequencially. - async fn save_to_database(self, pool: &PgPool) -> anyhow::Result<()> { - let mut tx = pool - .begin() - .await - .context("Failed to create SQL transaction")?; +/// Cross block context +struct SaveContext { + /// The last finalized block height according to the latest indexed block. + /// This is needed in order to compute the finalization time of blocks. + last_finalized_height: BlockHeight, + /// The last finalized block hash according to the latest indexed block. + /// This is needed in order to compute the finalization time of blocks. + last_finalized_hash: BlockHash, +} + +impl SaveContext { + /// The genesis block must already be in the database. + async fn load_from_database(pool: &PgPool) -> anyhow::Result { + let rec = sqlx::query!( + r#" +SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height ASC LIMIT 1 +"# + ) + .fetch_one(pool) + .await + .context("Failed to query data for save context")?; + + Ok(Self { + last_finalized_height: BlockHeight::from(u64::try_from(rec.height)?), + last_finalized_hash: rec.hash.parse()?, + }) + } +} +impl BlockData { + /// Relies on blocks being stored sequentially. + /// The genesis block must already be in the database. + async fn save_to_database( + self, + context: &mut SaveContext, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { let height = i64::try_from(self.finalized_block_info.height.height)?; let block_hash = self.finalized_block_info.block_hash.to_string(); let slot_time = self.block_info.block_slot_time.naive_utc(); @@ -160,18 +189,65 @@ impl BlockData { } else { None }; + let common_reward_data = match self.tokenomics_info { + RewardsOverview::V0 { data } => data, + RewardsOverview::V1 { common, .. } => common, + }; + let total_amount = i64::try_from(common_reward_data.total_amount.micro_ccd())?; + let total_staked = match self.tokenomics_info { + RewardsOverview::V0 { .. } => { + // TODO Compute the total staked capital. + 0i64 + }, + RewardsOverview::V1 { + total_staked_capital, + .. + } => i64::try_from(total_staked_capital.micro_ccd())?, + }; sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, finalized, baker_id) VALUES ($1, $2, $3, $4, $5);"#, + r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) +VALUES ($1, $2, $3, + (SELECT EXTRACT("MILLISECONDS" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)), + $4, $5, $6);"#, height, block_hash, slot_time, - self.block_info.finalized, - baker_id + baker_id, + total_amount, + total_staked ) - .execute(&mut *tx) + .execute(tx.as_mut()) .await?; + // Check if this block knows of a new finalized block. + // If so, mark the blocks since last time as finalized by this block. + if self.block_info.block_last_finalized != context.last_finalized_hash { + let last_height = i64::try_from(context.last_finalized_height.height)?; + let new_last_finalized_hash = self.block_info.block_last_finalized.to_string(); + + let rec = sqlx::query!( + r#" +WITH finalizer + AS (SELECT height FROM blocks WHERE hash = $1) +UPDATE blocks b + SET finalization_time = EXTRACT("MILLISECONDS" FROM $3 - b.slot_time), + finalized_by = finalizer.height +FROM finalizer +WHERE $2 <= b.height AND b.height < finalizer.height +RETURNING finalizer.height"#, + new_last_finalized_hash, + last_height, + slot_time + ) + .fetch_one(tx.as_mut()) + .await + .context("Failed updating finalization_time")?; + + context.last_finalized_height = u64::try_from(rec.height)?.into(); + context.last_finalized_hash = self.block_info.block_last_finalized; + } + for block_item in self.events { let block_index = i64::try_from(block_item.index.index).unwrap(); let tx_hash = block_item.hash.to_string(); @@ -250,7 +326,7 @@ VALUES .bind(success) .bind(events) .bind(reject) - .execute(&mut *tx) + .execute(tx.as_mut()) .await?; match block_item.details { @@ -263,21 +339,18 @@ VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, height, block_index ) - .execute(&mut *tx) + .execute(tx.as_mut()) .await?; }, _ => {}, } } - tx.commit() - .await - .context("Failed to commit SQL transaction")?; Ok(()) } } -async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { +pub async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { let mut client = v2::Client::new(endpoint).await?; let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); @@ -289,19 +362,36 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res let genesis_block_info = client.get_block_info(genesis_height).await?.response; let block_hash = genesis_block_info.block_hash.to_string(); let slot_time = genesis_block_info.block_slot_time.naive_utc(); - let finalized = genesis_block_info.finalized; let baker_id = if let Some(index) = genesis_block_info.block_baker { Some(i64::try_from(index.id.index)?) } else { None }; + let genesis_tokenomics = client.get_tokenomics_info(genesis_height).await?.response; + let common_reward = match genesis_tokenomics { + RewardsOverview::V0 { data } => data, + RewardsOverview::V1 { common, .. } => common, + }; + let total_staked = match genesis_tokenomics { + RewardsOverview::V0 { .. } => { + // TODO Compute the total staked capital. + 0i64 + }, + RewardsOverview::V1 { + total_staked_capital, + .. + } => i64::try_from(total_staked_capital.micro_ccd())?, + }; + + let total_amount = i64::try_from(common_reward.total_amount.micro_ccd())?; sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, finalized, baker_id) VALUES ($1, $2, $3, $4, $5);"#, + r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5, $6);"#, 0, block_hash, slot_time, - finalized, - baker_id + baker_id, + total_amount, + total_staked ) .execute(&mut *tx) .await?; diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index ef6797e6..e766d560 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -22,10 +22,7 @@ use sqlx::PgPool; use std::path::PathBuf; use tokio::{ net::TcpListener, - sync::{ - broadcast, - mpsc, - }, + sync::mpsc, }; mod graphql_api; @@ -42,12 +39,12 @@ pub async fn graphiql() -> impl IntoResponse { #[derive(Parser)] struct Cli { - /// The url used for the database, something of the form + /// The URL used for the database, something of the form /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] database_url: String, - /// GRPC interface of the node. + /// gRPC interface of the node. Several can be provided. #[arg(long, default_value = "http://localhost:20000")] node: Vec, @@ -55,6 +52,7 @@ struct Cli { #[arg(long)] indexer: bool, + /// Output the GraphQL Schema for the API to this path. #[arg(long)] schema_out: Option, } @@ -70,21 +68,38 @@ async fn main() -> anyhow::Result<()> { let pool = PgPool::connect(&cli.database_url) .await - .context("Failed constructin database connection pool")?; + .context("Failed constructing database connection pool")?; if cli.indexer { - println!("Starting indexer"); + eprintln!("Starting indexer"); + let last_height_stored = sqlx::query!( + r#" +SELECT height FROM blocks ORDER BY height DESC LIMIT 1 +"# + ) + .fetch_optional(&pool) + .await? + .map(|r| r.height); + + let start_height = if let Some(height) = last_height_stored { + u64::try_from(height)? + 1 + } else { + indexer::save_genesis_data(cli.node[0].clone(), &pool).await?; + 1 + }; + let (sender, receiver) = mpsc::channel(10); { - let pool = pool.clone(); + // TODO: Graceful shutdown if this task fails tokio::spawn(async move { - indexer::traverse_chain(cli.node, pool, sender) + indexer::traverse_chain(cli.node, sender, start_height.into()) .await .expect("failed") }); } { let pool = pool.clone(); + // TODO: Graceful shutdown if this task fails tokio::spawn( async move { indexer::save_blocks(receiver, pool).await.expect("failed") }, ); @@ -94,6 +109,7 @@ async fn main() -> anyhow::Result<()> { let (subscription, subscription_context) = graphql_api::Subscription::new(); { let pool = pool.clone(); + // TODO: Graceful shutdown if this task fails tokio::spawn(async move { graphql_api::Subscription::handle_notifications(subscription_context, pool) .await @@ -111,7 +127,7 @@ async fn main() -> anyhow::Result<()> { .finish(); if let Some(schema_file) = cli.schema_out { - println!("Writing schema to {}", schema_file.to_string_lossy()); + eprintln!("Writing schema to {}", schema_file.to_string_lossy()); std::fs::write(schema_file, schema.sdl()).context("Failed to write schema")?; } @@ -122,7 +138,7 @@ async fn main() -> anyhow::Result<()> { ) .route_service("/ws", GraphQLSubscription::new(schema)); - println!("Server is running at http://localhost:8000"); + eprintln!("Server is running at http://localhost:8000"); axum::serve( TcpListener::bind("127.0.0.1:8000") .await From a52eddffb0a6618a3bc04da5361388e14b9e81ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 23 Sep 2024 09:21:48 +0200 Subject: [PATCH 06/50] Add rust-sdk submodule and implement graceful shutdown --- .gitmodules | 3 + backend-rust/Cargo.lock | 41 +- backend-rust/Cargo.toml | 5 +- backend-rust/concordium-rust-sdk | 1 + backend-rust/src/graphql_api.rs | 137 +++++-- backend-rust/src/indexer.rs | 644 ++++++++++++++++++++----------- backend-rust/src/main.rs | 184 ++++----- 7 files changed, 613 insertions(+), 402 deletions(-) create mode 160000 backend-rust/concordium-rust-sdk diff --git a/.gitmodules b/.gitmodules index e69de29b..069687e4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend-rust/concordium-rust-sdk"] + path = backend-rust/concordium-rust-sdk + url = git@github.com:Concordium/concordium-rust-sdk.git diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index ddbe7302..b3bcb65d 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -571,6 +571,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "tokio-util", "tracing", "tracing-subscriber", ] @@ -857,9 +858,7 @@ checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "concordium-contracts-common" -version = "9.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dfda3a8d06d2d72e74aad650757680a82df4e528c18c66c49efe9f60b646c5" +version = "9.2.0" dependencies = [ "base64 0.21.7", "bs58", @@ -880,8 +879,6 @@ dependencies = [ [[package]] name = "concordium-contracts-common-derive" version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee3482ffacf3c18133be976c1b874b6e87e018ac0316e9385888b43df07fa39c" dependencies = [ "proc-macro2", "quote", @@ -890,9 +887,7 @@ dependencies = [ [[package]] name = "concordium-rust-sdk" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d0798ed24edd3a8212ba2f3d6c771635cb3561c327dc03463efa09fa9bd6083" +version = "5.0.0" dependencies = [ "aes-gcm", "anyhow", @@ -923,9 +918,7 @@ dependencies = [ [[package]] name = "concordium-smart-contract-engine" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec03be6a4a56c0501cf9225d135aaa6c155228039efde27d6551814bc6776db1" +version = "6.0.0" dependencies = [ "anyhow", "byteorder", @@ -948,9 +941,7 @@ dependencies = [ [[package]] name = "concordium-wasm" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26a581ef6ae1b23e149eaf5ed1e43c1da877ded9ae0de4989cd71b76d25c67d7" +version = "5.0.0" dependencies = [ "anyhow", "concordium-contracts-common", @@ -961,9 +952,7 @@ dependencies = [ [[package]] name = "concordium_base" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3dd5a345b61f667a8cb6a361f3e88268bf15b68eabab7f78e27ce513b20175" +version = "6.0.0" dependencies = [ "aes", "anyhow", @@ -1010,12 +999,10 @@ dependencies = [ [[package]] name = "concordium_base_derive" version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9a154e9a64d58d3e9f890c603df847b8685cfd0bac3fadd18498af539650bed" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.61", ] [[package]] @@ -2966,6 +2953,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3450,6 +3446,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.48.0", @@ -3502,9 +3499,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" dependencies = [ "bytes", "futures-core", diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 6445b4ce..6161d349 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -13,7 +13,7 @@ axum = "0.7" ciborium = "0.2" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env"] } -concordium-rust-sdk = "4.3" +concordium-rust-sdk = { path = "./concordium-rust-sdk" } derive_more = "0.99" dotenv = "0.15" futures = "0.3" @@ -22,9 +22,10 @@ serde = "1.0" serde_json = "1.0" sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } thiserror = "1.0" -tokio = { version = "1.37", features = ["rt-multi-thread", "sync"] } +tokio = { version = "1.37", features = ["rt-multi-thread", "sync", "signal"] } tokio-stream = { version = "0.1", features = ["sync"] } tracing = "0.1" tracing-subscriber = "0.3" rust_decimal = "1.35" iso8601-duration = { version = "0.2", features = ["chrono"] } +tokio-util = "0.7" diff --git a/backend-rust/concordium-rust-sdk b/backend-rust/concordium-rust-sdk new file mode 160000 index 00000000..1c37f334 --- /dev/null +++ b/backend-rust/concordium-rust-sdk @@ -0,0 +1 @@ +Subproject commit 1c37f334ccb79218cc494defb02fd11a373e4df0 diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 70b52b89..51fcb8ce 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -2,14 +2,19 @@ //! - Introduce default LIMITS for connections //! - Introduce a MAX LIMIT for connections +#![allow(unused_variables)] +#![allow(unreachable_code)] + use anyhow::Context as _; use async_graphql::{ + http::GraphiQLSource, types::{ self, connection, }, ComplexObject, Context, + EmptyMutation, Enum, InputObject, InputValueError, @@ -18,11 +23,13 @@ use async_graphql::{ Object, Scalar, ScalarType, + Schema, SimpleObject, Subscription, Union, Value, }; +use async_graphql_axum::GraphQLSubscription; use chrono::Duration; use futures::prelude::*; use sqlx::{ @@ -35,13 +42,58 @@ use std::{ str::FromStr as _, sync::Arc, }; -use tokio::sync::broadcast; +use tokio::{ + net::TcpListener, + sync::broadcast, +}; +use tokio_util::sync::CancellationToken; const VERSION: &str = env!("CARGO_PKG_VERSION"); /// The most transactions which can be queried at once. const QUERY_TRANSACTIONS_LIMIT: i64 = 100; +pub struct Service { + pub schema: Schema, +} +impl Service { + pub fn new(subscription: Subscription, pool: PgPool) -> Self { + let schema = Schema::build(Query, EmptyMutation, subscription) + .extension(async_graphql::extensions::Tracing) + .data(pool) + .finish(); + Self { schema } + } + + pub async fn serve( + self, + tcp_listener: TcpListener, + stop_signal: CancellationToken, + ) -> anyhow::Result<()> { + let app = axum::Router::new() + .route( + "/", + axum::routing::get(Self::graphiql) + .post_service(async_graphql_axum::GraphQL::new(self.schema.clone())), + ) + .route_service("/ws", GraphQLSubscription::new(self.schema)); + + axum::serve(tcp_listener, app) + .with_graceful_shutdown(stop_signal.cancelled_owned()) + .await?; + Ok(()) + } + + async fn graphiql() -> impl axum::response::IntoResponse { + axum::response::Html( + GraphiQLSource::build() + .endpoint("/") + .subscription_endpoint("/ws") + .finish(), + ) + } +} + #[derive(Debug, thiserror::Error, Clone)] enum ApiError { #[error("Could not find resource")] @@ -593,12 +645,7 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) pub struct Subscription { pub block_added: broadcast::Receiver>, } -pub struct SubscriptionContext { - block_added_sender: broadcast::Sender>, -} impl Subscription { - const BLOCK_ADDED_CHANNEL: &'static str = "block_added"; - pub fn new() -> (Self, SubscriptionContext) { let (block_added_sender, block_added) = broadcast::channel(100); ( @@ -606,11 +653,23 @@ impl Subscription { SubscriptionContext { block_added_sender }, ) } +} +#[Subscription] +impl Subscription { + async fn block_added( + &self, + ) -> impl Stream, tokio_stream::wrappers::errors::BroadcastStreamRecvError>> + { + tokio_stream::wrappers::BroadcastStream::new(self.block_added.resubscribe()) + } +} +pub struct SubscriptionContext { + block_added_sender: broadcast::Sender>, +} +impl SubscriptionContext { + const BLOCK_ADDED_CHANNEL: &'static str = "block_added"; - pub async fn handle_notifications( - context: SubscriptionContext, - pool: PgPool, - ) -> anyhow::Result<()> { + pub async fn listen(self, pool: PgPool, stop_signal: CancellationToken) -> anyhow::Result<()> { let mut listener = sqlx::postgres::PgListener::connect_with(&pool) .await .context("Failed to create a postgreSQL listener")?; @@ -619,32 +678,31 @@ impl Subscription { .await .context("Failed to listen to postgreSQL notifications")?; - loop { - let notification = listener.recv().await?; - match notification.channel() { - Self::BLOCK_ADDED_CHANNEL => { - let block_height = BlockHeight::from_str(notification.payload()) - .context("Failed to parse payload of block added")?; - let block = sqlx::query_as("SELECT * FROM blocks WHERE height=$1") - .bind(block_height) - .fetch_one(&pool) - .await?; - context.block_added_sender.send(Arc::new(block))?; - }, - unknown => { - anyhow::bail!("Unknown channel {}", unknown); - }, - } + let exit = stop_signal + .run_until_cancelled(async move { + loop { + let notification = listener.recv().await?; + match notification.channel() { + Self::BLOCK_ADDED_CHANNEL => { + let block_height = BlockHeight::from_str(notification.payload()) + .context("Failed to parse payload of block added")?; + let block = sqlx::query_as("SELECT * FROM blocks WHERE height=$1") + .bind(block_height) + .fetch_one(&pool) + .await?; + self.block_added_sender.send(Arc::new(block))?; + }, + unknown => { + anyhow::bail!("Unknown channel {}", unknown); + }, + } + } + }) + .await; + if let Some(result) = exit { + result.context("Failed listening")?; } - } -} -#[Subscription] -impl Subscription { - async fn block_added( - &self, - ) -> impl Stream, tokio_stream::wrappers::errors::BroadcastStreamRecvError>> - { - tokio_stream::wrappers::BroadcastStream::new(self.block_added.resubscribe()) + Ok(()) } } @@ -2017,6 +2075,7 @@ impl From for AccountTransactionTyp fn from(value: concordium_rust_sdk::types::TransactionType) -> Self { use concordium_rust_sdk::types::TransactionType as TT; use AccountTransactionType as ATT; + #[allow(deprecated)] match value { TT::DeployModule => ATT::DeployModule, TT::InitContract => ATT::InitializeSmartContractInstance, @@ -3158,6 +3217,9 @@ pub fn events_from_summary( }, )) }, + BakerEvent::DelegationRemoved { delegator_id } => { + todo!() + }, } }) .collect::>>()? @@ -3223,6 +3285,9 @@ pub fn events_from_summary( account_address: details.sender.into(), })) }, + DelegationEvent::BakerRemoved { baker_id } => { + todo!(); + }, } }) .collect::>>()? @@ -4148,7 +4213,7 @@ impl MetricsPeriod { } } -#[derive(sqlx::Type)] +#[derive(sqlx::Type, Copy, Clone)] #[sqlx(type_name = "transaction_type")] // only for PostgreSQL to match a type definition pub enum DbTransactionType { Account, diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index b32d13a8..4185f72f 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -9,17 +9,18 @@ use crate::graphql_api::{ UpdateTransactionType, }; use anyhow::Context; +use chrono::NaiveDateTime; use concordium_rust_sdk::{ - base::hashes::BlockHash, indexer::{ async_trait, + traverse_and_process, Indexer, + ProcessEvent, TraverseConfig, TraverseError, }, types::{ queries::BlockInfo, - AbsoluteBlockHeight, AccountTransactionDetails, AccountTransactionEffects, BlockHeight, @@ -32,69 +33,76 @@ use concordium_rust_sdk::{ ChainParameters, FinalizedBlockInfo, QueryResult, + RPCError, }, }; use futures::TryStreamExt; use sqlx::PgPool; -use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; +use tracing::info; -pub async fn traverse_chain( +pub struct CcdScanIndexer { endpoints: Vec, - sender: mpsc::Sender, - start_height: AbsoluteBlockHeight, -) -> anyhow::Result<()> { - let config = - TraverseConfig::new(endpoints, start_height).context("No gRPC endpoints provided")?; - let indexer = BlockIndexer; - - println!("Indexing from {}", start_height); - config - .traverse(indexer, sender) - .await - .context("Failed traversing the blocks in the chain") + start_height: u64, + block_processor: BlockProcessor, } -pub async fn save_blocks( - mut receiver: mpsc::Receiver, - pool: PgPool, -) -> anyhow::Result<()> { - let mut context = SaveContext::load_from_database(&pool).await?; +impl CcdScanIndexer { + pub async fn new(endpoints: Vec, pool: PgPool) -> anyhow::Result { + let last_height_stored = sqlx::query!( + r#" +SELECT height FROM blocks ORDER BY height DESC LIMIT 1 +"# + ) + .fetch_optional(&pool) + .await? + .map(|r| r.height); - while let Some(res) = receiver.recv().await { - // TODO: Improve this by batching blocks within some time frame into the same - // DB-transaction. - // TODO: Handle failures and probably retry a few times - println!( - "Saving {}:{}", - res.finalized_block_info.height, res.finalized_block_info.block_hash - ); - let mut tx = pool - .begin() - .await - .context("Failed to create SQL transaction")?; - res.save_to_database(&mut context, &mut tx) - .await - .context("Failed saving block")?; - tx.commit() - .await - .context("Failed to commit SQL transaction")?; + let start_height = if let Some(height) = last_height_stored { + u64::try_from(height)? + 1 + } else { + save_genesis_data(endpoints[0].clone(), &pool).await?; + 1 + }; + let block_processor = BlockProcessor::load_from_database(pool).await?; + + Ok(Self { + endpoints, + start_height, + block_processor, + }) + } + + pub async fn run(self, stop_signal: CancellationToken) -> anyhow::Result<()> { + let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) + .context("Failed setting up TraverseConfig")?; + let processor_config = concordium_rust_sdk::indexer::ProcessorConfig::new() + .set_stop_signal(stop_signal.cancelled_owned()); + + info!("Indexing from {}", self.start_height); + traverse_and_process( + traverse_config, + BlockIndexer, + processor_config, + self.block_processor, + ) + .await?; + Ok(()) } - Ok(()) } struct BlockIndexer; - #[async_trait] impl Indexer for BlockIndexer { type Context = (); - type Data = BlockData; + type Data = PreparedBlock; async fn on_connect<'a>( &mut self, - _endpoint: v2::Endpoint, + endpoint: v2::Endpoint, _client: &'a mut v2::Client, ) -> QueryResult { - println!("Indexer connection"); + info!("Connection established to node at uri: {}", endpoint.uri()); Ok(()) } @@ -116,13 +124,14 @@ impl Indexer for BlockIndexer { .await? .response; let tokenomics_info = client.get_tokenomics_info(fbi.height).await?.response; - Ok(BlockData { + let data = BlockData { finalized_block_info: fbi, block_info, events, chain_parameters, tokenomics_info, - }) + }; + Ok(PreparedBlock::prepare(&data).map_err(|err| RPCError::ParseError(err))?) } async fn on_failure( @@ -135,221 +144,100 @@ impl Indexer for BlockIndexer { } } -/// Information for a block which is relevant for storing it into the database. -pub struct BlockData { - finalized_block_info: FinalizedBlockInfo, - block_info: BlockInfo, - events: Vec, - chain_parameters: ChainParameters, - tokenomics_info: RewardsOverview, -} - -/// Cross block context -struct SaveContext { +struct BlockProcessor { + pool: PgPool, /// The last finalized block height according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. last_finalized_height: BlockHeight, /// The last finalized block hash according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. - last_finalized_hash: BlockHash, + last_finalized_hash: String, } -impl SaveContext { - /// The genesis block must already be in the database. - async fn load_from_database(pool: &PgPool) -> anyhow::Result { +impl BlockProcessor { + /// Construct the block processor by loading the initial state from the database. + /// This assumes at least the genesis block is in the database. + async fn load_from_database(pool: PgPool) -> anyhow::Result { let rec = sqlx::query!( r#" SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height ASC LIMIT 1 "# ) - .fetch_one(pool) + .fetch_one(&pool) .await .context("Failed to query data for save context")?; Ok(Self { + pool, last_finalized_height: BlockHeight::from(u64::try_from(rec.height)?), - last_finalized_hash: rec.hash.parse()?, + last_finalized_hash: rec.hash, }) } } -impl BlockData { - /// Relies on blocks being stored sequentially. - /// The genesis block must already be in the database. - async fn save_to_database( - self, - context: &mut SaveContext, - tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, - ) -> anyhow::Result<()> { - let height = i64::try_from(self.finalized_block_info.height.height)?; - let block_hash = self.finalized_block_info.block_hash.to_string(); - let slot_time = self.block_info.block_slot_time.naive_utc(); - let baker_id = if let Some(index) = self.block_info.block_baker { - Some(i64::try_from(index.id.index)?) - } else { - None - }; - let common_reward_data = match self.tokenomics_info { - RewardsOverview::V0 { data } => data, - RewardsOverview::V1 { common, .. } => common, - }; - let total_amount = i64::try_from(common_reward_data.total_amount.micro_ccd())?; - let total_staked = match self.tokenomics_info { - RewardsOverview::V0 { .. } => { - // TODO Compute the total staked capital. - 0i64 - }, - RewardsOverview::V1 { - total_staked_capital, - .. - } => i64::try_from(total_staked_capital.micro_ccd())?, - }; +#[async_trait] +impl ProcessEvent for BlockProcessor { + /// The type of events that are to be processed. Typically this will be all + /// of the transactions of interest for a single block."] + type Data = PreparedBlock; - sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) -VALUES ($1, $2, $3, - (SELECT EXTRACT("MILLISECONDS" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)), - $4, $5, $6);"#, - height, - block_hash, - slot_time, - baker_id, - total_amount, - total_staked - ) - .execute(tx.as_mut()) - .await?; + /// An error that can be signalled. + type Error = anyhow::Error; // TODO: introduce proper error type - // Check if this block knows of a new finalized block. - // If so, mark the blocks since last time as finalized by this block. - if self.block_info.block_last_finalized != context.last_finalized_hash { - let last_height = i64::try_from(context.last_finalized_height.height)?; - let new_last_finalized_hash = self.block_info.block_last_finalized.to_string(); + /// A description returned by the [`process`](ProcessEvent::process) method. + /// This message is logged by the [`ProcessorConfig`] and is intended to + /// describe the data that was just processed. + type Description = String; - let rec = sqlx::query!( - r#" -WITH finalizer - AS (SELECT height FROM blocks WHERE hash = $1) -UPDATE blocks b - SET finalization_time = EXTRACT("MILLISECONDS" FROM $3 - b.slot_time), - finalized_by = finalizer.height -FROM finalizer -WHERE $2 <= b.height AND b.height < finalizer.height -RETURNING finalizer.height"#, - new_last_finalized_hash, - last_height, - slot_time - ) - .fetch_one(tx.as_mut()) + /// Process a single item. This should work atomically in the sense that + /// either the entire `data` is processed or none of it is in case of an + /// error. This property is relied upon by the [`ProcessorConfig`] to retry + /// failed attempts. + async fn process(&mut self, data: &Self::Data) -> Result { + // TODO: Improve this by batching blocks within some time frame into the same + // DB-transaction. + // TODO: Handle failures and probably retry a few times + let mut tx = self + .pool + .begin() .await - .context("Failed updating finalization_time")?; - - context.last_finalized_height = u64::try_from(rec.height)?.into(); - context.last_finalized_hash = self.block_info.block_last_finalized; - } - - for block_item in self.events { - let block_index = i64::try_from(block_item.index.index).unwrap(); - let tx_hash = block_item.hash.to_string(); - let ccd_cost = i64::try_from( - self.chain_parameters - .ccd_cost(block_item.energy_cost) - .micro_ccd, - ) - .unwrap(); - let energy_cost = i64::try_from(block_item.energy_cost.energy).unwrap(); - let sender = block_item.sender_account().map(|a| a.to_string()); - let (transaction_type, account_type, credential_type, update_type) = - match &block_item.details { - BlockItemSummaryDetails::AccountTransaction(details) => { - let account_transaction_type = - details.transaction_type().map(AccountTransactionType::from); - ( - DbTransactionType::Account, - account_transaction_type, - None, - None, - ) - }, - BlockItemSummaryDetails::AccountCreation(details) => { - let credential_type = - CredentialDeploymentTransactionType::from(details.credential_type); - ( - DbTransactionType::CredentialDeployment, - None, - Some(credential_type), - None, - ) - }, - BlockItemSummaryDetails::Update(details) => { - let update_type = UpdateTransactionType::from(details.update_type()); - (DbTransactionType::Update, None, None, Some(update_type)) - }, - }; - let success = block_item.is_success(); - let (events, reject) = if success { - let events = - serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; - (Some(events), None) - } else { - let reject = if let BlockItemSummaryDetails::AccountTransaction( - AccountTransactionDetails { - effects: AccountTransactionEffects::None { reject_reason, .. }, - .. - }, - ) = &block_item.details - { - serde_json::to_value(crate::graphql_api::TransactionRejectReason::try_from( - reject_reason.clone(), - )?)? - } else { - anyhow::bail!("Invariant violation: Failed transaction without a reject reason") - }; - (None, Some(reject)) - }; - - sqlx::query( - r#"INSERT INTO transactions -(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, events, reject) -VALUES -($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12, $13);"#) - .bind(block_index) - .bind(tx_hash) - .bind(ccd_cost) - .bind(energy_cost) - .bind(height) - .bind(sender) - .bind(transaction_type) - .bind(account_type) - .bind(credential_type) - .bind(update_type) - .bind(success) - .bind(events) - .bind(reject) - .execute(tx.as_mut()) - .await?; - - match block_item.details { - BlockItemSummaryDetails::AccountCreation(details) => { - let account_address = details.address.to_string(); - sqlx::query!( - r#"INSERT INTO accounts (index, address, created_block, created_index, amount) -VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, - account_address, - height, - block_index - ) - .execute(tx.as_mut()) - .await?; - }, - _ => {}, - } - } + .context("Failed to create SQL transaction")?; + data.save(&mut self, &mut tx) + .await + .context("Failed saving block")?; + tx.commit() + .await + .context("Failed to commit SQL transaction")?; + Ok(format!("Processed block {}:{}", data.height, data.hash)) + } - Ok(()) + /// The `on_failure` method is invoked by the [`ProcessorConfig`] when it + /// fails to process an event. It is meant to retry to recreate the + /// resources, such as a database connection, that might have been + /// dropped. The return value should signal if the handler process + /// should continue (`true`) or not. + /// + /// The function takes the `error` that occurred at the latest + /// [`process`](Self::process) call that just failed, and the number of + /// attempts of calling `process` that failed. + async fn on_failure( + &mut self, + _error: Self::Error, + _failed_attempts: u32, + ) -> Result { + Ok(true) } } +/// Information for a block which is relevant for storing it into the database. +struct BlockData { + finalized_block_info: FinalizedBlockInfo, + block_info: BlockInfo, + events: Vec, + chain_parameters: ChainParameters, + tokenomics_info: RewardsOverview, +} + pub async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { let mut client = v2::Client::new(endpoint).await?; let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); @@ -422,3 +310,295 @@ pub async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow: .context("Failed to commit SQL transaction")?; Ok(()) } + +pub struct PreparedBlock { + hash: String, + height: i64, + slot_time: NaiveDateTime, + baker_id: Option, + total_amount: i64, + total_staked: i64, + block_last_finalized: String, + prepared_block_items: Vec, +} + +impl PreparedBlock { + fn prepare(data: &BlockData) -> anyhow::Result { + let height = i64::try_from(data.finalized_block_info.height.height)?; + let hash = data.finalized_block_info.block_hash.to_string(); + let block_last_finalized = data.block_info.block_last_finalized.to_string(); + let slot_time = data.block_info.block_slot_time.naive_utc(); + let baker_id = if let Some(index) = data.block_info.block_baker { + Some(i64::try_from(index.id.index)?) + } else { + None + }; + let common_reward_data = match data.tokenomics_info { + RewardsOverview::V0 { data } => data, + RewardsOverview::V1 { common, .. } => common, + }; + let total_amount = i64::try_from(common_reward_data.total_amount.micro_ccd())?; + let total_staked = match data.tokenomics_info { + RewardsOverview::V0 { .. } => { + // TODO Compute the total staked capital. + 0i64 + }, + RewardsOverview::V1 { + total_staked_capital, + .. + } => i64::try_from(total_staked_capital.micro_ccd())?, + }; + + let mut prepared_block_items = Vec::new(); + for block_item in data.events.iter() { + prepared_block_items.push(PreparedBlockItem::prepare(data, block_item)?) + } + + Ok(Self { + hash, + height, + slot_time, + baker_id, + total_amount, + total_staked, + block_last_finalized, + prepared_block_items, + }) + } + + async fn save( + &self, + context: &mut BlockProcessor, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + sqlx::query!( + r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) +VALUES ($1, $2, $3, + (SELECT EXTRACT("MILLISECONDS" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)), + $4, $5, $6);"#, + self.height, + self.hash, + self.slot_time, + self.baker_id, + self.total_amount, + self.total_staked + ) + .execute(tx.as_mut()) + .await?; + + // Check if this block knows of a new finalized block. + // If so, mark the blocks since last time as finalized by this block. + if self.block_last_finalized != context.last_finalized_hash { + let last_height = i64::try_from(context.last_finalized_height.height)?; + + let rec = sqlx::query!( + r#" +WITH finalizer + AS (SELECT height FROM blocks WHERE hash = $1) +UPDATE blocks b + SET finalization_time = EXTRACT("MILLISECONDS" FROM $3 - b.slot_time), + finalized_by = finalizer.height +FROM finalizer +WHERE $2 <= b.height AND b.height < finalizer.height +RETURNING finalizer.height"#, + self.block_last_finalized, + last_height, + self.slot_time + ) + .fetch_one(tx.as_mut()) + .await + .context("Failed updating finalization_time")?; + + context.last_finalized_height = u64::try_from(rec.height)?.into(); + context.last_finalized_hash = self.block_last_finalized.clone(); + } + + for item in self.prepared_block_items.iter() { + item.save(context, tx).await?; + } + Ok(()) + } +} + +struct PreparedBlockItem { + block_index: i64, + tx_hash: String, + ccd_cost: i64, + energy_cost: i64, + height: i64, + sender: Option, + transaction_type: DbTransactionType, + account_type: Option, + credential_type: Option, + update_type: Option, + success: bool, + events: Option, + reject: Option, + prepared_event: PreparedEvent, +} + +impl PreparedBlockItem { + fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result { + let height = i64::try_from(data.finalized_block_info.height.height)?; + let block_index = i64::try_from(block_item.index.index).unwrap(); + let tx_hash = block_item.hash.to_string(); + let ccd_cost = i64::try_from( + data.chain_parameters + .ccd_cost(block_item.energy_cost) + .micro_ccd, + ) + .unwrap(); + let energy_cost = i64::try_from(block_item.energy_cost.energy).unwrap(); + let sender = block_item.sender_account().map(|a| a.to_string()); + let (transaction_type, account_type, credential_type, update_type) = + match &block_item.details { + BlockItemSummaryDetails::AccountTransaction(details) => { + let account_transaction_type = + details.transaction_type().map(AccountTransactionType::from); + ( + DbTransactionType::Account, + account_transaction_type, + None, + None, + ) + }, + BlockItemSummaryDetails::AccountCreation(details) => { + let credential_type = + CredentialDeploymentTransactionType::from(details.credential_type); + ( + DbTransactionType::CredentialDeployment, + None, + Some(credential_type), + None, + ) + }, + BlockItemSummaryDetails::Update(details) => { + let update_type = UpdateTransactionType::from(details.update_type()); + (DbTransactionType::Update, None, None, Some(update_type)) + }, + }; + let success = block_item.is_success(); + let (events, reject) = if success { + let events = serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; + (Some(events), None) + } else { + let reject = + if let BlockItemSummaryDetails::AccountTransaction(AccountTransactionDetails { + effects: AccountTransactionEffects::None { reject_reason, .. }, + .. + }) = &block_item.details + { + serde_json::to_value(crate::graphql_api::TransactionRejectReason::try_from( + reject_reason.clone(), + )?)? + } else { + anyhow::bail!("Invariant violation: Failed transaction without a reject reason") + }; + (None, Some(reject)) + }; + let prepared_event = match &block_item.details { + BlockItemSummaryDetails::AccountCreation(details) => { + PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( + data, + &block_item, + details, + )?) + }, + _ => { + todo!() + }, + }; + + Ok(Self { + block_index, + tx_hash, + ccd_cost, + energy_cost, + height, + sender, + transaction_type, + account_type, + credential_type, + update_type, + success, + events, + reject, + prepared_event, + }) + } + + async fn save( + &self, + context: &mut BlockProcessor, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + sqlx::query!( + r#"INSERT INTO transactions +(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, events, reject) +VALUES +($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12, $13);"#, + self.block_index, + self.tx_hash, + self.ccd_cost, + self.energy_cost, + self.height, + self.sender, + self.transaction_type as DbTransactionType, + self.account_type as Option, + self.credential_type as Option, + self.update_type as Option, + self.success, + self.events, + self.reject) + .execute(tx.as_mut()) + .await?; + + match &self.prepared_event { + PreparedEvent::AccountCreation(event) => event.save(context, tx).await?, + } + Ok(()) + } +} + +enum PreparedEvent { + AccountCreation(PreparedAccountCreation), +} + +struct PreparedAccountCreation { + account_address: String, + height: i64, + block_index: i64, +} + +impl PreparedAccountCreation { + fn prepare( + data: &BlockData, + block_item: &BlockItemSummary, + details: &concordium_rust_sdk::types::AccountCreationDetails, + ) -> anyhow::Result { + let height = i64::try_from(data.finalized_block_info.height.height)?; + let block_index = i64::try_from(block_item.index.index)?; + Ok(Self { + account_address: details.address.to_string(), + height, + block_index, + }) + } + + async fn save( + &self, + _context: &mut BlockProcessor, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + sqlx::query!( + r#"INSERT INTO accounts (index, address, created_block, created_index, amount) +VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, + self.account_address, + self.height, + self.block_index + ) + .execute(tx.as_mut()) + .await?; + Ok(()) + } +} diff --git a/backend-rust/src/main.rs b/backend-rust/src/main.rs index e766d560..41dc2512 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/main.rs @@ -1,152 +1,116 @@ use anyhow::Context; -use async_graphql::{ - http::GraphiQLSource, - Schema, -}; -use async_graphql_axum::{ - GraphQL, - GraphQLSubscription, -}; -use axum::{ - response::{ - self, - IntoResponse, - }, - routing::get, - Router, -}; use clap::Parser; use concordium_rust_sdk::v2; use dotenv::dotenv; +use futures::future::OptionFuture; use sqlx::PgPool; -use std::path::PathBuf; -use tokio::{ - net::TcpListener, - sync::mpsc, +use std::{ + net::SocketAddr, + path::PathBuf, +}; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; +use tracing::{ + error, + info, + warn, }; mod graphql_api; mod indexer; -pub async fn graphiql() -> impl IntoResponse { - response::Html( - GraphiQLSource::build() - .endpoint("/") - .subscription_endpoint("/ws") - .finish(), - ) -} - +// TODO add env for remaining args. #[derive(Parser)] struct Cli { /// The URL used for the database, something of the form /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] database_url: String, - /// gRPC interface of the node. Several can be provided. #[arg(long, default_value = "http://localhost:20000")] node: Vec, - /// Whether to run the indexer. #[arg(long)] indexer: bool, - /// Output the GraphQL Schema for the API to this path. #[arg(long)] schema_out: Option, + /// Address to listen for API requests + #[arg(long, default_value = "127.0.0.1:8000")] + listen: SocketAddr, } #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv().ok(); let cli = Cli::parse(); - tracing_subscriber::fmt() .with_max_level(tracing::Level::INFO) .init(); - let pool = PgPool::connect(&cli.database_url) .await .context("Failed constructing database connection pool")?; + let cancel_token = CancellationToken::new(); - if cli.indexer { - eprintln!("Starting indexer"); - let last_height_stored = sqlx::query!( - r#" -SELECT height FROM blocks ORDER BY height DESC LIMIT 1 -"# - ) - .fetch_optional(&pool) - .await? - .map(|r| r.height); - - let start_height = if let Some(height) = last_height_stored { - u64::try_from(height)? + 1 - } else { - indexer::save_genesis_data(cli.node[0].clone(), &pool).await?; - 1 - }; - - let (sender, receiver) = mpsc::channel(10); - { - // TODO: Graceful shutdown if this task fails - tokio::spawn(async move { - indexer::traverse_chain(cli.node, sender, start_height.into()) - .await - .expect("failed") - }); - } - { - let pool = pool.clone(); - // TODO: Graceful shutdown if this task fails - tokio::spawn( - async move { indexer::save_blocks(receiver, pool).await.expect("failed") }, - ); - } - } - - let (subscription, subscription_context) = graphql_api::Subscription::new(); - { + let mut indexer_task = if cli.indexer { let pool = pool.clone(); - // TODO: Graceful shutdown if this task fails - tokio::spawn(async move { - graphql_api::Subscription::handle_notifications(subscription_context, pool) - .await - .expect("PostgreSQL notification task failed") - }); - } - - let schema = Schema::build( - graphql_api::Query, - async_graphql::EmptyMutation, - subscription, - ) - .extension(async_graphql::extensions::Tracing) - .data(pool) - .finish(); - - if let Some(schema_file) = cli.schema_out { - eprintln!("Writing schema to {}", schema_file.to_string_lossy()); - std::fs::write(schema_file, schema.sdl()).context("Failed to write schema")?; - } - - let app = Router::new() - .route( - "/", - get(graphiql).post_service(GraphQL::new(schema.clone())), - ) - .route_service("/ws", GraphQLSubscription::new(schema)); - - eprintln!("Server is running at http://localhost:8000"); - axum::serve( - TcpListener::bind("127.0.0.1:8000") + let stop_signal = cancel_token.child_token(); + let indexer = indexer::CcdScanIndexer::new(cli.node, pool).await?; + let task = tokio::spawn(async move { indexer.run(stop_signal).await }); + OptionFuture::from(Some(task)) + } else { + OptionFuture::from(None) + }; + + let (subscription, subscription_listener) = graphql_api::Subscription::new(); + let mut subscription_task = { + let pool = pool.clone(); + let stop_signal = cancel_token.child_token(); + tokio::spawn(async move { subscription_listener.listen(pool, stop_signal).await }) + }; + + let mut queries_task = { + let service = graphql_api::Service::new(subscription, pool); + if let Some(schema_file) = cli.schema_out { + info!("Writing schema to {}", schema_file.to_string_lossy()); + std::fs::write(schema_file, service.schema.sdl()).context("Failed to write schema")?; + } + let tcp_listener = TcpListener::bind(cli.listen) .await - .context("Parsing TCP listener address failed")?, - app, - ) - .await - .context("Server failed")?; - + .context("Parsing TCP listener address failed")?; + let stop_signal = cancel_token.child_token(); + info!("Server is running at {:?}", cli.listen); + tokio::spawn(async move { service.serve(tcp_listener, stop_signal).await }) + }; + + // Await for signal to shutdown or any of the tasks to stop. + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received signal to shutdown"); + }, + Some(result) = &mut indexer_task => { + error!("Indexer task stopped."); + if let Err(err) = result? { + error!("Indexer error: {}", err); + } + }, + result = &mut queries_task => { + error!("Queries task stopped."); + if let Err(err) = result? { + error!("Queries error: {}", err); + } + }, + result = &mut subscription_task => { + error!("Subscription task stopped."); + if let Err(err) = result? { + error!("Subscription error: {}", err); + } + }, + } + info!("Shutting down"); + cancel_token.cancel(); + let _ = indexer_task.await.transpose()?; + let _ = queries_task.await?; + let _ = subscription_task.await?; Ok(()) } From 72c0650e0d1a05c7708e16320f42a4d18538af87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 23 Sep 2024 13:54:12 +0200 Subject: [PATCH 07/50] Split binary into two --- backend-rust/Cargo.lock | 58 +++++++++---------- backend-rust/Cargo.toml | 2 +- .../src/{main.rs => bin/ccdscan-api.rs} | 51 +++++----------- backend-rust/src/bin/ccdscan-indexer.rs | 58 +++++++++++++++++++ backend-rust/src/indexer.rs | 7 +-- backend-rust/src/lib.rs | 2 + rust-toolchain.toml | 3 + 7 files changed, 110 insertions(+), 71 deletions(-) rename backend-rust/src/{main.rs => bin/ccdscan-api.rs} (66%) create mode 100644 backend-rust/src/bin/ccdscan-indexer.rs create mode 100644 backend-rust/src/lib.rs create mode 100644 rust-toolchain.toml diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index b3bcb65d..8bd5f6e6 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -547,35 +547,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backend-rust" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-graphql", - "async-graphql-axum", - "axum 0.7.5", - "chrono", - "ciborium", - "clap", - "concordium-rust-sdk", - "derive_more", - "dotenv", - "futures", - "hex", - "iso8601-duration", - "rust_decimal", - "serde", - "serde_json", - "sqlx", - "thiserror", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "tracing-subscriber", -] - [[package]] name = "backtrace" version = "0.3.71" @@ -916,6 +887,35 @@ dependencies = [ "tracing", ] +[[package]] +name = "concordium-scan" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-graphql", + "async-graphql-axum", + "axum 0.7.5", + "chrono", + "ciborium", + "clap", + "concordium-rust-sdk", + "derive_more", + "dotenv", + "futures", + "hex", + "iso8601-duration", + "rust_decimal", + "serde", + "serde_json", + "sqlx", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "concordium-smart-contract-engine" version = "6.0.0" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 6161d349..b95c25d7 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "backend-rust" +name = "concordium-scan" version = "0.1.0" edition = "2021" diff --git a/backend-rust/src/main.rs b/backend-rust/src/bin/ccdscan-api.rs similarity index 66% rename from backend-rust/src/main.rs rename to backend-rust/src/bin/ccdscan-api.rs index 41dc2512..449a8dda 100644 --- a/backend-rust/src/main.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -1,8 +1,7 @@ use anyhow::Context; use clap::Parser; -use concordium_rust_sdk::v2; +use concordium_scan::graphql_api; use dotenv::dotenv; -use futures::future::OptionFuture; use sqlx::PgPool; use std::{ net::SocketAddr, @@ -13,12 +12,8 @@ use tokio_util::sync::CancellationToken; use tracing::{ error, info, - warn, }; -mod graphql_api; -mod indexer; - // TODO add env for remaining args. #[derive(Parser)] struct Cli { @@ -26,12 +21,6 @@ struct Cli { /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] database_url: String, - /// gRPC interface of the node. Several can be provided. - #[arg(long, default_value = "http://localhost:20000")] - node: Vec, - /// Whether to run the indexer. - #[arg(long)] - indexer: bool, /// Output the GraphQL Schema for the API to this path. #[arg(long)] schema_out: Option, @@ -52,18 +41,8 @@ async fn main() -> anyhow::Result<()> { .context("Failed constructing database connection pool")?; let cancel_token = CancellationToken::new(); - let mut indexer_task = if cli.indexer { - let pool = pool.clone(); - let stop_signal = cancel_token.child_token(); - let indexer = indexer::CcdScanIndexer::new(cli.node, pool).await?; - let task = tokio::spawn(async move { indexer.run(stop_signal).await }); - OptionFuture::from(Some(task)) - } else { - OptionFuture::from(None) - }; - let (subscription, subscription_listener) = graphql_api::Subscription::new(); - let mut subscription_task = { + let mut pgnotify_listener = { let pool = pool.clone(); let stop_signal = cancel_token.child_token(); tokio::spawn(async move { subscription_listener.listen(pool, stop_signal).await }) @@ -87,30 +66,28 @@ async fn main() -> anyhow::Result<()> { tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Received signal to shutdown"); - }, - Some(result) = &mut indexer_task => { - error!("Indexer task stopped."); - if let Err(err) = result? { - error!("Indexer error: {}", err); - } + cancel_token.cancel(); + let _ = queries_task.await?; + let _ = pgnotify_listener.await?; }, result = &mut queries_task => { error!("Queries task stopped."); if let Err(err) = result? { error!("Queries error: {}", err); } + info!("Shutting down"); + cancel_token.cancel(); + let _ = pgnotify_listener.await?; }, - result = &mut subscription_task => { - error!("Subscription task stopped."); + result = &mut pgnotify_listener => { + error!("Pgnotify listener task stopped."); if let Err(err) = result? { - error!("Subscription error: {}", err); + error!("Pgnotify listener task error: {}", err); } + info!("Shutting down"); + cancel_token.cancel(); + let _ = queries_task.await?; }, } - info!("Shutting down"); - cancel_token.cancel(); - let _ = indexer_task.await.transpose()?; - let _ = queries_task.await?; - let _ = subscription_task.await?; Ok(()) } diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs new file mode 100644 index 00000000..1d653f1d --- /dev/null +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -0,0 +1,58 @@ +use anyhow::Context; +use clap::Parser; +use concordium_rust_sdk::v2; +use concordium_scan::indexer; +use dotenv::dotenv; +use sqlx::PgPool; +use tokio_util::sync::CancellationToken; +use tracing::{ + error, + info, +}; + +// TODO add env for remaining args. +#[derive(Parser)] +struct Cli { + /// The URL used for the database, something of the form + /// "postgres://postgres:example@localhost/ccd-scan" + #[arg(long, env = "DATABASE_URL")] + database_url: String, + /// gRPC interface of the node. Several can be provided. + #[arg(long, default_value = "http://localhost:20000")] + node: Vec, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenv().ok(); + let cli = Cli::parse(); + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + let pool = PgPool::connect(&cli.database_url) + .await + .context("Failed constructing database connection pool")?; + let cancel_token = CancellationToken::new(); + + let mut indexer_task = { + let pool = pool.clone(); + let stop_signal = cancel_token.child_token(); + let indexer = indexer::CcdScanIndexer::new(cli.node, pool).await?; + tokio::spawn(async move { indexer.run(stop_signal).await }) + }; + // Await for signal to shutdown or any of the tasks to stop. + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Received signal to shutdown"); + cancel_token.cancel(); + let _ = indexer_task.await?; + }, + result = &mut indexer_task => { + error!("Indexer task stopped."); + if let Err(err) = result? { + error!("Indexer error: {}", err); + } + } + } + Ok(()) +} diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 4185f72f..92ca0be3 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -440,15 +440,14 @@ struct PreparedBlockItem { impl PreparedBlockItem { fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result { let height = i64::try_from(data.finalized_block_info.height.height)?; - let block_index = i64::try_from(block_item.index.index).unwrap(); + let block_index = i64::try_from(block_item.index.index)?; let tx_hash = block_item.hash.to_string(); let ccd_cost = i64::try_from( data.chain_parameters .ccd_cost(block_item.energy_cost) .micro_ccd, - ) - .unwrap(); - let energy_cost = i64::try_from(block_item.energy_cost.energy).unwrap(); + )?; + let energy_cost = i64::try_from(block_item.energy_cost.energy)?; let sender = block_item.sender_account().map(|a| a.to_string()); let (transaction_type, account_type, credential_type, update_type) = match &block_item.details { diff --git a/backend-rust/src/lib.rs b/backend-rust/src/lib.rs new file mode 100644 index 00000000..d66732e8 --- /dev/null +++ b/backend-rust/src/lib.rs @@ -0,0 +1,2 @@ +pub mod graphql_api; +pub mod indexer; diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..b32f4610 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.80" +components = [ "rustfmt" ] \ No newline at end of file From 0b0bc5f0cf996e0146d511a9bed4e3794625c0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 23 Sep 2024 15:01:14 +0200 Subject: [PATCH 08/50] Update README with run instructions --- backend-rust/README.md | 45 ++++++++++++++++++------- backend-rust/src/bin/ccdscan-indexer.rs | 2 +- backend-rust/src/indexer.rs | 25 +++++++++----- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/backend-rust/README.md b/backend-rust/README.md index 6d43bca3..ab251bf2 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -2,39 +2,58 @@ This is the backend for [CCDScan](https://ccdscan.io/) Blockchain explorer for the [Concordium blockchain](https://concordium.com/). +The backend consists of two binaries: -## Setup for development +- `ccdscan-indexer`: Traversing the chain indexing events into the database. +- `ccdscan-api`: Running a GraphQL API for querying the database. -Install PostgreSQL server 16 or run `docker-compose up`. +The service is split to allow for running several instances of the GraphQL API and while having a single instance of the indexer. -Install `sqlx-cli` +## Dependencies -``` -cargo install sqlx-cli -``` +To run the service, the following dependencies are required to be available on the system: + +- PostgreSQL server 16 + +## Setup for development + +To develop this service the following tools are required, besides the dependencies listed above: -Create a `.env` file in this directory: +- [Rust and cargo](https://rustup.rs/) +- [sqlx-cli](https://crates.io/crates/sqlx-cli) + +Then set the environment variable `DATABASE_URL` pointing to the location of the SQL database, this can be done by creating a `.env` file within this directory. +Example: ``` # Postgres database connection used by sqlx-cli and this service. DATABASE_URL=postgres://postgres:example@localhost/ccd-scan ``` -Create the database +With the environment variable `DATABASE_URL` set, use the `sqlx` CLI to setup the database and tables and run all the migrations: ``` -sqlx database create +sqlx migrate run ``` -Setup tables: +The project can now be build using `cargo build` + +## Run the Indexer Service + +For instructions how to use the indexer run: ``` -sqlx migrate run +ccdscan-indexer --help ``` + -## Run the backend +## Run the GraphQL API Service +For instructions how to use the API service run: +``` +ccdscan-api --help +``` -TODO + diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index 1d653f1d..f5257a6b 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -53,6 +53,6 @@ async fn main() -> anyhow::Result<()> { error!("Indexer error: {}", err); } } - } + }; Ok(()) } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 92ca0be3..c23a4f45 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1,5 +1,11 @@ //! TODO: //! - Check endpoints are using the same chain. +//! - Extend with prometheus metrics. +//! - Batch blocks into the same SQL transaction. +//! - More logging +//! - Setup CI to check formatting and build. +//! - Build docker images. +//! - Setup CI for deployment. use crate::graphql_api::{ events_from_summary, @@ -23,7 +29,6 @@ use concordium_rust_sdk::{ queries::BlockInfo, AccountTransactionDetails, AccountTransactionEffects, - BlockHeight, BlockItemSummary, BlockItemSummaryDetails, RewardsOverview, @@ -79,7 +84,7 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 let processor_config = concordium_rust_sdk::indexer::ProcessorConfig::new() .set_stop_signal(stop_signal.cancelled_owned()); - info!("Indexing from {}", self.start_height); + info!("Indexing from block height {}", self.start_height); traverse_and_process( traverse_config, BlockIndexer, @@ -140,20 +145,22 @@ impl Indexer for BlockIndexer { _successive_failures: u64, _err: TraverseError, ) -> bool { + // TODO: Add logging true } } +/// Type implementing the `ProcessEvent` handling the insertion of prepared blocks. struct BlockProcessor { + /// Database connection pool pool: PgPool, /// The last finalized block height according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. - last_finalized_height: BlockHeight, + last_finalized_height: i64, /// The last finalized block hash according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. last_finalized_hash: String, } - impl BlockProcessor { /// Construct the block processor by loading the initial state from the database. /// This assumes at least the genesis block is in the database. @@ -169,7 +176,7 @@ SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height Ok(Self { pool, - last_finalized_height: BlockHeight::from(u64::try_from(rec.height)?), + last_finalized_height: rec.height, last_finalized_hash: rec.hash, }) } @@ -389,7 +396,7 @@ VALUES ($1, $2, $3, // Check if this block knows of a new finalized block. // If so, mark the blocks since last time as finalized by this block. if self.block_last_finalized != context.last_finalized_hash { - let last_height = i64::try_from(context.last_finalized_height.height)?; + let last_height = context.last_finalized_height; let rec = sqlx::query!( r#" @@ -409,7 +416,7 @@ RETURNING finalizer.height"#, .await .context("Failed updating finalization_time")?; - context.last_finalized_height = u64::try_from(rec.height)?.into(); + context.last_finalized_height = rec.height; context.last_finalized_hash = self.block_last_finalized.clone(); } @@ -503,8 +510,8 @@ impl PreparedBlockItem { details, )?) }, - _ => { - todo!() + details => { + todo!("details = \n {:#?}", details) }, }; From 28865a1ec48d2244c4dcfa2b26d316b549daca31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 23 Sep 2024 22:29:54 +0200 Subject: [PATCH 09/50] Prepare for metrics --- backend-rust/Cargo.lock | 30 ++++++++++++++ backend-rust/Cargo.toml | 1 + backend-rust/src/bin/ccdscan-indexer.rs | 33 +++++++++++++++- backend-rust/src/indexer.rs | 52 ++++++++++++++++++------- backend-rust/src/lib.rs | 1 + 5 files changed, 101 insertions(+), 16 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 8bd5f6e6..1906ba34 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -904,6 +904,7 @@ dependencies = [ "futures", "hex", "iso8601-duration", + "prometheus-client", "rust_decimal", "serde", "serde_json", @@ -1247,6 +1248,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + [[package]] name = "ed25519" version = "2.2.3" @@ -2524,6 +2531,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prometheus-client" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504ee9ff529add891127c4827eb481bd69dc0ebc72e9a682e187db4caa60c3ca" +dependencies = [ + "dtoa", + "itoa", + "parking_lot", + "prometheus-client-derive-encode", +] + +[[package]] +name = "prometheus-client-derive-encode" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.61", +] + [[package]] name = "prost" version = "0.12.4" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index b95c25d7..95ced39f 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -29,3 +29,4 @@ tracing-subscriber = "0.3" rust_decimal = "1.35" iso8601-duration = { version = "0.2", features = ["chrono"] } tokio-util = "0.7" +prometheus-client = "0.22" diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index f5257a6b..ce53cb1d 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -1,9 +1,15 @@ use anyhow::Context; use clap::Parser; use concordium_rust_sdk::v2; -use concordium_scan::indexer; +use concordium_scan::{ + indexer, + metrics, +}; use dotenv::dotenv; +use prometheus_client::registry::Registry; use sqlx::PgPool; +use std::net::SocketAddr; +use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tracing::{ error, @@ -20,6 +26,9 @@ struct Cli { /// gRPC interface of the node. Several can be provided. #[arg(long, default_value = "http://localhost:20000")] node: Vec, + /// Address to listen for metrics requests + #[arg(long, default_value = "127.0.0.1:8001")] + metrics_listen: SocketAddr, } #[tokio::main] @@ -34,24 +43,44 @@ async fn main() -> anyhow::Result<()> { .context("Failed constructing database connection pool")?; let cancel_token = CancellationToken::new(); + let mut registry = Registry::with_prefix("indexer"); let mut indexer_task = { let pool = pool.clone(); let stop_signal = cancel_token.child_token(); - let indexer = indexer::CcdScanIndexer::new(cli.node, pool).await?; + let indexer = indexer::IndexerService::new(cli.node, pool, &mut registry).await?; tokio::spawn(async move { indexer.run(stop_signal).await }) }; + let mut metrics_task = { + let tcp_listener = TcpListener::bind(cli.metrics_listen) + .await + .context("Parsing TCP listener address failed")?; + let stop_signal = cancel_token.child_token(); + info!("Metrics server is running at {:?}", cli.metrics_listen); + tokio::spawn(metrics::serve(registry, tcp_listener, stop_signal)) + }; // Await for signal to shutdown or any of the tasks to stop. tokio::select! { _ = tokio::signal::ctrl_c() => { info!("Received signal to shutdown"); cancel_token.cancel(); let _ = indexer_task.await?; + let _ = metrics_task.await?; }, result = &mut indexer_task => { error!("Indexer task stopped."); if let Err(err) = result? { error!("Indexer error: {}", err); } + cancel_token.cancel(); + let _ = metrics_task.await?; + } + result = &mut metrics_task => { + error!("Metrics task stopped."); + if let Err(err) = result? { + error!("Metrics error: {}", err); + } + cancel_token.cancel(); + let _ = indexer_task.await?; } }; Ok(()) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index c23a4f45..c6c63907 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -42,18 +42,29 @@ use concordium_rust_sdk::{ }, }; use futures::TryStreamExt; +use prometheus_client::{ + metrics::counter::Counter, + registry::Registry, +}; use sqlx::PgPool; use tokio_util::sync::CancellationToken; -use tracing::info; +use tracing::{ + info, + warn, +}; -pub struct CcdScanIndexer { +pub struct IndexerService { endpoints: Vec, start_height: u64, block_processor: BlockProcessor, } -impl CcdScanIndexer { - pub async fn new(endpoints: Vec, pool: PgPool) -> anyhow::Result { +impl IndexerService { + pub async fn new( + endpoints: Vec, + pool: PgPool, + registry: &mut Registry, + ) -> anyhow::Result { let last_height_stored = sqlx::query!( r#" SELECT height FROM blocks ORDER BY height DESC LIMIT 1 @@ -69,7 +80,8 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 save_genesis_data(endpoints[0].clone(), &pool).await?; 1 }; - let block_processor = BlockProcessor::load_from_database(pool).await?; + let block_processor = + BlockProcessor::new(pool, registry.sub_registry_with_prefix("processor")).await?; Ok(Self { endpoints, @@ -160,11 +172,13 @@ struct BlockProcessor { /// The last finalized block hash according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. last_finalized_hash: String, + /// Metric counting how many blocks was saved to the database successfully. + blocks_processed: Counter, } impl BlockProcessor { /// Construct the block processor by loading the initial state from the database. /// This assumes at least the genesis block is in the database. - async fn load_from_database(pool: PgPool) -> anyhow::Result { + async fn new(pool: PgPool, registry: &mut Registry) -> anyhow::Result { let rec = sqlx::query!( r#" SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height ASC LIMIT 1 @@ -174,10 +188,18 @@ SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height .await .context("Failed to query data for save context")?; + let blocks_processed = Counter::default(); + registry.register( + "blocks_processed", + "Number of blocks save to the database", + blocks_processed.clone(), + ); + Ok(Self { pool, last_finalized_height: rec.height, last_finalized_hash: rec.hash, + blocks_processed, }) } } @@ -215,6 +237,7 @@ impl ProcessEvent for BlockProcessor { tx.commit() .await .context("Failed to commit SQL transaction")?; + self.blocks_processed.inc(); Ok(format!("Processed block {}:{}", data.height, data.hash)) } @@ -441,7 +464,8 @@ struct PreparedBlockItem { success: bool, events: Option, reject: Option, - prepared_event: PreparedEvent, + // This is an option temporarily, until we are able to handle every type of event. + prepared_event: Option, } impl PreparedBlockItem { @@ -504,14 +528,13 @@ impl PreparedBlockItem { }; let prepared_event = match &block_item.details { BlockItemSummaryDetails::AccountCreation(details) => { - PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( - data, - &block_item, - details, - )?) + Some(PreparedEvent::AccountCreation( + PreparedAccountCreation::prepare(data, &block_item, details)?, + )) }, details => { - todo!("details = \n {:#?}", details) + warn!("details = \n {:#?}", details); + None }, }; @@ -560,7 +583,8 @@ VALUES .await?; match &self.prepared_event { - PreparedEvent::AccountCreation(event) => event.save(context, tx).await?, + Some(PreparedEvent::AccountCreation(event)) => event.save(context, tx).await?, + _ => {}, } Ok(()) } diff --git a/backend-rust/src/lib.rs b/backend-rust/src/lib.rs index d66732e8..ff12407d 100644 --- a/backend-rust/src/lib.rs +++ b/backend-rust/src/lib.rs @@ -1,2 +1,3 @@ pub mod graphql_api; pub mod indexer; +pub mod metrics; From ce6decbd2381da130ab4b3d5e212400c2a021ac1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Tue, 24 Sep 2024 11:42:29 +0200 Subject: [PATCH 10/50] Label the node in metrics --- backend-rust/src/indexer.rs | 176 +++++++++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 33 deletions(-) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index c6c63907..b59a6340 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -43,23 +43,35 @@ use concordium_rust_sdk::{ }; use futures::TryStreamExt; use prometheus_client::{ - metrics::counter::Counter, + metrics::{ + counter::Counter, + family::Family, + gauge::Gauge, + }, registry::Registry, }; use sqlx::PgPool; +use tokio::try_join; use tokio_util::sync::CancellationToken; use tracing::{ info, warn, }; +/// Service traversing each block of the chain, indexing it into a database. pub struct IndexerService { + /// List of Concordium nodes to cycle through when traversing. endpoints: Vec, + /// The block height to traversing from. start_height: u64, + /// State tracked by the block preprocessor during traversing. + block_pre_processor: BlockPreProcessor, + /// State tracked by the block processor, which is submitting to the database. block_processor: BlockProcessor, } impl IndexerService { + /// Construct the service. This reads the current state from the database. pub async fn new( endpoints: Vec, pool: PgPool, @@ -80,26 +92,30 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 save_genesis_data(endpoints[0].clone(), &pool).await?; 1 }; + let block_pre_processor = + BlockPreProcessor::new(registry.sub_registry_with_prefix("preprocessor")); let block_processor = BlockProcessor::new(pool, registry.sub_registry_with_prefix("processor")).await?; Ok(Self { endpoints, start_height, + block_pre_processor, block_processor, }) } - pub async fn run(self, stop_signal: CancellationToken) -> anyhow::Result<()> { + /// Run the service. This future will only stop when signalled by the `cancel_token`. + pub async fn run(self, cancel_token: CancellationToken) -> anyhow::Result<()> { let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) .context("Failed setting up TraverseConfig")?; let processor_config = concordium_rust_sdk::indexer::ProcessorConfig::new() - .set_stop_signal(stop_signal.cancelled_owned()); + .set_stop_signal(cancel_token.cancelled_owned()); info!("Indexing from block height {}", self.start_height); traverse_and_process( traverse_config, - BlockIndexer, + self.block_pre_processor, processor_config, self.block_processor, ) @@ -108,56 +124,150 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 } } -struct BlockIndexer; +/// Represents the labels used for metrics related to Concordium Node. +#[derive(Clone, Debug, Hash, PartialEq, Eq, prometheus_client::encoding::EncodeLabelSet)] +struct NodeMetricLabels { + /// URI of the node + node: String, +} +impl NodeMetricLabels { + fn new(endpoint: &v2::Endpoint) -> Self { + Self { + node: endpoint.uri().to_string(), + } + } +} + +/// State tracked during block preprocessing, this also holds the implementation of +/// [`Indexer`](concordium_rust_sdk::indexer::Indexer). Since several preprocessors can run in +/// parallel, this must be `Sync`. +struct BlockPreProcessor { + /// Metric counting the total number of connections ever established to a node. + established_node_connections: Family, + /// Metric counting the total number of failed attempts to preprocess blocks. + total_failures: Family, + /// Metric tracking the number of blocks currently being preprocessed. + blocks_being_preprocessed: Family, +} +impl BlockPreProcessor { + fn new(registry: &mut Registry) -> Self { + let established_node_connections = Family::default(); + registry.register( + "established_node_connections", + "Total number of established Concordium Node connections", + established_node_connections.clone(), + ); + let total_failures = Family::default(); + registry.register( + "preprocessing_failures", + "Total number of failed attempts to preprocess blocks", + total_failures.clone(), + ); + let blocks_being_preprocessed = Family::default(); + registry.register( + "blocks_being_preprocessed", + "Current number of blocks being preprocessed", + blocks_being_preprocessed.clone(), + ); + Self { + established_node_connections, + total_failures, + blocks_being_preprocessed, + } + } +} #[async_trait] -impl Indexer for BlockIndexer { - type Context = (); +impl Indexer for BlockPreProcessor { + type Context = NodeMetricLabels; type Data = PreparedBlock; + /// Called when a new connection is established to the given endpoint. + /// The return value from this method is passed to each call of on_finalized. async fn on_connect<'a>( &mut self, endpoint: v2::Endpoint, _client: &'a mut v2::Client, ) -> QueryResult { + // TODO: check the genesis hash matches, i.e. that the node is running on the same network. info!("Connection established to node at uri: {}", endpoint.uri()); - Ok(()) + let label = NodeMetricLabels::new(&endpoint); + self.established_node_connections + .get_or_create(&label) + .inc(); + Ok(label) } + /// The main method of this trait. It is called for each finalized block + /// that the indexer discovers. Note that the indexer might call this + /// concurrently for multiple blocks at the same time to speed up indexing. + /// + /// This method is meant to return errors that are unexpected, and if it + /// does return an error the indexer will attempt to reconnect to the + /// next endpoint. async fn on_finalized<'a>( &self, mut client: v2::Client, - _ctx: &'a Self::Context, + label: &'a Self::Context, fbi: FinalizedBlockInfo, ) -> QueryResult { - let block_info = client.get_block_info(fbi.height).await?.response; - let events: Vec<_> = client - .get_block_transaction_events(fbi.height) - .await? - .response - .try_collect() - .await?; - let chain_parameters = client - .get_block_chain_parameters(fbi.height) - .await? - .response; - let tokenomics_info = client.get_tokenomics_info(fbi.height).await?.response; - let data = BlockData { - finalized_block_info: fbi, - block_info, - events, - chain_parameters, - tokenomics_info, - }; - Ok(PreparedBlock::prepare(&data).map_err(|err| RPCError::ParseError(err))?) + self.blocks_being_preprocessed.get_or_create(label).inc(); + // We block together the computation, so we can update the metric in the error case, before + // returning early. + let result = async move { + let mut client1 = client.clone(); + let mut client2 = client.clone(); + let mut client3 = client.clone(); + let get_events = async move { + let events = client3 + .get_block_transaction_events(fbi.height) + .await? + .response + .try_collect::>() + .await?; + Ok(events) + }; + + let (block_info, chain_parameters, events, tokenomics_info) = try_join!( + client1.get_block_info(fbi.height), + client2.get_block_chain_parameters(fbi.height), + get_events, + client.get_tokenomics_info(fbi.height) + ) + .map_err(|err| err)?; + + let data = BlockData { + finalized_block_info: fbi, + block_info: block_info.response, + events, + chain_parameters: chain_parameters.response, + tokenomics_info: tokenomics_info.response, + }; + let prepared_block = + PreparedBlock::prepare(&data).map_err(|err| RPCError::ParseError(err))?; + Ok(prepared_block) + } + .await; + self.blocks_being_preprocessed.get_or_create(label).dec(); + result } + /// Called when either connecting to the node or querying the node fails. + /// The number of successive failures without progress is passed to the + /// method which should return whether to stop indexing `true` or not + /// `false` async fn on_failure( &mut self, - _ep: v2::Endpoint, - _successive_failures: u64, - _err: TraverseError, + endpoint: v2::Endpoint, + successive_failures: u64, + err: TraverseError, ) -> bool { - // TODO: Add logging + info!( + "Failed preprocessing {} times in row: {}", + successive_failures, err + ); + self.total_failures + .get_or_create(&NodeMetricLabels::new(&endpoint)) + .inc(); true } } From 5a4c230058fd894ab18e19deee5356dd7cdc9fd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 25 Sep 2024 14:29:12 +0200 Subject: [PATCH 11/50] Remove .env and added missing metrics.rs file --- .gitignore | 2 ++ backend-rust/.env | 2 -- backend-rust/src/metrics.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) delete mode 100644 backend-rust/.env create mode 100644 backend-rust/src/metrics.rs diff --git a/.gitignore b/.gitignore index abd33add..b57c37c0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ pgbackrest.conf target/ .vscode/ + +.env \ No newline at end of file diff --git a/backend-rust/.env b/backend-rust/.env deleted file mode 100644 index 1cb082f1..00000000 --- a/backend-rust/.env +++ /dev/null @@ -1,2 +0,0 @@ -# Postgres -DATABASE_URL=postgres://postgres:example@localhost/ccd-scan \ No newline at end of file diff --git a/backend-rust/src/metrics.rs b/backend-rust/src/metrics.rs new file mode 100644 index 00000000..aff23ead --- /dev/null +++ b/backend-rust/src/metrics.rs @@ -0,0 +1,29 @@ +use axum::extract::State; +use prometheus_client::registry::Registry; +use std::sync::Arc; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +/// Run server exposing the Prometheus metrics +pub async fn serve( + registry: Registry, + tcp_listener: TcpListener, + stop_signal: CancellationToken, +) -> anyhow::Result<()> { + let app = axum::Router::new() + .route("/metrics", axum::routing::get(metrics)) + .with_state(Arc::new(registry)); + axum::serve(tcp_listener, app) + .with_graceful_shutdown(stop_signal.cancelled_owned()) + .await?; + Ok(()) +} + +/// GET Handler for route `/metrics`. +/// Exposes the metrics in the registry in the Prometheus format. +async fn metrics(State(registry): State>) -> Result { + let mut buffer = String::new(); + prometheus_client::encoding::text::encode(&mut buffer, ®istry) + .map_err(|err| err.to_string())?; + Ok(buffer) +} From e3547ec6cd26986eda2ee88aefd3e58aeb4cbd18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 25 Sep 2024 15:12:39 +0200 Subject: [PATCH 12/50] Update readme --- backend-rust/README.md | 2 +- backend-rust/src/indexer.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend-rust/README.md b/backend-rust/README.md index ab251bf2..12c8b533 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -1,6 +1,6 @@ # CCDScan Backend -This is the backend for [CCDScan](https://ccdscan.io/) Blockchain explorer for the [Concordium blockchain](https://concordium.com/). +This is the backend for the [CCDScan](https://ccdscan.io/) Blockchain explorer for the [Concordium blockchain](https://concordium.com/). The backend consists of two binaries: diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index b59a6340..5e0a5823 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -335,7 +335,6 @@ impl ProcessEvent for BlockProcessor { async fn process(&mut self, data: &Self::Data) -> Result { // TODO: Improve this by batching blocks within some time frame into the same // DB-transaction. - // TODO: Handle failures and probably retry a few times let mut tx = self .pool .begin() @@ -365,11 +364,13 @@ impl ProcessEvent for BlockProcessor { _error: Self::Error, _failed_attempts: u32, ) -> Result { + // TODO add logging. + // TODO add metric counting failures. Ok(true) } } -/// Information for a block which is relevant for storing it into the database. +/// Raw block Information fetched from a Concordium Node. struct BlockData { finalized_block_info: FinalizedBlockInfo, block_info: BlockInfo, @@ -378,7 +379,9 @@ struct BlockData { tokenomics_info: RewardsOverview, } -pub async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { +/// Function for initializing the database with the genesis block. +/// This should only be called if the database is empty. +async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { let mut client = v2::Client::new(endpoint).await?; let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); @@ -451,7 +454,7 @@ pub async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow: Ok(()) } -pub struct PreparedBlock { +struct PreparedBlock { hash: String, height: i64, slot_time: NaiveDateTime, From 45acaaaa4417669425b5d096a21547efcb3f5279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 25 Sep 2024 15:15:43 +0200 Subject: [PATCH 13/50] Use the usual rustfmt.toml config --- backend-rust/rustfmt.toml | 31 +- backend-rust/src/bin/ccdscan-api.rs | 23 +- backend-rust/src/bin/ccdscan-indexer.rs | 18 +- backend-rust/src/graphql_api.rs | 2213 +++++++++++------------ backend-rust/src/indexer.rs | 267 ++- backend-rust/src/metrics.rs | 4 +- 6 files changed, 1242 insertions(+), 1314 deletions(-) diff --git a/backend-rust/rustfmt.toml b/backend-rust/rustfmt.toml index 87395aaa..576a9f05 100644 --- a/backend-rust/rustfmt.toml +++ b/backend-rust/rustfmt.toml @@ -1,15 +1,22 @@ -edition = "2021" -version = "Two" -imports_granularity = "Crate" -group_imports = "One" -imports_layout = "Vertical" +edition="2018" +combine_control_expr = false wrap_comments = true -comment_width = 100 -condense_wildcard_suffixes = true -force_multiline_blocks = true +brace_style = "PreferSameLine" +enum_discrim_align_threshold = 20 +fn_single_line = true +format_strings = true +format_macro_matchers = true +format_macro_bodies = true +imports_granularity="Crate" +normalize_comments = false +reorder_impl_items = true +reorder_imports = true +struct_field_align_threshold = 20 +trailing_semicolon = true +type_punctuation_density = "Wide" +use_field_init_shorthand = true +use_try_shorthand = true format_code_in_doc_comments = true -format_generated_files = false +overflow_delimited_expr = true normalize_doc_attributes = true -match_block_trailing_comma = true -normalize_comments = true -use_field_init_shorthand = true +use_small_heuristics = "Off" diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index 449a8dda..72e19d28 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -3,16 +3,10 @@ use clap::Parser; use concordium_scan::graphql_api; use dotenv::dotenv; use sqlx::PgPool; -use std::{ - net::SocketAddr, - path::PathBuf, -}; +use std::{net::SocketAddr, path::PathBuf}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; -use tracing::{ - error, - info, -}; +use tracing::{error, info}; // TODO add env for remaining args. #[derive(Parser)] @@ -23,19 +17,17 @@ struct Cli { database_url: String, /// Output the GraphQL Schema for the API to this path. #[arg(long)] - schema_out: Option, + schema_out: Option, /// Address to listen for API requests #[arg(long, default_value = "127.0.0.1:8000")] - listen: SocketAddr, + listen: SocketAddr, } #[tokio::main] async fn main() -> anyhow::Result<()> { dotenv().ok(); let cli = Cli::parse(); - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .init(); + tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); let pool = PgPool::connect(&cli.database_url) .await .context("Failed constructing database connection pool")?; @@ -54,9 +46,8 @@ async fn main() -> anyhow::Result<()> { info!("Writing schema to {}", schema_file.to_string_lossy()); std::fs::write(schema_file, service.schema.sdl()).context("Failed to write schema")?; } - let tcp_listener = TcpListener::bind(cli.listen) - .await - .context("Parsing TCP listener address failed")?; + let tcp_listener = + TcpListener::bind(cli.listen).await.context("Parsing TCP listener address failed")?; let stop_signal = cancel_token.child_token(); info!("Server is running at {:?}", cli.listen); tokio::spawn(async move { service.serve(tcp_listener, stop_signal).await }) diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index ce53cb1d..54c1df56 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -1,20 +1,14 @@ use anyhow::Context; use clap::Parser; use concordium_rust_sdk::v2; -use concordium_scan::{ - indexer, - metrics, -}; +use concordium_scan::{indexer, metrics}; use dotenv::dotenv; use prometheus_client::registry::Registry; use sqlx::PgPool; use std::net::SocketAddr; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; -use tracing::{ - error, - info, -}; +use tracing::{error, info}; // TODO add env for remaining args. #[derive(Parser)] @@ -22,10 +16,10 @@ struct Cli { /// The URL used for the database, something of the form /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] - database_url: String, + database_url: String, /// gRPC interface of the node. Several can be provided. #[arg(long, default_value = "http://localhost:20000")] - node: Vec, + node: Vec, /// Address to listen for metrics requests #[arg(long, default_value = "127.0.0.1:8001")] metrics_listen: SocketAddr, @@ -35,9 +29,7 @@ struct Cli { async fn main() -> anyhow::Result<()> { dotenv().ok(); let cli = Cli::parse(); - tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .init(); + tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); let pool = PgPool::connect(&cli.database_url) .await .context("Failed constructing database connection pool")?; diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 51fcb8ce..d0712f2e 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -8,44 +8,16 @@ use anyhow::Context as _; use async_graphql::{ http::GraphiQLSource, - types::{ - self, - connection, - }, - ComplexObject, - Context, - EmptyMutation, - Enum, - InputObject, - InputValueError, - InputValueResult, - Interface, - Object, - Scalar, - ScalarType, - Schema, - SimpleObject, - Subscription, - Union, - Value, + types::{self, connection}, + ComplexObject, Context, EmptyMutation, Enum, InputObject, InputValueError, InputValueResult, + Interface, Object, Scalar, ScalarType, Schema, SimpleObject, Subscription, Union, Value, }; use async_graphql_axum::GraphQLSubscription; use chrono::Duration; use futures::prelude::*; -use sqlx::{ - postgres::types::PgInterval, - PgPool, - Postgres, -}; -use std::{ - error::Error, - str::FromStr as _, - sync::Arc, -}; -use tokio::{ - net::TcpListener, - sync::broadcast, -}; +use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; +use std::{error::Error, str::FromStr as _, sync::Arc}; +use tokio::{net::TcpListener, sync::broadcast}; use tokio_util::sync::CancellationToken; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -62,7 +34,9 @@ impl Service { .extension(async_graphql::extensions::Tracing) .data(pool) .finish(); - Self { schema } + Self { + schema, + } } pub async fn serve( @@ -86,10 +60,7 @@ impl Service { async fn graphiql() -> impl axum::response::IntoResponse { axum::response::Html( - GraphiQLSource::build() - .endpoint("/") - .subscription_endpoint("/ws") - .finish(), + GraphiQLSource::build().endpoint("/").subscription_endpoint("/ws").finish(), ) } } @@ -125,9 +96,7 @@ enum ApiError { } impl From for ApiError { - fn from(value: sqlx::Error) -> Self { - ApiError::FailedDatabaseQuery(Arc::new(value)) - } + fn from(value: sqlx::Error) -> Self { ApiError::FailedDatabaseQuery(Arc::new(value)) } } type ApiResult = Result; @@ -161,16 +130,15 @@ impl Query { backend_versions: VERSION.to_string(), } } + async fn block<'a>(&self, ctx: &Context<'a>, height_id: types::ID) -> ApiResult { - let height: i64 = height_id - .clone() - .try_into() - .map_err(ApiError::InvalidIdInt)?; + let height: i64 = height_id.clone().try_into().map_err(ApiError::InvalidIdInt)?; sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", height) .fetch_optional(get_pool(ctx)?) .await? .ok_or(ApiError::NotFound) } + async fn block_by_block_hash<'a>( &self, ctx: &Context<'a>, @@ -189,9 +157,7 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { check_connection_query(&first, &last)?; @@ -200,42 +166,39 @@ impl Query { sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM blocks"); match (after, before) { - (None, None) => {}, + (None, None) => {} (None, Some(before)) => { builder .push(" WHERE height < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } (Some(after), None) => { builder .push(" WHERE height > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } (Some(after), Some(before)) => { builder .push(" WHERE height > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) .push(" AND height < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } } match (first, &last) { (None, None) => { builder.push(" ORDER BY height ASC)"); - }, + } (None, Some(last)) => { builder .push(" ORDER BY height DESC LIMIT ") .push_bind(last) .push(") ORDER BY height ASC "); - }, + } (Some(first), None) => { - builder - .push(" ORDER BY height ASC LIMIT ") - .push_bind(first) - .push(")"); - }, + builder.push(" ORDER BY height ASC LIMIT ").push_bind(first).push(")"); + } (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } @@ -243,9 +206,7 @@ impl Query { let mut connection = connection::Connection::new(true, true); while let Some(block) = block_stream.try_next().await? { - connection - .edges - .push(connection::Edge::new(block.height.to_string(), block)); + connection.edges.push(connection::Edge::new(block.height.to_string(), block)); } if last.is_some() { if let Some(edge) = connection.edges.last() { @@ -269,6 +230,7 @@ impl Query { .await? .ok_or(ApiError::NotFound) } + async fn transaction_by_transaction_hash<'a>( &self, ctx: &Context<'a>, @@ -280,6 +242,7 @@ impl Query { .await? .ok_or(ApiError::NotFound) } + async fn transactions<'a>( &self, ctx: &Context<'a>, @@ -287,9 +250,7 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { check_connection_query(&first, &last)?; @@ -300,17 +261,20 @@ impl Query { if last.is_some() { builder.push("SELECT * FROM ("); } - builder.push("SELECT * FROM ( + builder.push( + "SELECT * FROM ( SELECT - block, index, hash, ccd_cost, energy_cost, sender, type, type_account, type_credential_deployment, + block, index, hash, ccd_cost, energy_cost, sender, type, type_account, \ + type_credential_deployment, type_update, success, events, reject, LAG(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_prev, LEAD(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_next FROM transactions -)"); +)", + ); match (after_id, before_id) { - (None, None) => {}, + (None, None) => {} (None, Some(before_id)) => { builder .push(" WHERE block < ") @@ -319,7 +283,7 @@ impl Query { .push_bind(before_id.block) .push(" AND index < ") .push_bind(before_id.index); - }, + } (Some(after_id), None) => { builder .push(" WHERE block > ") @@ -328,7 +292,7 @@ impl Query { .push_bind(after_id.block) .push(" AND index > ") .push_bind(after_id.index); - }, + } (Some(after_id), Some(before_id)) => { builder .push(" WHERE (block > ") @@ -344,7 +308,7 @@ impl Query { .push(" AND index < ") .push_bind(before_id.index) .push(")"); - }, + } } match (first, last) { @@ -352,23 +316,22 @@ impl Query { builder .push(" ORDER BY block ASC, index ASC LIMIT ") .push_bind(QUERY_TRANSACTIONS_LIMIT); - }, + } (None, Some(last)) => { builder .push(" ORDER BY block DESC, index DESC LIMIT ") .push_bind(last.min(QUERY_TRANSACTIONS_LIMIT)) .push(") ORDER BY block ASC, index ASC"); - }, + } (Some(first), None) => { builder .push(" ORDER BY block ASC, index ASC LIMIT ") .push_bind(first.min(QUERY_TRANSACTIONS_LIMIT)); - }, + } (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } - let mut row_stream = builder - .build_query_as::() - .fetch(get_pool(ctx)?); + let mut row_stream = + builder.build_query_as::().fetch(get_pool(ctx)?); let mut connection = connection::Connection::new(true, true); let mut first_row = true; while let Some(row) = row_stream.try_next().await? { @@ -385,6 +348,7 @@ impl Query { Ok(connection) } + async fn account<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; sqlx::query_as("SELECT * FROM accounts WHERE index=$1") @@ -393,6 +357,7 @@ impl Query { .await? .ok_or(ApiError::NotFound) } + async fn account_by_address<'a>( &self, ctx: &Context<'a>, @@ -404,6 +369,7 @@ impl Query { .await? .ok_or(ApiError::NotFound) } + async fn accounts<'a>( &self, ctx: &Context<'a>, @@ -413,9 +379,7 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { check_connection_query(&first, &last)?; @@ -442,45 +406,44 @@ impl Query { ); match (after, before) { - (None, None) => {}, + (None, None) => {} (None, Some(before)) => { builder .push(" WHERE index < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } (Some(after), None) => { builder .push(" WHERE index > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } (Some(after), Some(before)) => { builder .push(" WHERE index > ") .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) .push(" AND index < ") .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - }, + } } match (first, &last) { (None, None) => { builder.push(" ORDER BY index ASC"); - }, + } (None, Some(last)) => { builder .push(" ORDER BY index DESC LIMIT ") .push_bind(last) .push(") ORDER BY index ASC "); - }, + } (Some(first), None) => { builder.push(" ORDER BY index ASC LIMIT ").push_bind(first); - }, + } (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } - let mut row_stream = builder - .build_query_as::() - .fetch(get_pool(ctx)?); + let mut row_stream = + builder.build_query_as::().fetch(get_pool(ctx)?); let mut connection = connection::Connection::new(true, true); let mut first_row = true; @@ -489,21 +452,18 @@ impl Query { connection.has_previous_page = row.has_prev; first_row = false; } - connection.edges.push(connection::Edge::new( - row.account.index.to_string(), - row.account, - )); + connection + .edges + .push(connection::Edge::new(row.account.index.to_string(), row.account)); connection.has_next_page = row.has_next; } Ok(connection) } - async fn baker(&self, _id: types::ID) -> Baker { - todo!() - } - async fn baker_by_baker_id(&self, _id: BakerId) -> Baker { - todo!() - } + + async fn baker(&self, _id: types::ID) -> Baker { todo!() } + + async fn baker_by_baker_id(&self, _id: BakerId) -> Baker { todo!() } async fn bakers( &self, @@ -513,17 +473,18 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() } async fn search(&self, query: String) -> SearchResult { - SearchResult { _query: query } + SearchResult { + _query: query, + } } + async fn block_metrics<'a>( &self, ctx: &Context<'a>, @@ -548,9 +509,8 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", .await?; let bucket_width = period.bucket_width(); - let bucket_interval: PgInterval = bucket_width - .try_into() - .map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; + let bucket_interval: PgInterval = + bucket_width.try_into().map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; let res = sqlx::query!( " WITH data AS ( @@ -595,9 +555,7 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) let y_block_time_avg = row.y_block_time_avg.unwrap_or(0) as f64 / 1000.0; buckets.y_block_time_avg.push(y_block_time_avg); let y_finalization_time_avg = row.y_finalization_time_avg.unwrap_or(0) as f64 / 1000.0; - buckets - .y_finalization_time_avg - .push(y_finalization_time_avg); + buckets.y_finalization_time_avg.push(y_finalization_time_avg); buckets .y_last_total_micro_ccd_staked .push(row.y_last_total_micro_ccd_staked.unwrap_or(0)); @@ -617,29 +575,33 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) // transactionMetrics(period: MetricsPeriod!): TransactionMetrics // bakerMetrics(period: MetricsPeriod!): BakerMetrics! // rewardMetrics(period: MetricsPeriod!): RewardMetrics! - // rewardMetricsForAccount(accountId: ID! period: MetricsPeriod!): RewardMetrics! - // poolRewardMetricsForPassiveDelegation(period: MetricsPeriod!): PoolRewardMetrics! - // poolRewardMetricsForBakerPool(bakerId: ID! period: MetricsPeriod!): PoolRewardMetrics! - // passiveDelegation: PassiveDelegation + // rewardMetricsForAccount(accountId: ID! period: MetricsPeriod!): + // RewardMetrics! poolRewardMetricsForPassiveDelegation(period: + // MetricsPeriod!): PoolRewardMetrics! + // poolRewardMetricsForBakerPool(bakerId: ID! period: MetricsPeriod!): + // PoolRewardMetrics! passiveDelegation: PassiveDelegation // paydayStatus: PaydayStatus // latestChainParameters: ChainParameters // importState: ImportState - // nodeStatuses(sortField: NodeSortField! sortDirection: NodeSortDirection! "Returns the first - // _n_ elements from the list." first: Int "Returns the elements in the list that come after the - // specified cursor." after: String "Returns the last _n_ elements from the list." last: Int - // "Returns the elements in the list that come before the specified cursor." before: String): + // nodeStatuses(sortField: NodeSortField! sortDirection: NodeSortDirection! + // "Returns the first _n_ elements from the list." first: Int "Returns the + // elements in the list that come after the specified cursor." after: String + // "Returns the last _n_ elements from the list." last: Int "Returns the + // elements in the list that come before the specified cursor." before: String): // NodeStatusesConnection nodeStatus(id: ID!): NodeStatus - // tokens("Returns the first _n_ elements from the list." first: Int "Returns the elements in - // the list that come after the specified cursor." after: String "Returns the last _n_ elements - // from the list." last: Int "Returns the elements in the list that come before the specified - // cursor." before: String): TokensConnection token(contractIndex: UnsignedLong! - // contractSubIndex: UnsignedLong! tokenId: String!): Token! contract(contractAddressIndex: - // UnsignedLong! contractAddressSubIndex: UnsignedLong!): Contract contracts("Returns the - // first _n_ elements from the list." first: Int "Returns the elements in the list that come - // after the specified cursor." after: String "Returns the last _n_ elements from the list." - // last: Int "Returns the elements in the list that come before the specified cursor." before: - // String): ContractsConnection moduleReferenceEvent(moduleReference: String!): - // ModuleReferenceEvent + // tokens("Returns the first _n_ elements from the list." first: Int "Returns + // the elements in the list that come after the specified cursor." after: + // String "Returns the last _n_ elements from the list." last: Int "Returns + // the elements in the list that come before the specified cursor." before: + // String): TokensConnection token(contractIndex: UnsignedLong! + // contractSubIndex: UnsignedLong! tokenId: String!): Token! + // contract(contractAddressIndex: UnsignedLong! contractAddressSubIndex: + // UnsignedLong!): Contract contracts("Returns the first _n_ elements from + // the list." first: Int "Returns the elements in the list that come + // after the specified cursor." after: String "Returns the last _n_ elements + // from the list." last: Int "Returns the elements in the list that come + // before the specified cursor." before: String): ContractsConnection + // moduleReferenceEvent(moduleReference: String!): ModuleReferenceEvent } pub struct Subscription { @@ -649,8 +611,12 @@ impl Subscription { pub fn new() -> (Self, SubscriptionContext) { let (block_added_sender, block_added) = broadcast::channel(100); ( - Subscription { block_added }, - SubscriptionContext { block_added_sender }, + Subscription { + block_added, + }, + SubscriptionContext { + block_added_sender, + }, ) } } @@ -691,10 +657,10 @@ impl SubscriptionContext { .fetch_one(&pool) .await?; self.block_added_sender.send(Arc::new(block))?; - }, + } unknown => { anyhow::bail!("Unknown channel {}", unknown); - }, + } } } }) @@ -706,8 +672,8 @@ impl SubscriptionContext { } } -/// The UnsignedLong scalar type represents a unsigned 64-bit numeric non-fractional value greater -/// than or equal to 0. +/// The UnsignedLong scalar type represents a unsigned 64-bit numeric +/// non-fractional value greater than or equal to 0. #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] #[repr(transparent)] #[serde(transparent)] @@ -725,13 +691,11 @@ impl ScalarType for UnsignedLong { } } - fn to_value(&self) -> Value { - Value::Number(self.0.into()) - } + fn to_value(&self) -> Value { Value::Number(self.0.into()) } } -/// The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can -/// represent values between -(2^63) and 2^63 - 1. +/// The `Long` scalar type represents non-fractional signed whole 64-bit numeric +/// values. Long can represent values between -(2^63) and 2^63 - 1. #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] #[repr(transparent)] #[serde(transparent)] @@ -749,9 +713,7 @@ impl ScalarType for Long { } } - fn to_value(&self) -> Value { - Value::Number(self.0.into()) - } + fn to_value(&self) -> Value { Value::Number(self.0.into()) } } #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] #[repr(transparent)] @@ -774,9 +736,7 @@ impl ScalarType for Byte { } } - fn to_value(&self) -> Value { - Value::Number(self.0.into()) - } + fn to_value(&self) -> Value { Value::Number(self.0.into()) } } #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] @@ -792,9 +752,7 @@ impl ScalarType for Decimal { Ok(Self(rust_decimal::Decimal::from_str(string.as_str())?)) } - fn to_value(&self) -> Value { - Value::String(self.0.to_string()) - } + fn to_value(&self) -> Value { Value::String(self.0.to_string()) } } impl From for Decimal { @@ -817,31 +775,22 @@ impl ScalarType for TimeSpan { Ok(Self::try_from(string)?) } - fn to_value(&self) -> Value { - Value::String(self.0.to_string()) - } + fn to_value(&self) -> Value { Value::String(self.0.to_string()) } } impl TryFrom for TimeSpan { type Error = anyhow::Error; + fn try_from(value: String) -> Result { let duration = iso8601_duration::Duration::from_str(value.as_str()) .map_err(|err| anyhow::anyhow!("Invalid duration, expected ISO-8601"))?; - Ok(Self( - duration - .to_chrono() - .context("Failed to construct duration")?, - )) + Ok(Self(duration.to_chrono().context("Failed to construct duration")?)) } } impl From for String { - fn from(time: TimeSpan) -> Self { - time.0.to_string() - } + fn from(time: TimeSpan) -> Self { time.0.to_string() } } impl From for TimeSpan { - fn from(duration: Duration) -> Self { - TimeSpan(duration) - } + fn from(duration: Duration) -> Self { TimeSpan(duration) } } type BlockHeight = i64; @@ -865,22 +814,22 @@ struct Versions { #[graphql(complex)] pub struct Block { #[graphql(name = "blockHash")] - hash: BlockHash, + hash: BlockHash, #[graphql(name = "blockHeight")] - height: BlockHeight, + height: BlockHeight, /// Time of the block being baked. #[graphql(name = "blockSlotTime")] - slot_time: DateTime, + slot_time: DateTime, #[graphql(skip)] - block_time: i32, + block_time: i32, #[graphql(skip)] finalization_time: Option, #[graphql(skip)] - finalized_by: Option, - baker_id: Option, - total_amount: Amount, + finalized_by: Option, + baker_id: Option, + total_amount: Amount, #[graphql(skip)] - total_staked: Amount, + total_staked: Amount, // chain_parameters: ChainParameters, // balance_statistics: BalanceStatistics, // block_statistics: BlockStatistics, @@ -889,47 +838,38 @@ pub struct Block { #[ComplexObject] impl Block { /// Absolute block height. - async fn id(&self) -> types::ID { - types::ID::from(self.height) - } + async fn id(&self) -> types::ID { types::ID::from(self.height) } /// Number of transactions included in this block. async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { - let result = sqlx::query!( - "SELECT COUNT(*) FROM transactions WHERE block=$1", - self.height - ) - .fetch_one(get_pool(ctx)?) - .await?; + let result = sqlx::query!("SELECT COUNT(*) FROM transactions WHERE block=$1", self.height) + .fetch_one(get_pool(ctx)?) + .await?; Ok(result.count.unwrap_or(0)) } async fn special_events( &self, - #[graphql( - desc = "Filter special events by special event type. Set to null to return all special events (no filtering)." - )] + #[graphql(desc = "Filter special events by special event type. Set to null to return \ + all special events (no filtering).")] include_filters: Option>, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { todo!() } + async fn transactions( &self, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { todo!() @@ -951,20 +891,21 @@ enum SpecialEventTypeFilter { #[derive(SimpleObject)] #[graphql(complex)] struct Contract { - contract_address_index: ContractIndex, + contract_address_index: ContractIndex, contract_address_sub_index: ContractIndex, - contract_address: String, - creator: AccountAddress, - block_height: BlockHeight, - transaction_hash: String, - block_slot_time: DateTime, - snapshot: ContractSnapshot, + contract_address: String, + creator: AccountAddress, + block_height: BlockHeight, + transaction_hash: String, + block_slot_time: DateTime, + snapshot: ContractSnapshot, } #[ComplexObject] impl Contract { async fn contract_events(&self, skip: i32, take: i32) -> ContractEventsCollectionSegment { todo!() } + async fn contract_reject_events( &self, _skip: i32, @@ -972,18 +913,17 @@ impl Contract { ) -> ContractRejectEventsCollectionSegment { todo!() } - async fn tokens(&self, skip: i32, take: i32) -> TokensCollectionSegment { - todo!() - } + + async fn tokens(&self, skip: i32, take: i32) -> TokensCollectionSegment { todo!() } } /// A segment of a collection. #[derive(SimpleObject)] struct TokensCollectionSegment { /// Information to aid in pagination. - page_info: CollectionSegmentInfo, + page_info: CollectionSegmentInfo, /// A flattened list of the items. - items: Vec, + items: Vec, total_count: i32, } @@ -991,21 +931,21 @@ struct TokensCollectionSegment { #[derive(SimpleObject)] struct ContractRejectEventsCollectionSegment { /// Information to aid in pagination. - page_info: CollectionSegmentInfo, + page_info: CollectionSegmentInfo, /// A flattened list of the items. - items: Vec, + items: Vec, total_count: i32, } #[derive(SimpleObject)] struct ContractRejectEvent { - contract_address_index: ContractIndex, + contract_address_index: ContractIndex, contract_address_sub_index: ContractIndex, - sender: AccountAddress, - rejected_event: TransactionRejectReason, - block_height: BlockHeight, - transaction_hash: TransactionHash, - block_slot_time: DateTime, + sender: AccountAddress, + rejected_event: TransactionRejectReason, + block_height: BlockHeight, + transaction_hash: TransactionHash, + block_slot_time: DateTime, } #[derive(Union, serde::Serialize, serde::Deserialize)] @@ -1071,7 +1011,8 @@ pub enum TransactionRejectReason { pub struct ModuleNotWf { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1084,12 +1025,12 @@ pub struct ModuleHashAlreadyExists { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct InvalidInitMethod { module_ref: String, - init_name: String, + init_name: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct InvalidReceiveMethod { - module_ref: String, + module_ref: String, receive_name: String, } @@ -1112,7 +1053,8 @@ pub struct InvalidContractAddress { pub struct RuntimeFailure { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1120,14 +1062,15 @@ pub struct RuntimeFailure { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct AmountTooLarge { address: Address, - amount: Amount, + amount: Amount, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct SerializationFailure { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1136,7 +1079,8 @@ pub struct SerializationFailure { pub struct OutOfEnergy { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1148,10 +1092,10 @@ pub struct RejectedInit { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct RejectedReceive { - reject_reason: i32, + reject_reason: i32, contract_address: ContractAddress, - receive_name: String, - message_as_hex: String, + receive_name: String, + message_as_hex: String, // TODO message: String, } @@ -1164,7 +1108,8 @@ pub struct NonExistentRewardAccount { pub struct InvalidProof { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1183,7 +1128,8 @@ pub struct NotABaker { pub struct InsufficientBalanceForBakerStake { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1192,7 +1138,8 @@ pub struct InsufficientBalanceForBakerStake { pub struct InsufficientBalanceForDelegationStake { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1201,7 +1148,8 @@ pub struct InsufficientBalanceForDelegationStake { pub struct InsufficientDelegationStake { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1210,7 +1158,8 @@ pub struct InsufficientDelegationStake { pub struct StakeUnderMinimumThresholdForBaking { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1219,7 +1168,8 @@ pub struct StakeUnderMinimumThresholdForBaking { pub struct StakeOverMaximumThresholdForPool { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1228,7 +1178,8 @@ pub struct StakeOverMaximumThresholdForPool { pub struct BakerInCooldown { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1242,7 +1193,8 @@ pub struct DuplicateAggregationKey { pub struct NonExistentCredentialId { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1251,7 +1203,8 @@ pub struct NonExistentCredentialId { pub struct KeyIndexAlreadyInUse { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1260,7 +1213,8 @@ pub struct KeyIndexAlreadyInUse { pub struct InvalidAccountThreshold { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1269,7 +1223,8 @@ pub struct InvalidAccountThreshold { pub struct InvalidCredentialKeySignThreshold { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1278,7 +1233,8 @@ pub struct InvalidCredentialKeySignThreshold { pub struct InvalidEncryptedAmountTransferProof { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1287,7 +1243,8 @@ pub struct InvalidEncryptedAmountTransferProof { pub struct InvalidTransferToPublicProof { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1301,7 +1258,8 @@ pub struct EncryptedAmountSelfTransfer { pub struct InvalidIndexOnEncryptedTransfer { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1310,7 +1268,8 @@ pub struct InvalidIndexOnEncryptedTransfer { pub struct ZeroScheduledAmount { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1319,7 +1278,8 @@ pub struct ZeroScheduledAmount { pub struct NonIncreasingSchedule { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1328,7 +1288,8 @@ pub struct NonIncreasingSchedule { pub struct FirstScheduledReleaseExpired { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1342,7 +1303,8 @@ pub struct ScheduledSelfTransfer { pub struct InvalidCredentials { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1361,7 +1323,8 @@ pub struct NonExistentCredIds { pub struct RemoveFirstCredential { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1370,7 +1333,8 @@ pub struct RemoveFirstCredential { pub struct CredentialHolderDidNotSign { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1379,7 +1343,8 @@ pub struct CredentialHolderDidNotSign { pub struct NotAllowedMultipleCredentials { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1388,7 +1353,8 @@ pub struct NotAllowedMultipleCredentials { pub struct NotAllowedToReceiveEncrypted { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1397,7 +1363,8 @@ pub struct NotAllowedToReceiveEncrypted { pub struct NotAllowedToHandleEncrypted { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1406,7 +1373,8 @@ pub struct NotAllowedToHandleEncrypted { pub struct MissingBakerAddParameters { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1415,7 +1383,8 @@ pub struct MissingBakerAddParameters { pub struct FinalizationRewardCommissionNotInRange { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1424,7 +1393,8 @@ pub struct FinalizationRewardCommissionNotInRange { pub struct BakingRewardCommissionNotInRange { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1433,7 +1403,8 @@ pub struct BakingRewardCommissionNotInRange { pub struct TransactionFeeCommissionNotInRange { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1442,7 +1413,8 @@ pub struct TransactionFeeCommissionNotInRange { pub struct AlreadyADelegator { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1451,7 +1423,8 @@ pub struct AlreadyADelegator { pub struct MissingDelegationAddParameters { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1460,7 +1433,8 @@ pub struct MissingDelegationAddParameters { pub struct DelegatorInCooldown { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1479,7 +1453,8 @@ pub struct DelegationTargetNotABaker { pub struct PoolWouldBecomeOverDelegated { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1488,28 +1463,29 @@ pub struct PoolWouldBecomeOverDelegated { pub struct PoolClosed { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } #[derive(SimpleObject)] struct ContractSnapshot { - block_height: BlockHeight, - contract_address_index: ContractIndex, + block_height: BlockHeight, + contract_address_index: ContractIndex, contract_address_sub_index: ContractIndex, - contract_name: String, - module_reference: String, - amount: Amount, + contract_name: String, + module_reference: String, + amount: Amount, } /// A segment of a collection. #[derive(SimpleObject)] struct ContractEventsCollectionSegment { /// Information to aid in pagination. - page_info: CollectionSegmentInfo, + page_info: CollectionSegmentInfo, /// A flattened list of the items. - items: Option>, + items: Option>, total_count: i32, } @@ -1527,19 +1503,21 @@ struct ContractEvent { /// Information about the offset pagination. #[derive(SimpleObject)] struct CollectionSegmentInfo { - /// Indicates whether more items exist following the set defined by the clients arguments. - has_next_page: bool, - /// Indicates whether more items exist prior the set defined by the clients arguments. + /// Indicates whether more items exist following the set defined by the + /// clients arguments. + has_next_page: bool, + /// Indicates whether more items exist prior the set defined by the clients + /// arguments. has_previous_page: bool, } #[derive(SimpleObject)] struct AccountReward { - block: Block, - id: types::ID, - timestamp: DateTime, + block: Block, + id: types::ID, + timestamp: DateTime, reward_type: RewardType, - amount: Amount, + amount: Amount, } #[derive(Enum, Copy, Clone, PartialEq, Eq)] @@ -1552,11 +1530,11 @@ enum RewardType { #[derive(SimpleObject)] struct AccountStatementEntry { - reference: BlockOrTransaction, - id: types::ID, - timestamp: DateTime, - entry_type: AccountStatementEntryType, - amount: i64, + reference: BlockOrTransaction, + id: types::ID, + timestamp: DateTime, + entry_type: AccountStatementEntryType, + amount: i64, account_balance: Amount, } @@ -1568,37 +1546,37 @@ struct AccountTransactionRelation { #[derive(SimpleObject)] struct AccountAddressAmount { account_address: AccountAddress, - amount: Amount, + amount: Amount, } #[derive(SimpleObject)] struct AccountReleaseScheduleItem { transaction: Transaction, - timestamp: DateTime, - amount: Amount, + timestamp: DateTime, + amount: Amount, } #[derive(SimpleObject)] struct AccountToken { - contract_index: ContractIndex, + contract_index: ContractIndex, contract_sub_index: ContractIndex, - token_id: String, - balance: BigInteger, - token: Token, - account_id: i64, - account: Account, + token_id: String, + balance: BigInteger, + token: Token, + account_id: i64, + account: Account, } #[derive(SimpleObject)] struct Token { - initial_transaction: Transaction, - contract_index: ContractIndex, - contract_sub_index: ContractIndex, - token_id: String, - metadata_url: String, - total_supply: BigInteger, + initial_transaction: Transaction, + contract_index: ContractIndex, + contract_sub_index: ContractIndex, + token_id: String, + metadata_url: String, + total_supply: BigInteger, contract_address_formatted: String, - token_address: String, + token_address: String, // TODO accounts(skip: Int take: Int): AccountsCollectionSegment // TODO tokenEvents(skip: Int take: Int): TokenEventsCollectionSegment } @@ -1628,7 +1606,7 @@ struct MintSpecialEvent { #[graphql(complex)] struct FinalizationRewardsSpecialEvent { remainder: Amount, - id: types::ID, + id: types::ID, } #[ComplexObject] @@ -1639,9 +1617,7 @@ impl FinalizationRewardsSpecialEvent { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() @@ -1664,7 +1640,7 @@ struct BlockRewardsSpecialEvent { #[graphql(complex)] struct BakingRewardsSpecialEvent { remainder: Amount, - id: types::ID, + id: types::ID, } #[ComplexObject] impl BakingRewardsSpecialEvent { @@ -1674,9 +1650,7 @@ impl BakingRewardsSpecialEvent { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() @@ -1686,53 +1660,53 @@ impl BakingRewardsSpecialEvent { #[derive(SimpleObject)] struct PaydayAccountRewardSpecialEvent { /// The account that got rewarded. - account: AccountAddress, + account: AccountAddress, /// The transaction fee reward at payday to the account. - transaction_fees: Amount, + transaction_fees: Amount, /// The baking reward at payday to the account. - baker_reward: Amount, + baker_reward: Amount, /// The finalization reward at payday to the account. finalization_reward: Amount, - id: types::ID, + id: types::ID, } #[derive(SimpleObject)] struct BlockAccrueRewardSpecialEvent { /// The total fees paid for transactions in the block. - transaction_fees: Amount, + transaction_fees: Amount, /// The old balance of the GAS account. - old_gas_account: Amount, + old_gas_account: Amount, /// The new balance of the GAS account. - new_gas_account: Amount, + new_gas_account: Amount, /// The amount awarded to the baker. - baker_reward: Amount, + baker_reward: Amount, /// The amount awarded to the passive delegators. - passive_reward: Amount, + passive_reward: Amount, /// The amount awarded to the foundation. foundation_charge: Amount, /// The baker of the block, who will receive the award. - baker_id: BakerId, - id: types::ID, + baker_id: BakerId, + id: types::ID, } #[derive(SimpleObject)] struct PaydayFoundationRewardSpecialEvent { foundation_account: AccountAddress, development_charge: Amount, - id: types::ID, + id: types::ID, } #[derive(SimpleObject)] struct PaydayPoolRewardSpecialEvent { /// The pool awarded. - pool: PoolRewardTarget, + pool: PoolRewardTarget, /// Accrued transaction fees for pool. - transaction_fees: Amount, + transaction_fees: Amount, /// Accrued baking rewards for pool. - baker_reward: Amount, + baker_reward: Amount, /// Accrued finalization rewards for pool. finalization_reward: Amount, - id: types::ID, + id: types::ID, } #[derive(Union)] @@ -1745,7 +1719,8 @@ enum PoolRewardTarget { struct PassiveDelegationPoolRewardTarget { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1754,7 +1729,8 @@ struct PassiveDelegationPoolRewardTarget { struct PassiveDelegationTarget { #[graphql( name = "_", - deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL type (which does not allow types without any fields)" + deprecation = "Don't use! This field is only in the schema to make this a valid GraphQL \ + type (which does not allow types without any fields)" )] dummy: bool, } @@ -1773,16 +1749,17 @@ struct BakerDelegationTarget { struct BalanceStatistics { /// The total CCD in existence total_amount: Amount, - /// The total CCD Released. This is total CCD supply not counting the balances of non - /// circulating accounts. + /// The total CCD Released. This is total CCD supply not counting the + /// balances of non circulating accounts. total_amount_released: Amount, /// The total CCD Unlocked according to the Concordium promise published on - /// deck.concordium.com. Will be null for blocks with slot time before the published release - /// schedule. + /// deck.concordium.com. Will be null for blocks with slot time before the + /// published release schedule. total_amount_unlocked: Amount, /// The total CCD in encrypted balances. total_amount_encrypted: Amount, - /// The total CCD locked in release schedules (from transfers with schedule). + /// The total CCD locked in release schedules (from transfers with + /// schedule). total_amount_locked_in_release_schedules: Amount, /// The total CCD staked. total_amount_staked: Amount, @@ -1796,7 +1773,7 @@ struct BalanceStatistics { #[derive(SimpleObject)] struct BlockStatistics { - block_time: f32, + block_time: f32, finalization_time: f32, } @@ -1820,9 +1797,9 @@ struct ChainParametersV0 { // bakerCooldownEpochs: UnsignedLong! // rewardParameters: RewardParametersV0! // minimumThresholdForBaking: UnsignedLong! - euro_per_energy: ExchangeRate, - micro_ccd_per_euro: ExchangeRate, - account_creation_limit: i32, + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, foundation_account_address: AccountAddress, } @@ -1844,9 +1821,9 @@ struct ChainParametersV1 { // minimumEquityCapital: UnsignedLong! // capitalBound: Decimal! // leverageBound: LeverageFactor! - euro_per_energy: ExchangeRate, - micro_ccd_per_euro: ExchangeRate, - account_creation_limit: i32, + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, foundation_account_address: AccountAddress, } @@ -1867,15 +1844,15 @@ struct ChainParametersV2 { // minimumEquityCapital: UnsignedLong! // capitalBound: Decimal! // leverageBound: LeverageFactor! - euro_per_energy: ExchangeRate, - micro_ccd_per_euro: ExchangeRate, - account_creation_limit: i32, + euro_per_energy: ExchangeRate, + micro_ccd_per_euro: ExchangeRate, + account_creation_limit: i32, foundation_account_address: AccountAddress, } #[derive(SimpleObject)] struct ExchangeRate { - numerator: u64, + numerator: u64, denominator: u64, } @@ -1892,7 +1869,9 @@ impl From for AccountAddress impl From for AccountAddress { fn from(as_string: String) -> Self { - Self { as_string } + Self { + as_string, + } } } @@ -1905,9 +1884,8 @@ impl std::str::FromStr for IdTransaction { type Err = ApiError; fn from_str(value: &str) -> Result { - let (height_str, index_str) = value - .split_once(':') - .ok_or(ApiError::InvalidIdTransaction)?; + let (height_str, index_str) = + value.split_once(':').ok_or(ApiError::InvalidIdTransaction)?; Ok(IdTransaction { block: height_str.parse().map_err(ApiError::InvalidIdInt)?, index: index_str.parse().map_err(ApiError::InvalidIdInt)?, @@ -1916,9 +1894,8 @@ impl std::str::FromStr for IdTransaction { } impl TryFrom for IdTransaction { type Error = ApiError; - fn try_from(value: types::ID) -> Result { - value.0.parse() - } + + fn try_from(value: types::ID) -> Result { value.0.parse() } } // impl From for types::ID { // fn from(value: IdTransaction) -> Self { @@ -1936,8 +1913,8 @@ impl std::fmt::Display for IdTransaction { struct TransactionConnectionQuery { #[sqlx(flatten)] transaction: Transaction, - has_prev: bool, - has_next: bool, + has_prev: bool, + has_next: bool, } #[derive(SimpleObject, sqlx::FromRow)] @@ -1971,9 +1948,7 @@ struct Transaction { #[ComplexObject] impl Transaction { /// Transaction query ID, formatted as ":". - async fn id(&self) -> types::ID { - self.id_transaction().into() - } + async fn id(&self) -> types::ID { self.id_transaction().into() } async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { let result = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", self.block) @@ -2000,11 +1975,23 @@ impl Transaction { DbTransactionType::Account => TransactionType::AccountTransaction(AccountTransaction { account_transaction_type: self.type_account, }), - DbTransactionType::CredentialDeployment => TransactionType::CredentialDeploymentTransaction(CredentialDeploymentTransaction { - credential_deployment_transaction_type: self.type_credential_deployment.ok_or(ApiError::InternalError("Database invariant violated, transaction type is credential deployment, but credential deployment type is null".to_string()))?, - }), + DbTransactionType::CredentialDeployment => { + TransactionType::CredentialDeploymentTransaction(CredentialDeploymentTransaction { + credential_deployment_transaction_type: self.type_credential_deployment.ok_or( + ApiError::InternalError( + "Database invariant violated, transaction type is credential \ + deployment, but credential deployment type is null" + .to_string(), + ), + )?, + }) + } DbTransactionType::Update => TransactionType::UpdateTransaction(UpdateTransaction { - update_transaction_type: self.type_update.ok_or(ApiError::InternalError("Database invariant violated, transaction type is update, but update type is null".to_string()))?, + update_transaction_type: self.type_update.ok_or(ApiError::InternalError( + "Database invariant violated, transaction type is update, but update type is \ + null" + .to_string(), + ))?, }), }; Ok(tt) @@ -2012,15 +1999,21 @@ impl Transaction { async fn result(&self) -> ApiResult> { if self.success { - let events = self.events.as_ref().ok_or(ApiError::InternalError( - "Success events is null".to_string(), - ))?; - Ok(TransactionResult::Success(Success { events })) + let events = self + .events + .as_ref() + .ok_or(ApiError::InternalError("Success events is null".to_string()))?; + Ok(TransactionResult::Success(Success { + events, + })) } else { - let reason = self.reject.as_ref().ok_or(ApiError::InternalError( - "Success events is null".to_string(), - ))?; - Ok(TransactionResult::Rejected(Rejected { reason })) + let reason = self + .reject + .as_ref() + .ok_or(ApiError::InternalError("Success events is null".to_string()))?; + Ok(TransactionResult::Rejected(Rejected { + reason, + })) } } } @@ -2169,14 +2162,14 @@ impl From for UpdateTransactionType { UpdateType::UpdateMintDistribution => UpdateTransactionType::UpdateMintDistribution, UpdateType::UpdateTransactionFeeDistribution => { UpdateTransactionType::UpdateTransactionFeeDistribution - }, + } UpdateType::UpdateGASRewards => UpdateTransactionType::UpdateGasRewards, UpdateType::UpdateAddAnonymityRevoker => { UpdateTransactionType::UpdateAddAnonymityRevoker - }, + } UpdateType::UpdateAddIdentityProvider => { UpdateTransactionType::UpdateAddIdentityProvider - }, + } UpdateType::UpdateRootKeys => UpdateTransactionType::UpdateRootKeys, UpdateType::UpdateLevel1Keys => UpdateTransactionType::UpdateLevel1Keys, UpdateType::UpdateLevel2Keys => UpdateTransactionType::UpdateLevel2Keys, @@ -2189,7 +2182,7 @@ impl From for UpdateTransactionType { UpdateType::UpdateBlockEnergyLimit => UpdateTransactionType::BlockEnergyLimitUpdate, UpdateType::UpdateFinalizationCommitteeParameters => { UpdateTransactionType::FinalizationCommitteeParametersUpdate - }, + } } } } @@ -2212,9 +2205,7 @@ impl Success<'_> { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { check_connection_query(&first, &last)?; @@ -2262,7 +2253,7 @@ struct Rejected<'a> { #[derive(sqlx::FromRow)] struct AccountConnectionQuery { #[sqlx(flatten)] - account: Account, + account: Account, has_prev: bool, has_next: bool, } @@ -2272,18 +2263,19 @@ struct AccountConnectionQuery { struct Account { // release_schedule: AccountReleaseSchedule, #[graphql(skip)] - index: i64, + index: i64, /// Height of the block with the transaction creating this account. #[graphql(skip)] created_block: BlockHeight, - /// Index of transaction creating this account within a block. Only Null for genesis accounts. + /// Index of transaction creating this account within a block. Only Null for + /// genesis accounts. #[graphql(skip)] created_index: Option, /// The address of the account in Base58Check. #[sqlx(try_from = "String")] - address: AccountAddress, + address: AccountAddress, /// The total amount of CCD hold by the account. - amount: Amount, + amount: Amount, // Get baker information if this account is baking. // baker: Option, // delegation: Option, @@ -2291,29 +2283,21 @@ struct Account { #[ComplexObject] impl Account { - async fn id(&self) -> types::ID { - types::ID::from(self.index) - } + async fn id(&self) -> types::ID { types::ID::from(self.index) } /// Timestamp of the block where this account was created. async fn created_at<'a>(&self, ctx: &Context<'a>) -> ApiResult { - let rec = sqlx::query!( - "SELECT slot_time FROM blocks WHERE height=$1", - self.created_block - ) - .fetch_one(get_pool(ctx)?) - .await?; + let rec = sqlx::query!("SELECT slot_time FROM blocks WHERE height=$1", self.created_block) + .fetch_one(get_pool(ctx)?) + .await?; Ok(rec.slot_time) } /// Number of transactions where this account is used as sender. async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { - let rec = sqlx::query!( - "SELECT COUNT(*) FROM transactions WHERE sender=$1", - self.index - ) - .fetch_one(get_pool(ctx)?) - .await?; + let rec = sqlx::query!("SELECT COUNT(*) FROM transactions WHERE sender=$1", self.index) + .fetch_one(get_pool(ctx)?) + .await?; Ok(rec.count.unwrap_or(0)) } @@ -2323,48 +2307,43 @@ impl Account { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() } + async fn transactions( &self, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() } + async fn account_statement( &self, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() } + async fn rewards( &self, #[graphql(desc = "Returns the first _n_ elements from the list.")] first: i32, #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() @@ -2384,9 +2363,7 @@ impl AccountReleaseSchedule { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] after: String, #[graphql(desc = "Returns the last _n_ elements from the list.")] last: i32, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { todo!() @@ -2395,10 +2372,10 @@ impl AccountReleaseSchedule { #[derive(SimpleObject)] struct Baker { - account: Box, - id: types::ID, + account: Box, + id: types::ID, baker_id: BakerId, - state: BakerState, + state: BakerState, // /// Get the transactions that have affected the baker. // transactions("Returns the first _n_ elements from the list." first: Int "Returns the // elements in the list that come after the specified cursor." after: String "Returns the last @@ -2414,12 +2391,13 @@ enum BakerState { #[derive(SimpleObject)] struct ActiveBakerState { - /// The status of the bakers node. Will be null if no status for the node exists. - node_status: NodeStatus, - staked_amount: Amount, + /// The status of the bakers node. Will be null if no status for the node + /// exists. + node_status: NodeStatus, + staked_amount: Amount, restake_earnings: bool, - pool: BakerPool, - pending_change: PendingBakerChange, + pool: BakerPool, + pending_change: PendingBakerChange, } #[derive(Union)] @@ -2436,7 +2414,7 @@ struct PendingBakerRemoval { #[derive(SimpleObject)] struct PendingBakerReduceStake { new_staked_amount: Amount, - effective_time: DateTime, + effective_time: DateTime, } #[derive(SimpleObject)] @@ -2499,27 +2477,29 @@ struct NodeStatus { #[derive(SimpleObject)] struct BakerPool { - /// Total stake of the baker pool as a percentage of all CCDs in existence. Value may be null - /// for brand new bakers where statistics have not been calculated yet. This should be rare and - /// only a temporary condition. - total_stake_percentage: Decimal, - lottery_power: Decimal, + /// Total stake of the baker pool as a percentage of all CCDs in existence. + /// Value may be null for brand new bakers where statistics have not + /// been calculated yet. This should be rare and only a temporary + /// condition. + total_stake_percentage: Decimal, + lottery_power: Decimal, payday_commission_rates: CommissionRates, - open_status: BakerPoolOpenStatus, - commission_rates: CommissionRates, - metadata_url: String, + open_status: BakerPoolOpenStatus, + commission_rates: CommissionRates, + metadata_url: String, /// The total amount staked by delegation to this baker pool. - delegated_stake: Amount, - /// The maximum amount that may be delegated to the pool, accounting for leverage and stake - /// limits. - delegated_stake_cap: Amount, - /// The total amount staked in this baker pool. Includes both baker stake and delegated stake. - total_stake: Amount, - delegator_count: i32, - /// Ranking of the baker pool by total staked amount. Value may be null for brand new bakers - /// where statistics have not been calculated yet. This should be rare and only a temporary - /// condition. - ranking_by_total_stake: Ranking, + delegated_stake: Amount, + /// The maximum amount that may be delegated to the pool, accounting for + /// leverage and stake limits. + delegated_stake_cap: Amount, + /// The total amount staked in this baker pool. Includes both baker stake + /// and delegated stake. + total_stake: Amount, + delegator_count: i32, + /// Ranking of the baker pool by total staked amount. Value may be null for + /// brand new bakers where statistics have not been calculated yet. This + /// should be rare and only a temporary condition. + ranking_by_total_stake: Ranking, // TODO: apy(period: ApyPeriod!): PoolApy! // TODO: delegators("Returns the first _n_ elements from the list." first: Int "Returns the // elements in the list that come after the specified cursor." after: String "Returns the last @@ -2533,9 +2513,9 @@ struct BakerPool { #[derive(SimpleObject)] struct CommissionRates { - transaction_commission: Decimal, + transaction_commission: Decimal, finalization_commission: Decimal, - baking_commission: Decimal, + baking_commission: Decimal, } #[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -2558,17 +2538,17 @@ impl From for BakerPoolOpenStatus { #[derive(SimpleObject)] struct Ranking { - rank: i32, + rank: i32, total: i32, } #[derive(SimpleObject)] struct Delegation { - delegator_id: i64, - staked_amount: Amount, - restake_earnings: bool, + delegator_id: i64, + staked_amount: Amount, + restake_earnings: bool, delegation_target: DelegationTarget, - pending_change: PendingDelegationChange, + pending_change: PendingDelegationChange, } #[derive(Union, serde::Serialize, serde::Deserialize)] @@ -2579,21 +2559,20 @@ enum DelegationTarget { impl TryFrom for DelegationTarget { type Error = anyhow::Error; + fn try_from(target: concordium_rust_sdk::types::DelegationTarget) -> Result { use concordium_rust_sdk::types::DelegationTarget as Target; match target { Target::Passive => { - Ok(DelegationTarget::PassiveDelegationTarget( - PassiveDelegationTarget { dummy: true }, - )) - }, - Target::Baker { baker_id } => { - Ok(DelegationTarget::BakerDelegationTarget( - BakerDelegationTarget { - baker_id: baker_id.id.index.try_into()?, - }, - )) - }, + Ok(DelegationTarget::PassiveDelegationTarget(PassiveDelegationTarget { + dummy: true, + })) + } + Target::Baker { + baker_id, + } => Ok(DelegationTarget::BakerDelegationTarget(BakerDelegationTarget { + baker_id: baker_id.id.index.try_into()?, + })), } } } @@ -2612,7 +2591,7 @@ struct PendingDelegationRemoval { #[derive(SimpleObject)] struct PendingDelegationReduceStake { new_staked_amount: Amount, - effective_time: DateTime, + effective_time: DateTime, } #[derive(Enum, Clone, Copy, PartialEq, Eq)] @@ -2655,7 +2634,7 @@ struct AccountFilterInput { #[derive(InputObject)] struct BakerFilterInput { open_status_filter: BakerPoolOpenStatus, - include_removed: bool, + include_removed: bool, } #[derive(Enum, Clone, Copy, PartialEq, Eq, Default)] @@ -2688,9 +2667,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2698,13 +2675,13 @@ impl SearchResult { // async fn modules( // &self, - // #[graphql(desc = "Returns the first _n_ elements from the list.")] _first: Option, - // #[graphql(desc = "Returns the elements in the list that come after the specified - // cursor.")] _after: Option, - // #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - // #[graphql( - // desc = "Returns the elements in the list that come before the specified cursor." - // )] + // #[graphql(desc = "Returns the first _n_ elements from the list.")] + // _first: Option, #[graphql(desc = "Returns the elements in the + // list that come after the specified cursor.")] _after: Option, + // #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: + // Option, #[graphql( + // desc = "Returns the elements in the list that come before the + // specified cursor." )] // _before: Option, // ) -> ApiResult> { // todo!() @@ -2716,9 +2693,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2730,9 +2705,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2744,9 +2717,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2758,9 +2729,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2772,9 +2741,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2786,9 +2753,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] _after: Option, #[graphql(desc = "Returns the last _n_ elements from the list.")] _last: Option, - #[graphql( - desc = "Returns the elements in the list that come before the specified cursor." - )] + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { todo!() @@ -2797,7 +2762,7 @@ impl SearchResult { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] struct ContractAddress { - index: ContractIndex, + index: ContractIndex, sub_index: ContractIndex, as_string: String, } @@ -2805,7 +2770,7 @@ struct ContractAddress { impl From for ContractAddress { fn from(value: concordium_rust_sdk::types::ContractAddress) -> Self { Self { - index: value.index.into(), + index: value.index.into(), sub_index: value.subindex.into(), as_string: value.to_string(), } @@ -2830,8 +2795,8 @@ impl From for Address { #[derive(Union, serde::Serialize, serde::Deserialize)] pub enum Event { - /// A transfer of CCD. Can be either from an account or a smart contract instance, but the - /// receiver in this event is always an account. + /// A transfer of CCD. Can be either from an account or a smart contract + /// instance, but the receiver in this event is always an account. Transferred(Transferred), AccountCreated(AccountCreated), AmountAddedByDecryption(AmountAddedByDecryption), @@ -2874,125 +2839,151 @@ pub enum Event { pub fn events_from_summary( value: concordium_rust_sdk::types::BlockItemSummaryDetails, ) -> anyhow::Result> { - use concordium_rust_sdk::types::{ - AccountTransactionEffects, - BlockItemSummaryDetails, - }; + use concordium_rust_sdk::types::{AccountTransactionEffects, BlockItemSummaryDetails}; let events = match value { BlockItemSummaryDetails::AccountTransaction(details) => { match details.effects { - AccountTransactionEffects::None { .. } => { + AccountTransactionEffects::None { + .. + } => { anyhow::bail!("Transaction was rejected") - }, - AccountTransactionEffects::ModuleDeployed { module_ref } => { + } + AccountTransactionEffects::ModuleDeployed { + module_ref, + } => { vec![Event::ContractModuleDeployed(ContractModuleDeployed { module_ref: module_ref.to_string(), })] - }, - AccountTransactionEffects::ContractInitialized { data } => { + } + AccountTransactionEffects::ContractInitialized { + data, + } => { vec![Event::ContractInitialized(ContractInitialized { - module_ref: data.origin_ref.to_string(), + module_ref: data.origin_ref.to_string(), contract_address: data.address.into(), - amount: i64::try_from(data.amount.micro_ccd)?, - init_name: data.init_name.to_string(), - version: data.contract_version.into(), + amount: i64::try_from(data.amount.micro_ccd)?, + init_name: data.init_name.to_string(), + version: data.contract_version.into(), })] - }, - AccountTransactionEffects::ContractUpdateIssued { effects } => { + } + AccountTransactionEffects::ContractUpdateIssued { + effects, + } => { use concordium_rust_sdk::types::ContractTraceElement; effects .into_iter() .map(|effect| { match effect { - ContractTraceElement::Updated { data } => { + ContractTraceElement::Updated { + data, + } => { Ok(Event::ContractUpdated(ContractUpdated { contract_address: data.address.into(), - instigator: data.instigator.into(), - amount: data.amount.micro_ccd().try_into()?, - message_as_hex: hex::encode(data.message.as_ref()), - receive_name: data.receive_name.to_string(), - version: data.contract_version.into(), + instigator: data.instigator.into(), + amount: data.amount.micro_ccd().try_into()?, + message_as_hex: hex::encode(data.message.as_ref()), + receive_name: data.receive_name.to_string(), + version: data.contract_version.into(), // TODO message: (), })) - }, - ContractTraceElement::Transferred { from, amount, to } => { - Ok(Event::Transferred(Transferred { - amount: amount.micro_ccd().try_into()?, - from: Address::ContractAddress(from.into()), - to: to.into(), - })) - }, - ContractTraceElement::Interrupted { address, events } => { - Ok(Event::ContractInterrupted(ContractInterrupted { - contract_address: address.into(), - })) - }, - ContractTraceElement::Resumed { address, success } => { - Ok(Event::ContractResumed(ContractResumed { - contract_address: address.into(), - success, - })) - }, - ContractTraceElement::Upgraded { address, from, to } => { - Ok(Event::ContractUpgraded(ContractUpgraded { - contract_address: address.into(), - from: from.to_string(), - to: to.to_string(), - })) - }, + } + ContractTraceElement::Transferred { + from, + amount, + to, + } => Ok(Event::Transferred(Transferred { + amount: amount.micro_ccd().try_into()?, + from: Address::ContractAddress(from.into()), + to: to.into(), + })), + ContractTraceElement::Interrupted { + address, + events, + } => Ok(Event::ContractInterrupted(ContractInterrupted { + contract_address: address.into(), + })), + ContractTraceElement::Resumed { + address, + success, + } => Ok(Event::ContractResumed(ContractResumed { + contract_address: address.into(), + success, + })), + ContractTraceElement::Upgraded { + address, + from, + to, + } => Ok(Event::ContractUpgraded(ContractUpgraded { + contract_address: address.into(), + from: from.to_string(), + to: to.to_string(), + })), } }) .collect::>>()? - }, - AccountTransactionEffects::AccountTransfer { amount, to } => { + } + AccountTransactionEffects::AccountTransfer { + amount, + to, + } => { vec![Event::Transferred(Transferred { amount: i64::try_from(amount.micro_ccd)?, - from: Address::AccountAddress(details.sender.into()), - to: to.into(), + from: Address::AccountAddress(details.sender.into()), + to: to.into(), })] - }, - AccountTransactionEffects::AccountTransferWithMemo { amount, to, memo } => { + } + AccountTransactionEffects::AccountTransferWithMemo { + amount, + to, + memo, + } => { vec![ Event::Transferred(Transferred { amount: i64::try_from(amount.micro_ccd)?, - from: Address::AccountAddress(details.sender.into()), - to: to.into(), + from: Address::AccountAddress(details.sender.into()), + to: to.into(), }), Event::TransferMemo(memo.into()), ] - }, - AccountTransactionEffects::BakerAdded { data } => { + } + AccountTransactionEffects::BakerAdded { + data, + } => { vec![Event::BakerAdded(BakerAdded { - staked_amount: data.stake.micro_ccd.try_into()?, + staked_amount: data.stake.micro_ccd.try_into()?, restake_earnings: data.restake_earnings, - baker_id: data.keys_event.baker_id.id.index.try_into()?, - sign_key: serde_json::to_string(&data.keys_event.sign_key)?, - election_key: serde_json::to_string(&data.keys_event.election_key)?, - aggregation_key: serde_json::to_string(&data.keys_event.aggregation_key)?, + baker_id: data.keys_event.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.keys_event.sign_key)?, + election_key: serde_json::to_string(&data.keys_event.election_key)?, + aggregation_key: serde_json::to_string(&data.keys_event.aggregation_key)?, })] - }, - AccountTransactionEffects::BakerRemoved { baker_id } => { + } + AccountTransactionEffects::BakerRemoved { + baker_id, + } => { vec![Event::BakerRemoved(BakerRemoved { baker_id: baker_id.id.index.try_into()?, })] - }, - AccountTransactionEffects::BakerStakeUpdated { data } => { + } + AccountTransactionEffects::BakerStakeUpdated { + data, + } => { if let Some(data) = data { if data.increased { vec![Event::BakerStakeIncreased(BakerStakeIncreased { - baker_id: data.baker_id.id.index.try_into()?, + baker_id: data.baker_id.id.index.try_into()?, new_staked_amount: data.new_stake.micro_ccd.try_into()?, })] } else { vec![Event::BakerStakeDecreased(BakerStakeDecreased { - baker_id: data.baker_id.id.index.try_into()?, + baker_id: data.baker_id.id.index.try_into()?, new_staked_amount: data.new_stake.micro_ccd.try_into()?, })] } } else { Vec::new() } - }, + } AccountTransactionEffects::BakerRestakeEarningsUpdated { baker_id, restake_earnings, @@ -3001,21 +2992,26 @@ pub fn events_from_summary( baker_id: baker_id.id.index.try_into()?, restake_earnings, })] - }, - AccountTransactionEffects::BakerKeysUpdated { data } => { + } + AccountTransactionEffects::BakerKeysUpdated { + data, + } => { vec![Event::BakerKeysUpdated(BakerKeysUpdated { - baker_id: data.baker_id.id.index.try_into()?, - sign_key: serde_json::to_string(&data.sign_key)?, - election_key: serde_json::to_string(&data.election_key)?, + baker_id: data.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.sign_key)?, + election_key: serde_json::to_string(&data.election_key)?, aggregation_key: serde_json::to_string(&data.aggregation_key)?, })] - }, - AccountTransactionEffects::EncryptedAmountTransferred { removed, added } => { + } + AccountTransactionEffects::EncryptedAmountTransferred { + removed, + added, + } => { vec![ Event::EncryptedAmountsRemoved((*removed).try_into()?), Event::NewEncryptedAmount((*added).try_into()?), ] - }, + } AccountTransactionEffects::EncryptedAmountTransferredWithMemo { removed, added, @@ -3026,40 +3022,52 @@ pub fn events_from_summary( Event::NewEncryptedAmount((*added).try_into()?), Event::TransferMemo(memo.into()), ] - }, - AccountTransactionEffects::TransferredToEncrypted { data } => { + } + AccountTransactionEffects::TransferredToEncrypted { + data, + } => { vec![Event::EncryptedSelfAmountAdded(EncryptedSelfAmountAdded { - account_address: data.account.into(), + account_address: data.account.into(), new_encrypted_amount: serde_json::to_string(&data.new_amount)?, - amount: data.amount.micro_ccd.try_into()?, + amount: data.amount.micro_ccd.try_into()?, })] - }, - AccountTransactionEffects::TransferredToPublic { removed, amount } => { + } + AccountTransactionEffects::TransferredToPublic { + removed, + amount, + } => { vec![ Event::EncryptedAmountsRemoved((*removed).try_into()?), Event::AmountAddedByDecryption(AmountAddedByDecryption { - amount: amount.micro_ccd().try_into()?, + amount: amount.micro_ccd().try_into()?, account_address: details.sender.into(), }), ] - }, - AccountTransactionEffects::TransferredWithSchedule { to, amount } => { + } + AccountTransactionEffects::TransferredWithSchedule { + to, + amount, + } => { vec![Event::TransferredWithSchedule(TransferredWithSchedule { from_account_address: details.sender.into(), - to_account_address: to.into(), - total_amount: amount + to_account_address: to.into(), + total_amount: amount .into_iter() .map(|(_, amount)| amount.micro_ccd()) .sum::() .try_into()?, })] - }, - AccountTransactionEffects::TransferredWithScheduleAndMemo { to, amount, memo } => { + } + AccountTransactionEffects::TransferredWithScheduleAndMemo { + to, + amount, + memo, + } => { vec![ Event::TransferredWithSchedule(TransferredWithSchedule { from_account_address: details.sender.into(), - to_account_address: to.into(), - total_amount: amount + to_account_address: to.into(), + total_amount: amount .into_iter() .map(|(_, amount)| amount.micro_ccd()) .sum::() @@ -3067,20 +3075,22 @@ pub fn events_from_summary( }), Event::TransferMemo(memo.try_into()?), ] - }, - AccountTransactionEffects::CredentialKeysUpdated { cred_id } => { + } + AccountTransactionEffects::CredentialKeysUpdated { + cred_id, + } => { vec![Event::CredentialKeysUpdated(CredentialKeysUpdated { cred_id: cred_id.to_string(), })] - }, + } AccountTransactionEffects::CredentialsUpdated { new_cred_ids, removed_cred_ids, new_threshold, } => { vec![Event::CredentialsUpdated(CredentialsUpdated { - account_address: details.sender.into(), - new_cred_ids: new_cred_ids + account_address: details.sender.into(), + new_cred_ids: new_cred_ids .into_iter() .map(|cred| cred.to_string()) .collect(), @@ -3088,217 +3098,196 @@ pub fn events_from_summary( .into_iter() .map(|cred| cred.to_string()) .collect(), - new_threshold: Byte(u8::from(new_threshold)), + new_threshold: Byte(u8::from(new_threshold)), })] - }, - AccountTransactionEffects::DataRegistered { data } => { - vec![Event::DataRegistered(DataRegistered { + } + AccountTransactionEffects::DataRegistered { + data, + } => { + vec![Event::DataRegistered(DataRegistered { data_as_hex: hex::encode(data.as_ref()), - decoded: DecodedText::from_bytes(data.as_ref()), + decoded: DecodedText::from_bytes(data.as_ref()), })] - }, - AccountTransactionEffects::BakerConfigured { data } => { - data.into_iter() - .map(|baker_event| { - use concordium_rust_sdk::types::BakerEvent; - match baker_event { - BakerEvent::BakerAdded { data } => { - Ok(Event::BakerAdded(BakerAdded { - staked_amount: data.stake.micro_ccd.try_into()?, - restake_earnings: data.restake_earnings, - baker_id: data.keys_event.baker_id.id.index.try_into()?, - sign_key: serde_json::to_string(&data.keys_event.sign_key)?, - election_key: serde_json::to_string( - &data.keys_event.election_key, - )?, - aggregation_key: serde_json::to_string( - &data.keys_event.aggregation_key, - )?, - })) - }, - BakerEvent::BakerRemoved { baker_id } => { - Ok(Event::BakerRemoved(BakerRemoved { - baker_id: baker_id.id.index.try_into()?, - })) - }, - BakerEvent::BakerStakeIncreased { - baker_id, - new_stake, - } => { - Ok(Event::BakerStakeIncreased(BakerStakeIncreased { - baker_id: baker_id.id.index.try_into()?, - new_staked_amount: new_stake.micro_ccd.try_into()?, - })) - }, - BakerEvent::BakerStakeDecreased { - baker_id, - new_stake, - } => { - Ok(Event::BakerStakeDecreased(BakerStakeDecreased { - baker_id: baker_id.id.index.try_into()?, - new_staked_amount: new_stake.micro_ccd.try_into()?, - })) - }, - BakerEvent::BakerRestakeEarningsUpdated { - baker_id, - restake_earnings, - } => { - Ok(Event::BakerSetRestakeEarnings(BakerSetRestakeEarnings { - baker_id: baker_id.id.index.try_into()?, - restake_earnings, - })) - }, - BakerEvent::BakerKeysUpdated { data } => { - Ok(Event::BakerKeysUpdated(BakerKeysUpdated { - baker_id: data.baker_id.id.index.try_into()?, - sign_key: serde_json::to_string(&data.sign_key)?, - election_key: serde_json::to_string(&data.election_key)?, - aggregation_key: serde_json::to_string( - &data.aggregation_key, - )?, - })) - }, - BakerEvent::BakerSetOpenStatus { - baker_id, - open_status, - } => { - Ok(Event::BakerSetOpenStatus(BakerSetOpenStatus { - baker_id: baker_id.id.index.try_into()?, - account_address: details.sender.into(), - open_status: open_status.into(), - })) - }, - BakerEvent::BakerSetMetadataURL { - baker_id, - metadata_url, - } => { - Ok(Event::BakerSetMetadataURL(BakerSetMetadataURL { - baker_id: baker_id.id.index.try_into()?, - account_address: details.sender.into(), - metadata_url: metadata_url.into(), - })) - }, - BakerEvent::BakerSetTransactionFeeCommission { - baker_id, - transaction_fee_commission, - } => { - Ok(Event::BakerSetTransactionFeeCommission( - BakerSetTransactionFeeCommission { - baker_id: baker_id.id.index.try_into()?, - account_address: details.sender.into(), - transaction_fee_commission: transaction_fee_commission - .into(), - }, - )) - }, - BakerEvent::BakerSetBakingRewardCommission { - baker_id, - baking_reward_commission, - } => { - Ok(Event::BakerSetBakingRewardCommission( - BakerSetBakingRewardCommission { - baker_id: baker_id.id.index.try_into()?, - account_address: details.sender.into(), - baking_reward_commission: baking_reward_commission - .into(), - }, - )) + } + AccountTransactionEffects::BakerConfigured { + data, + } => data + .into_iter() + .map(|baker_event| { + use concordium_rust_sdk::types::BakerEvent; + match baker_event { + BakerEvent::BakerAdded { + data, + } => Ok(Event::BakerAdded(BakerAdded { + staked_amount: data.stake.micro_ccd.try_into()?, + restake_earnings: data.restake_earnings, + baker_id: data.keys_event.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.keys_event.sign_key)?, + election_key: serde_json::to_string( + &data.keys_event.election_key, + )?, + aggregation_key: serde_json::to_string( + &data.keys_event.aggregation_key, + )?, + })), + BakerEvent::BakerRemoved { + baker_id, + } => Ok(Event::BakerRemoved(BakerRemoved { + baker_id: baker_id.id.index.try_into()?, + })), + BakerEvent::BakerStakeIncreased { + baker_id, + new_stake, + } => Ok(Event::BakerStakeIncreased(BakerStakeIncreased { + baker_id: baker_id.id.index.try_into()?, + new_staked_amount: new_stake.micro_ccd.try_into()?, + })), + BakerEvent::BakerStakeDecreased { + baker_id, + new_stake, + } => Ok(Event::BakerStakeDecreased(BakerStakeDecreased { + baker_id: baker_id.id.index.try_into()?, + new_staked_amount: new_stake.micro_ccd.try_into()?, + })), + BakerEvent::BakerRestakeEarningsUpdated { + baker_id, + restake_earnings, + } => Ok(Event::BakerSetRestakeEarnings(BakerSetRestakeEarnings { + baker_id: baker_id.id.index.try_into()?, + restake_earnings, + })), + BakerEvent::BakerKeysUpdated { + data, + } => Ok(Event::BakerKeysUpdated(BakerKeysUpdated { + baker_id: data.baker_id.id.index.try_into()?, + sign_key: serde_json::to_string(&data.sign_key)?, + election_key: serde_json::to_string(&data.election_key)?, + aggregation_key: serde_json::to_string(&data.aggregation_key)?, + })), + BakerEvent::BakerSetOpenStatus { + baker_id, + open_status, + } => Ok(Event::BakerSetOpenStatus(BakerSetOpenStatus { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + open_status: open_status.into(), + })), + BakerEvent::BakerSetMetadataURL { + baker_id, + metadata_url, + } => Ok(Event::BakerSetMetadataURL(BakerSetMetadataURL { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + metadata_url: metadata_url.into(), + })), + BakerEvent::BakerSetTransactionFeeCommission { + baker_id, + transaction_fee_commission, + } => Ok(Event::BakerSetTransactionFeeCommission( + BakerSetTransactionFeeCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + transaction_fee_commission: transaction_fee_commission.into(), }, - BakerEvent::BakerSetFinalizationRewardCommission { - baker_id, - finalization_reward_commission, - } => { - Ok(Event::BakerSetFinalizationRewardCommission( - BakerSetFinalizationRewardCommission { - baker_id: baker_id.id.index.try_into()?, - account_address: details.sender.into(), - finalization_reward_commission: - finalization_reward_commission.into(), - }, - )) + )), + BakerEvent::BakerSetBakingRewardCommission { + baker_id, + baking_reward_commission, + } => Ok(Event::BakerSetBakingRewardCommission( + BakerSetBakingRewardCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + baking_reward_commission: baking_reward_commission.into(), }, - BakerEvent::DelegationRemoved { delegator_id } => { - todo!() + )), + BakerEvent::BakerSetFinalizationRewardCommission { + baker_id, + finalization_reward_commission, + } => Ok(Event::BakerSetFinalizationRewardCommission( + BakerSetFinalizationRewardCommission { + baker_id: baker_id.id.index.try_into()?, + account_address: details.sender.into(), + finalization_reward_commission: finalization_reward_commission + .into(), }, + )), + BakerEvent::DelegationRemoved { + delegator_id, + } => { + todo!() } - }) - .collect::>>()? - }, - AccountTransactionEffects::DelegationConfigured { data } => { + } + }) + .collect::>>()?, + AccountTransactionEffects::DelegationConfigured { + data, + } => { use concordium_rust_sdk::types::DelegationEvent; data.into_iter() - .map(|event| { - match event { - DelegationEvent::DelegationStakeIncreased { - delegator_id, - new_stake, - } => { - Ok(Event::DelegationStakeIncreased(DelegationStakeIncreased { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - new_staked_amount: new_stake.micro_ccd().try_into()?, - })) - }, - DelegationEvent::DelegationStakeDecreased { - delegator_id, - new_stake, - } => { - Ok(Event::DelegationStakeDecreased(DelegationStakeDecreased { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - new_staked_amount: new_stake.micro_ccd().try_into()?, - })) - }, - DelegationEvent::DelegationSetRestakeEarnings { - delegator_id, + .map(|event| match event { + DelegationEvent::DelegationStakeIncreased { + delegator_id, + new_stake, + } => Ok(Event::DelegationStakeIncreased(DelegationStakeIncreased { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + new_staked_amount: new_stake.micro_ccd().try_into()?, + })), + DelegationEvent::DelegationStakeDecreased { + delegator_id, + new_stake, + } => Ok(Event::DelegationStakeDecreased(DelegationStakeDecreased { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + new_staked_amount: new_stake.micro_ccd().try_into()?, + })), + DelegationEvent::DelegationSetRestakeEarnings { + delegator_id, + restake_earnings, + } => Ok(Event::DelegationSetRestakeEarnings( + DelegationSetRestakeEarnings { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), restake_earnings, - } => { - Ok(Event::DelegationSetRestakeEarnings( - DelegationSetRestakeEarnings { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - restake_earnings, - }, - )) - }, - DelegationEvent::DelegationSetDelegationTarget { - delegator_id, - delegation_target, - } => { - Ok(Event::DelegationSetDelegationTarget( - DelegationSetDelegationTarget { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - delegation_target: delegation_target.try_into()?, - }, - )) }, - DelegationEvent::DelegationAdded { delegator_id } => { - Ok(Event::DelegationAdded(DelegationAdded { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - })) - }, - DelegationEvent::DelegationRemoved { delegator_id } => { - Ok(Event::DelegationRemoved(DelegationRemoved { - delegator_id: delegator_id.id.index.try_into()?, - account_address: details.sender.into(), - })) - }, - DelegationEvent::BakerRemoved { baker_id } => { - todo!(); + )), + DelegationEvent::DelegationSetDelegationTarget { + delegator_id, + delegation_target, + } => Ok(Event::DelegationSetDelegationTarget( + DelegationSetDelegationTarget { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + delegation_target: delegation_target.try_into()?, }, + )), + DelegationEvent::DelegationAdded { + delegator_id, + } => Ok(Event::DelegationAdded(DelegationAdded { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + })), + DelegationEvent::DelegationRemoved { + delegator_id, + } => Ok(Event::DelegationRemoved(DelegationRemoved { + delegator_id: delegator_id.id.index.try_into()?, + account_address: details.sender.into(), + })), + DelegationEvent::BakerRemoved { + baker_id, + } => { + todo!(); } }) .collect::>>()? - }, + } } - }, + } BlockItemSummaryDetails::AccountCreation(details) => { vec![Event::AccountCreated(AccountCreated { account_address: details.address.into(), })] - }, + } BlockItemSummaryDetails::Update(details) => { vec![Event::ChainUpdateEnqueued(ChainUpdateEnqueued { effective_time: chrono::DateTime::from_timestamp( @@ -3307,9 +3296,9 @@ pub fn events_from_summary( ) .context("Failed to parse effective time")? .naive_utc(), - payload: true, // placeholder + payload: true, // placeholder })] - }, + } }; Ok(events) } @@ -3320,81 +3309,65 @@ impl TryFrom for TransactionRejectReas fn try_from(reason: concordium_rust_sdk::types::RejectReason) -> Result { use concordium_rust_sdk::types::RejectReason; match reason { - RejectReason::ModuleNotWF => { - Ok(TransactionRejectReason::ModuleNotWf(ModuleNotWf { - dummy: true, - })) - }, - RejectReason::ModuleHashAlreadyExists { contents } => { - Ok(TransactionRejectReason::ModuleHashAlreadyExists( - ModuleHashAlreadyExists { - module_ref: contents.to_string(), - }, - )) - }, - RejectReason::InvalidAccountReference { contents } => { - Ok(TransactionRejectReason::InvalidAccountReference( - InvalidAccountReference { - account_address: contents.into(), - }, - )) - }, - RejectReason::InvalidInitMethod { contents } => { - Ok(TransactionRejectReason::InvalidInitMethod( - InvalidInitMethod { - module_ref: contents.0.to_string(), - init_name: contents.1.to_string(), - }, - )) - }, - RejectReason::InvalidReceiveMethod { contents } => { - Ok(TransactionRejectReason::InvalidReceiveMethod( - InvalidReceiveMethod { - module_ref: contents.0.to_string(), - receive_name: contents.1.to_string(), - }, - )) - }, - RejectReason::InvalidModuleReference { contents } => { - Ok(TransactionRejectReason::InvalidModuleReference( - InvalidModuleReference { - module_ref: contents.to_string(), - }, - )) - }, - RejectReason::InvalidContractAddress { contents } => { - Ok(TransactionRejectReason::InvalidContractAddress( - InvalidContractAddress { - contract_address: contents.into(), - }, - )) - }, + RejectReason::ModuleNotWF => Ok(TransactionRejectReason::ModuleNotWf(ModuleNotWf { + dummy: true, + })), + RejectReason::ModuleHashAlreadyExists { + contents, + } => Ok(TransactionRejectReason::ModuleHashAlreadyExists(ModuleHashAlreadyExists { + module_ref: contents.to_string(), + })), + RejectReason::InvalidAccountReference { + contents, + } => Ok(TransactionRejectReason::InvalidAccountReference(InvalidAccountReference { + account_address: contents.into(), + })), + RejectReason::InvalidInitMethod { + contents, + } => Ok(TransactionRejectReason::InvalidInitMethod(InvalidInitMethod { + module_ref: contents.0.to_string(), + init_name: contents.1.to_string(), + })), + RejectReason::InvalidReceiveMethod { + contents, + } => Ok(TransactionRejectReason::InvalidReceiveMethod(InvalidReceiveMethod { + module_ref: contents.0.to_string(), + receive_name: contents.1.to_string(), + })), + RejectReason::InvalidModuleReference { + contents, + } => Ok(TransactionRejectReason::InvalidModuleReference(InvalidModuleReference { + module_ref: contents.to_string(), + })), + RejectReason::InvalidContractAddress { + contents, + } => Ok(TransactionRejectReason::InvalidContractAddress(InvalidContractAddress { + contract_address: contents.into(), + })), RejectReason::RuntimeFailure => { Ok(TransactionRejectReason::RuntimeFailure(RuntimeFailure { dummy: true, })) - }, - RejectReason::AmountTooLarge { contents } => { - Ok(TransactionRejectReason::AmountTooLarge(AmountTooLarge { - address: contents.0.into(), - amount: contents.1.micro_ccd().try_into()?, - })) - }, + } + RejectReason::AmountTooLarge { + contents, + } => Ok(TransactionRejectReason::AmountTooLarge(AmountTooLarge { + address: contents.0.into(), + amount: contents.1.micro_ccd().try_into()?, + })), RejectReason::SerializationFailure => { - Ok(TransactionRejectReason::SerializationFailure( - SerializationFailure { dummy: true }, - )) - }, - RejectReason::OutOfEnergy => { - Ok(TransactionRejectReason::OutOfEnergy(OutOfEnergy { + Ok(TransactionRejectReason::SerializationFailure(SerializationFailure { dummy: true, })) - }, - RejectReason::RejectedInit { reject_reason } => { - Ok(TransactionRejectReason::RejectedInit(RejectedInit { - reject_reason, - })) - }, + } + RejectReason::OutOfEnergy => Ok(TransactionRejectReason::OutOfEnergy(OutOfEnergy { + dummy: true, + })), + RejectReason::RejectedInit { + reject_reason, + } => Ok(TransactionRejectReason::RejectedInit(RejectedInit { + reject_reason, + })), RejectReason::RejectedReceive { reject_reason, contract_address, @@ -3408,238 +3381,244 @@ impl TryFrom for TransactionRejectReas message_as_hex: hex::encode(parameter.as_ref()), // message: todo!(), })) - }, - RejectReason::InvalidProof => { - Ok(TransactionRejectReason::InvalidProof(InvalidProof { - dummy: true, - })) - }, - RejectReason::AlreadyABaker { contents } => { - Ok(TransactionRejectReason::AlreadyABaker(AlreadyABaker { - baker_id: contents.id.index.try_into()?, - })) - }, - RejectReason::NotABaker { contents } => { - Ok(TransactionRejectReason::NotABaker(NotABaker { - account_address: contents.into(), - })) - }, + } + RejectReason::InvalidProof => Ok(TransactionRejectReason::InvalidProof(InvalidProof { + dummy: true, + })), + RejectReason::AlreadyABaker { + contents, + } => Ok(TransactionRejectReason::AlreadyABaker(AlreadyABaker { + baker_id: contents.id.index.try_into()?, + })), + RejectReason::NotABaker { + contents, + } => Ok(TransactionRejectReason::NotABaker(NotABaker { + account_address: contents.into(), + })), RejectReason::InsufficientBalanceForBakerStake => { Ok(TransactionRejectReason::InsufficientBalanceForBakerStake( - InsufficientBalanceForBakerStake { dummy: true }, + InsufficientBalanceForBakerStake { + dummy: true, + }, )) - }, + } RejectReason::StakeUnderMinimumThresholdForBaking => { - Ok( - TransactionRejectReason::StakeUnderMinimumThresholdForBaking( - StakeUnderMinimumThresholdForBaking { dummy: true }, - ), - ) - }, + Ok(TransactionRejectReason::StakeUnderMinimumThresholdForBaking( + StakeUnderMinimumThresholdForBaking { + dummy: true, + }, + )) + } RejectReason::BakerInCooldown => { Ok(TransactionRejectReason::BakerInCooldown(BakerInCooldown { dummy: true, })) - }, - RejectReason::DuplicateAggregationKey { contents } => { - Ok(TransactionRejectReason::DuplicateAggregationKey( - DuplicateAggregationKey { - aggregation_key: serde_json::to_string(&contents)?, - }, - )) - }, + } + RejectReason::DuplicateAggregationKey { + contents, + } => Ok(TransactionRejectReason::DuplicateAggregationKey(DuplicateAggregationKey { + aggregation_key: serde_json::to_string(&contents)?, + })), RejectReason::NonExistentCredentialID => { - Ok(TransactionRejectReason::NonExistentCredentialId( - NonExistentCredentialId { dummy: true }, - )) - }, + Ok(TransactionRejectReason::NonExistentCredentialId(NonExistentCredentialId { + dummy: true, + })) + } RejectReason::KeyIndexAlreadyInUse => { - Ok(TransactionRejectReason::KeyIndexAlreadyInUse( - KeyIndexAlreadyInUse { dummy: true }, - )) - }, + Ok(TransactionRejectReason::KeyIndexAlreadyInUse(KeyIndexAlreadyInUse { + dummy: true, + })) + } RejectReason::InvalidAccountThreshold => { - Ok(TransactionRejectReason::InvalidAccountThreshold( - InvalidAccountThreshold { dummy: true }, - )) - }, + Ok(TransactionRejectReason::InvalidAccountThreshold(InvalidAccountThreshold { + dummy: true, + })) + } RejectReason::InvalidCredentialKeySignThreshold => { Ok(TransactionRejectReason::InvalidCredentialKeySignThreshold( - InvalidCredentialKeySignThreshold { dummy: true }, + InvalidCredentialKeySignThreshold { + dummy: true, + }, )) - }, + } RejectReason::InvalidEncryptedAmountTransferProof => { - Ok( - TransactionRejectReason::InvalidEncryptedAmountTransferProof( - InvalidEncryptedAmountTransferProof { dummy: true }, - ), - ) - }, + Ok(TransactionRejectReason::InvalidEncryptedAmountTransferProof( + InvalidEncryptedAmountTransferProof { + dummy: true, + }, + )) + } RejectReason::InvalidTransferToPublicProof => { Ok(TransactionRejectReason::InvalidTransferToPublicProof( - InvalidTransferToPublicProof { dummy: true }, - )) - }, - RejectReason::EncryptedAmountSelfTransfer { contents } => { - Ok(TransactionRejectReason::EncryptedAmountSelfTransfer( - EncryptedAmountSelfTransfer { - account_address: contents.into(), + InvalidTransferToPublicProof { + dummy: true, }, )) - }, + } + RejectReason::EncryptedAmountSelfTransfer { + contents, + } => Ok(TransactionRejectReason::EncryptedAmountSelfTransfer( + EncryptedAmountSelfTransfer { + account_address: contents.into(), + }, + )), RejectReason::InvalidIndexOnEncryptedTransfer => { Ok(TransactionRejectReason::InvalidIndexOnEncryptedTransfer( - InvalidIndexOnEncryptedTransfer { dummy: true }, + InvalidIndexOnEncryptedTransfer { + dummy: true, + }, )) - }, + } RejectReason::ZeroScheduledAmount => { - Ok(TransactionRejectReason::ZeroScheduledAmount( - ZeroScheduledAmount { dummy: true }, - )) - }, + Ok(TransactionRejectReason::ZeroScheduledAmount(ZeroScheduledAmount { + dummy: true, + })) + } RejectReason::NonIncreasingSchedule => { - Ok(TransactionRejectReason::NonIncreasingSchedule( - NonIncreasingSchedule { dummy: true }, - )) - }, + Ok(TransactionRejectReason::NonIncreasingSchedule(NonIncreasingSchedule { + dummy: true, + })) + } RejectReason::FirstScheduledReleaseExpired => { Ok(TransactionRejectReason::FirstScheduledReleaseExpired( - FirstScheduledReleaseExpired { dummy: true }, - )) - }, - RejectReason::ScheduledSelfTransfer { contents } => { - Ok(TransactionRejectReason::ScheduledSelfTransfer( - ScheduledSelfTransfer { - account_address: contents.into(), + FirstScheduledReleaseExpired { + dummy: true, }, )) - }, + } + RejectReason::ScheduledSelfTransfer { + contents, + } => Ok(TransactionRejectReason::ScheduledSelfTransfer(ScheduledSelfTransfer { + account_address: contents.into(), + })), RejectReason::InvalidCredentials => { - Ok(TransactionRejectReason::InvalidCredentials( - InvalidCredentials { dummy: true }, - )) - }, - RejectReason::DuplicateCredIDs { contents } => { - Ok(TransactionRejectReason::DuplicateCredIds( - DuplicateCredIds { - cred_ids: contents - .into_iter() - .map(|cred_id| cred_id.to_string()) - .collect(), - }, - )) - }, - RejectReason::NonExistentCredIDs { contents } => { - Ok(TransactionRejectReason::NonExistentCredIds( - NonExistentCredIds { - cred_ids: contents - .into_iter() - .map(|cred_id| cred_id.to_string()) - .collect(), - }, - )) - }, + Ok(TransactionRejectReason::InvalidCredentials(InvalidCredentials { + dummy: true, + })) + } + RejectReason::DuplicateCredIDs { + contents, + } => Ok(TransactionRejectReason::DuplicateCredIds(DuplicateCredIds { + cred_ids: contents.into_iter().map(|cred_id| cred_id.to_string()).collect(), + })), + RejectReason::NonExistentCredIDs { + contents, + } => Ok(TransactionRejectReason::NonExistentCredIds(NonExistentCredIds { + cred_ids: contents.into_iter().map(|cred_id| cred_id.to_string()).collect(), + })), RejectReason::RemoveFirstCredential => { - Ok(TransactionRejectReason::RemoveFirstCredential( - RemoveFirstCredential { dummy: true }, - )) - }, - RejectReason::CredentialHolderDidNotSign => { - Ok(TransactionRejectReason::CredentialHolderDidNotSign( - CredentialHolderDidNotSign { dummy: true }, - )) - }, + Ok(TransactionRejectReason::RemoveFirstCredential(RemoveFirstCredential { + dummy: true, + })) + } + RejectReason::CredentialHolderDidNotSign => Ok( + TransactionRejectReason::CredentialHolderDidNotSign(CredentialHolderDidNotSign { + dummy: true, + }), + ), RejectReason::NotAllowedMultipleCredentials => { Ok(TransactionRejectReason::NotAllowedMultipleCredentials( - NotAllowedMultipleCredentials { dummy: true }, + NotAllowedMultipleCredentials { + dummy: true, + }, )) - }, + } RejectReason::NotAllowedToReceiveEncrypted => { Ok(TransactionRejectReason::NotAllowedToReceiveEncrypted( - NotAllowedToReceiveEncrypted { dummy: true }, - )) - }, - RejectReason::NotAllowedToHandleEncrypted => { - Ok(TransactionRejectReason::NotAllowedToHandleEncrypted( - NotAllowedToHandleEncrypted { dummy: true }, + NotAllowedToReceiveEncrypted { + dummy: true, + }, )) - }, + } + RejectReason::NotAllowedToHandleEncrypted => Ok( + TransactionRejectReason::NotAllowedToHandleEncrypted(NotAllowedToHandleEncrypted { + dummy: true, + }), + ), RejectReason::MissingBakerAddParameters => { - Ok(TransactionRejectReason::MissingBakerAddParameters( - MissingBakerAddParameters { dummy: true }, - )) - }, + Ok(TransactionRejectReason::MissingBakerAddParameters(MissingBakerAddParameters { + dummy: true, + })) + } RejectReason::FinalizationRewardCommissionNotInRange => { - Ok( - TransactionRejectReason::FinalizationRewardCommissionNotInRange( - FinalizationRewardCommissionNotInRange { dummy: true }, - ), - ) - }, + Ok(TransactionRejectReason::FinalizationRewardCommissionNotInRange( + FinalizationRewardCommissionNotInRange { + dummy: true, + }, + )) + } RejectReason::BakingRewardCommissionNotInRange => { Ok(TransactionRejectReason::BakingRewardCommissionNotInRange( - BakingRewardCommissionNotInRange { dummy: true }, + BakingRewardCommissionNotInRange { + dummy: true, + }, )) - }, + } RejectReason::TransactionFeeCommissionNotInRange => { Ok(TransactionRejectReason::TransactionFeeCommissionNotInRange( - TransactionFeeCommissionNotInRange { dummy: true }, + TransactionFeeCommissionNotInRange { + dummy: true, + }, )) - }, + } RejectReason::AlreadyADelegator => { - Ok(TransactionRejectReason::AlreadyADelegator( - AlreadyADelegator { dummy: true }, - )) - }, + Ok(TransactionRejectReason::AlreadyADelegator(AlreadyADelegator { + dummy: true, + })) + } RejectReason::InsufficientBalanceForDelegationStake => { - Ok( - TransactionRejectReason::InsufficientBalanceForDelegationStake( - InsufficientBalanceForDelegationStake { dummy: true }, - ), - ) - }, + Ok(TransactionRejectReason::InsufficientBalanceForDelegationStake( + InsufficientBalanceForDelegationStake { + dummy: true, + }, + )) + } RejectReason::MissingDelegationAddParameters => { Ok(TransactionRejectReason::MissingDelegationAddParameters( - MissingDelegationAddParameters { dummy: true }, - )) - }, - RejectReason::InsufficientDelegationStake => { - Ok(TransactionRejectReason::InsufficientDelegationStake( - InsufficientDelegationStake { dummy: true }, + MissingDelegationAddParameters { + dummy: true, + }, )) - }, + } + RejectReason::InsufficientDelegationStake => Ok( + TransactionRejectReason::InsufficientDelegationStake(InsufficientDelegationStake { + dummy: true, + }), + ), RejectReason::DelegatorInCooldown => { - Ok(TransactionRejectReason::DelegatorInCooldown( - DelegatorInCooldown { dummy: true }, - )) - }, - RejectReason::NotADelegator { address } => { - Ok(TransactionRejectReason::NotADelegator(NotADelegator { - account_address: address.into(), + Ok(TransactionRejectReason::DelegatorInCooldown(DelegatorInCooldown { + dummy: true, })) - }, - RejectReason::DelegationTargetNotABaker { target } => { - Ok(TransactionRejectReason::DelegationTargetNotABaker( - DelegationTargetNotABaker { - baker_id: target.id.index.try_into()?, - }, - )) - }, + } + RejectReason::NotADelegator { + address, + } => Ok(TransactionRejectReason::NotADelegator(NotADelegator { + account_address: address.into(), + })), + RejectReason::DelegationTargetNotABaker { + target, + } => { + Ok(TransactionRejectReason::DelegationTargetNotABaker(DelegationTargetNotABaker { + baker_id: target.id.index.try_into()?, + })) + } RejectReason::StakeOverMaximumThresholdForPool => { Ok(TransactionRejectReason::StakeOverMaximumThresholdForPool( - StakeOverMaximumThresholdForPool { dummy: true }, + StakeOverMaximumThresholdForPool { + dummy: true, + }, )) - }, + } RejectReason::PoolWouldBecomeOverDelegated => { Ok(TransactionRejectReason::PoolWouldBecomeOverDelegated( - PoolWouldBecomeOverDelegated { dummy: true }, + PoolWouldBecomeOverDelegated { + dummy: true, + }, )) - }, - RejectReason::PoolClosed => { - Ok(TransactionRejectReason::PoolClosed(PoolClosed { - dummy: true, - })) - }, + } + RejectReason::PoolClosed => Ok(TransactionRejectReason::PoolClosed(PoolClosed { + dummy: true, + })), } } } @@ -3656,8 +3635,8 @@ impl From for TransferMemo { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct Transferred { amount: Amount, - from: Address, - to: AccountAddress, + from: Address, + to: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -3667,40 +3646,36 @@ pub struct AccountCreated { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct AmountAddedByDecryption { - amount: Amount, + amount: Amount, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] pub struct BakerAdded { - staked_amount: Amount, + staked_amount: Amount, restake_earnings: bool, - baker_id: BakerId, - sign_key: String, - election_key: String, - aggregation_key: String, + baker_id: BakerId, + sign_key: String, + election_key: String, + aggregation_key: String, } #[ComplexObject] impl BakerAdded { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] pub struct BakerKeysUpdated { - baker_id: BakerId, - sign_key: String, - election_key: String, + baker_id: BakerId, + sign_key: String, + election_key: String, aggregation_key: String, } #[ComplexObject] impl BakerKeysUpdated { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -3710,57 +3685,49 @@ pub struct BakerRemoved { } #[ComplexObject] impl BakerRemoved { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] pub struct BakerSetRestakeEarnings { - baker_id: BakerId, + baker_id: BakerId, restake_earnings: bool, } #[ComplexObject] impl BakerSetRestakeEarnings { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] pub struct BakerStakeDecreased { - baker_id: BakerId, + baker_id: BakerId, new_staked_amount: Amount, } #[ComplexObject] impl BakerStakeDecreased { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] #[graphql(complex)] pub struct BakerStakeIncreased { - baker_id: BakerId, + baker_id: BakerId, new_staked_amount: Amount, } #[ComplexObject] impl BakerStakeIncreased { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { - todo!() - } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ContractInitialized { - module_ref: String, + module_ref: String, contract_address: ContractAddress, - amount: Amount, - init_name: String, - version: ContractVersion, + amount: Amount, + init_name: String, + version: ContractVersion, // TODO: eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the // elements in the list that come after the specified cursor." after: String "Returns the last // _n_ elements from the list." last: Int "Returns the elements in the list that come before @@ -3799,7 +3766,7 @@ pub struct ContractCall { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct CredentialDeployed { - reg_id: String, + reg_id: String, account_address: AccountAddress, } @@ -3810,26 +3777,27 @@ pub struct CredentialKeysUpdated { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct CredentialsUpdated { - account_address: AccountAddress, - new_cred_ids: Vec, + account_address: AccountAddress, + new_cred_ids: Vec, removed_cred_ids: Vec, - new_threshold: Byte, + new_threshold: Byte, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DataRegistered { - decoded: DecodedText, + decoded: DecodedText, data_as_hex: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DecodedText { - text: String, + text: String, decode_type: TextDecodeType, } impl DecodedText { - /// Attempt to parse the bytes as a CBOR string otherwise use HEX to present the bytes. + /// Attempt to parse the bytes as a CBOR string otherwise use HEX to present + /// the bytes. fn from_bytes(bytes: &[u8]) -> Self { if let Ok(text) = ciborium::from_reader::(bytes) { Self { @@ -3838,7 +3806,7 @@ impl DecodedText { } } else { Self { - text: hex::encode(bytes), + text: hex::encode(bytes), decode_type: TextDecodeType::Hex, } } @@ -3853,10 +3821,10 @@ pub enum TextDecodeType { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct EncryptedAmountsRemoved { - account_address: AccountAddress, + account_address: AccountAddress, new_encrypted_amount: String, - input_amount: String, - up_to_index: u64, + input_amount: String, + up_to_index: u64, } impl TryFrom for EncryptedAmountsRemoved { @@ -3866,25 +3834,25 @@ impl TryFrom for Encryp removed: concordium_rust_sdk::types::EncryptedAmountRemovedEvent, ) -> Result { Ok(EncryptedAmountsRemoved { - account_address: removed.account.into(), + account_address: removed.account.into(), new_encrypted_amount: serde_json::to_string(&removed.new_amount)?, - input_amount: serde_json::to_string(&removed.input_amount)?, - up_to_index: removed.up_to_index.index, + input_amount: serde_json::to_string(&removed.input_amount)?, + up_to_index: removed.up_to_index.index, }) } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct EncryptedSelfAmountAdded { - account_address: AccountAddress, + account_address: AccountAddress, new_encrypted_amount: String, - amount: Amount, + amount: Amount, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct NewEncryptedAmount { - account_address: AccountAddress, - new_index: u64, + account_address: AccountAddress, + new_index: u64, encrypted_amount: String, } @@ -3895,8 +3863,8 @@ impl TryFrom for NewEncrypt added: concordium_rust_sdk::types::NewEncryptedAmountEvent, ) -> Result { Ok(NewEncryptedAmount { - account_address: added.receiver.into(), - new_index: added.new_index.index, + account_address: added.receiver.into(), + new_index: added.new_index.index, encrypted_amount: serde_json::to_string(&added.encrypted_amount)?, }) } @@ -3911,8 +3879,8 @@ pub struct TransferMemo { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct TransferredWithSchedule { from_account_address: AccountAddress, - to_account_address: AccountAddress, - total_amount: Amount, + to_account_address: AccountAddress, + total_amount: Amount, // TODO: amountsSchedule("Returns the first _n_ elements from the list." first: Int "Returns // the elements in the list that come after the specified cursor." after: String "Returns the // last _n_ elements from the list." last: Int "Returns the elements in the list that come @@ -3922,11 +3890,11 @@ pub struct TransferredWithSchedule { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ModuleReferenceEvent { module_reference: String, - sender: AccountAddress, - block_height: BlockHeight, + sender: AccountAddress, + block_height: BlockHeight, transaction_hash: String, - block_slot_time: DateTime, - display_schema: String, + block_slot_time: DateTime, + display_schema: String, // TODO: // moduleReferenceRejectEvents(skip: Int take: Int): // ModuleReferenceRejectEventsCollectionSegment moduleReferenceContractLinkEvents(skip: Int @@ -3938,17 +3906,18 @@ pub struct ModuleReferenceEvent { pub struct ChainUpdateEnqueued { effective_time: DateTime, // effective_immediately: bool, // Not sure this makes sense. - payload: bool, // ChainUpdatePayload, + payload: bool, // ChainUpdatePayload, } // union ChainUpdatePayload = MinBlockTimeUpdate | TimeoutParametersUpdate | -// FinalizationCommitteeParametersUpdate | BlockEnergyLimitUpdate | GasRewardsCpv2Update | -// ProtocolChainUpdatePayload | ElectionDifficultyChainUpdatePayload | -// EuroPerEnergyChainUpdatePayload | MicroCcdPerEuroChainUpdatePayload | -// FoundationAccountChainUpdatePayload | MintDistributionChainUpdatePayload | +// FinalizationCommitteeParametersUpdate | BlockEnergyLimitUpdate | +// GasRewardsCpv2Update | ProtocolChainUpdatePayload | +// ElectionDifficultyChainUpdatePayload | EuroPerEnergyChainUpdatePayload | +// MicroCcdPerEuroChainUpdatePayload | FoundationAccountChainUpdatePayload | +// MintDistributionChainUpdatePayload | // TransactionFeeDistributionChainUpdatePayload | GasRewardsChainUpdatePayload | -// BakerStakeThresholdChainUpdatePayload | RootKeysChainUpdatePayload | Level1KeysChainUpdatePayload -// | AddAnonymityRevokerChainUpdatePayload | AddIdentityProviderChainUpdatePayload | +// BakerStakeThresholdChainUpdatePayload | RootKeysChainUpdatePayload | +// Level1KeysChainUpdatePayload | AddAnonymityRevokerChainUpdatePayload | AddIdentityProviderChainUpdatePayload | // CooldownParametersChainUpdatePayload | PoolParametersChainUpdatePayload | // TimeParametersChainUpdatePayload | MintDistributionV1ChainUpdatePayload #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -3972,17 +3941,17 @@ pub struct ContractInterrupted { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ContractResumed { contract_address: ContractAddress, - success: bool, + success: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ContractUpdated { contract_address: ContractAddress, - instigator: Address, - amount: Amount, - message_as_hex: String, - receive_name: String, - version: ContractVersion, + instigator: Address, + amount: Amount, + message_as_hex: String, + receive_name: String, + version: ContractVersion, // eventsAsHex("Returns the first _n_ elements from the list." first: Int "Returns the elements // in the list that come after the specified cursor." after: String "Returns the last _n_ // elements from the list." last: Int "Returns the elements in the list that come before the @@ -3997,14 +3966,14 @@ pub struct ContractUpdated { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ContractUpgraded { contract_address: ContractAddress, - from: String, - to: String, + from: String, + to: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct BakerSetBakingRewardCommission { - baker_id: BakerId, - account_address: AccountAddress, + baker_id: BakerId, + account_address: AccountAddress, baking_reward_commission: Decimal, } @@ -4017,75 +3986,76 @@ pub struct BakerSetFinalizationRewardCommission { #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct BakerSetTransactionFeeCommission { - baker_id: BakerId, - account_address: AccountAddress, + baker_id: BakerId, + account_address: AccountAddress, transaction_fee_commission: Decimal, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct BakerSetMetadataURL { - baker_id: BakerId, + baker_id: BakerId, account_address: AccountAddress, - metadata_url: String, + metadata_url: String, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct BakerSetOpenStatus { - baker_id: BakerId, + baker_id: BakerId, account_address: AccountAddress, - open_status: BakerPoolOpenStatus, + open_status: BakerPoolOpenStatus, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationAdded { - delegator_id: AccountIndex, + delegator_id: AccountIndex, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationRemoved { - delegator_id: AccountIndex, + delegator_id: AccountIndex, account_address: AccountAddress, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationSetDelegationTarget { - delegator_id: AccountIndex, - account_address: AccountAddress, + delegator_id: AccountIndex, + account_address: AccountAddress, delegation_target: DelegationTarget, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationSetRestakeEarnings { - delegator_id: AccountIndex, - account_address: AccountAddress, + delegator_id: AccountIndex, + account_address: AccountAddress, restake_earnings: bool, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationStakeDecreased { - delegator_id: AccountIndex, - account_address: AccountAddress, + delegator_id: AccountIndex, + account_address: AccountAddress, new_staked_amount: Amount, } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct DelegationStakeIncreased { - delegator_id: AccountIndex, - account_address: AccountAddress, + delegator_id: AccountIndex, + account_address: AccountAddress, new_staked_amount: Amount, } #[derive(SimpleObject)] struct BlockMetrics { - /// The most recent block height. Equals the total length of the chain minus one (genesis block - /// is at height zero). - last_block_height: BlockHeight, + /// The most recent block height. Equals the total length of the chain minus + /// one (genesis block is at height zero). + last_block_height: BlockHeight, /// Total number of blocks added in requested period. - blocks_added: i64, - /// The average block time (slot-time difference between two adjacent blocks) in the requested - /// period. Will be null if no blocks have been added in the requested period. - avg_block_time: Option, + blocks_added: i64, + /// The average block time (slot-time difference between two adjacent + /// blocks) in the requested period. Will be null if no blocks have been + /// added in the requested period. + avg_block_time: Option, // /// The average finalization time (slot-time difference between a given block and the block // /// that holds its finalization proof) in the requested period. Will be null if no blocks // have /// been finalized in the requested period. @@ -4108,7 +4078,7 @@ struct BlockMetrics { // last_total_percentage_encrypted: f32, // /// The current percentage of CCD staked (of total CCD in existence). // last_total_percentage_staked: f32, - buckets: BlockMetricsBuckets, + buckets: BlockMetricsBuckets, } #[derive(SimpleObject)] @@ -4118,7 +4088,8 @@ struct BlockMetricsBuckets { /// Start of the bucket time period. Intended x-axis value. #[graphql(name = "x_Time")] x_time: Vec, - /// Number of blocks added within the bucket time period. Intended y-axis value. + /// Number of blocks added within the bucket time period. Intended y-axis + /// value. #[graphql(name = "y_BlocksAdded")] y_blocks_added: Vec, // /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket @@ -4126,9 +4097,9 @@ struct BlockMetricsBuckets { // /// period. // #[graphql(name = "y_BlockTimeMin")] // y_block_time_min: Vec, - /// The average block time (slot-time difference between two adjacent blocks) in the bucket - /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket - /// period. + /// The average block time (slot-time difference between two adjacent + /// blocks) in the bucket period. Intended y-axis value. Will be null if + /// no blocks have been added in the bucket period. #[graphql(name = "y_BlockTimeAvg")] y_block_time_avg: Vec, // /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket @@ -4141,9 +4112,10 @@ struct BlockMetricsBuckets { // /// null if no blocks have been finalized in the bucket period. // #[graphql(name = "y_FinalizationTimeMin")] // y_finalization_time_min: Vec, - /// The average finalization time (slot-time difference between a given block and the block - /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be - /// null if no blocks have been finalized in the bucket period. + /// The average finalization time (slot-time difference between a given + /// block and the block that holds its finalization proof) in the bucket + /// period. Intended y-axis value. Will be null if no blocks have been + /// finalized in the bucket period. #[graphql(name = "y_FinalizationTimeAvg")] y_finalization_time_avg: Vec, // /// The maximum finalization time (slot-time difference between a given block and the block @@ -4175,7 +4147,8 @@ struct BlockMetricsBuckets { // null /// if no blocks have been added in the bucket period. // #[graphql(name = "y_MaxTotalMicroCcdStaked")] // y_max_total_micro_ccd_staked: Vec, - /// The total amount of CCD staked at the end of the bucket period. Intended y-axis value. + /// The total amount of CCD staked at the end of the bucket period. Intended + /// y-axis value. #[graphql(name = "y_LastTotalMicroCcdStaked")] y_last_total_micro_ccd_staked: Vec, } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 5e0a5823..7a2234be 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -8,66 +8,42 @@ //! - Setup CI for deployment. use crate::graphql_api::{ - events_from_summary, - AccountTransactionType, - CredentialDeploymentTransactionType, - DbTransactionType, - UpdateTransactionType, + events_from_summary, AccountTransactionType, CredentialDeploymentTransactionType, + DbTransactionType, UpdateTransactionType, }; use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ indexer::{ - async_trait, - traverse_and_process, - Indexer, - ProcessEvent, - TraverseConfig, - TraverseError, + async_trait, traverse_and_process, Indexer, ProcessEvent, TraverseConfig, TraverseError, }, types::{ - queries::BlockInfo, - AccountTransactionDetails, - AccountTransactionEffects, - BlockItemSummary, - BlockItemSummaryDetails, - RewardsOverview, - }, - v2::{ - self, - ChainParameters, - FinalizedBlockInfo, - QueryResult, - RPCError, + queries::BlockInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, + BlockItemSummaryDetails, RewardsOverview, }, + v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult, RPCError}, }; use futures::TryStreamExt; use prometheus_client::{ - metrics::{ - counter::Counter, - family::Family, - gauge::Gauge, - }, + metrics::{counter::Counter, family::Family, gauge::Gauge}, registry::Registry, }; use sqlx::PgPool; use tokio::try_join; use tokio_util::sync::CancellationToken; -use tracing::{ - info, - warn, -}; +use tracing::{info, warn}; /// Service traversing each block of the chain, indexing it into a database. pub struct IndexerService { /// List of Concordium nodes to cycle through when traversing. - endpoints: Vec, + endpoints: Vec, /// The block height to traversing from. - start_height: u64, + start_height: u64, /// State tracked by the block preprocessor during traversing. block_pre_processor: BlockPreProcessor, - /// State tracked by the block processor, which is submitting to the database. - block_processor: BlockProcessor, + /// State tracked by the block processor, which is submitting to the + /// database. + block_processor: BlockProcessor, } impl IndexerService { @@ -105,7 +81,8 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 }) } - /// Run the service. This future will only stop when signalled by the `cancel_token`. + /// Run the service. This future will only stop when signalled by the + /// `cancel_token`. pub async fn run(self, cancel_token: CancellationToken) -> anyhow::Result<()> { let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) .context("Failed setting up TraverseConfig")?; @@ -138,16 +115,18 @@ impl NodeMetricLabels { } } -/// State tracked during block preprocessing, this also holds the implementation of -/// [`Indexer`](concordium_rust_sdk::indexer::Indexer). Since several preprocessors can run in -/// parallel, this must be `Sync`. +/// State tracked during block preprocessing, this also holds the implementation +/// of [`Indexer`](concordium_rust_sdk::indexer::Indexer). Since several +/// preprocessors can run in parallel, this must be `Sync`. struct BlockPreProcessor { - /// Metric counting the total number of connections ever established to a node. + /// Metric counting the total number of connections ever established to a + /// node. established_node_connections: Family, - /// Metric counting the total number of failed attempts to preprocess blocks. - total_failures: Family, + /// Metric counting the total number of failed attempts to preprocess + /// blocks. + total_failures: Family, /// Metric tracking the number of blocks currently being preprocessed. - blocks_being_preprocessed: Family, + blocks_being_preprocessed: Family, } impl BlockPreProcessor { fn new(registry: &mut Registry) -> Self { @@ -182,18 +161,18 @@ impl Indexer for BlockPreProcessor { type Data = PreparedBlock; /// Called when a new connection is established to the given endpoint. - /// The return value from this method is passed to each call of on_finalized. + /// The return value from this method is passed to each call of + /// on_finalized. async fn on_connect<'a>( &mut self, endpoint: v2::Endpoint, _client: &'a mut v2::Client, ) -> QueryResult { - // TODO: check the genesis hash matches, i.e. that the node is running on the same network. + // TODO: check the genesis hash matches, i.e. that the node is running on the + // same network. info!("Connection established to node at uri: {}", endpoint.uri()); let label = NodeMetricLabels::new(&endpoint); - self.established_node_connections - .get_or_create(&label) - .inc(); + self.established_node_connections.get_or_create(&label).inc(); Ok(label) } @@ -211,8 +190,8 @@ impl Indexer for BlockPreProcessor { fbi: FinalizedBlockInfo, ) -> QueryResult { self.blocks_being_preprocessed.get_or_create(label).inc(); - // We block together the computation, so we can update the metric in the error case, before - // returning early. + // We block together the computation, so we can update the metric in the error + // case, before returning early. let result = async move { let mut client1 = client.clone(); let mut client2 = client.clone(); @@ -261,33 +240,30 @@ impl Indexer for BlockPreProcessor { successive_failures: u64, err: TraverseError, ) -> bool { - info!( - "Failed preprocessing {} times in row: {}", - successive_failures, err - ); - self.total_failures - .get_or_create(&NodeMetricLabels::new(&endpoint)) - .inc(); + info!("Failed preprocessing {} times in row: {}", successive_failures, err); + self.total_failures.get_or_create(&NodeMetricLabels::new(&endpoint)).inc(); true } } -/// Type implementing the `ProcessEvent` handling the insertion of prepared blocks. +/// Type implementing the `ProcessEvent` handling the insertion of prepared +/// blocks. struct BlockProcessor { /// Database connection pool - pool: PgPool, + pool: PgPool, /// The last finalized block height according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. last_finalized_height: i64, /// The last finalized block hash according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. - last_finalized_hash: String, + last_finalized_hash: String, /// Metric counting how many blocks was saved to the database successfully. - blocks_processed: Counter, + blocks_processed: Counter, } impl BlockProcessor { - /// Construct the block processor by loading the initial state from the database. - /// This assumes at least the genesis block is in the database. + /// Construct the block processor by loading the initial state from the + /// database. This assumes at least the genesis block is in the + /// database. async fn new(pool: PgPool, registry: &mut Registry) -> anyhow::Result { let rec = sqlx::query!( r#" @@ -319,14 +295,14 @@ impl ProcessEvent for BlockProcessor { /// The type of events that are to be processed. Typically this will be all /// of the transactions of interest for a single block."] type Data = PreparedBlock; - - /// An error that can be signalled. - type Error = anyhow::Error; // TODO: introduce proper error type + // TODO: introduce proper error type /// A description returned by the [`process`](ProcessEvent::process) method. /// This message is logged by the [`ProcessorConfig`] and is intended to /// describe the data that was just processed. type Description = String; + /// An error that can be signalled. + type Error = anyhow::Error; /// Process a single item. This should work atomically in the sense that /// either the entire `data` is processed or none of it is in case of an @@ -335,17 +311,9 @@ impl ProcessEvent for BlockProcessor { async fn process(&mut self, data: &Self::Data) -> Result { // TODO: Improve this by batching blocks within some time frame into the same // DB-transaction. - let mut tx = self - .pool - .begin() - .await - .context("Failed to create SQL transaction")?; - data.save(&mut self, &mut tx) - .await - .context("Failed saving block")?; - tx.commit() - .await - .context("Failed to commit SQL transaction")?; + let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; + data.save(&mut self, &mut tx).await.context("Failed saving block")?; + tx.commit().await.context("Failed to commit SQL transaction")?; self.blocks_processed.inc(); Ok(format!("Processed block {}:{}", data.height, data.hash)) } @@ -373,10 +341,10 @@ impl ProcessEvent for BlockProcessor { /// Raw block Information fetched from a Concordium Node. struct BlockData { finalized_block_info: FinalizedBlockInfo, - block_info: BlockInfo, - events: Vec, - chain_parameters: ChainParameters, - tokenomics_info: RewardsOverview, + block_info: BlockInfo, + events: Vec, + chain_parameters: ChainParameters, + tokenomics_info: RewardsOverview, } /// Function for initializing the database with the genesis block. @@ -385,10 +353,7 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res let mut client = v2::Client::new(endpoint).await?; let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); - let mut tx = pool - .begin() - .await - .context("Failed to create SQL transaction")?; + let mut tx = pool.begin().await.context("Failed to create SQL transaction")?; let genesis_block_info = client.get_block_info(genesis_height).await?.response; let block_hash = genesis_block_info.block_hash.to_string(); @@ -400,14 +365,21 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res }; let genesis_tokenomics = client.get_tokenomics_info(genesis_height).await?.response; let common_reward = match genesis_tokenomics { - RewardsOverview::V0 { data } => data, - RewardsOverview::V1 { common, .. } => common, + RewardsOverview::V0 { + data, + } => data, + RewardsOverview::V1 { + common, + .. + } => common, }; let total_staked = match genesis_tokenomics { - RewardsOverview::V0 { .. } => { + RewardsOverview::V0 { + .. + } => { // TODO Compute the total staked capital. 0i64 - }, + } RewardsOverview::V1 { total_staked_capital, .. @@ -429,10 +401,7 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res let mut genesis_accounts = client.get_account_list(genesis_height).await?.response; while let Some(account) = genesis_accounts.try_next().await? { - let info = client - .get_account_info(&account.into(), genesis_height) - .await? - .response; + let info = client.get_account_info(&account.into(), genesis_height).await?.response; let index = i64::try_from(info.account_index.index)?; let account_address = account.to_string(); let amount = i64::try_from(info.account_amount.micro_ccd)?; @@ -448,19 +417,17 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res .execute(&mut *tx) .await?; } - tx.commit() - .await - .context("Failed to commit SQL transaction")?; + tx.commit().await.context("Failed to commit SQL transaction")?; Ok(()) } struct PreparedBlock { - hash: String, - height: i64, - slot_time: NaiveDateTime, - baker_id: Option, - total_amount: i64, - total_staked: i64, + hash: String, + height: i64, + slot_time: NaiveDateTime, + baker_id: Option, + total_amount: i64, + total_staked: i64, block_last_finalized: String, prepared_block_items: Vec, } @@ -477,15 +444,22 @@ impl PreparedBlock { None }; let common_reward_data = match data.tokenomics_info { - RewardsOverview::V0 { data } => data, - RewardsOverview::V1 { common, .. } => common, + RewardsOverview::V0 { + data, + } => data, + RewardsOverview::V1 { + common, + .. + } => common, }; let total_amount = i64::try_from(common_reward_data.total_amount.micro_ccd())?; let total_staked = match data.tokenomics_info { - RewardsOverview::V0 { .. } => { + RewardsOverview::V0 { + .. + } => { // TODO Compute the total staked capital. 0i64 - }, + } RewardsOverview::V1 { total_staked_capital, .. @@ -564,21 +538,21 @@ RETURNING finalizer.height"#, } struct PreparedBlockItem { - block_index: i64, - tx_hash: String, - ccd_cost: i64, - energy_cost: i64, - height: i64, - sender: Option, + block_index: i64, + tx_hash: String, + ccd_cost: i64, + energy_cost: i64, + height: i64, + sender: Option, transaction_type: DbTransactionType, - account_type: Option, - credential_type: Option, - update_type: Option, - success: bool, - events: Option, - reject: Option, + account_type: Option, + credential_type: Option, + update_type: Option, + success: bool, + events: Option, + reject: Option, // This is an option temporarily, until we are able to handle every type of event. - prepared_event: Option, + prepared_event: Option, } impl PreparedBlockItem { @@ -586,11 +560,8 @@ impl PreparedBlockItem { let height = i64::try_from(data.finalized_block_info.height.height)?; let block_index = i64::try_from(block_item.index.index)?; let tx_hash = block_item.hash.to_string(); - let ccd_cost = i64::try_from( - data.chain_parameters - .ccd_cost(block_item.energy_cost) - .micro_ccd, - )?; + let ccd_cost = + i64::try_from(data.chain_parameters.ccd_cost(block_item.energy_cost).micro_ccd)?; let energy_cost = i64::try_from(block_item.energy_cost.energy)?; let sender = block_item.sender_account().map(|a| a.to_string()); let (transaction_type, account_type, credential_type, update_type) = @@ -598,27 +569,17 @@ impl PreparedBlockItem { BlockItemSummaryDetails::AccountTransaction(details) => { let account_transaction_type = details.transaction_type().map(AccountTransactionType::from); - ( - DbTransactionType::Account, - account_transaction_type, - None, - None, - ) - }, + (DbTransactionType::Account, account_transaction_type, None, None) + } BlockItemSummaryDetails::AccountCreation(details) => { let credential_type = CredentialDeploymentTransactionType::from(details.credential_type); - ( - DbTransactionType::CredentialDeployment, - None, - Some(credential_type), - None, - ) - }, + (DbTransactionType::CredentialDeployment, None, Some(credential_type), None) + } BlockItemSummaryDetails::Update(details) => { let update_type = UpdateTransactionType::from(details.update_type()); (DbTransactionType::Update, None, None, Some(update_type)) - }, + } }; let success = block_item.is_success(); let (events, reject) = if success { @@ -627,7 +588,11 @@ impl PreparedBlockItem { } else { let reject = if let BlockItemSummaryDetails::AccountTransaction(AccountTransactionDetails { - effects: AccountTransactionEffects::None { reject_reason, .. }, + effects: + AccountTransactionEffects::None { + reject_reason, + .. + }, .. }) = &block_item.details { @@ -641,14 +606,16 @@ impl PreparedBlockItem { }; let prepared_event = match &block_item.details { BlockItemSummaryDetails::AccountCreation(details) => { - Some(PreparedEvent::AccountCreation( - PreparedAccountCreation::prepare(data, &block_item, details)?, - )) - }, + Some(PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( + data, + &block_item, + details, + )?)) + } details => { warn!("details = \n {:#?}", details); None - }, + } }; Ok(Self { @@ -697,7 +664,7 @@ VALUES match &self.prepared_event { Some(PreparedEvent::AccountCreation(event)) => event.save(context, tx).await?, - _ => {}, + _ => {} } Ok(()) } @@ -709,8 +676,8 @@ enum PreparedEvent { struct PreparedAccountCreation { account_address: String, - height: i64, - block_index: i64, + height: i64, + block_index: i64, } impl PreparedAccountCreation { diff --git a/backend-rust/src/metrics.rs b/backend-rust/src/metrics.rs index aff23ead..7b2d450f 100644 --- a/backend-rust/src/metrics.rs +++ b/backend-rust/src/metrics.rs @@ -13,9 +13,7 @@ pub async fn serve( let app = axum::Router::new() .route("/metrics", axum::routing::get(metrics)) .with_state(Arc::new(registry)); - axum::serve(tcp_listener, app) - .with_graceful_shutdown(stop_signal.cancelled_owned()) - .await?; + axum::serve(tcp_listener, app).with_graceful_shutdown(stop_signal.cancelled_owned()).await?; Ok(()) } From 7f8b46d1d2506893e2c78fa372b553553838894d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 25 Sep 2024 20:37:24 +0200 Subject: [PATCH 14/50] Finish metrics for indexer --- backend-rust/Cargo.toml | 2 +- backend-rust/src/bin/ccdscan-indexer.rs | 11 ++++ backend-rust/src/indexer.rs | 79 +++++++++++++++++++------ 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 95ced39f..a8765580 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -12,7 +12,7 @@ async-graphql-axum = "7.0" axum = "0.7" ciborium = "0.2" chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4.5", features = ["derive", "env"] } +clap = { version = "4.5", features = ["derive", "env", "cargo"] } concordium-rust-sdk = { path = "./concordium-rust-sdk" } derive_more = "0.99" dotenv = "0.15" diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index 54c1df56..f41274db 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -36,6 +36,17 @@ async fn main() -> anyhow::Result<()> { let cancel_token = CancellationToken::new(); let mut registry = Registry::with_prefix("indexer"); + registry.register( + "service", + "Information about the software", + prometheus_client::metrics::info::Info::new(vec![("version", clap::crate_version!())]), + ); + registry.register( + "service_startup_timestamp_millis", + "Timestamp of starting up the node (Unix time in milliseconds)", + prometheus_client::metrics::gauge::ConstGauge::new(chrono::Utc::now().timestamp_millis()), + ); + let mut indexer_task = { let pool = pool.clone(); let stop_signal = cancel_token.child_token(); diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 7a2234be..a815427f 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -25,11 +25,16 @@ use concordium_rust_sdk::{ }; use futures::TryStreamExt; use prometheus_client::{ - metrics::{counter::Counter, family::Family, gauge::Gauge}, + metrics::{ + counter::Counter, + family::Family, + gauge::Gauge, + histogram::{self, Histogram}, + }, registry::Registry, }; use sqlx::PgPool; -use tokio::try_join; +use tokio::{time::Instant, try_join}; use tokio_util::sync::CancellationToken; use tracing::{info, warn}; @@ -81,7 +86,7 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 }) } - /// Run the service. This future will only stop when signalled by the + /// Run the service. This future will only stop when signaled by the /// `cancel_token`. pub async fn run(self, cancel_token: CancellationToken) -> anyhow::Result<()> { let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) @@ -124,9 +129,12 @@ struct BlockPreProcessor { established_node_connections: Family, /// Metric counting the total number of failed attempts to preprocess /// blocks. - total_failures: Family, + preprocessing_failures: Family, /// Metric tracking the number of blocks currently being preprocessed. blocks_being_preprocessed: Family, + /// Histogram collecting the time it takes for fetching all the block data + /// from the node. + node_response_time: Family, } impl BlockPreProcessor { fn new(registry: &mut Registry) -> Self { @@ -136,11 +144,11 @@ impl BlockPreProcessor { "Total number of established Concordium Node connections", established_node_connections.clone(), ); - let total_failures = Family::default(); + let preprocessing_failures = Family::default(); registry.register( "preprocessing_failures", "Total number of failed attempts to preprocess blocks", - total_failures.clone(), + preprocessing_failures.clone(), ); let blocks_being_preprocessed = Family::default(); registry.register( @@ -148,10 +156,21 @@ impl BlockPreProcessor { "Current number of blocks being preprocessed", blocks_being_preprocessed.clone(), ); + let node_response_time: Family = + Family::new_with_constructor(|| { + Histogram::new(histogram::exponential_buckets(0.010, 2.0, 10)) + }); + registry.register( + "node_response_time_seconds", + "Duration of seconds used to fetch all of the block information", + node_response_time.clone(), + ); + Self { established_node_connections, - total_failures, + preprocessing_failures, blocks_being_preprocessed, + node_response_time, } } } @@ -206,6 +225,7 @@ impl Indexer for BlockPreProcessor { Ok(events) }; + let start_fetching = Instant::now(); let (block_info, chain_parameters, events, tokenomics_info) = try_join!( client1.get_block_info(fbi.height), client2.get_block_chain_parameters(fbi.height), @@ -213,6 +233,8 @@ impl Indexer for BlockPreProcessor { client.get_tokenomics_info(fbi.height) ) .map_err(|err| err)?; + let node_response_time = start_fetching.elapsed(); + self.node_response_time.get_or_create(label).observe(node_response_time.as_secs_f64()); let data = BlockData { finalized_block_info: fbi, @@ -241,7 +263,7 @@ impl Indexer for BlockPreProcessor { err: TraverseError, ) -> bool { info!("Failed preprocessing {} times in row: {}", successive_failures, err); - self.total_failures.get_or_create(&NodeMetricLabels::new(&endpoint)).inc(); + self.preprocessing_failures.get_or_create(&NodeMetricLabels::new(&endpoint)).inc(); true } } @@ -250,15 +272,20 @@ impl Indexer for BlockPreProcessor { /// blocks. struct BlockProcessor { /// Database connection pool - pool: PgPool, + pool: PgPool, /// The last finalized block height according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. last_finalized_height: i64, /// The last finalized block hash according to the latest indexed block. /// This is needed in order to compute the finalization time of blocks. - last_finalized_hash: String, + last_finalized_hash: String, /// Metric counting how many blocks was saved to the database successfully. - blocks_processed: Counter, + blocks_processed: Counter, + /// Metric counting the total number of failed attempts to process + /// blocks. + processing_failures: Counter, + /// Histogram collecting the time it took to process a block. + processing_duration_seconds: Histogram, } impl BlockProcessor { /// Construct the block processor by loading the initial state from the @@ -280,12 +307,27 @@ SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height "Number of blocks save to the database", blocks_processed.clone(), ); + let processing_failures = Counter::default(); + registry.register( + "processing_failures", + "Number of blocks save to the database", + processing_failures.clone(), + ); + let processing_duration_seconds = + Histogram::new(histogram::exponential_buckets(0.01, 2.0, 10)); + registry.register( + "processing_duration_seconds", + "Time taken for processing a block", + processing_duration_seconds.clone(), + ); Ok(Self { pool, last_finalized_height: rec.height, last_finalized_hash: rec.hash, blocks_processed, + processing_failures, + processing_duration_seconds, }) } } @@ -295,12 +337,11 @@ impl ProcessEvent for BlockProcessor { /// The type of events that are to be processed. Typically this will be all /// of the transactions of interest for a single block."] type Data = PreparedBlock; - // TODO: introduce proper error type - /// A description returned by the [`process`](ProcessEvent::process) method. /// This message is logged by the [`ProcessorConfig`] and is intended to /// describe the data that was just processed. type Description = String; + // TODO: introduce proper error type /// An error that can be signalled. type Error = anyhow::Error; @@ -311,9 +352,12 @@ impl ProcessEvent for BlockProcessor { async fn process(&mut self, data: &Self::Data) -> Result { // TODO: Improve this by batching blocks within some time frame into the same // DB-transaction. + let start_time = Instant::now(); let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; data.save(&mut self, &mut tx).await.context("Failed saving block")?; tx.commit().await.context("Failed to commit SQL transaction")?; + let duration = start_time.elapsed(); + self.processing_duration_seconds.observe(duration.as_secs_f64()); self.blocks_processed.inc(); Ok(format!("Processed block {}:{}", data.height, data.hash)) } @@ -329,11 +373,11 @@ impl ProcessEvent for BlockProcessor { /// attempts of calling `process` that failed. async fn on_failure( &mut self, - _error: Self::Error, - _failed_attempts: u32, + error: Self::Error, + successive_failures: u32, ) -> Result { - // TODO add logging. - // TODO add metric counting failures. + info!("Failed processing {} times in row: {}", successive_failures, error); + self.processing_failures.inc(); Ok(true) } } @@ -421,6 +465,7 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res Ok(()) } +/// Preprocessed block which is ready to be saved in the database. struct PreparedBlock { hash: String, height: i64, From 7e97ebc014ba2ae5e79dca7528db56af55be2f97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Fri, 27 Sep 2024 08:45:49 +0200 Subject: [PATCH 15/50] Implement batching --- backend-rust/concordium-rust-sdk | 2 +- backend-rust/src/bin/ccdscan-api.rs | 9 +++- backend-rust/src/bin/ccdscan-indexer.rs | 11 ++++- backend-rust/src/indexer.rs | 60 ++++++++++++++++--------- 4 files changed, 58 insertions(+), 24 deletions(-) diff --git a/backend-rust/concordium-rust-sdk b/backend-rust/concordium-rust-sdk index 1c37f334..301b6933 160000 --- a/backend-rust/concordium-rust-sdk +++ b/backend-rust/concordium-rust-sdk @@ -1 +1 @@ -Subproject commit 1c37f334ccb79218cc494defb02fd11a373e4df0 +Subproject commit 301b69331476b4cc25a94935f54fe940b7e327bd diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index 72e19d28..be424d6a 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -1,4 +1,5 @@ use anyhow::Context; +use async_graphql::SDLExportOptions; use clap::Parser; use concordium_scan::graphql_api; use dotenv::dotenv; @@ -44,7 +45,13 @@ async fn main() -> anyhow::Result<()> { let service = graphql_api::Service::new(subscription, pool); if let Some(schema_file) = cli.schema_out { info!("Writing schema to {}", schema_file.to_string_lossy()); - std::fs::write(schema_file, service.schema.sdl()).context("Failed to write schema")?; + std::fs::write( + schema_file, + service + .schema + .sdl_with_options(SDLExportOptions::new().prefer_single_line_descriptions()), + ) + .context("Failed to write schema")?; } let tcp_listener = TcpListener::bind(cli.listen).await.context("Parsing TCP listener address failed")?; diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index f41274db..f494d139 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -1,7 +1,10 @@ use anyhow::Context; use clap::Parser; use concordium_rust_sdk::v2; -use concordium_scan::{indexer, metrics}; +use concordium_scan::{ + indexer::{self, IndexerServiceConfig}, + metrics, +}; use dotenv::dotenv; use prometheus_client::registry::Registry; use sqlx::PgPool; @@ -12,6 +15,7 @@ use tracing::{error, info}; // TODO add env for remaining args. #[derive(Parser)] +#[command(version, author, about)] struct Cli { /// The URL used for the database, something of the form /// "postgres://postgres:example@localhost/ccd-scan" @@ -23,6 +27,8 @@ struct Cli { /// Address to listen for metrics requests #[arg(long, default_value = "127.0.0.1:8001")] metrics_listen: SocketAddr, + #[command(flatten, next_help_heading = "Performance tuning")] + indexer_config: IndexerServiceConfig, } #[tokio::main] @@ -50,7 +56,8 @@ async fn main() -> anyhow::Result<()> { let mut indexer_task = { let pool = pool.clone(); let stop_signal = cancel_token.child_token(); - let indexer = indexer::IndexerService::new(cli.node, pool, &mut registry).await?; + let indexer = + indexer::IndexerService::new(cli.node, pool, &mut registry, cli.indexer_config).await?; tokio::spawn(async move { indexer.run(stop_signal).await }) }; let mut metrics_task = { diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index a815427f..6353492f 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -14,16 +14,14 @@ use crate::graphql_api::{ use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ - indexer::{ - async_trait, traverse_and_process, Indexer, ProcessEvent, TraverseConfig, TraverseError, - }, + indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, types::{ queries::BlockInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, RewardsOverview, }, v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult, RPCError}, }; -use futures::TryStreamExt; +use futures::{StreamExt, TryStreamExt}; use prometheus_client::{ metrics::{ counter::Counter, @@ -49,6 +47,23 @@ pub struct IndexerService { /// State tracked by the block processor, which is submitting to the /// database. block_processor: BlockProcessor, + config: IndexerServiceConfig, +} + +#[derive(clap::Args)] +pub struct IndexerServiceConfig { + /// Maximum number of blocks being preprocessed in parallel. + #[arg(long, default_value = "8")] + pub max_parallel_block_preprocessors: usize, + /// Maximum number of blocks allowed to be batched into the same database + /// transaction. + #[arg(long, default_value = "4")] + pub max_processing_batch: usize, + /// Set the maximum amount of seconds the last finalized block of the node + /// can be behind before it is deemed too far behind, and another node + /// is tried. + #[arg(long, default_value = "60")] + pub node_max_behind: u64, } impl IndexerService { @@ -57,6 +72,7 @@ impl IndexerService { endpoints: Vec, pool: PgPool, registry: &mut Registry, + config: IndexerServiceConfig, ) -> anyhow::Result { let last_height_stored = sqlx::query!( r#" @@ -83,6 +99,7 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 start_height, block_pre_processor, block_processor, + config, }) } @@ -90,19 +107,20 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 /// `cancel_token`. pub async fn run(self, cancel_token: CancellationToken) -> anyhow::Result<()> { let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) - .context("Failed setting up TraverseConfig")?; + .context("Failed setting up TraverseConfig")? + .set_max_parallel(self.config.max_parallel_block_preprocessors) + .set_max_behind(std::time::Duration::from_secs(self.config.node_max_behind)); let processor_config = concordium_rust_sdk::indexer::ProcessorConfig::new() .set_stop_signal(cancel_token.cancelled_owned()); + let (sender, receiver) = tokio::sync::mpsc::channel(self.config.max_processing_batch); + let receiver = tokio_stream::wrappers::ReceiverStream::from(receiver) + .ready_chunks(self.config.max_processing_batch); + let traverse_future = traverse_config.traverse(self.block_pre_processor, sender); + let process_future = processor_config.process_event_stream(self.block_processor, receiver); info!("Indexing from block height {}", self.start_height); - traverse_and_process( - traverse_config, - self.block_pre_processor, - processor_config, - self.block_processor, - ) - .await?; - Ok(()) + let (result, ()) = futures::join!(traverse_future, process_future); + Ok(result?) } } @@ -336,7 +354,7 @@ SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height impl ProcessEvent for BlockProcessor { /// The type of events that are to be processed. Typically this will be all /// of the transactions of interest for a single block."] - type Data = PreparedBlock; + type Data = Vec; /// A description returned by the [`process`](ProcessEvent::process) method. /// This message is logged by the [`ProcessorConfig`] and is intended to /// describe the data that was just processed. @@ -349,17 +367,19 @@ impl ProcessEvent for BlockProcessor { /// either the entire `data` is processed or none of it is in case of an /// error. This property is relied upon by the [`ProcessorConfig`] to retry /// failed attempts. - async fn process(&mut self, data: &Self::Data) -> Result { - // TODO: Improve this by batching blocks within some time frame into the same - // DB-transaction. + async fn process(&mut self, batch: &Self::Data) -> Result { let start_time = Instant::now(); + let mut out = format!("Processed {} blocks:", batch.len()); let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; - data.save(&mut self, &mut tx).await.context("Failed saving block")?; + for data in batch { + data.save(&mut self, &mut tx).await.context("Failed saving block")?; + out.push_str(format!("\n- {}:{}", data.height, data.hash).as_str()) + } tx.commit().await.context("Failed to commit SQL transaction")?; let duration = start_time.elapsed(); self.processing_duration_seconds.observe(duration.as_secs_f64()); - self.blocks_processed.inc(); - Ok(format!("Processed block {}:{}", data.height, data.hash)) + self.blocks_processed.inc_by(u64::try_from(batch.len())?); + Ok(out) } /// The `on_failure` method is invoked by the [`ProcessorConfig`] when it From ded34aa918932e4b7980b8fc35d7a82cd2ecd8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Fri, 27 Sep 2024 08:58:38 +0200 Subject: [PATCH 16/50] Update readme with migration instructions --- backend-rust/Cargo.toml | 4 ++ backend-rust/README.md | 43 +++++++++++++------ .../migrations/0001_initialize.down.sql | 1 + ..._initialize.sql => 0001_initialize.up.sql} | 0 4 files changed, 35 insertions(+), 13 deletions(-) create mode 100644 backend-rust/migrations/0001_initialize.down.sql rename backend-rust/migrations/{20240508183938_initialize.sql => 0001_initialize.up.sql} (100%) diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index a8765580..3252e212 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -2,6 +2,10 @@ name = "concordium-scan" version = "0.1.0" edition = "2021" +description = "CCDScan: Indexer and API for the Concordium blockchain" +authors = ["Concordium "] +publish = false + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/backend-rust/README.md b/backend-rust/README.md index 12c8b533..4774249b 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -5,16 +5,37 @@ This is the backend for the [CCDScan](https://ccdscan.io/) Blockchain explorer f The backend consists of two binaries: - `ccdscan-indexer`: Traversing the chain indexing events into the database. -- `ccdscan-api`: Running a GraphQL API for querying the database. +- `ccdscan-api`: Providing a GraphQL API for querying the database. The service is split to allow for running several instances of the GraphQL API and while having a single instance of the indexer. ## Dependencies -To run the service, the following dependencies are required to be available on the system: +To run the services, the following dependencies are required to be available on the system: - PostgreSQL server 16 +## Run the Indexer Service + +For instructions how to use the indexer run: + +``` +ccdscan-indexer --help +``` + + + +## Run the GraphQL API Service + +For instructions how to use the API service run: + +``` +ccdscan-api --help +``` + + + + ## Setup for development To develop this service the following tools are required, besides the dependencies listed above: @@ -38,22 +59,18 @@ sqlx migrate run The project can now be build using `cargo build` -## Run the Indexer Service +### Database migrations -For instructions how to use the indexer run: +Database migrations are tracked in the `migrations` directory. To introduce a new one run: ``` -ccdscan-indexer --help +sqlx migrate add '' ``` - - -## Run the GraphQL API Service +where `` is replaced by a short description of the nature of the migration. -For instructions how to use the API service run: +This will create two files in the directory: -``` -ccdscan-api --help -``` +- `_.up.sql` for the SQL code to bring the database up from the previous version. +- `_.down.sql` for the SQL code reverting back to the previous version. - diff --git a/backend-rust/migrations/0001_initialize.down.sql b/backend-rust/migrations/0001_initialize.down.sql new file mode 100644 index 00000000..d2f607c5 --- /dev/null +++ b/backend-rust/migrations/0001_initialize.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend-rust/migrations/20240508183938_initialize.sql b/backend-rust/migrations/0001_initialize.up.sql similarity index 100% rename from backend-rust/migrations/20240508183938_initialize.sql rename to backend-rust/migrations/0001_initialize.up.sql From e687e0f69b2c1c22e221edf95f2737e721816605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 30 Sep 2024 09:02:59 +0200 Subject: [PATCH 17/50] Add monitoring to API service --- backend-rust/src/bin/ccdscan-api.rs | 46 +++++++- backend-rust/src/graphql_api.rs | 156 +++++++++++++++++++++++++++- backend-rust/src/metrics.rs | 5 +- 3 files changed, 194 insertions(+), 13 deletions(-) diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index be424d6a..4f4689b7 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -1,8 +1,9 @@ use anyhow::Context; use async_graphql::SDLExportOptions; use clap::Parser; -use concordium_scan::graphql_api; +use concordium_scan::{graphql_api, metrics}; use dotenv::dotenv; +use prometheus_client::registry::Registry; use sqlx::PgPool; use std::{net::SocketAddr, path::PathBuf}; use tokio::net::TcpListener; @@ -15,13 +16,16 @@ struct Cli { /// The URL used for the database, something of the form /// "postgres://postgres:example@localhost/ccd-scan" #[arg(long, env = "DATABASE_URL")] - database_url: String, + database_url: String, /// Output the GraphQL Schema for the API to this path. #[arg(long)] - schema_out: Option, + schema_out: Option, /// Address to listen for API requests #[arg(long, default_value = "127.0.0.1:8000")] - listen: SocketAddr, + listen: SocketAddr, + /// Address to listen for API requests + #[arg(long, default_value = "127.0.0.1:8003")] + metrics_listen: SocketAddr, } #[tokio::main] @@ -34,6 +38,18 @@ async fn main() -> anyhow::Result<()> { .context("Failed constructing database connection pool")?; let cancel_token = CancellationToken::new(); + let mut registry = Registry::with_prefix("api"); + registry.register( + "service", + "Information about the software", + prometheus_client::metrics::info::Info::new(vec![("version", clap::crate_version!())]), + ); + registry.register( + "service_startup_timestamp_millis", + "Timestamp of starting up the node (Unix time in milliseconds)", + prometheus_client::metrics::gauge::ConstGauge::new(chrono::Utc::now().timestamp_millis()), + ); + let (subscription, subscription_listener) = graphql_api::Subscription::new(); let mut pgnotify_listener = { let pool = pool.clone(); @@ -42,7 +58,7 @@ async fn main() -> anyhow::Result<()> { }; let mut queries_task = { - let service = graphql_api::Service::new(subscription, pool); + let service = graphql_api::Service::new(subscription, &mut registry, pool); if let Some(schema_file) = cli.schema_out { info!("Writing schema to {}", schema_file.to_string_lossy()); std::fs::write( @@ -59,6 +75,14 @@ async fn main() -> anyhow::Result<()> { info!("Server is running at {:?}", cli.listen); tokio::spawn(async move { service.serve(tcp_listener, stop_signal).await }) }; + let mut metrics_task = { + let tcp_listener = TcpListener::bind(cli.metrics_listen) + .await + .context("Parsing TCP listener address failed")?; + let stop_signal = cancel_token.child_token(); + info!("Metrics server is running at {:?}", cli.metrics_listen); + tokio::spawn(metrics::serve(registry, tcp_listener, stop_signal)) + }; // Await for signal to shutdown or any of the tasks to stop. tokio::select! { @@ -67,6 +91,7 @@ async fn main() -> anyhow::Result<()> { cancel_token.cancel(); let _ = queries_task.await?; let _ = pgnotify_listener.await?; + let _ = metrics_task.await?; }, result = &mut queries_task => { error!("Queries task stopped."); @@ -76,6 +101,7 @@ async fn main() -> anyhow::Result<()> { info!("Shutting down"); cancel_token.cancel(); let _ = pgnotify_listener.await?; + let _ = metrics_task.await?; }, result = &mut pgnotify_listener => { error!("Pgnotify listener task stopped."); @@ -85,7 +111,17 @@ async fn main() -> anyhow::Result<()> { info!("Shutting down"); cancel_token.cancel(); let _ = queries_task.await?; + let _ = metrics_task.await?; }, + result = &mut metrics_task => { + error!("Metrics task stopped."); + if let Err(err) = result? { + error!("Metrics error: {}", err); + } + cancel_token.cancel(); + let _ = queries_task.await?; + let _ = pgnotify_listener.await?; + } } Ok(()) } diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index d0712f2e..5f9dfc1c 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -15,12 +15,13 @@ use async_graphql::{ use async_graphql_axum::GraphQLSubscription; use chrono::Duration; use futures::prelude::*; +use prometheus_client::registry::Registry; use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; use std::{error::Error, str::FromStr as _, sync::Arc}; use tokio::{net::TcpListener, sync::broadcast}; use tokio_util::sync::CancellationToken; -const VERSION: &str = env!("CARGO_PKG_VERSION"); +const VERSION: &str = clap::crate_version!(); /// The most transactions which can be queried at once. const QUERY_TRANSACTIONS_LIMIT: i64 = 100; @@ -29,9 +30,10 @@ pub struct Service { pub schema: Schema, } impl Service { - pub fn new(subscription: Subscription, pool: PgPool) -> Self { + pub fn new(subscription: Subscription, registry: &mut Registry, pool: PgPool) -> Self { let schema = Schema::build(Query, EmptyMutation, subscription) .extension(async_graphql::extensions::Tracing) + .extension(monitor::MonitorExtension::new(registry)) .data(pool) .finish(); Self { @@ -64,6 +66,148 @@ impl Service { ) } } +/// Module containing types and logic for building an async_graphql extension +/// which allows for monitoring of the service. +mod monitor { + use async_graphql::async_trait::async_trait; + use futures::prelude::*; + use prometheus_client::{ + encoding::EncodeLabelSet, + metrics::{ + counter::Counter, + family::Family, + gauge::Gauge, + histogram::{self, Histogram}, + }, + registry::Registry, + }; + use std::sync::Arc; + use tokio::time::Instant; + + /// Type representing the Prometheus labels used for metrics related to + /// queries. + #[derive(Debug, Clone, EncodeLabelSet, PartialEq, Eq, Hash)] + struct QueryLabels { + /// Identifier of the top level query. + query: String, + } + /// Extension for async_graphql adding monitoring. + #[derive(Clone)] + pub struct MonitorExtension { + /// Metric for tracking current number of requests in-flight. + in_flight_requests: Family, + /// Metric for counting total number of requests. + total_requests: Family, + /// Metric for collecting execution duration for requests. + request_duration: Family, + /// Metric tracking current open subscriptions. + active_subscriptions: Gauge, + } + impl MonitorExtension { + pub fn new(registry: &mut Registry) -> Self { + let in_flight_requests: Family = Default::default(); + registry.register( + "in_flight_queries", + "Current number of queries in-flight", + in_flight_requests.clone(), + ); + let total_requests: Family = Default::default(); + registry.register( + "requests", + "Total number of requests received", + total_requests.clone(), + ); + let request_duration: Family = + Family::new_with_constructor(|| { + Histogram::new(histogram::exponential_buckets(0.010, 2.0, 10)) + }); + registry.register( + "request_duration_seconds", + "Duration of seconds used to fetch all of the block information", + request_duration.clone(), + ); + let active_subscriptions: Gauge = Default::default(); + registry.register( + "active_subscription", + "Current number of active subscriptions", + active_subscriptions.clone(), + ); + MonitorExtension { + in_flight_requests, + total_requests, + request_duration, + active_subscriptions, + } + } + } + impl async_graphql::extensions::ExtensionFactory for MonitorExtension { + fn create(&self) -> Arc { + return Arc::new(self.clone()); + } + } + #[async_trait] + impl async_graphql::extensions::Extension for MonitorExtension { + async fn execute( + &self, + ctx: &async_graphql::extensions::ExtensionContext<'_>, + operation_name: Option<&str>, + next: async_graphql::extensions::NextExecute<'_>, + ) -> async_graphql::Response { + let label = QueryLabels { + query: operation_name.unwrap_or("").to_owned(), + }; + self.total_requests.get_or_create(&label).inc(); + let start = Instant::now(); + let response = next.run(ctx, operation_name).await; + let duration = start.elapsed(); + self.request_duration.get_or_create(&label).observe(duration.as_secs_f64()); + self.in_flight_requests.get_or_create(&label).dec(); + response + } + + /// Called at subscribe request. + fn subscribe<'s>( + &self, + ctx: &async_graphql::extensions::ExtensionContext<'_>, + stream: stream::BoxStream<'s, async_graphql::Response>, + next: async_graphql::extensions::NextSubscribe<'_>, + ) -> stream::BoxStream<'s, async_graphql::Response> { + let stream = next.run(ctx, stream); + let wrapped_stream = WrappedStream::new(stream, self.active_subscriptions.clone()); + wrapped_stream.boxed() + } + } + /// Wrapper around a stream to update metrics when it gets dropped. + struct WrappedStream<'s> { + inner: stream::BoxStream<'s, async_graphql::Response>, + active_subscriptions: Gauge, + } + impl<'s> WrappedStream<'s> { + fn new( + stream: stream::BoxStream<'s, async_graphql::Response>, + active_subscriptions: Gauge, + ) -> Self { + active_subscriptions.inc(); + Self { + inner: stream, + active_subscriptions, + } + } + } + impl<'a> futures::stream::Stream for WrappedStream<'a> { + type Item = async_graphql::Response; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_next_unpin(cx) + } + } + impl<'a> std::ops::Drop for WrappedStream<'a> { + fn drop(&mut self) { self.active_subscriptions.dec(); } + } +} #[derive(Debug, thiserror::Error, Clone)] enum ApiError { @@ -2397,6 +2541,7 @@ struct ActiveBakerState { staked_amount: Amount, restake_earnings: bool, pool: BakerPool, + // This will not be used starting from P7 pending_change: PendingBakerChange, } @@ -3917,9 +4062,10 @@ pub struct ChainUpdateEnqueued { // MintDistributionChainUpdatePayload | // TransactionFeeDistributionChainUpdatePayload | GasRewardsChainUpdatePayload | // BakerStakeThresholdChainUpdatePayload | RootKeysChainUpdatePayload | -// Level1KeysChainUpdatePayload | AddAnonymityRevokerChainUpdatePayload | AddIdentityProviderChainUpdatePayload | -// CooldownParametersChainUpdatePayload | PoolParametersChainUpdatePayload | -// TimeParametersChainUpdatePayload | MintDistributionV1ChainUpdatePayload +// Level1KeysChainUpdatePayload | AddAnonymityRevokerChainUpdatePayload | +// AddIdentityProviderChainUpdatePayload | CooldownParametersChainUpdatePayload +// | PoolParametersChainUpdatePayload | TimeParametersChainUpdatePayload | +// MintDistributionV1ChainUpdatePayload #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] pub struct ChainUpdatePayload { todo: bool, diff --git a/backend-rust/src/metrics.rs b/backend-rust/src/metrics.rs index 7b2d450f..6dfed359 100644 --- a/backend-rust/src/metrics.rs +++ b/backend-rust/src/metrics.rs @@ -10,9 +10,8 @@ pub async fn serve( tcp_listener: TcpListener, stop_signal: CancellationToken, ) -> anyhow::Result<()> { - let app = axum::Router::new() - .route("/metrics", axum::routing::get(metrics)) - .with_state(Arc::new(registry)); + let app = + axum::Router::new().route("/", axum::routing::get(metrics)).with_state(Arc::new(registry)); axum::serve(tcp_listener, app).with_graceful_shutdown(stop_signal.cancelled_owned()).await?; Ok(()) } From 89293f37b3755b1c03aad97cdecaa0009a0e9a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Tue, 1 Oct 2024 14:01:16 +0200 Subject: [PATCH 18/50] Support basic baker indexing and queries --- backend-rust/concordium-rust-sdk | 2 +- backend-rust/database.odb | Bin 2272 -> 0 bytes .../migrations/0001_initialize.up.sql | 45 ++ backend-rust/src/graphql_api.rs | 223 ++++++-- backend-rust/src/indexer.rs | 513 ++++++++++++++++-- 5 files changed, 692 insertions(+), 91 deletions(-) delete mode 100644 backend-rust/database.odb diff --git a/backend-rust/concordium-rust-sdk b/backend-rust/concordium-rust-sdk index 301b6933..9cf8451b 160000 --- a/backend-rust/concordium-rust-sdk +++ b/backend-rust/concordium-rust-sdk @@ -1 +1 @@ -Subproject commit 301b69331476b4cc25a94935f54fe940b7e327bd +Subproject commit 9cf8451b043072ddd50ce4814531d0731022a640 diff --git a/backend-rust/database.odb b/backend-rust/database.odb deleted file mode 100644 index 1f88191369f3c0fb19d21f65adc73c3edc73c8fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2272 zcmaJ?2{@E_9R6H0vyMo_x^+0Rj@&|`atwyh4h_jM#;`MknQ>J}*jC7jwn`?b>f zQ>Fkz5dZ-8!+Hf^&=@2kECA^d5bzV)%Y%T%Vbz1N-fB1xJQ}Zt3qWGMab83W5=&6? z^uQzSw>E=7AX}TU`2Q9kYvqN*64=O4%ug9`Tl}D|Pz?j_`_>94bV!Bti;H#Dgorzp zkfSFEi4swetGi%&hRJXP4xLuX=@|Evd@=i{+RF90hFm-uu1G)OQrcNDZCjBNhj?I9 z-pSltAzkV?F3k)rLFQL>#7YlrgRN12>PNJ5%Qv(D7v|l29lm%_(+c`zALn*vo!2enk6bA(_+;+ z(|INE_}bdp<`pLMp81~;9v!c6>(3WA76eV&?(9X@X*Ep59;W#uzN0>u@RBFB=|rHFg~QKXdl#;3cp-P_&gz&=k_pwqsaf`hJLr;M-Xq%DO8TeL z-lQ2$dHJ}H)w%RbRs9ZjsIKg?wLW|#-7`6T6lL{cXFqZWW3`!Y;7L~!QSWit za90%RR6WkTp7bGWp05m@ktFvjzem$mYAU!!`nAejz|b0@%1>&QS^+}w^_|Kz3BzVP znl=%#C)9tiSp+d%)QgGScKuA@&UEj{iT+#d2>!5;EW5ZzwZmQv?dTj)*J_@<*ZG1a z@&XK>+84vhNe|o<|P#vslfZ1HOWRU56`;Ioo&61(w-jivbG>sykba}-9;bF+%c8)%I z`PKu~W2mCvN_!C-14^)m<-e@g$4|~{RBP`Me0UAaSwS?)m|yigBsd{&N>s&bdcXrM zCM^^L6Qh(uY0h(^1(8#ih>f+3$fF{gWL0cU0POYI+t1D~zu%lG{w@9h%_u`(j9Xue zp1NGTfxnv1aaOO=#W%rv5c>W22USZ8hnYByr*iNU5}+;@MU4ena*(k9ted227ipR| zx1@kOn^74rCmxNOd#V7JD1zF@xe9x%IW|avM%SYtWjvu12-);(Z;7}mmu}ytu|vsL z;*QV6Bc)osq1KM82j3Jl+vSTVkW-XDGG*G!JiAb1oXSOiq>#$I?s6_UA+8A7@O5Xy zEqs5cH1_X#Kz_wR!|J;5>yy;en%ix;+X9B;;UZR1qH~|xrD#ntwFP&4i^*>Il<~x& z!(BvwMXkz(C&u*&VBQWtGX)3XwC^q&^JdDubndgWU5+u!Ke8w?WJFZ!sWWvS90a9T z)J^pzyypKboLd*Rw&x^+GIWipJ+;&%b6@LosT31zZw-c&NE;8m=K_EwegOD88Mb5Q z-&kNR*fA4?48R2u@ao@239(QoaabQTiWsyNLwL=9&|ga%)`0*XNg$xHC_FoE(o$Zf zL~HQJ&8~~s%sRLlTUsoA*jFTI53Qq^jV2#hTN%VOd7xIJo++#{H>?IX=V|4w0^t=g zvCg?9OF-c4{&cguwXnhreVOAcPJO*m1ugv2_Yp_+C-=gtrCi>3rFDsUPM!JHXIkqy z*N+sX=bSK2Q99J{_n9}*yb(v~g#LQ!9*%p3g$vS)4vVrnBO6b}CX1nMfl}RpHyuV= zI)7--er$VNz6ev=qeR80U-X|}H_0O2o78ktOucsg9d0)CbGH-X`!O-;hmWork7)9* z7P zQp4`k=tjWd)sXWP*fT$_xwc|z@2kqGxSOsq3GF?FPC1XwHNqkK<*8{D@g6xn-crYq zyj}=MwFhbB`Xg|YOs;{NsO}4?>OFYkMgdGrP}Px!=+R2nckG;$oWl^95M{=hlIM_1V zR-HKsZ~(yLEtxquMSyS5O_q+kYI};>TmQ}4mVUQE*aLE>Y+1`)<8qt$E&uEgQQW)T z^yMXYPyoR8(@rI_SpRj`mzUk$Wo&=#)C(4Sx7+^jw(Kt4sZO4qp8WE(-D0!5a;Fwq YoUit%y*1C)?qC26q_X~#E|^XF3+ { + return Err(ApiError::InternalError(String::from("Not implemented"))) + }; +} + use anyhow::Context as _; use async_graphql::{ http::GraphiQLSource, @@ -14,6 +23,7 @@ use async_graphql::{ }; use async_graphql_axum::GraphQLSubscription; use chrono::Duration; +use concordium_rust_sdk::types::AmountFraction; use futures::prelude::*; use prometheus_client::registry::Registry; use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; @@ -495,11 +505,8 @@ impl Query { async fn account<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; - sqlx::query_as("SELECT * FROM accounts WHERE index=$1") - .bind(index) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + let pool = get_pool(ctx)?; + Account::query_by_index(pool, index).await } async fn account_by_address<'a>( @@ -605,9 +612,14 @@ impl Query { Ok(connection) } - async fn baker(&self, _id: types::ID) -> Baker { todo!() } + async fn baker<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { + let id = IdBaker::try_from(id)?.baker_id; + Baker::query_by_id(get_pool(ctx)?, id).await + } - async fn baker_by_baker_id(&self, _id: BakerId) -> Baker { todo!() } + async fn baker_by_baker_id<'a>(&self, ctx: &Context<'a>, id: BakerId) -> ApiResult { + Baker::query_by_id(get_pool(ctx)?, id).await + } async fn bakers( &self, @@ -620,7 +632,7 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn search(&self, query: String) -> SearchResult { @@ -948,6 +960,7 @@ type Energy = i64; // TODO: should be UnsignedLong in graphQL type DateTime = chrono::NaiveDateTime; // TODO check format matches. type ContractIndex = UnsignedLong; // TODO check format. type BigInteger = u64; // TODO check format. +type MetadataUrl = String; #[derive(SimpleObject)] struct Versions { @@ -2425,6 +2438,15 @@ struct Account { // delegation: Option, } +impl Account { + async fn query_by_index(pool: &PgPool, index: AccountIndex) -> ApiResult { + sqlx::query_as!(Account, "SELECT * FROM accounts WHERE index=$1", index) + .fetch_optional(pool) + .await? + .ok_or(ApiError::NotFound) + } +} + #[ComplexObject] impl Account { async fn id(&self) -> types::ID { types::ID::from(self.index) } @@ -2514,35 +2536,125 @@ impl AccountReleaseSchedule { } } -#[derive(SimpleObject)] -struct Baker { - account: Box, - id: types::ID, +#[repr(transparent)] +struct IdBaker { baker_id: BakerId, - state: BakerState, - // /// Get the transactions that have affected the baker. - // transactions("Returns the first _n_ elements from the list." first: Int "Returns the - // elements in the list that come after the specified cursor." after: String "Returns the last - // _n_ elements from the list." last: Int "Returns the elements in the list that come before - // the specified cursor." before: String): BakerTransactionRelationConnection +} +impl std::str::FromStr for IdBaker { + type Err = ApiError; + + fn from_str(value: &str) -> Result { + let baker_id = value.parse()?; + Ok(IdBaker { + baker_id, + }) + } +} +impl TryFrom for IdBaker { + type Error = ApiError; + + fn try_from(value: types::ID) -> Result { value.0.parse() } +} + +struct Baker { + id: BakerId, + staked: Amount, + restake_earnings: bool, + open_status: Option, + metadata_url: Option, + transaction_commission: Option, + baking_commission: Option, + finalization_commission: Option, +} +impl Baker { + async fn query_by_id(pool: &PgPool, baker_id: BakerId) -> ApiResult { + sqlx::query_as!( + Baker, + r#"SELECT + id, + staked, + restake_earnings, + open_status as "open_status: BakerPoolOpenStatus", + metadata_url, + transaction_commission, + baking_commission, + finalization_commission + FROM bakers WHERE id=$1 +"#, + baker_id + ) + .fetch_optional(pool) + .await? + .ok_or(ApiError::NotFound) + } +} +#[Object] +impl Baker { + async fn id(&self) -> types::ID { types::ID::from(self.id.to_string()) } + + async fn baker_id(&self) -> BakerId { self.id } + + async fn state<'a>(&'a self) -> ApiResult> { + let transaction_commission = self + .transaction_commission + .map(u32::try_from) + .transpose()? + .map(|c| AmountFraction::new_unchecked(c).into()); + let baking_commission = self + .baking_commission + .map(u32::try_from) + .transpose()? + .map(|c| AmountFraction::new_unchecked(c).into()); + let finalization_commission = self + .finalization_commission + .map(u32::try_from) + .transpose()? + .map(|c| AmountFraction::new_unchecked(c).into()); + + let out = BakerState::ActiveBakerState(ActiveBakerState { + staked_amount: self.staked, + restake_earnings: self.restake_earnings, + pool: BakerPool { + open_status: self.open_status, + commission_rates: CommissionRates { + transaction_commission, + baking_commission, + finalization_commission, + }, + metadata_url: self.metadata_url.as_deref(), + }, + pending_change: None, // This is not used starting from P7. + }); + Ok(out) + } + + async fn account<'a>(&self, ctx: &Context<'a>) -> ApiResult { + Account::query_by_index(get_pool(ctx)?, self.id).await + } + + // transactions("Returns the first _n_ elements from the list." first: Int + // "Returns the elements in the list that come after the specified cursor." + // after: String "Returns the last _n_ elements from the list." last: Int + // "Returns the elements in the list that come before the specified cursor." + // before: String): BakerTransactionRelationConnection } #[derive(Union)] -enum BakerState { - ActiveBakerState(ActiveBakerState), +enum BakerState<'a> { + ActiveBakerState(ActiveBakerState<'a>), RemovedBakerState(RemovedBakerState), } #[derive(SimpleObject)] -struct ActiveBakerState { - /// The status of the bakers node. Will be null if no status for the node - /// exists. - node_status: NodeStatus, +struct ActiveBakerState<'a> { + // /// The status of the bakers node. Will be null if no status for the node + // /// exists. + // node_status: NodeStatus, staked_amount: Amount, restake_earnings: bool, - pool: BakerPool, + pool: BakerPool<'a>, // This will not be used starting from P7 - pending_change: PendingBakerChange, + pending_change: Option, } #[derive(Union)] @@ -2621,30 +2733,30 @@ struct NodeStatus { } #[derive(SimpleObject)] -struct BakerPool { - /// Total stake of the baker pool as a percentage of all CCDs in existence. - /// Value may be null for brand new bakers where statistics have not - /// been calculated yet. This should be rare and only a temporary - /// condition. - total_stake_percentage: Decimal, - lottery_power: Decimal, - payday_commission_rates: CommissionRates, - open_status: BakerPoolOpenStatus, - commission_rates: CommissionRates, - metadata_url: String, - /// The total amount staked by delegation to this baker pool. - delegated_stake: Amount, - /// The maximum amount that may be delegated to the pool, accounting for - /// leverage and stake limits. - delegated_stake_cap: Amount, - /// The total amount staked in this baker pool. Includes both baker stake - /// and delegated stake. - total_stake: Amount, - delegator_count: i32, - /// Ranking of the baker pool by total staked amount. Value may be null for - /// brand new bakers where statistics have not been calculated yet. This - /// should be rare and only a temporary condition. - ranking_by_total_stake: Ranking, +struct BakerPool<'a> { + // /// Total stake of the baker pool as a percentage of all CCDs in existence. + // /// Value may be null for brand new bakers where statistics have not + // /// been calculated yet. This should be rare and only a temporary + // /// condition. + // total_stake_percentage: Decimal, + // lottery_power: Decimal, + // payday_commission_rates: CommissionRates, + open_status: Option, + commission_rates: CommissionRates, + metadata_url: Option<&'a str>, + // /// The total amount staked by delegation to this baker pool. + // delegated_stake: Amount, + // /// The maximum amount that may be delegated to the pool, accounting for + // /// leverage and stake limits. + // delegated_stake_cap: Amount, + // /// The total amount staked in this baker pool. Includes both baker stake + // /// and delegated stake. + // total_stake: Amount, + // delegator_count: i32, + // /// Ranking of the baker pool by total staked amount. Value may be null for + // /// brand new bakers where statistics have not been calculated yet. This + // /// should be rare and only a temporary condition. + // ranking_by_total_stake: Ranking, // TODO: apy(period: ApyPeriod!): PoolApy! // TODO: delegators("Returns the first _n_ elements from the list." first: Int "Returns the // elements in the list that come after the specified cursor." after: String "Returns the last @@ -2658,13 +2770,14 @@ struct BakerPool { #[derive(SimpleObject)] struct CommissionRates { - transaction_commission: Decimal, - finalization_commission: Decimal, - baking_commission: Decimal, + transaction_commission: Option, + finalization_commission: Option, + baking_commission: Option, } -#[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -enum BakerPoolOpenStatus { +#[derive(Enum, Copy, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, sqlx::Type)] +#[sqlx(type_name = "pool_open_status")] // only for PostgreSQL to match a type definition +pub enum BakerPoolOpenStatus { OpenForAll, ClosedForNew, ClosedForAll, diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 6353492f..ee2a35f5 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1,23 +1,15 @@ -//! TODO: -//! - Check endpoints are using the same chain. -//! - Extend with prometheus metrics. -//! - Batch blocks into the same SQL transaction. -//! - More logging -//! - Setup CI to check formatting and build. -//! - Build docker images. -//! - Setup CI for deployment. - use crate::graphql_api::{ - events_from_summary, AccountTransactionType, CredentialDeploymentTransactionType, - DbTransactionType, UpdateTransactionType, + events_from_summary, AccountTransactionType, BakerPoolOpenStatus, + CredentialDeploymentTransactionType, DbTransactionType, UpdateTransactionType, }; use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, types::{ - queries::BlockInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, - BlockItemSummaryDetails, RewardsOverview, + queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, + AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, + PartsPerHundredThousands, RewardsOverview, }, v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult, RPCError}, }; @@ -359,7 +351,6 @@ impl ProcessEvent for BlockProcessor { /// This message is logged by the [`ProcessorConfig`] and is intended to /// describe the data that was just processed. type Description = String; - // TODO: introduce proper error type /// An error that can be signalled. type Error = anyhow::Error; @@ -402,7 +393,7 @@ impl ProcessEvent for BlockProcessor { } } -/// Raw block Information fetched from a Concordium Node. +/// Raw block information fetched from a Concordium Node. struct BlockData { finalized_block_info: FinalizedBlockInfo, block_info: BlockInfo, @@ -469,7 +460,6 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res let index = i64::try_from(info.account_index.index)?; let account_address = account.to_string(); let amount = i64::try_from(info.account_amount.micro_ccd)?; - sqlx::query!( r#"INSERT INTO accounts (index, address, created_block, amount) VALUES ($1, $2, $3, $4)"#, @@ -480,7 +470,46 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res ) .execute(&mut *tx) .await?; + + if let Some(AccountStakingInfo::Baker { + staked_amount, + restake_earnings, + baker_info: _, + pending_change: _, + pool_info, + }) = info.account_stake + { + let stake = i64::try_from(staked_amount.micro_ccd())?; + let open_status = pool_info.as_ref().map(|i| BakerPoolOpenStatus::from(i.open_status)); + let metadata_url = pool_info.as_ref().map(|i| i.metadata_url.to_string()); + let transaction_commission = pool_info.as_ref().map(|i| { + i64::from(u32::from(PartsPerHundredThousands::from(i.commission_rates.transaction))) + }); + let baking_commission = pool_info.as_ref().map(|i| { + i64::from(u32::from(PartsPerHundredThousands::from(i.commission_rates.baking))) + }); + let finalization_commission = pool_info.as_ref().map(|i| { + i64::from(u32::from(PartsPerHundredThousands::from( + i.commission_rates.finalization, + ))) + }); + sqlx::query!( + r#"INSERT INTO bakers (id, staked, restake_earnings, open_status, metadata_url, transaction_commission, baking_commission, finalization_commission) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"#, + index, + stake, + restake_earnings, + open_status as Option, + metadata_url, + transaction_commission, + baking_commission, + finalization_commission + ) + .execute(&mut *tx) + .await?; + } } + tx.commit().await.context("Failed to commit SQL transaction")?; Ok(()) } @@ -591,12 +620,13 @@ RETURNING finalizer.height"#, .await .context("Failed updating finalization_time")?; + // TODO: Updating the context should be done, when we know nothing has failed. context.last_finalized_height = rec.height; context.last_finalized_hash = self.block_last_finalized.clone(); } for item in self.prepared_block_items.iter() { - item.save(context, tx).await?; + item.save(tx).await?; } Ok(()) } @@ -669,19 +699,8 @@ impl PreparedBlockItem { }; (None, Some(reject)) }; - let prepared_event = match &block_item.details { - BlockItemSummaryDetails::AccountCreation(details) => { - Some(PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( - data, - &block_item, - details, - )?)) - } - details => { - warn!("details = \n {:#?}", details); - None - } - }; + + let prepared_event = PreparedEvent::prepare(&data, &block_item)?; Ok(Self { block_index, @@ -703,7 +722,6 @@ impl PreparedBlockItem { async fn save( &self, - context: &mut BlockProcessor, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, ) -> anyhow::Result<()> { sqlx::query!( @@ -727,9 +745,8 @@ VALUES .execute(tx.as_mut()) .await?; - match &self.prepared_event { - Some(PreparedEvent::AccountCreation(event)) => event.save(context, tx).await?, - _ => {} + if let Some(prepared_event) = &self.prepared_event { + prepared_event.save(tx).await?; } Ok(()) } @@ -737,6 +754,169 @@ VALUES enum PreparedEvent { AccountCreation(PreparedAccountCreation), + BakerEvents(Vec), + NoOperation, +} +impl PreparedEvent { + fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result> { + let prepared_event = match &block_item.details { + BlockItemSummaryDetails::AccountCreation(details) => { + Some(PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( + data, + &block_item, + details, + )?)) + } + BlockItemSummaryDetails::AccountTransaction(details) => match &details.effects { + AccountTransactionEffects::None { + transaction_type, + reject_reason, + } => None, + AccountTransactionEffects::ModuleDeployed { + module_ref, + } => None, + AccountTransactionEffects::ContractInitialized { + data, + } => None, + AccountTransactionEffects::ContractUpdateIssued { + effects, + } => None, + AccountTransactionEffects::AccountTransfer { + amount, + to, + } => None, + AccountTransactionEffects::AccountTransferWithMemo { + amount, + to, + memo, + } => None, + AccountTransactionEffects::BakerAdded { + data: event_data, + } => { + let event = concordium_rust_sdk::types::BakerEvent::BakerAdded { + data: event_data.clone(), + }; + let prepared = PreparedBakerEvent::prepare(&event)?; + Some(PreparedEvent::BakerEvents(vec![prepared])) + } + AccountTransactionEffects::BakerRemoved { + baker_id, + } => { + let event = concordium_rust_sdk::types::BakerEvent::BakerRemoved { + baker_id: *baker_id, + }; + let prepared = PreparedBakerEvent::prepare(&event)?; + Some(PreparedEvent::BakerEvents(vec![prepared])) + } + AccountTransactionEffects::BakerStakeUpdated { + data: update, + } => { + if let Some(update) = update { + let event = if update.increased { + concordium_rust_sdk::types::BakerEvent::BakerStakeIncreased { + baker_id: update.baker_id, + new_stake: update.new_stake, + } + } else { + concordium_rust_sdk::types::BakerEvent::BakerStakeDecreased { + baker_id: update.baker_id, + new_stake: update.new_stake, + } + }; + let prepared = PreparedBakerEvent::prepare(&event)?; + Some(PreparedEvent::BakerEvents(vec![prepared])) + } else { + Some(PreparedEvent::NoOperation) + } + } + AccountTransactionEffects::BakerRestakeEarningsUpdated { + baker_id, + restake_earnings, + } => { + let prepared = PreparedEvent::BakerEvents(vec![PreparedBakerEvent::prepare( + &concordium_rust_sdk::types::BakerEvent::BakerRestakeEarningsUpdated { + baker_id: *baker_id, + restake_earnings: *restake_earnings, + }, + )?]); + Some(prepared) + } + AccountTransactionEffects::BakerKeysUpdated { + .. + } => Some(PreparedEvent::NoOperation), + AccountTransactionEffects::BakerConfigured { + data: events, + } => Some(PreparedEvent::BakerEvents( + events + .iter() + .map(|event| PreparedBakerEvent::prepare(event)) + .collect::>>()?, + )), + + AccountTransactionEffects::EncryptedAmountTransferred { + removed, + added, + } => None, + AccountTransactionEffects::EncryptedAmountTransferredWithMemo { + removed, + added, + memo, + } => None, + AccountTransactionEffects::TransferredToEncrypted { + data, + } => None, + AccountTransactionEffects::TransferredToPublic { + removed, + amount, + } => None, + AccountTransactionEffects::TransferredWithSchedule { + to, + amount, + } => None, + AccountTransactionEffects::TransferredWithScheduleAndMemo { + to, + amount, + memo, + } => None, + AccountTransactionEffects::CredentialKeysUpdated { + cred_id, + } => None, + AccountTransactionEffects::CredentialsUpdated { + new_cred_ids, + removed_cred_ids, + new_threshold, + } => None, + AccountTransactionEffects::DataRegistered { + data, + } => None, + + AccountTransactionEffects::DelegationConfigured { + data, + } => None, + }, + details => { + warn!("details = \n {:#?}", details); + None + } + }; + Ok(prepared_event) + } + + async fn save( + &self, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + match self { + PreparedEvent::AccountCreation(event) => event.save(tx).await, + PreparedEvent::BakerEvents(events) => { + for event in events { + event.save(tx).await?; + } + Ok(()) + } + PreparedEvent::NoOperation => Ok(()), + } + } } struct PreparedAccountCreation { @@ -762,7 +942,6 @@ impl PreparedAccountCreation { async fn save( &self, - _context: &mut BlockProcessor, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, ) -> anyhow::Result<()> { sqlx::query!( @@ -777,3 +956,267 @@ VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, Ok(()) } } + +enum PreparedBakerEvent { + Add { + baker_id: i64, + staked: i64, + restake_earnings: bool, + }, + Remove { + baker_id: i64, + }, + StakeIncrease { + baker_id: i64, + staked: i64, + }, + StakeDecrease { + baker_id: i64, + staked: i64, + }, + SetRestakeEarnings { + baker_id: i64, + restake_earnings: bool, + }, + SetOpenStatus { + baker_id: i64, + open_status: BakerPoolOpenStatus, + }, + SetMetadataUrl { + baker_id: i64, + metadata_url: String, + }, + SetTransactionFeeCommission { + baker_id: i64, + commission: i64, + }, + SetBakingRewardCommission { + baker_id: i64, + commission: i64, + }, + SetFinalizationRewardCommission { + baker_id: i64, + commission: i64, + }, + RemoveDelegation { + delegator_id: i64, + }, + NoOperation, +} +impl PreparedBakerEvent { + fn prepare(event: &concordium_rust_sdk::types::BakerEvent) -> anyhow::Result { + use concordium_rust_sdk::types::BakerEvent; + let prepared = match event { + BakerEvent::BakerAdded { + data: details, + } => PreparedBakerEvent::Add { + baker_id: details.keys_event.baker_id.id.index.try_into()?, + staked: details.stake.micro_ccd().try_into()?, + restake_earnings: details.restake_earnings, + }, + BakerEvent::BakerRemoved { + baker_id, + } => PreparedBakerEvent::Remove { + baker_id: baker_id.id.index.try_into()?, + }, + BakerEvent::BakerStakeIncreased { + baker_id, + new_stake, + } => PreparedBakerEvent::StakeIncrease { + baker_id: baker_id.id.index.try_into()?, + staked: new_stake.micro_ccd().try_into()?, + }, + BakerEvent::BakerStakeDecreased { + baker_id, + new_stake, + } => PreparedBakerEvent::StakeDecrease { + baker_id: baker_id.id.index.try_into()?, + staked: new_stake.micro_ccd().try_into()?, + }, + BakerEvent::BakerRestakeEarningsUpdated { + baker_id, + restake_earnings, + } => PreparedBakerEvent::SetRestakeEarnings { + baker_id: baker_id.id.index.try_into()?, + restake_earnings: *restake_earnings, + }, + BakerEvent::BakerKeysUpdated { + .. + } => PreparedBakerEvent::NoOperation, + BakerEvent::BakerSetOpenStatus { + baker_id, + open_status, + } => PreparedBakerEvent::SetOpenStatus { + baker_id: baker_id.id.index.try_into()?, + open_status: open_status.to_owned().into(), + }, + BakerEvent::BakerSetMetadataURL { + baker_id, + metadata_url, + } => PreparedBakerEvent::SetMetadataUrl { + baker_id: baker_id.id.index.try_into()?, + metadata_url: metadata_url.to_string(), + }, + BakerEvent::BakerSetTransactionFeeCommission { + baker_id, + transaction_fee_commission, + } => PreparedBakerEvent::SetTransactionFeeCommission { + baker_id: baker_id.id.index.try_into()?, + commission: u32::from(PartsPerHundredThousands::from(*transaction_fee_commission)) + .into(), + }, + BakerEvent::BakerSetBakingRewardCommission { + baker_id, + baking_reward_commission, + } => PreparedBakerEvent::SetBakingRewardCommission { + baker_id: baker_id.id.index.try_into()?, + commission: u32::from(PartsPerHundredThousands::from(*baking_reward_commission)) + .into(), + }, + BakerEvent::BakerSetFinalizationRewardCommission { + baker_id, + finalization_reward_commission, + } => PreparedBakerEvent::SetFinalizationRewardCommission { + baker_id: baker_id.id.index.try_into()?, + commission: u32::from(PartsPerHundredThousands::from( + *finalization_reward_commission, + )) + .into(), + }, + BakerEvent::DelegationRemoved { + delegator_id, + } => PreparedBakerEvent::RemoveDelegation { + delegator_id: delegator_id.id.index.try_into()?, + }, + }; + Ok(prepared) + } + + async fn save( + &self, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + match self { + PreparedBakerEvent::Add { + baker_id, + staked, + restake_earnings, + } => { + sqlx::query!( + r#" +INSERT INTO bakers (id, staked, restake_earnings) +VALUES ($1, $2, $3) +"#, + baker_id, + staked, + restake_earnings, + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::Remove { + baker_id, + } => { + sqlx::query!(r#"DELETE FROM bakers WHERE id=$1"#, baker_id,) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::StakeIncrease { + baker_id, + staked, + } => { + sqlx::query!(r#"UPDATE bakers SET staked = $2 WHERE id=$1"#, baker_id, staked,) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::StakeDecrease { + baker_id, + staked, + } => { + sqlx::query!(r#"UPDATE bakers SET staked = $2 WHERE id=$1"#, baker_id, staked,) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetRestakeEarnings { + baker_id, + restake_earnings, + } => { + sqlx::query!( + r#"UPDATE bakers SET restake_earnings = $2 WHERE id=$1"#, + baker_id, + restake_earnings, + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetOpenStatus { + baker_id, + open_status, + } => { + sqlx::query!( + r#"UPDATE bakers SET open_status = $2 WHERE id=$1"#, + baker_id, + *open_status as BakerPoolOpenStatus, + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetMetadataUrl { + baker_id, + metadata_url, + } => { + sqlx::query!( + r#"UPDATE bakers SET metadata_url = $2 WHERE id=$1"#, + baker_id, + metadata_url + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetTransactionFeeCommission { + baker_id, + commission, + } => { + sqlx::query!( + r#"UPDATE bakers SET transaction_commission = $2 WHERE id=$1"#, + baker_id, + commission + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetBakingRewardCommission { + baker_id, + commission, + } => { + sqlx::query!( + r#"UPDATE bakers SET baking_commission = $2 WHERE id=$1"#, + baker_id, + commission + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::SetFinalizationRewardCommission { + baker_id, + commission, + } => { + sqlx::query!( + r#"UPDATE bakers SET finalization_commission = $2 WHERE id=$1"#, + baker_id, + commission + ) + .execute(tx.as_mut()) + .await?; + } + PreparedBakerEvent::RemoveDelegation { + delegator_id: _, + } => { + // TODO: Implement this when database is tracking delegation as well. + todo!() + } + PreparedBakerEvent::NoOperation => (), + } + Ok(()) + } +} From 380570cf4b5f8efea56fb7f9d352af697c0c4d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Tue, 1 Oct 2024 20:53:50 +0200 Subject: [PATCH 19/50] Introduce env variables for arguments --- backend-rust/src/bin/ccdscan-api.rs | 8 +- backend-rust/src/bin/ccdscan-indexer.rs | 11 +- backend-rust/src/graphql_api.rs | 202 +++++++++++++----------- backend-rust/src/indexer.rs | 10 +- 4 files changed, 126 insertions(+), 105 deletions(-) diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index 4f4689b7..79fcbe39 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -21,11 +21,13 @@ struct Cli { #[arg(long)] schema_out: Option, /// Address to listen for API requests - #[arg(long, default_value = "127.0.0.1:8000")] + #[arg(long, env = "CCDSCAN_API_ADDRESS", default_value = "127.0.0.1:8000")] listen: SocketAddr, /// Address to listen for API requests - #[arg(long, default_value = "127.0.0.1:8003")] + #[arg(long, env = "CCDSCAN_API_METRICS_ADDRESS", default_value = "127.0.0.1:8003")] metrics_listen: SocketAddr, + #[command(flatten, next_help_heading = "Configuration")] + api_config: graphql_api::ApiServiceConfig, } #[tokio::main] @@ -58,7 +60,7 @@ async fn main() -> anyhow::Result<()> { }; let mut queries_task = { - let service = graphql_api::Service::new(subscription, &mut registry, pool); + let service = graphql_api::Service::new(subscription, &mut registry, pool, cli.config); if let Some(schema_file) = cli.schema_out { info!("Writing schema to {}", schema_file.to_string_lossy()); std::fs::write( diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index f494d139..08654819 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -13,7 +13,6 @@ use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tracing::{error, info}; -// TODO add env for remaining args. #[derive(Parser)] #[command(version, author, about)] struct Cli { @@ -22,10 +21,16 @@ struct Cli { #[arg(long, env = "DATABASE_URL")] database_url: String, /// gRPC interface of the node. Several can be provided. - #[arg(long, default_value = "http://localhost:20000")] + #[arg( + long, + env = "CCDSCAN_INDEXER_GRPC_ENDPOINTS", + value_delimiter = ',', + num_args = 1.., + default_value = "http://localhost:20000" + )] node: Vec, /// Address to listen for metrics requests - #[arg(long, default_value = "127.0.0.1:8001")] + #[arg(long, env = "CCDSCAN_INDEXER_METRICS_ADDRESS", default_value = "127.0.0.1:8001")] metrics_listen: SocketAddr, #[command(flatten, next_help_heading = "Performance tuning")] indexer_config: IndexerServiceConfig, diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 1537f81d..c6a69e6b 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -23,7 +23,7 @@ use async_graphql::{ }; use async_graphql_axum::GraphQLSubscription; use chrono::Duration; -use concordium_rust_sdk::types::AmountFraction; +use concordium_rust_sdk::{id::types as sdk_types, types::AmountFraction}; use futures::prelude::*; use prometheus_client::registry::Registry; use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; @@ -33,18 +33,31 @@ use tokio_util::sync::CancellationToken; const VERSION: &str = clap::crate_version!(); -/// The most transactions which can be queried at once. -const QUERY_TRANSACTIONS_LIMIT: i64 = 100; +#[derive(clap::Args)] +pub struct ApiServiceConfig { + /// Account(s) that should not be considered in circulation. + #[arg(long, env = "CCDSCAN_API_CONFIG_NON_CIRCULATING_ACCOUNTS", value_delimiter = ',')] + non_circulating_account: Vec, + /// The most transactions which can be queried at once. + #[arg(long, env = "CCDSCAN_API_CONFIG_TRANSACTION_CONNECTION_LIMIT", default_value = "100")] + transaction_connection_limit: i64, +} pub struct Service { pub schema: Schema, } impl Service { - pub fn new(subscription: Subscription, registry: &mut Registry, pool: PgPool) -> Self { + pub fn new( + subscription: Subscription, + registry: &mut Registry, + pool: PgPool, + config: ApiServiceConfig, + ) -> Self { let schema = Schema::build(Query, EmptyMutation, subscription) .extension(async_graphql::extensions::Tracing) .extension(monitor::MonitorExtension::new(registry)) .data(pool) + .data(config) .finish(); Self { schema, @@ -57,12 +70,12 @@ impl Service { stop_signal: CancellationToken, ) -> anyhow::Result<()> { let app = axum::Router::new() + .route("/", axum::routing::get(Self::graphiql)) .route( - "/", - axum::routing::get(Self::graphiql) - .post_service(async_graphql_axum::GraphQL::new(self.schema.clone())), + "/api/graphql", + axum::routing::post_service(async_graphql_axum::GraphQL::new(self.schema.clone())), ) - .route_service("/ws", GraphQLSubscription::new(self.schema)); + .route_service("/ws/graphql", GraphQLSubscription::new(self.schema)); axum::serve(tcp_listener, app) .with_graceful_shutdown(stop_signal.cancelled_owned()) @@ -72,7 +85,10 @@ impl Service { async fn graphiql() -> impl axum::response::IntoResponse { axum::response::Html( - GraphiQLSource::build().endpoint("/").subscription_endpoint("/ws").finish(), + GraphiQLSource::build() + .endpoint("/api/graphql") + .subscription_endpoint("/ws/graphql") + .finish(), ) } } @@ -225,6 +241,8 @@ enum ApiError { NotFound, #[error("Internal error: {}", .0.message)] NoDatabasePool(async_graphql::Error), + #[error("Internal error: {}", .0.message)] + NoServiceConfig(async_graphql::Error), #[error("Internal error: {0}")] FailedDatabaseQuery(Arc), #[error("Invalid ID format: {0}")] @@ -259,6 +277,10 @@ fn get_pool<'a>(ctx: &Context<'a>) -> ApiResult<&'a PgPool> { ctx.data::().map_err(ApiError::NoDatabasePool) } +fn get_config<'a>(ctx: &Context<'a>) -> ApiResult<&'a ApiServiceConfig> { + ctx.data::().map_err(ApiError::NoServiceConfig) +} + fn check_connection_query(first: &Option, last: &Option) -> ApiResult<()> { if first.is_some() && last.is_some() { return Err(ApiError::QueryConnectionFirstLast); @@ -407,6 +429,8 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { + let config = get_config(ctx)?; + check_connection_query(&first, &last)?; let after_id = after.as_deref().map(IdTransaction::from_str).transpose()?; let before_id = before.as_deref().map(IdTransaction::from_str).transpose()?; @@ -469,18 +493,18 @@ impl Query { (None, None) => { builder .push(" ORDER BY block ASC, index ASC LIMIT ") - .push_bind(QUERY_TRANSACTIONS_LIMIT); + .push_bind(config.transaction_connection_limit); } (None, Some(last)) => { builder .push(" ORDER BY block DESC, index DESC LIMIT ") - .push_bind(last.min(QUERY_TRANSACTIONS_LIMIT)) + .push_bind(last.min(config.transaction_connection_limit)) .push(") ORDER BY block ASC, index ASC"); } (Some(first), None) => { builder .push(" ORDER BY block ASC, index ASC LIMIT ") - .push_bind(first.min(QUERY_TRANSACTIONS_LIMIT)); + .push_bind(first.min(config.transaction_connection_limit)); } (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), } @@ -647,18 +671,43 @@ impl Query { period: MetricsPeriod, ) -> ApiResult { let pool = get_pool(ctx)?; + let config = get_config(ctx)?; + let non_circulating_accounts = + config.non_circulating_account.iter().map(|a| a.to_string()).collect::>(); + + let latest_block = sqlx::query!( + r#" +WITH non_circulating_accounts AS ( + SELECT + COALESCE(SUM(amount), 0)::BIGINT AS total_amount + FROM accounts + WHERE address=ANY($1) +) +SELECT + height, + blocks.total_amount, + total_staked, + (blocks.total_amount - non_circulating_accounts.total_amount)::BIGINT AS total_amount_released +FROM blocks, non_circulating_accounts +ORDER BY height DESC +LIMIT 1"#, + non_circulating_accounts.as_slice() + ) + .fetch_one(pool) + .await?; + let interval: PgInterval = period .as_duration() .try_into() .map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; - let rec = sqlx::query!( - "SELECT -MAX(height) as last_block_height, -COUNT(*) as blocks_added, -(MAX(slot_time) - MIN(slot_time)) / (COUNT(*) - 1) as avg_block_time, -(MAX(total_amount)) as last_total_micro_ccd + let period_query = sqlx::query!( + r#" +SELECT + COUNT(*) as blocks_added, + AVG(block_time)::integer as avg_block_time, + AVG(finalization_time)::integer as avg_finalization_time FROM blocks -WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", +WHERE slot_time > (LOCALTIMESTAMP - $1::interval)"#, interval ) .fetch_one(pool) @@ -667,7 +716,7 @@ WHERE slot_time > (LOCALTIMESTAMP - $1::interval)", let bucket_width = period.bucket_width(); let bucket_interval: PgInterval = bucket_width.try_into().map_err(|err| ApiError::DurationOutOfRange(Arc::new(err)))?; - let res = sqlx::query!( + let bucket_query = sqlx::query!( " WITH data AS ( SELECT @@ -703,7 +752,7 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) y_finalization_time_avg: Vec::new(), y_last_total_micro_ccd_staked: Vec::new(), }; - for row in res { + for row in bucket_query { buckets.x_time.push(row.time.ok_or(ApiError::InternalError( "Unexpected missing time for bucket".to_string(), ))?); @@ -718,10 +767,14 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) } Ok(BlockMetrics { - last_block_height: rec.last_block_height.unwrap_or(0), - blocks_added: rec.blocks_added.unwrap_or(0), - avg_block_time: rec.avg_block_time.map(|i| i.microseconds as f64 / 1000.0), - last_total_micro_ccd: rec.last_total_micro_ccd.unwrap_or(0), + blocks_added: period_query.blocks_added.unwrap_or(0), + avg_block_time: period_query.avg_block_time.map(|i| i as f64 / 1000.0), + avg_finalization_time: period_query.avg_finalization_time.map(|i| i as f64 / 1000.0), + last_block_height: latest_block.height, + last_total_micro_ccd: latest_block.total_amount, + last_total_micro_ccd_staked: latest_block.total_staked, + last_total_micro_ccd_released: latest_block.total_amount_released.unwrap_or(0), + last_total_micro_ccd_unlocked: None, // TODO implement unlocking schedule // TODO check what format this is expected to be in. buckets, }) @@ -997,6 +1050,9 @@ impl Block { /// Absolute block height. async fn id(&self) -> types::ID { types::ID::from(self.height) } + /// Whether the block is finalized. + async fn finalized(&self) -> bool { true } + /// Number of transactions included in this block. async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { let result = sqlx::query!("SELECT COUNT(*) FROM transactions WHERE block=$1", self.height) @@ -4308,36 +4364,30 @@ pub struct DelegationStakeIncreased { struct BlockMetrics { /// The most recent block height. Equals the total length of the chain minus /// one (genesis block is at height zero). - last_block_height: BlockHeight, + last_block_height: BlockHeight, /// Total number of blocks added in requested period. - blocks_added: i64, - /// The average block time (slot-time difference between two adjacent - /// blocks) in the requested period. Will be null if no blocks have been - /// added in the requested period. - avg_block_time: Option, - // /// The average finalization time (slot-time difference between a given block and the block - // /// that holds its finalization proof) in the requested period. Will be null if no blocks - // have /// been finalized in the requested period. - // avg_finalization_time: Option, + blocks_added: i64, + /// The average block time in seconds (slot-time difference between two + /// adjacent blocks) in the requested period. Will be null if no blocks + /// have been added in the requested period. + avg_block_time: Option, + /// The average finalization time in seconds (slot-time difference between a + /// given block and the block that holds its finalization proof) in the + /// requested period. Will be null if no blocks have been finalized in + /// the requested period. + avg_finalization_time: Option, /// The current total amount of CCD in existence. last_total_micro_ccd: Amount, - // /// The total CCD Released. This is total CCD supply not counting the balances of non - // circulating accounts. last_total_micro_ccd_released: Option, - // /// The current total CCD released according to the Concordium promise published on - // deck.concordium.com. Will be null for blocks with slot time before the published release - // schedule. last_total_micro_ccd_unlocked: Option, - // /// The current total amount of CCD in encrypted balances. - // last_total_micro_ccd_encrypted: Long, - // /// The current total amount of CCD staked. - // last_total_micro_ccd_staked: Long, - // /// The current percentage of CCD released (of total CCD in existence) according to the - // Concordium promise published on deck.concordium.com. Will be null for blocks with slot time - // before the published release schedule." last_total_percentage_released: Option, - // /// The current percentage of CCD encrypted (of total CCD in existence). - // last_total_percentage_encrypted: f32, - // /// The current percentage of CCD staked (of total CCD in existence). - // last_total_percentage_staked: f32, - buckets: BlockMetricsBuckets, + /// The total CCD Released. This is total CCD supply not counting the + /// balances of non circulating accounts. + last_total_micro_ccd_released: Amount, + /// The current total CCD released according to the Concordium promise + /// published on deck.concordium.com. Will be null for blocks with slot + /// time before the published release schedule. + last_total_micro_ccd_unlocked: Option, + /// The current total amount of CCD staked. + last_total_micro_ccd_staked: Amount, + buckets: BlockMetricsBuckets, } #[derive(SimpleObject)] @@ -4351,61 +4401,17 @@ struct BlockMetricsBuckets { /// value. #[graphql(name = "y_BlocksAdded")] y_blocks_added: Vec, - // /// The minimum block time (slot-time difference between two adjacent blocks) in the bucket - // /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket - // /// period. - // #[graphql(name = "y_BlockTimeMin")] - // y_block_time_min: Vec, /// The average block time (slot-time difference between two adjacent /// blocks) in the bucket period. Intended y-axis value. Will be null if /// no blocks have been added in the bucket period. #[graphql(name = "y_BlockTimeAvg")] y_block_time_avg: Vec, - // /// The maximum block time (slot-time difference between two adjacent blocks) in the bucket - // /// period. Intended y-axis value. Will be null if no blocks have been added in the bucket - // /// period. - // #[graphql(name = "y_BlockTimeMax")] - // y_block_time_max: Vec, - // /// The minimum finalization time (slot-time difference between a given block and the block - // /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be - // /// null if no blocks have been finalized in the bucket period. - // #[graphql(name = "y_FinalizationTimeMin")] - // y_finalization_time_min: Vec, /// The average finalization time (slot-time difference between a given /// block and the block that holds its finalization proof) in the bucket /// period. Intended y-axis value. Will be null if no blocks have been /// finalized in the bucket period. #[graphql(name = "y_FinalizationTimeAvg")] y_finalization_time_avg: Vec, - // /// The maximum finalization time (slot-time difference between a given block and the block - // /// that holds its finalization proof) in the bucket period. Intended y-axis value. Will be - // /// null if no blocks have been finalized in the bucket period. - // #[graphql(name = "y_FinalizationTimeMax")] - // y_finalization_time_max: Vec, - // /// The total amount of CCD in existence at the end of the bucket period. Intended y-axis - // /// value. - // #[graphql(name = "y_LastTotalMicroCcd")] - // y_last_total_micro_ccd: Vec, - // /// The minimum amount of CCD in encrypted balances in the bucket period. Intended y-axis - // /// value. Will be null if no blocks have been added in the bucket period. - // #[graphql(name = "y_MinTotalMicroCcdEncrypted")] - // y_min_total_micro_ccd_encrypted: Vec, - // /// The maximum amount of CCD in encrypted balances in the bucket period. Intended y-axis - // /// value. Will be null if no blocks have been added in the bucket period. - // #[graphql(name = "y_MaxTotalMicroCcdEncrypted")] - // y_max_total_micro_ccd_encrypted: Vec, - // /// The total amount of CCD in encrypted balances at the end of the bucket period. Intended - // /// y-axis value. - // #[graphql(name = "y_LastTotalMicroCcdEncrypted")] - // y_last_total_micro_ccd_encrypted: Vec, - // /// The minimum amount of CCD staked in the bucket period. Intended y-axis value. Will be - // null /// if no blocks have been added in the bucket period. - // #[graphql(name = "y_MinTotalMicroCcdStaked")] - // y_min_total_micro_ccd_staked: Vec, - // /// The maximum amount of CCD staked in the bucket period. Intended y-axis value. Will be - // null /// if no blocks have been added in the bucket period. - // #[graphql(name = "y_MaxTotalMicroCcdStaked")] - // y_max_total_micro_ccd_staked: Vec, /// The total amount of CCD staked at the end of the bucket period. Intended /// y-axis value. #[graphql(name = "y_LastTotalMicroCcdStaked")] @@ -4415,9 +4421,13 @@ struct BlockMetricsBuckets { #[derive(Enum, Clone, Copy, PartialEq, Eq)] enum MetricsPeriod { LastHour, + #[graphql(name = "LAST24_HOURS")] Last24Hours, + #[graphql(name = "LAST7_DAYS")] Last7Days, + #[graphql(name = "LAST30_DAYS")] Last30Days, + #[graphql(name = "LAST_YEAR")] LastYear, } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index ee2a35f5..438202d1 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -45,16 +45,20 @@ pub struct IndexerService { #[derive(clap::Args)] pub struct IndexerServiceConfig { /// Maximum number of blocks being preprocessed in parallel. - #[arg(long, default_value = "8")] + #[arg( + long, + env = "CCDSCAN_INDEXER_CONFIG_MAX_PARALLEL_BLOCK_PREPROCESSORS", + default_value = "8" + )] pub max_parallel_block_preprocessors: usize, /// Maximum number of blocks allowed to be batched into the same database /// transaction. - #[arg(long, default_value = "4")] + #[arg(long, env = "CCDSCAN_INDEXER_CONFIG_MAX_PROCESSING_BATCH", default_value = "4")] pub max_processing_batch: usize, /// Set the maximum amount of seconds the last finalized block of the node /// can be behind before it is deemed too far behind, and another node /// is tried. - #[arg(long, default_value = "60")] + #[arg(long, env = "CCDSCAN_INDEXER_CONFIG_NODE_MAX_BEHIND", default_value = "60")] pub node_max_behind: u64, } From 1ecf0892db20b59b5039dbc36be308396cc578bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Tue, 1 Oct 2024 20:55:08 +0200 Subject: [PATCH 20/50] Use rust-backend for block metrics --- backend-rust/src/bin/ccdscan-api.rs | 2 +- frontend/nuxt.config.ts | 11 +++++++ frontend/src/queries/useChartBlockMetrics.ts | 6 +--- .../queries/useRewardMetricsForBakerQuery.ts | 33 ------------------- 4 files changed, 13 insertions(+), 39 deletions(-) delete mode 100644 frontend/src/queries/useRewardMetricsForBakerQuery.ts diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index 79fcbe39..d53e8175 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -60,7 +60,7 @@ async fn main() -> anyhow::Result<()> { }; let mut queries_task = { - let service = graphql_api::Service::new(subscription, &mut registry, pool, cli.config); + let service = graphql_api::Service::new(subscription, &mut registry, pool, cli.api_config); if let Some(schema_file) = cli.schema_out { info!("Writing schema to {}", schema_file.to_string_lossy()); std::fs::write( diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 875d055e..5d302a31 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -35,6 +35,17 @@ const getConfig = (env: Environment): Config => { } export default defineNuxtConfig({ + vite: { + server: { + proxy: { + '/api': 'http://127.0.0.1:8000', + '/ws': { + target: 'ws://127.0.0.1:8000', + ws: true, + }, + }, + }, + }, srcDir: 'src/', components: [ '~/components', diff --git a/frontend/src/queries/useChartBlockMetrics.ts b/frontend/src/queries/useChartBlockMetrics.ts index 64e2c769..534704dd 100644 --- a/frontend/src/queries/useChartBlockMetrics.ts +++ b/frontend/src/queries/useChartBlockMetrics.ts @@ -21,13 +21,8 @@ const BlockMetricsQuery = gql` bucketWidth x_Time y_BlocksAdded - y_BlockTimeMin y_BlockTimeAvg - y_BlockTimeMax - y_LastTotalMicroCcd y_FinalizationTimeAvg - y_MinTotalMicroCcdStaked - y_MaxTotalMicroCcdStaked y_LastTotalMicroCcdStaked } } @@ -36,6 +31,7 @@ const BlockMetricsQuery = gql` export const useBlockMetricsQuery = (period: Ref) => { const { data, executeQuery, fetching } = useQuery({ + context: { url: '/api/graphql' }, query: BlockMetricsQuery, requestPolicy: 'cache-and-network', variables: { period }, diff --git a/frontend/src/queries/useRewardMetricsForBakerQuery.ts b/frontend/src/queries/useRewardMetricsForBakerQuery.ts deleted file mode 100644 index e8101eb8..00000000 --- a/frontend/src/queries/useRewardMetricsForBakerQuery.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useQuery, gql } from '@urql/vue' -import { Ref } from 'vue' -import { RewardMetrics, MetricsPeriod } from '~/types/generated' - -export type RewardMetricsForBakerQueryResponse = { - rewardMetricsForBaker: RewardMetrics -} - -const RewardMetricsForBakerQuery = gql` - query ($bakerId: Long!, $period: MetricsPeriod!) { - rewardMetricsForBaker(bakerId: $bakerId, period: $period) { - sumRewardAmount - buckets { - bucketWidth - x_Time - y_SumRewards - } - } - } -` - -export const useRewardMetricsForBakerQueryQuery = ( - bakerId: Ref, - period: Ref -) => { - const { data, executeQuery, fetching } = useQuery({ - query: RewardMetricsForBakerQuery, - requestPolicy: 'cache-and-network', - variables: { bakerId, period }, - }) - - return { data, executeQuery, fetching } -} From 5dd35d2295f14632a3103e638888fe253735c947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 2 Oct 2024 11:19:26 +0200 Subject: [PATCH 21/50] Update Rust SDK with TraverseConfig::traverse improvements --- backend-rust/concordium-rust-sdk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-rust/concordium-rust-sdk b/backend-rust/concordium-rust-sdk index 9cf8451b..38358f10 160000 --- a/backend-rust/concordium-rust-sdk +++ b/backend-rust/concordium-rust-sdk @@ -1 +1 @@ -Subproject commit 9cf8451b043072ddd50ce4814531d0731022a640 +Subproject commit 38358f10dbd33474b507744b3b3a522a37165a4d From b21814efe629525bc93e59d70df3d240b5c674fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 2 Oct 2024 11:20:05 +0200 Subject: [PATCH 22/50] Check network of nodes when first connecting --- backend-rust/Cargo.lock | 1 + backend-rust/Cargo.toml | 1 + backend-rust/src/indexer.rs | 45 +++++++++++++++++++++++++++++-------- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 1906ba34..87fa8065 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -913,6 +913,7 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "tonic", "tracing", "tracing-subscriber", ] diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 3252e212..747e89a7 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -34,3 +34,4 @@ rust_decimal = "1.35" iso8601-duration = { version = "0.2", features = ["chrono"] } tokio-util = "0.7" prometheus-client = "0.22" +tonic = "0.10.2" diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 438202d1..c35f416b 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -7,11 +7,11 @@ use chrono::NaiveDateTime; use concordium_rust_sdk::{ indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, types::{ - queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, + self as sdk_types, queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, PartsPerHundredThousands, RewardsOverview, }, - v2::{self, ChainParameters, FinalizedBlockInfo, QueryResult, RPCError}, + v2::{self, ChainParameters, FinalizedBlockInfo, QueryError, QueryResult, RPCError}, }; use futures::{StreamExt, TryStreamExt}; use prometheus_client::{ @@ -26,7 +26,7 @@ use prometheus_client::{ use sqlx::PgPool; use tokio::{time::Instant, try_join}; use tokio_util::sync::CancellationToken; -use tracing::{info, warn}; +use tracing::{error, info, warn}; /// Service traversing each block of the chain, indexing it into a database. pub struct IndexerService { @@ -85,8 +85,17 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 save_genesis_data(endpoints[0].clone(), &pool).await?; 1 }; - let block_pre_processor = - BlockPreProcessor::new(registry.sub_registry_with_prefix("preprocessor")); + let genesis_block_hash: sdk_types::hashes::BlockHash = + sqlx::query!(r#"SELECT hash FROM blocks WHERE height=0"#) + .fetch_one(&pool) + .await? + .hash + .parse()?; + + let block_pre_processor = BlockPreProcessor::new( + genesis_block_hash, + registry.sub_registry_with_prefix("preprocessor"), + ); let block_processor = BlockProcessor::new(pool, registry.sub_registry_with_prefix("processor")).await?; @@ -138,6 +147,8 @@ impl NodeMetricLabels { /// of [`Indexer`](concordium_rust_sdk::indexer::Indexer). Since several /// preprocessors can run in parallel, this must be `Sync`. struct BlockPreProcessor { + /// Genesis hash, used to ensure the nodes are on the expected network. + genesis_hash: sdk_types::hashes::BlockHash, /// Metric counting the total number of connections ever established to a /// node. established_node_connections: Family, @@ -151,7 +162,7 @@ struct BlockPreProcessor { node_response_time: Family, } impl BlockPreProcessor { - fn new(registry: &mut Registry) -> Self { + fn new(genesis_hash: sdk_types::hashes::BlockHash, registry: &mut Registry) -> Self { let established_node_connections = Family::default(); registry.register( "established_node_connections", @@ -181,6 +192,7 @@ impl BlockPreProcessor { ); Self { + genesis_hash, established_node_connections, preprocessing_failures, blocks_being_preprocessed, @@ -199,10 +211,25 @@ impl Indexer for BlockPreProcessor { async fn on_connect<'a>( &mut self, endpoint: v2::Endpoint, - _client: &'a mut v2::Client, + client: &'a mut v2::Client, ) -> QueryResult { - // TODO: check the genesis hash matches, i.e. that the node is running on the - // same network. + let info = client.get_consensus_info().await?; + if info.genesis_block != self.genesis_hash { + error!( + "Invalid client: {} is on network with genesis hash {} expected {}", + endpoint.uri(), + info.genesis_block, + self.genesis_hash + ); + return Err(QueryError::RPCError(RPCError::CallError( + tonic::Status::failed_precondition(format!( + "Invalid client: {} is on network with genesis hash {} expected {}", + endpoint.uri(), + info.genesis_block, + self.genesis_hash + )), + ))); + } info!("Connection established to node at uri: {}", endpoint.uri()); let label = NodeMetricLabels::new(&endpoint); self.established_node_connections.get_or_create(&label).inc(); From ab8f2b2abab9457e39c10a8f52c773901d2f27ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 9 Oct 2024 14:59:11 +0200 Subject: [PATCH 23/50] Address review comments --- backend-rust/README.md | 19 +- backend-rust/concordium-rust-sdk | 2 +- .../migrations/0001_initialize.up.sql | 16 +- backend-rust/src/bin/ccdscan-api.rs | 2 +- backend-rust/src/bin/ccdscan-indexer.rs | 2 +- backend-rust/src/graphql_api.rs | 7 +- backend-rust/src/indexer.rs | 337 +++++++++++------- 7 files changed, 246 insertions(+), 139 deletions(-) diff --git a/backend-rust/README.md b/backend-rust/README.md index 4774249b..b0cfae1a 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -43,6 +43,22 @@ To develop this service the following tools are required, besides the dependenci - [Rust and cargo](https://rustup.rs/) - [sqlx-cli](https://crates.io/crates/sqlx-cli) +This project have some dependencies tracked as Git submodules, so make sure to initialize these: + +``` +git submodule update --init --recursive +``` + +### Running the database server + +Both services depend on having a PostgreSQL server running, this can be done in several ways, but it can be done using [docker](https://www.docker.com/) with the command below: + +``` +docker run -p 5432:5432 -e 'POSTGRES_PASSWORD=example' -e 'POSTGRES_DB=ccd-scan' postgres:16 +``` + +### Initializing a database + Then set the environment variable `DATABASE_URL` pointing to the location of the SQL database, this can be done by creating a `.env` file within this directory. Example: @@ -61,7 +77,8 @@ The project can now be build using `cargo build` ### Database migrations -Database migrations are tracked in the `migrations` directory. To introduce a new one run: +Database migrations are tracked in the `migrations` directory. +To introduce a new one run: ``` sqlx migrate add '' diff --git a/backend-rust/concordium-rust-sdk b/backend-rust/concordium-rust-sdk index 38358f10..7026a7f2 160000 --- a/backend-rust/concordium-rust-sdk +++ b/backend-rust/concordium-rust-sdk @@ -1 +1 @@ -Subproject commit 38358f10dbd33474b507744b3b3a522a37165a4d +Subproject commit 7026a7f29a036bbab9317c960045d0bbe11060f1 diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index e25022f0..c1a474d9 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -117,7 +117,7 @@ CREATE TABLE transactions( BIGINT NOT NULL, -- Absolute height of the block containing the transaction. - block + block_height BIGINT REFERENCES blocks(height) NOT NULL, @@ -143,7 +143,8 @@ CREATE TABLE transactions( type transaction_type NOT NULL, - -- NULL if the transaction type is not account or the account transaction have no effect on chain. + -- NULL if the transaction type is not an account transaction or an account transaction which + -- got rejected due to deserialization failure. type_account account_transaction_type, -- NULL if the transaction type is not credential deployment. @@ -164,8 +165,7 @@ CREATE TABLE transactions( JSONB, -- Make the block height and transaction index the primary key. - PRIMARY KEY (block, index) - -- transaction_type: TransactionType, + PRIMARY KEY (block_height, index) ); -- Every account on chain. @@ -193,7 +193,7 @@ CREATE TABLE accounts( BIGINT NOT NULL, -- Connect the account with the transaction creating it. - FOREIGN KEY (created_block, created_index) REFERENCES transactions(block, index) + FOREIGN KEY (created_block, created_index) REFERENCES transactions(block_height, index) -- credential_registration_id ); @@ -248,7 +248,7 @@ CREATE TABLE bakers( ); -CREATE OR REPLACE FUNCTION notify_trigger() RETURNS trigger AS $trigger$ +CREATE OR REPLACE FUNCTION block_added_notify_trigger_function() RETURNS trigger AS $trigger$ DECLARE rec blocks; payload TEXT; @@ -263,7 +263,7 @@ BEGIN END; $trigger$ LANGUAGE plpgsql; -CREATE TRIGGER user_notify AFTER INSERT OR UPDATE OR DELETE +CREATE TRIGGER block_added_notify_trigger AFTER INSERT ON blocks -FOR EACH ROW EXECUTE PROCEDURE notify_trigger(); +FOR EACH ROW EXECUTE PROCEDURE block_added_notify_trigger_function(); diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index d53e8175..2fa13029 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -48,7 +48,7 @@ async fn main() -> anyhow::Result<()> { ); registry.register( "service_startup_timestamp_millis", - "Timestamp of starting up the node (Unix time in milliseconds)", + "Timestamp of starting up the API service (Unix time in milliseconds)", prometheus_client::metrics::gauge::ConstGauge::new(chrono::Utc::now().timestamp_millis()), ); diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index 08654819..f1eed5c7 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -54,7 +54,7 @@ async fn main() -> anyhow::Result<()> { ); registry.register( "service_startup_timestamp_millis", - "Timestamp of starting up the node (Unix time in milliseconds)", + "Timestamp of starting up the Indexer service (Unix time in milliseconds)", prometheus_client::metrics::gauge::ConstGauge::new(chrono::Utc::now().timestamp_millis()), ); diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index c6a69e6b..35e14364 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -1055,9 +1055,10 @@ impl Block { /// Number of transactions included in this block. async fn transaction_count<'a>(&self, ctx: &Context<'a>) -> ApiResult { - let result = sqlx::query!("SELECT COUNT(*) FROM transactions WHERE block=$1", self.height) - .fetch_one(get_pool(ctx)?) - .await?; + let result = + sqlx::query!("SELECT COUNT(*) FROM transactions WHERE block_height=$1", self.height) + .fetch_one(get_pool(ctx)?) + .await?; Ok(result.count.unwrap_or(0)) } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index c35f416b..0d8b53fd 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -5,13 +5,17 @@ use crate::graphql_api::{ use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ + common::types::Amount, indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, types::{ self as sdk_types, queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, PartsPerHundredThousands, RewardsOverview, }, - v2::{self, ChainParameters, FinalizedBlockInfo, QueryError, QueryResult, RPCError}, + v2::{ + self, BlockIdentifier, ChainParameters, FinalizedBlockInfo, QueryError, QueryResult, + RPCError, + }, }; use futures::{StreamExt, TryStreamExt}; use prometheus_client::{ @@ -24,6 +28,7 @@ use prometheus_client::{ registry::Registry, }; use sqlx::PgPool; +use std::borrow::Borrow; use tokio::{time::Instant, try_join}; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -60,6 +65,10 @@ pub struct IndexerServiceConfig { /// is tried. #[arg(long, env = "CCDSCAN_INDEXER_CONFIG_NODE_MAX_BEHIND", default_value = "60")] pub node_max_behind: u64, + /// Set the max number of acceptable successive failures before shutting + /// down the service. + #[arg(long, env = "CCDSCAN_INDEXER_CONFIG_MAX_SUCCESSIVE_FAILURES", default_value = "10")] + pub max_successive_failures: u32, } impl IndexerService { @@ -94,10 +103,15 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 let block_pre_processor = BlockPreProcessor::new( genesis_block_hash, + config.max_successive_failures.into(), registry.sub_registry_with_prefix("preprocessor"), ); - let block_processor = - BlockProcessor::new(pool, registry.sub_registry_with_prefix("processor")).await?; + let block_processor = BlockProcessor::new( + pool, + config.max_successive_failures, + registry.sub_registry_with_prefix("processor"), + ) + .await?; Ok(Self { endpoints, @@ -160,9 +174,16 @@ struct BlockPreProcessor { /// Histogram collecting the time it takes for fetching all the block data /// from the node. node_response_time: Family, + /// Max number of acceptable successive failures before shutting down the + /// service. + max_successive_failures: u64, } impl BlockPreProcessor { - fn new(genesis_hash: sdk_types::hashes::BlockHash, registry: &mut Registry) -> Self { + fn new( + genesis_hash: sdk_types::hashes::BlockHash, + max_successive_failures: u64, + registry: &mut Registry, + ) -> Self { let established_node_connections = Family::default(); registry.register( "established_node_connections", @@ -197,6 +218,7 @@ impl BlockPreProcessor { preprocessing_failures, blocks_being_preprocessed, node_response_time, + max_successive_failures, } } } @@ -272,8 +294,23 @@ impl Indexer for BlockPreProcessor { client2.get_block_chain_parameters(fbi.height), get_events, client.get_tokenomics_info(fbi.height) - ) - .map_err(|err| err)?; + )?; + let total_staked_capital = match tokenomics_info.response { + RewardsOverview::V0 { + .. + } => { + compute_total_stake_capital( + &mut client, + BlockIdentifier::AbsoluteHeight(fbi.height), + ) + .await? + } + RewardsOverview::V1 { + total_staked_capital, + .. + } => total_staked_capital, + }; + let node_response_time = start_fetching.elapsed(); self.node_response_time.get_or_create(label).observe(node_response_time.as_secs_f64()); @@ -283,6 +320,7 @@ impl Indexer for BlockPreProcessor { events, chain_parameters: chain_parameters.response, tokenomics_info: tokenomics_info.response, + total_staked_capital, }; let prepared_block = PreparedBlock::prepare(&data).map_err(|err| RPCError::ParseError(err))?; @@ -305,8 +343,31 @@ impl Indexer for BlockPreProcessor { ) -> bool { info!("Failed preprocessing {} times in row: {}", successive_failures, err); self.preprocessing_failures.get_or_create(&NodeMetricLabels::new(&endpoint)).inc(); - true + successive_failures > self.max_successive_failures + } +} + +/// Compute the total stake capital by summing all the stake of the bakers. +/// This is only needed for older blocks, which does not provide this +/// information as part of the tokenomics info query. +async fn compute_total_stake_capital( + client: &mut v2::Client, + block_height: v2::BlockIdentifier, +) -> QueryResult { + let mut total_staked_capital = Amount::zero(); + let mut bakers = client.get_baker_list(block_height).await?.response; + while let Some(baker_id) = bakers.try_next().await? { + let account_info = client + .get_account_info(&v2::AccountIdentifier::Index(baker_id.id), block_height) + .await? + .response; + total_staked_capital += account_info + .account_stake + .context("Expected baker to have account stake information") + .map_err(RPCError::ParseError)? + .staked_amount(); } + Ok(total_staked_capital) } /// Type implementing the `ProcessEvent` handling the insertion of prepared @@ -314,12 +375,6 @@ impl Indexer for BlockPreProcessor { struct BlockProcessor { /// Database connection pool pool: PgPool, - /// The last finalized block height according to the latest indexed block. - /// This is needed in order to compute the finalization time of blocks. - last_finalized_height: i64, - /// The last finalized block hash according to the latest indexed block. - /// This is needed in order to compute the finalization time of blocks. - last_finalized_hash: String, /// Metric counting how many blocks was saved to the database successfully. blocks_processed: Counter, /// Metric counting the total number of failed attempts to process @@ -327,15 +382,31 @@ struct BlockProcessor { processing_failures: Counter, /// Histogram collecting the time it took to process a block. processing_duration_seconds: Histogram, + /// Max number of acceptable successive failures before shutting down the + /// service. + max_successive_failures: u32, + /// Starting context which is tracked across processing blocks. + current_context: BlockProcessingContext, } impl BlockProcessor { /// Construct the block processor by loading the initial state from the /// database. This assumes at least the genesis block is in the /// database. - async fn new(pool: PgPool, registry: &mut Registry) -> anyhow::Result { - let rec = sqlx::query!( + async fn new( + pool: PgPool, + max_successive_failures: u32, + registry: &mut Registry, + ) -> anyhow::Result { + let starting_context = sqlx::query_as!( + BlockProcessingContext, r#" -SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height ASC LIMIT 1 +SELECT + height as last_finalized_height, + hash as last_finalized_hash +FROM blocks +WHERE finalization_time IS NULL +ORDER BY height ASC +LIMIT 1 "# ) .fetch_one(&pool) @@ -364,11 +435,11 @@ SELECT height, hash FROM blocks WHERE finalization_time IS NULL ORDER BY height Ok(Self { pool, - last_finalized_height: rec.height, - last_finalized_hash: rec.hash, + current_context: starting_context, blocks_processed, processing_failures, processing_duration_seconds, + max_successive_failures, }) } } @@ -393,11 +464,19 @@ impl ProcessEvent for BlockProcessor { let start_time = Instant::now(); let mut out = format!("Processed {} blocks:", batch.len()); let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; + let mut override_context = None; for data in batch { - data.save(&mut self, &mut tx).await.context("Failed saving block")?; + let context = override_context.as_ref().unwrap_or(self.current_context.borrow()); + let new_context = data.save(context, &mut tx).await.context("Failed saving block")?; + if new_context.is_some() { + override_context = new_context; + } out.push_str(format!("\n- {}:{}", data.height, data.hash).as_str()) } tx.commit().await.context("Failed to commit SQL transaction")?; + if let Some(context) = override_context { + self.current_context = context; + } let duration = start_time.elapsed(); self.processing_duration_seconds.observe(duration.as_secs_f64()); self.blocks_processed.inc_by(u64::try_from(batch.len())?); @@ -420,10 +499,19 @@ impl ProcessEvent for BlockProcessor { ) -> Result { info!("Failed processing {} times in row: {}", successive_failures, error); self.processing_failures.inc(); - Ok(true) + Ok(self.max_successive_failures >= successive_failures) } } +struct BlockProcessingContext { + /// The last finalized block height according to the latest indexed block. + /// This is needed in order to compute the finalization time of blocks. + last_finalized_height: i64, + /// The last finalized block hash according to the latest indexed block. + /// This is needed in order to compute the finalization time of blocks. + last_finalized_hash: String, +} + /// Raw block information fetched from a Concordium Node. struct BlockData { finalized_block_info: FinalizedBlockInfo, @@ -431,59 +519,45 @@ struct BlockData { events: Vec, chain_parameters: ChainParameters, tokenomics_info: RewardsOverview, + total_staked_capital: Amount, } /// Function for initializing the database with the genesis block. /// This should only be called if the database is empty. async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Result<()> { let mut client = v2::Client::new(endpoint).await?; - let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); - let mut tx = pool.begin().await.context("Failed to create SQL transaction")?; - - let genesis_block_info = client.get_block_info(genesis_height).await?.response; - let block_hash = genesis_block_info.block_hash.to_string(); - let slot_time = genesis_block_info.block_slot_time.naive_utc(); - let baker_id = if let Some(index) = genesis_block_info.block_baker { - Some(i64::try_from(index.id.index)?) - } else { - None - }; - let genesis_tokenomics = client.get_tokenomics_info(genesis_height).await?.response; - let common_reward = match genesis_tokenomics { - RewardsOverview::V0 { - data, - } => data, - RewardsOverview::V1 { - common, - .. - } => common, - }; - let total_staked = match genesis_tokenomics { - RewardsOverview::V0 { - .. - } => { - // TODO Compute the total staked capital. - 0i64 - } - RewardsOverview::V1 { - total_staked_capital, - .. - } => i64::try_from(total_staked_capital.micro_ccd())?, - }; - - let total_amount = i64::try_from(common_reward.total_amount.micro_ccd())?; - sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5, $6);"#, + let genesis_height = v2::BlockIdentifier::AbsoluteHeight(0.into()); + { + let genesis_block_info = client.get_block_info(genesis_height).await?.response; + let block_hash = genesis_block_info.block_hash.to_string(); + let slot_time = genesis_block_info.block_slot_time.naive_utc(); + let genesis_tokenomics = client.get_tokenomics_info(genesis_height).await?.response; + let total_staked = match genesis_tokenomics { + RewardsOverview::V0 { + .. + } => { + let total_staked_capital = + compute_total_stake_capital(&mut client, genesis_height).await?; + i64::try_from(total_staked_capital.micro_ccd())? + } + RewardsOverview::V1 { + total_staked_capital, + .. + } => i64::try_from(total_staked_capital.micro_ccd())?, + }; + let total_amount = + i64::try_from(genesis_tokenomics.common_reward_data().total_amount.micro_ccd())?; + sqlx::query!( + r#"INSERT INTO blocks (height, hash, slot_time, block_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5);"#, 0, block_hash, slot_time, - baker_id, - total_amount, - total_staked - ) - .execute(&mut *tx) - .await?; + total_amount, + total_staked + ).execute(&mut *tx) + .await?; + } let mut genesis_accounts = client.get_account_list(genesis_height).await?.response; while let Some(account) = genesis_accounts.try_next().await? { @@ -547,13 +621,22 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res /// Preprocessed block which is ready to be saved in the database. struct PreparedBlock { + /// Hash of the block. hash: String, + /// Absolute height of the block. height: i64, + /// Block slot time (UTC). slot_time: NaiveDateTime, + /// Id of the validator which constructed the block. Is only None for the + /// genesis block. baker_id: Option, + /// Total amount of CCD in existence at the time of this block. total_amount: i64, + /// Total staked CCD at the time of this block. total_staked: i64, + /// Block hash of the last finalized block. block_last_finalized: String, + /// Preprocessed block items, ready to be saved in the database. prepared_block_items: Vec, } @@ -568,34 +651,13 @@ impl PreparedBlock { } else { None }; - let common_reward_data = match data.tokenomics_info { - RewardsOverview::V0 { - data, - } => data, - RewardsOverview::V1 { - common, - .. - } => common, - }; - let total_amount = i64::try_from(common_reward_data.total_amount.micro_ccd())?; - let total_staked = match data.tokenomics_info { - RewardsOverview::V0 { - .. - } => { - // TODO Compute the total staked capital. - 0i64 - } - RewardsOverview::V1 { - total_staked_capital, - .. - } => i64::try_from(total_staked_capital.micro_ccd())?, - }; - + let total_amount = + i64::try_from(data.tokenomics_info.common_reward_data().total_amount.micro_ccd())?; + let total_staked = i64::try_from(data.total_staked_capital.micro_ccd())?; let mut prepared_block_items = Vec::new(); for block_item in data.events.iter() { prepared_block_items.push(PreparedBlockItem::prepare(data, block_item)?) } - Ok(Self { hash, height, @@ -610,9 +672,9 @@ impl PreparedBlock { async fn save( &self, - context: &mut BlockProcessor, + context: &BlockProcessingContext, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, - ) -> anyhow::Result<()> { + ) -> anyhow::Result> { sqlx::query!( r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) VALUES ($1, $2, $3, @@ -630,7 +692,7 @@ VALUES ($1, $2, $3, // Check if this block knows of a new finalized block. // If so, mark the blocks since last time as finalized by this block. - if self.block_last_finalized != context.last_finalized_hash { + let new_context = if self.block_last_finalized != context.last_finalized_hash { let last_height = context.last_finalized_height; let rec = sqlx::query!( @@ -650,42 +712,65 @@ RETURNING finalizer.height"#, .fetch_one(tx.as_mut()) .await .context("Failed updating finalization_time")?; - - // TODO: Updating the context should be done, when we know nothing has failed. - context.last_finalized_height = rec.height; - context.last_finalized_hash = self.block_last_finalized.clone(); - } - + let new_context = BlockProcessingContext { + last_finalized_hash: self.block_last_finalized.clone(), + last_finalized_height: rec.height, + }; + Some(new_context) + } else { + None + }; for item in self.prepared_block_items.iter() { item.save(tx).await?; } - Ok(()) + Ok(new_context) } } +/// Prepared block item (transaction), ready to be inserted in the database. struct PreparedBlockItem { - block_index: i64, - tx_hash: String, + /// Index of the block item within the block. + block_item_index: i64, + /// Hash of the transaction + block_item_hash: String, + /// Cost for the account signing the block item (in microCCD), always 0 for + /// update and credential deployments. ccd_cost: i64, + /// Energy cost of the execution of the block item. energy_cost: i64, - height: i64, + /// Absolute height of the block. + block_height: i64, + /// Base58check representation of the account address which signed the + /// block, none for update and credential deployments. sender: Option, + /// Whether the block item is an account transaction, update or credential + /// deployment. transaction_type: DbTransactionType, + /// The type of account transaction, is none if not an account transaction + /// or if the account transaction got rejected due to deserialization + /// failing. account_type: Option, + /// The type of credential deployment transaction, is none if not a + /// credential deployment transaction. credential_type: Option, + /// The type of update transaction, is none if not an update transaction. update_type: Option, + /// Whether the block item was successful i.e. not rejected. success: bool, + /// Events of the block item. Is none for rejected block items. events: Option, + /// Reject reason the block item. Is none for successful block items. reject: Option, // This is an option temporarily, until we are able to handle every type of event. + /// Block item events prepared for inserting into the database. prepared_event: Option, } impl PreparedBlockItem { fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result { - let height = i64::try_from(data.finalized_block_info.height.height)?; - let block_index = i64::try_from(block_item.index.index)?; - let tx_hash = block_item.hash.to_string(); + let block_height = i64::try_from(data.finalized_block_info.height.height)?; + let block_item_index = i64::try_from(block_item.index.index)?; + let block_item_hash = block_item.hash.to_string(); let ccd_cost = i64::try_from(data.chain_parameters.ccd_cost(block_item.energy_cost).micro_ccd)?; let energy_cost = i64::try_from(block_item.energy_cost.energy)?; @@ -734,11 +819,11 @@ impl PreparedBlockItem { let prepared_event = PreparedEvent::prepare(&data, &block_item)?; Ok(Self { - block_index, - tx_hash, + block_item_index, + block_item_hash, ccd_cost, energy_cost, - height, + block_height, sender, transaction_type, account_type, @@ -757,14 +842,14 @@ impl PreparedBlockItem { ) -> anyhow::Result<()> { sqlx::query!( r#"INSERT INTO transactions -(index, hash, ccd_cost, energy_cost, block, sender, type, type_account, type_credential_deployment, type_update, success, events, reject) +(index, hash, ccd_cost, energy_cost, block_height, sender, type, type_account, type_credential_deployment, type_update, success, events, reject) VALUES ($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12, $13);"#, - self.block_index, - self.tx_hash, + self.block_item_index, + self.block_item_hash, self.ccd_cost, self.energy_cost, - self.height, + self.block_height, self.sender, self.transaction_type as DbTransactionType, self.account_type as Option, @@ -783,9 +868,13 @@ VALUES } } +/// Different types of block item events that can be prepared. enum PreparedEvent { + /// A new account got created. AccountCreation(PreparedAccountCreation), + /// Changes related to validators (previously referred to as bakers). BakerEvents(Vec), + /// No changes in the database was caused by this event. NoOperation, } impl PreparedEvent { @@ -950,10 +1039,14 @@ impl PreparedEvent { } } +/// Prepared database insertion of a new account. struct PreparedAccountCreation { - account_address: String, - height: i64, - block_index: i64, + /// The base58check representation of the canonical account address. + account_address: String, + /// The absolute block height for the block creating this account. + block_height: i64, + /// The transaction index within the block creating this account. + block_item_index: i64, } impl PreparedAccountCreation { @@ -962,12 +1055,10 @@ impl PreparedAccountCreation { block_item: &BlockItemSummary, details: &concordium_rust_sdk::types::AccountCreationDetails, ) -> anyhow::Result { - let height = i64::try_from(data.finalized_block_info.height.height)?; - let block_index = i64::try_from(block_item.index.index)?; Ok(Self { - account_address: details.address.to_string(), - height, - block_index, + account_address: details.address.to_string(), + block_height: i64::try_from(data.finalized_block_info.height.height)?, + block_item_index: i64::try_from(block_item.index.index)?, }) } @@ -979,8 +1070,8 @@ impl PreparedAccountCreation { r#"INSERT INTO accounts (index, address, created_block, created_index, amount) VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, self.account_address, - self.height, - self.block_index + self.block_height, + self.block_item_index ) .execute(tx.as_mut()) .await?; @@ -988,6 +1079,7 @@ VALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)"#, } } +/// Event changing state related to validators (bakers). enum PreparedBakerEvent { Add { baker_id: i64, @@ -1134,10 +1226,7 @@ impl PreparedBakerEvent { restake_earnings, } => { sqlx::query!( - r#" -INSERT INTO bakers (id, staked, restake_earnings) -VALUES ($1, $2, $3) -"#, + r#"INSERT INTO bakers (id, staked, restake_earnings) VALUES ($1, $2, $3)"#, baker_id, staked, restake_earnings, From da1e8bf273e4c5110fc9308fa096b25e385ddbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 9 Oct 2024 15:40:54 +0200 Subject: [PATCH 24/50] Enable checks for rust backend in CI --- .github/workflows/check-format-build.yml | 63 +++++++++++ ...92359f0952b97d7ae668ddc3fabf7c12daf31.json | 15 +++ ...93c8b1646b2852530e4935ad56dd2e1bddb4c.json | 16 +++ ...c1868e8767ee7082b5c085edd1a52b10581a0.json | 15 +++ ...41eda3d128a04340dc9eaa8fb688a39a71fbe.json | 24 ++++ ...f811ab51ea8fba0888851c94ec377f68976ba.json | 22 ++++ ...1e1253b2a10af9a864218ae1b3fd08ad59a27.json | 46 ++++++++ ...0126c8d095ee920d1582912060d7a8040e691.json | 75 ++++++++++++ ...b4c3e4bfcd18563efa4597fe6870c337eaee6.json | 22 ++++ ...582e44f516370ac80a97b5866bbd807921ddb.json | 32 ++++++ ...7723215cb9e25084609902e803af3e8e2a753.json | 34 ++++++ ...699f563b1d097925615b2a85dcf5852ad0635.json | 18 +++ ...4a273b14c188ee0ce25520b898386305a96cd.json | 15 +++ ...8aa517cf86b90c4a6ce44dc1fdbd10a37334b.json | 14 +++ ...801d6a9de0eef8f3aa29183bb5e5dacfd799c.json | 20 ++++ ...7728119736e988d20fe1f81fafbbd93bdf93d.json | 15 +++ ...aebfbdf664c11c425d63bdccc7344c181609b.json | 15 +++ ...4467acfc69ab96a0d78ede363644d2f164a5a.json | 22 ++++ ...b205a17b7717121ef0c8eafae3ddf5d95cbb1.json | 70 ++++++++++++ ...ff359d7567c5cc540a87b57ef7e7f0905dde2.json | 15 +++ ...71a95f5c7cff53d6e63f0158c2337dcf3dcef.json | 40 +++++++ ...8a5ebe6ecf32264b81c22d87b036b280a9af3.json | 26 +++++ ...a71ee5ea8cff7e7a091bf36070c039d365ca7.json | 22 ++++ ...78d250ea046851b2446c61d88ad2b1c97849c.json | 19 ++++ ...6d61cf803559d95cffe5a921fddd4e16c1deb.json | 17 +++ ...9e6ad100dfb110542456c98575729594ca57a.json | 20 ++++ ...f99eb882334e0728306839a631973a05fcbd5.json | 70 ++++++++++++ ...7e43bac757ef9edb29a93d11da32855c8e537.json | 16 +++ ...bd8ccbedacb81b627037b20484ab2a673587b.json | 46 ++++++++ ...3513fb0b1f40abc01a4ba48b6c861925cdc03.json | 26 +++++ ...4eb3032a01758be41d968d987b0dde5de2a4e.json | 107 ++++++++++++++++++ backend-rust/src/graphql_api.rs | 6 +- 32 files changed, 979 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/check-format-build.yml create mode 100644 backend-rust/.sqlx/query-02518427e68de1cc612678cfe5792359f0952b97d7ae668ddc3fabf7c12daf31.json create mode 100644 backend-rust/.sqlx/query-12606f816ea8a49c983c968851293c8b1646b2852530e4935ad56dd2e1bddb4c.json create mode 100644 backend-rust/.sqlx/query-133ff8586d1d69567259299dd1fc1868e8767ee7082b5c085edd1a52b10581a0.json create mode 100644 backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json create mode 100644 backend-rust/.sqlx/query-1ebcb64ad0ff478f246a8f46e46f811ab51ea8fba0888851c94ec377f68976ba.json create mode 100644 backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json create mode 100644 backend-rust/.sqlx/query-38822385789ad387659bec8d2430126c8d095ee920d1582912060d7a8040e691.json create mode 100644 backend-rust/.sqlx/query-3a8582ae728281ba74f29a240a5b4c3e4bfcd18563efa4597fe6870c337eaee6.json create mode 100644 backend-rust/.sqlx/query-44265e9d95a4e8d13849d8dab52582e44f516370ac80a97b5866bbd807921ddb.json create mode 100644 backend-rust/.sqlx/query-49f0e97a6e89a4084d17707dc4e7723215cb9e25084609902e803af3e8e2a753.json create mode 100644 backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json create mode 100644 backend-rust/.sqlx/query-5063adb3ca3312535f1e2180ae44a273b14c188ee0ce25520b898386305a96cd.json create mode 100644 backend-rust/.sqlx/query-5b43581d891d787caae5b97bf168aa517cf86b90c4a6ce44dc1fdbd10a37334b.json create mode 100644 backend-rust/.sqlx/query-6a41925d858c0220db3c3b7ce2d801d6a9de0eef8f3aa29183bb5e5dacfd799c.json create mode 100644 backend-rust/.sqlx/query-776815732579143e95a48e7b8037728119736e988d20fe1f81fafbbd93bdf93d.json create mode 100644 backend-rust/.sqlx/query-78d26a7ddcbaaa044d1951fdf03aebfbdf664c11c425d63bdccc7344c181609b.json create mode 100644 backend-rust/.sqlx/query-793f029fb35301b7008d0d3f7ca4467acfc69ab96a0d78ede363644d2f164a5a.json create mode 100644 backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json create mode 100644 backend-rust/.sqlx/query-afdaa33b7d5a6dbc4e8d811d933ff359d7567c5cc540a87b57ef7e7f0905dde2.json create mode 100644 backend-rust/.sqlx/query-b5a479d6a39f388d4b1c6cc868471a95f5c7cff53d6e63f0158c2337dcf3dcef.json create mode 100644 backend-rust/.sqlx/query-bc2a7a48eba8eff422cdc179a348a5ebe6ecf32264b81c22d87b036b280a9af3.json create mode 100644 backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json create mode 100644 backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json create mode 100644 backend-rust/.sqlx/query-d1a7b29a8e6c54eaf234df54a276d61cf803559d95cffe5a921fddd4e16c1deb.json create mode 100644 backend-rust/.sqlx/query-d2b1b263ea1e5f7137508c5ef4c9e6ad100dfb110542456c98575729594ca57a.json create mode 100644 backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json create mode 100644 backend-rust/.sqlx/query-da5e3e79999991fade20dd84b0f7e43bac757ef9edb29a93d11da32855c8e537.json create mode 100644 backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json create mode 100644 backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json create mode 100644 backend-rust/.sqlx/query-fba71388ff31763f6cae9d853cd4eb3032a01758be41d968d987b0dde5de2a4e.json diff --git a/.github/workflows/check-format-build.yml b/.github/workflows/check-format-build.yml new file mode 100644 index 00000000..be677d80 --- /dev/null +++ b/.github/workflows/check-format-build.yml @@ -0,0 +1,63 @@ +name: Backend (Rust) Check formatting and build + +on: + push: + branches: main + pull_request: + branches: [ main ] + +env: + RUST_FMT: nightly-2023-04-01-x86_64-unknown-linux-gnu + RUST_VERSION: 1.80 + +jobs: + rustfmt: + name: format + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Format + working-directory: backend-rust + run: | + rustup default $RUST_FMT + rustup component add rustfmt + cargo fmt -- --color=always --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Setup Rust + run: | + rustup default $RUST_VERSION + rustup component add clippy + - name: Install sqlx-cli + uses: taiki-e/install-action@v2 + with: + tool: sqlx-cli@0.7.4 + - name: Setup Rust + working-directory: backend-rust + run: | + cargo clippy --color=always --tests -- -D warnings + cargo sqlx prepare --check + + test: + name: test + # Don't run on draft pull requests + if: ${{ !github.event.pull_request.draft }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + - name: Run unit tests + working-directory: backend-rust + run: | + rustup default $RUST_VERSION + cargo test diff --git a/backend-rust/.sqlx/query-02518427e68de1cc612678cfe5792359f0952b97d7ae668ddc3fabf7c12daf31.json b/backend-rust/.sqlx/query-02518427e68de1cc612678cfe5792359f0952b97d7ae668ddc3fabf7c12daf31.json new file mode 100644 index 00000000..f0d1057c --- /dev/null +++ b/backend-rust/.sqlx/query-02518427e68de1cc612678cfe5792359f0952b97d7ae668ddc3fabf7c12daf31.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET transaction_commission = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "02518427e68de1cc612678cfe5792359f0952b97d7ae668ddc3fabf7c12daf31" +} diff --git a/backend-rust/.sqlx/query-12606f816ea8a49c983c968851293c8b1646b2852530e4935ad56dd2e1bddb4c.json b/backend-rust/.sqlx/query-12606f816ea8a49c983c968851293c8b1646b2852530e4935ad56dd2e1bddb4c.json new file mode 100644 index 00000000..39a9d8c6 --- /dev/null +++ b/backend-rust/.sqlx/query-12606f816ea8a49c983c968851293c8b1646b2852530e4935ad56dd2e1bddb4c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO accounts (index, address, created_block, created_index, amount)\nVALUES ((SELECT COALESCE(MAX(index) + 1, 0) FROM accounts), $1, $2, $3, 0)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bpchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "12606f816ea8a49c983c968851293c8b1646b2852530e4935ad56dd2e1bddb4c" +} diff --git a/backend-rust/.sqlx/query-133ff8586d1d69567259299dd1fc1868e8767ee7082b5c085edd1a52b10581a0.json b/backend-rust/.sqlx/query-133ff8586d1d69567259299dd1fc1868e8767ee7082b5c085edd1a52b10581a0.json new file mode 100644 index 00000000..e377a435 --- /dev/null +++ b/backend-rust/.sqlx/query-133ff8586d1d69567259299dd1fc1868e8767ee7082b5c085edd1a52b10581a0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET metadata_url = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "133ff8586d1d69567259299dd1fc1868e8767ee7082b5c085edd1a52b10581a0" +} diff --git a/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json b/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json new file mode 100644 index 00000000..4c0b3021 --- /dev/null +++ b/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\nWITH finalizer\n AS (SELECT height FROM blocks WHERE hash = $1)\nUPDATE blocks b\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM $3 - b.slot_time),\n finalized_by = finalizer.height\nFROM finalizer\nWHERE $2 <= b.height AND b.height < finalizer.height\nRETURNING finalizer.height", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bpchar", + "Int8", + "Timestamp" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe" +} diff --git a/backend-rust/.sqlx/query-1ebcb64ad0ff478f246a8f46e46f811ab51ea8fba0888851c94ec377f68976ba.json b/backend-rust/.sqlx/query-1ebcb64ad0ff478f246a8f46e46f811ab51ea8fba0888851c94ec377f68976ba.json new file mode 100644 index 00000000..6fd1ce37 --- /dev/null +++ b/backend-rust/.sqlx/query-1ebcb64ad0ff478f246a8f46e46f811ab51ea8fba0888851c94ec377f68976ba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM transactions WHERE block_height=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1ebcb64ad0ff478f246a8f46e46f811ab51ea8fba0888851c94ec377f68976ba" +} diff --git a/backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json b/backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json new file mode 100644 index 00000000..c80c4491 --- /dev/null +++ b/backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM accounts WHERE index=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "address", + "type_info": "Bpchar" + }, + { + "ordinal": 2, + "name": "created_block", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "created_index", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "amount", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27" +} diff --git a/backend-rust/.sqlx/query-38822385789ad387659bec8d2430126c8d095ee920d1582912060d7a8040e691.json b/backend-rust/.sqlx/query-38822385789ad387659bec8d2430126c8d095ee920d1582912060d7a8040e691.json new file mode 100644 index 00000000..4934fd0d --- /dev/null +++ b/backend-rust/.sqlx/query-38822385789ad387659bec8d2430126c8d095ee920d1582912060d7a8040e691.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id,\n staked,\n restake_earnings,\n open_status as \"open_status: BakerPoolOpenStatus\",\n metadata_url,\n transaction_commission,\n baking_commission,\n finalization_commission\n FROM bakers WHERE id=$1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "staked", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "restake_earnings", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "open_status: BakerPoolOpenStatus", + "type_info": { + "Custom": { + "name": "pool_open_status", + "kind": { + "Enum": [ + "OpenForAll", + "ClosedForNew", + "ClosedForAll" + ] + } + } + } + }, + { + "ordinal": 4, + "name": "metadata_url", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "transaction_commission", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "baking_commission", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "finalization_commission", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "38822385789ad387659bec8d2430126c8d095ee920d1582912060d7a8040e691" +} diff --git a/backend-rust/.sqlx/query-3a8582ae728281ba74f29a240a5b4c3e4bfcd18563efa4597fe6870c337eaee6.json b/backend-rust/.sqlx/query-3a8582ae728281ba74f29a240a5b4c3e4bfcd18563efa4597fe6870c337eaee6.json new file mode 100644 index 00000000..ec16b16d --- /dev/null +++ b/backend-rust/.sqlx/query-3a8582ae728281ba74f29a240a5b4c3e4bfcd18563efa4597fe6870c337eaee6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM transactions WHERE sender=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3a8582ae728281ba74f29a240a5b4c3e4bfcd18563efa4597fe6870c337eaee6" +} diff --git a/backend-rust/.sqlx/query-44265e9d95a4e8d13849d8dab52582e44f516370ac80a97b5866bbd807921ddb.json b/backend-rust/.sqlx/query-44265e9d95a4e8d13849d8dab52582e44f516370ac80a97b5866bbd807921ddb.json new file mode 100644 index 00000000..436f4afa --- /dev/null +++ b/backend-rust/.sqlx/query-44265e9d95a4e8d13849d8dab52582e44f516370ac80a97b5866bbd807921ddb.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO bakers (id, staked, restake_earnings, open_status, metadata_url, transaction_commission, baking_commission, finalization_commission)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bool", + { + "Custom": { + "name": "pool_open_status", + "kind": { + "Enum": [ + "OpenForAll", + "ClosedForNew", + "ClosedForAll" + ] + } + } + }, + "Varchar", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "44265e9d95a4e8d13849d8dab52582e44f516370ac80a97b5866bbd807921ddb" +} diff --git a/backend-rust/.sqlx/query-49f0e97a6e89a4084d17707dc4e7723215cb9e25084609902e803af3e8e2a753.json b/backend-rust/.sqlx/query-49f0e97a6e89a4084d17707dc4e7723215cb9e25084609902e803af3e8e2a753.json new file mode 100644 index 00000000..abddf945 --- /dev/null +++ b/backend-rust/.sqlx/query-49f0e97a6e89a4084d17707dc4e7723215cb9e25084609902e803af3e8e2a753.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n COUNT(*) as blocks_added,\n AVG(block_time)::integer as avg_block_time,\n AVG(finalization_time)::integer as avg_finalization_time\nFROM blocks\nWHERE slot_time > (LOCALTIMESTAMP - $1::interval)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "blocks_added", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "avg_block_time", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "avg_finalization_time", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Interval" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "49f0e97a6e89a4084d17707dc4e7723215cb9e25084609902e803af3e8e2a753" +} diff --git a/backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json b/backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json new file mode 100644 index 00000000..b136add8 --- /dev/null +++ b/backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO blocks (height, hash, slot_time, block_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Timestamp", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635" +} diff --git a/backend-rust/.sqlx/query-5063adb3ca3312535f1e2180ae44a273b14c188ee0ce25520b898386305a96cd.json b/backend-rust/.sqlx/query-5063adb3ca3312535f1e2180ae44a273b14c188ee0ce25520b898386305a96cd.json new file mode 100644 index 00000000..3fec3086 --- /dev/null +++ b/backend-rust/.sqlx/query-5063adb3ca3312535f1e2180ae44a273b14c188ee0ce25520b898386305a96cd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET baking_commission = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5063adb3ca3312535f1e2180ae44a273b14c188ee0ce25520b898386305a96cd" +} diff --git a/backend-rust/.sqlx/query-5b43581d891d787caae5b97bf168aa517cf86b90c4a6ce44dc1fdbd10a37334b.json b/backend-rust/.sqlx/query-5b43581d891d787caae5b97bf168aa517cf86b90c4a6ce44dc1fdbd10a37334b.json new file mode 100644 index 00000000..58ed0de6 --- /dev/null +++ b/backend-rust/.sqlx/query-5b43581d891d787caae5b97bf168aa517cf86b90c4a6ce44dc1fdbd10a37334b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM bakers WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5b43581d891d787caae5b97bf168aa517cf86b90c4a6ce44dc1fdbd10a37334b" +} diff --git a/backend-rust/.sqlx/query-6a41925d858c0220db3c3b7ce2d801d6a9de0eef8f3aa29183bb5e5dacfd799c.json b/backend-rust/.sqlx/query-6a41925d858c0220db3c3b7ce2d801d6a9de0eef8f3aa29183bb5e5dacfd799c.json new file mode 100644 index 00000000..162fdb99 --- /dev/null +++ b/backend-rust/.sqlx/query-6a41925d858c0220db3c3b7ce2d801d6a9de0eef8f3aa29183bb5e5dacfd799c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT height FROM blocks ORDER BY height DESC LIMIT 1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "6a41925d858c0220db3c3b7ce2d801d6a9de0eef8f3aa29183bb5e5dacfd799c" +} diff --git a/backend-rust/.sqlx/query-776815732579143e95a48e7b8037728119736e988d20fe1f81fafbbd93bdf93d.json b/backend-rust/.sqlx/query-776815732579143e95a48e7b8037728119736e988d20fe1f81fafbbd93bdf93d.json new file mode 100644 index 00000000..c5ee1ec2 --- /dev/null +++ b/backend-rust/.sqlx/query-776815732579143e95a48e7b8037728119736e988d20fe1f81fafbbd93bdf93d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET staked = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "776815732579143e95a48e7b8037728119736e988d20fe1f81fafbbd93bdf93d" +} diff --git a/backend-rust/.sqlx/query-78d26a7ddcbaaa044d1951fdf03aebfbdf664c11c425d63bdccc7344c181609b.json b/backend-rust/.sqlx/query-78d26a7ddcbaaa044d1951fdf03aebfbdf664c11c425d63bdccc7344c181609b.json new file mode 100644 index 00000000..84e6118e --- /dev/null +++ b/backend-rust/.sqlx/query-78d26a7ddcbaaa044d1951fdf03aebfbdf664c11c425d63bdccc7344c181609b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET restake_earnings = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "78d26a7ddcbaaa044d1951fdf03aebfbdf664c11c425d63bdccc7344c181609b" +} diff --git a/backend-rust/.sqlx/query-793f029fb35301b7008d0d3f7ca4467acfc69ab96a0d78ede363644d2f164a5a.json b/backend-rust/.sqlx/query-793f029fb35301b7008d0d3f7ca4467acfc69ab96a0d78ede363644d2f164a5a.json new file mode 100644 index 00000000..6240960e --- /dev/null +++ b/backend-rust/.sqlx/query-793f029fb35301b7008d0d3f7ca4467acfc69ab96a0d78ede363644d2f164a5a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT address FROM accounts WHERE index=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "address", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "793f029fb35301b7008d0d3f7ca4467acfc69ab96a0d78ede363644d2f164a5a" +} diff --git a/backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json b/backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json new file mode 100644 index 00000000..a7c3bf12 --- /dev/null +++ b/backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM blocks WHERE hash=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 2, + "name": "slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "block_time", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "finalization_time", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "finalized_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "baker_id", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "total_amount", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "total_staked", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bpchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1" +} diff --git a/backend-rust/.sqlx/query-afdaa33b7d5a6dbc4e8d811d933ff359d7567c5cc540a87b57ef7e7f0905dde2.json b/backend-rust/.sqlx/query-afdaa33b7d5a6dbc4e8d811d933ff359d7567c5cc540a87b57ef7e7f0905dde2.json new file mode 100644 index 00000000..40baa1a3 --- /dev/null +++ b/backend-rust/.sqlx/query-afdaa33b7d5a6dbc4e8d811d933ff359d7567c5cc540a87b57ef7e7f0905dde2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET finalization_commission = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "afdaa33b7d5a6dbc4e8d811d933ff359d7567c5cc540a87b57ef7e7f0905dde2" +} diff --git a/backend-rust/.sqlx/query-b5a479d6a39f388d4b1c6cc868471a95f5c7cff53d6e63f0158c2337dcf3dcef.json b/backend-rust/.sqlx/query-b5a479d6a39f388d4b1c6cc868471a95f5c7cff53d6e63f0158c2337dcf3dcef.json new file mode 100644 index 00000000..536f6974 --- /dev/null +++ b/backend-rust/.sqlx/query-b5a479d6a39f388d4b1c6cc868471a95f5c7cff53d6e63f0158c2337dcf3dcef.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\nWITH non_circulating_accounts AS (\n SELECT\n COALESCE(SUM(amount), 0)::BIGINT AS total_amount\n FROM accounts\n WHERE address=ANY($1)\n)\nSELECT\n height,\n blocks.total_amount,\n total_staked,\n (blocks.total_amount - non_circulating_accounts.total_amount)::BIGINT AS total_amount_released\nFROM blocks, non_circulating_accounts\nORDER BY height DESC\nLIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "total_amount", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "total_staked", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "total_amount_released", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "BpcharArray" + ] + }, + "nullable": [ + false, + false, + false, + null + ] + }, + "hash": "b5a479d6a39f388d4b1c6cc868471a95f5c7cff53d6e63f0158c2337dcf3dcef" +} diff --git a/backend-rust/.sqlx/query-bc2a7a48eba8eff422cdc179a348a5ebe6ecf32264b81c22d87b036b280a9af3.json b/backend-rust/.sqlx/query-bc2a7a48eba8eff422cdc179a348a5ebe6ecf32264b81c22d87b036b280a9af3.json new file mode 100644 index 00000000..d499f93d --- /dev/null +++ b/backend-rust/.sqlx/query-bc2a7a48eba8eff422cdc179a348a5ebe6ecf32264b81c22d87b036b280a9af3.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE bakers SET open_status = $2 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "pool_open_status", + "kind": { + "Enum": [ + "OpenForAll", + "ClosedForNew", + "ClosedForAll" + ] + } + } + } + ] + }, + "nullable": [] + }, + "hash": "bc2a7a48eba8eff422cdc179a348a5ebe6ecf32264b81c22d87b036b280a9af3" +} diff --git a/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json b/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json new file mode 100644 index 00000000..a7741fec --- /dev/null +++ b/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT slot_time FROM blocks WHERE height=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slot_time", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7" +} diff --git a/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json b/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json new file mode 100644 index 00000000..a4282e2a --- /dev/null +++ b/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nVALUES ($1, $2, $3,\n (SELECT EXTRACT(\"MILLISECONDS\" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)),\n $4, $5, $6);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Timestamp", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c" +} diff --git a/backend-rust/.sqlx/query-d1a7b29a8e6c54eaf234df54a276d61cf803559d95cffe5a921fddd4e16c1deb.json b/backend-rust/.sqlx/query-d1a7b29a8e6c54eaf234df54a276d61cf803559d95cffe5a921fddd4e16c1deb.json new file mode 100644 index 00000000..2776c8cf --- /dev/null +++ b/backend-rust/.sqlx/query-d1a7b29a8e6c54eaf234df54a276d61cf803559d95cffe5a921fddd4e16c1deb.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO accounts (index, address, created_block, amount)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d1a7b29a8e6c54eaf234df54a276d61cf803559d95cffe5a921fddd4e16c1deb" +} diff --git a/backend-rust/.sqlx/query-d2b1b263ea1e5f7137508c5ef4c9e6ad100dfb110542456c98575729594ca57a.json b/backend-rust/.sqlx/query-d2b1b263ea1e5f7137508c5ef4c9e6ad100dfb110542456c98575729594ca57a.json new file mode 100644 index 00000000..00ed8193 --- /dev/null +++ b/backend-rust/.sqlx/query-d2b1b263ea1e5f7137508c5ef4c9e6ad100dfb110542456c98575729594ca57a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT hash FROM blocks WHERE height=0", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "d2b1b263ea1e5f7137508c5ef4c9e6ad100dfb110542456c98575729594ca57a" +} diff --git a/backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json b/backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json new file mode 100644 index 00000000..ca73f20c --- /dev/null +++ b/backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM blocks WHERE height=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 2, + "name": "slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "block_time", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "finalization_time", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "finalized_by", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "baker_id", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "total_amount", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "total_staked", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5" +} diff --git a/backend-rust/.sqlx/query-da5e3e79999991fade20dd84b0f7e43bac757ef9edb29a93d11da32855c8e537.json b/backend-rust/.sqlx/query-da5e3e79999991fade20dd84b0f7e43bac757ef9edb29a93d11da32855c8e537.json new file mode 100644 index 00000000..8f873643 --- /dev/null +++ b/backend-rust/.sqlx/query-da5e3e79999991fade20dd84b0f7e43bac757ef9edb29a93d11da32855c8e537.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO bakers (id, staked, restake_earnings) VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "da5e3e79999991fade20dd84b0f7e43bac757ef9edb29a93d11da32855c8e537" +} diff --git a/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json b/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json new file mode 100644 index 00000000..56bb768a --- /dev/null +++ b/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\nWITH data AS (\n SELECT\n date_bin($1::interval, slot_time, TIMESTAMP '2001-01-01') as time,\n block_time,\n finalization_time,\n LAST_VALUE(total_staked) OVER (\n PARTITION BY date_bin($1::interval, slot_time, TIMESTAMP '2001-01-01')\n ORDER BY height ASC\n ) as total_staked\n FROM blocks\n ORDER BY height\n)\nSELECT\n time,\n COUNT(*) as y_blocks_added,\n AVG(block_time)::integer as y_block_time_avg,\n AVG(finalization_time)::integer as y_finalization_time_avg,\n MAX(total_staked) as y_last_total_micro_ccd_staked\nFROM data\nGROUP BY time\nLIMIT 30", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "time", + "type_info": "Timestamp" + }, + { + "ordinal": 1, + "name": "y_blocks_added", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "y_block_time_avg", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "y_finalization_time_avg", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "y_last_total_micro_ccd_staked", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Interval" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b" +} diff --git a/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json b/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json new file mode 100644 index 00000000..f54a4ed4 --- /dev/null +++ b/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n height as last_finalized_height,\n hash as last_finalized_hash\nFROM blocks\nWHERE finalization_time IS NULL\nORDER BY height ASC\nLIMIT 1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_finalized_height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "last_finalized_hash", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03" +} diff --git a/backend-rust/.sqlx/query-fba71388ff31763f6cae9d853cd4eb3032a01758be41d968d987b0dde5de2a4e.json b/backend-rust/.sqlx/query-fba71388ff31763f6cae9d853cd4eb3032a01758be41d968d987b0dde5de2a4e.json new file mode 100644 index 00000000..f9b3fa08 --- /dev/null +++ b/backend-rust/.sqlx/query-fba71388ff31763f6cae9d853cd4eb3032a01758be41d968d987b0dde5de2a4e.json @@ -0,0 +1,107 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO transactions\n(index, hash, ccd_cost, energy_cost, block_height, sender, type, type_account, type_credential_deployment, type_update, success, events, reject)\nVALUES\n($1, $2, $3, $4, $5, (SELECT index FROM accounts WHERE address=$6), $7, $8, $9, $10, $11, $12, $13);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bpchar", + "Int8", + "Int8", + "Int8", + "Bpchar", + { + "Custom": { + "name": "transaction_type", + "kind": { + "Enum": [ + "Account", + "CredentialDeployment", + "Update" + ] + } + } + }, + { + "Custom": { + "name": "account_transaction_type", + "kind": { + "Enum": [ + "InitializeSmartContractInstance", + "UpdateSmartContractInstance", + "SimpleTransfer", + "EncryptedTransfer", + "SimpleTransferWithMemo", + "EncryptedTransferWithMemo", + "TransferWithScheduleWithMemo", + "DeployModule", + "AddBaker", + "RemoveBaker", + "UpdateBakerStake", + "UpdateBakerRestakeEarnings", + "UpdateBakerKeys", + "UpdateCredentialKeys", + "TransferToEncrypted", + "TransferToPublic", + "TransferWithSchedule", + "UpdateCredentials", + "RegisterData", + "ConfigureBaker", + "ConfigureDelegation" + ] + } + } + }, + { + "Custom": { + "name": "credential_deployment_transaction_type", + "kind": { + "Enum": [ + "Initial", + "Normal" + ] + } + } + }, + { + "Custom": { + "name": "update_transaction_type", + "kind": { + "Enum": [ + "UpdateProtocol", + "UpdateElectionDifficulty", + "UpdateEuroPerEnergy", + "UpdateMicroGtuPerEuro", + "UpdateFoundationAccount", + "UpdateMintDistribution", + "UpdateTransactionFeeDistribution", + "UpdateGasRewards", + "UpdateBakerStakeThreshold", + "UpdateAddAnonymityRevoker", + "UpdateAddIdentityProvider", + "UpdateRootKeys", + "UpdateLevel1Keys", + "UpdateLevel2Keys", + "UpdatePoolParameters", + "UpdateCooldownParameters", + "UpdateTimeParameters", + "MintDistributionCpv1Update", + "GasRewardsCpv2Update", + "TimeoutParametersUpdate", + "MinBlockTimeUpdate", + "BlockEnergyLimitUpdate", + "FinalizationCommitteeParametersUpdate" + ] + } + } + }, + "Bool", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "fba71388ff31763f6cae9d853cd4eb3032a01758be41d968d987b0dde5de2a4e" +} diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 35e14364..9aabdf82 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -388,10 +388,8 @@ impl Query { if let Some(edge) = connection.edges.last() { connection.has_previous_page = edge.node.height != 0; } - } else { - if let Some(edge) = connection.edges.first() { - connection.has_previous_page = edge.node.height != 0; - } + } else if let Some(edge) = connection.edges.first() { + connection.has_previous_page = edge.node.height != 0; } Ok(connection) From 8507ca9b3aa410e8daf0a84705deb9b0ae4617a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 9 Oct 2024 15:49:30 +0200 Subject: [PATCH 25/50] Fix CI --- .github/workflows/check-format-build.yml | 11 ++++++----- rust-toolchain.toml | 3 --- 2 files changed, 6 insertions(+), 8 deletions(-) delete mode 100644 rust-toolchain.toml diff --git a/.github/workflows/check-format-build.yml b/.github/workflows/check-format-build.yml index be677d80..6ec574dd 100644 --- a/.github/workflows/check-format-build.yml +++ b/.github/workflows/check-format-build.yml @@ -7,8 +7,9 @@ on: branches: [ main ] env: - RUST_FMT: nightly-2023-04-01-x86_64-unknown-linux-gnu + RUST_FMT: nightly-2023-04-01 RUST_VERSION: 1.80 + SQLX_CLI_VERSION: 0.7.4 jobs: rustfmt: @@ -20,7 +21,7 @@ jobs: - name: Format working-directory: backend-rust run: | - rustup default $RUST_FMT + rustup default ${{ env.RUST_FMT }} rustup component add rustfmt cargo fmt -- --color=always --check @@ -34,12 +35,12 @@ jobs: submodules: recursive - name: Setup Rust run: | - rustup default $RUST_VERSION + rustup default ${{ env.RUST_VERSION }} rustup component add clippy - name: Install sqlx-cli uses: taiki-e/install-action@v2 with: - tool: sqlx-cli@0.7.4 + tool: sqlx-cli@${{ env.SQLX_CLI_VERSION }} - name: Setup Rust working-directory: backend-rust run: | @@ -59,5 +60,5 @@ jobs: - name: Run unit tests working-directory: backend-rust run: | - rustup default $RUST_VERSION + rustup default ${{ env.RUST_VERSION }} cargo test diff --git a/rust-toolchain.toml b/rust-toolchain.toml deleted file mode 100644 index b32f4610..00000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "1.80" -components = [ "rustfmt" ] \ No newline at end of file From 5a12bbb6e49e77d887009a3bfb407cdf0ce7c2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 10 Oct 2024 11:58:04 +0200 Subject: [PATCH 26/50] Fix clippy warnings and restructure --- .github/workflows/check-format-build.yml | 14 +- ...480d2fee392b1814f1de2ff8249eaa879b92.json} | 18 +- ...cbbc3f927ddd75448a6f0e28c51449256a63.json} | 38 +- ...76d2ffe2878c1f2f5373c24db7a55055bf33e.json | 175 ++++++++ ...7659b083f029f472180cbd27b342abf2611a3.json | 176 +++++++++ ...9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json} | 38 +- ...bb6ef9191868b471c65e4f4f8f68f80d48b7b.json | 40 ++ backend-rust/src/graphql_api.rs | 372 +++++++++++------- backend-rust/src/indexer.rs | 14 +- 9 files changed, 659 insertions(+), 226 deletions(-) rename backend-rust/.sqlx/{query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json => query-25a3b7685ce9f461524249bdff68480d2fee392b1814f1de2ff8249eaa879b92.json} (71%) rename backend-rust/.sqlx/{query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json => query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json} (55%) create mode 100644 backend-rust/.sqlx/query-296663cefb009b3c6dd1c056f4b76d2ffe2878c1f2f5373c24db7a55055bf33e.json create mode 100644 backend-rust/.sqlx/query-b31f45f67b49b721b5cd23e6d757659b083f029f472180cbd27b342abf2611a3.json rename backend-rust/.sqlx/{query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json => query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json} (55%) create mode 100644 backend-rust/.sqlx/query-c7b8450c1a116be7ec304a77852bb6ef9191868b471c65e4f4f8f68f80d48b7b.json diff --git a/.github/workflows/check-format-build.yml b/.github/workflows/check-format-build.yml index 6ec574dd..fcdef409 100644 --- a/.github/workflows/check-format-build.yml +++ b/.github/workflows/check-format-build.yml @@ -7,9 +7,8 @@ on: branches: [ main ] env: - RUST_FMT: nightly-2023-04-01 - RUST_VERSION: 1.80 - SQLX_CLI_VERSION: 0.7.4 + RUST_FMT: "nightly-2023-04-01" + RUST_VERSION: "1.80" jobs: rustfmt: @@ -18,7 +17,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Format + - name: Check formatting working-directory: backend-rust run: | rustup default ${{ env.RUST_FMT }} @@ -37,15 +36,10 @@ jobs: run: | rustup default ${{ env.RUST_VERSION }} rustup component add clippy - - name: Install sqlx-cli - uses: taiki-e/install-action@v2 - with: - tool: sqlx-cli@${{ env.SQLX_CLI_VERSION }} - - name: Setup Rust + - name: Run clippy working-directory: backend-rust run: | cargo clippy --color=always --tests -- -D warnings - cargo sqlx prepare --check test: name: test diff --git a/backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json b/backend-rust/.sqlx/query-25a3b7685ce9f461524249bdff68480d2fee392b1814f1de2ff8249eaa879b92.json similarity index 71% rename from backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json rename to backend-rust/.sqlx/query-25a3b7685ce9f461524249bdff68480d2fee392b1814f1de2ff8249eaa879b92.json index c80c4491..97e5900a 100644 --- a/backend-rust/.sqlx/query-37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27.json +++ b/backend-rust/.sqlx/query-25a3b7685ce9f461524249bdff68480d2fee392b1814f1de2ff8249eaa879b92.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT * FROM accounts WHERE index=$1", + "query": "\nSELECT\n index, created_block, address, amount\nFROM accounts\nWHERE index=$1\n", "describe": { "columns": [ { @@ -10,21 +10,16 @@ }, { "ordinal": 1, - "name": "address", - "type_info": "Bpchar" - }, - { - "ordinal": 2, "name": "created_block", "type_info": "Int8" }, { - "ordinal": 3, - "name": "created_index", - "type_info": "Int8" + "ordinal": 2, + "name": "address", + "type_info": "Bpchar" }, { - "ordinal": 4, + "ordinal": 3, "name": "amount", "type_info": "Int8" } @@ -38,9 +33,8 @@ false, false, false, - true, false ] }, - "hash": "37d0468b48afb9283720ab6c52a1e1253b2a10af9a864218ae1b3fd08ad59a27" + "hash": "25a3b7685ce9f461524249bdff68480d2fee392b1814f1de2ff8249eaa879b92" } diff --git a/backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json b/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json similarity index 55% rename from backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json rename to backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json index ca73f20c..94b6476b 100644 --- a/backend-rust/.sqlx/query-d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5.json +++ b/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json @@ -1,17 +1,17 @@ { "db_name": "PostgreSQL", - "query": "SELECT * FROM blocks WHERE height=$1", + "query": "SELECT hash, height, slot_time, baker_id, total_amount FROM blocks WHERE height=$1", "describe": { "columns": [ { "ordinal": 0, - "name": "height", - "type_info": "Int8" + "name": "hash", + "type_info": "Bpchar" }, { "ordinal": 1, - "name": "hash", - "type_info": "Bpchar" + "name": "height", + "type_info": "Int8" }, { "ordinal": 2, @@ -20,33 +20,13 @@ }, { "ordinal": 3, - "name": "block_time", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "finalization_time", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "finalized_by", - "type_info": "Int8" - }, - { - "ordinal": 6, "name": "baker_id", "type_info": "Int8" }, { - "ordinal": 7, + "ordinal": 4, "name": "total_amount", "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "total_staked", - "type_info": "Int8" } ], "parameters": { @@ -58,13 +38,9 @@ false, false, false, - false, true, - true, - true, - false, false ] }, - "hash": "d9b3661d49c6ce846f38ffbccedf99eb882334e0728306839a631973a05fcbd5" + "hash": "268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63" } diff --git a/backend-rust/.sqlx/query-296663cefb009b3c6dd1c056f4b76d2ffe2878c1f2f5373c24db7a55055bf33e.json b/backend-rust/.sqlx/query-296663cefb009b3c6dd1c056f4b76d2ffe2878c1f2f5373c24db7a55055bf33e.json new file mode 100644 index 00000000..8a1940a2 --- /dev/null +++ b/backend-rust/.sqlx/query-296663cefb009b3c6dd1c056f4b76d2ffe2878c1f2f5373c24db7a55055bf33e.json @@ -0,0 +1,175 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n block_height,\n index,\n hash,\n ccd_cost,\n energy_cost,\n sender,\n type as \"tx_type: DbTransactionType\",\n type_account as \"type_account: AccountTransactionType\",\n type_credential_deployment as \"type_credential_deployment: CredentialDeploymentTransactionType\",\n type_update as \"type_update: UpdateTransactionType\",\n success,\n events as \"events: sqlx::types::Json>\",\n reject as \"reject: sqlx::types::Json\"\nFROM transactions\nWHERE hash=$1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "ccd_cost", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "energy_cost", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "sender", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "tx_type: DbTransactionType", + "type_info": { + "Custom": { + "name": "transaction_type", + "kind": { + "Enum": [ + "Account", + "CredentialDeployment", + "Update" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "type_account: AccountTransactionType", + "type_info": { + "Custom": { + "name": "account_transaction_type", + "kind": { + "Enum": [ + "InitializeSmartContractInstance", + "UpdateSmartContractInstance", + "SimpleTransfer", + "EncryptedTransfer", + "SimpleTransferWithMemo", + "EncryptedTransferWithMemo", + "TransferWithScheduleWithMemo", + "DeployModule", + "AddBaker", + "RemoveBaker", + "UpdateBakerStake", + "UpdateBakerRestakeEarnings", + "UpdateBakerKeys", + "UpdateCredentialKeys", + "TransferToEncrypted", + "TransferToPublic", + "TransferWithSchedule", + "UpdateCredentials", + "RegisterData", + "ConfigureBaker", + "ConfigureDelegation" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "type_credential_deployment: CredentialDeploymentTransactionType", + "type_info": { + "Custom": { + "name": "credential_deployment_transaction_type", + "kind": { + "Enum": [ + "Initial", + "Normal" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "type_update: UpdateTransactionType", + "type_info": { + "Custom": { + "name": "update_transaction_type", + "kind": { + "Enum": [ + "UpdateProtocol", + "UpdateElectionDifficulty", + "UpdateEuroPerEnergy", + "UpdateMicroGtuPerEuro", + "UpdateFoundationAccount", + "UpdateMintDistribution", + "UpdateTransactionFeeDistribution", + "UpdateGasRewards", + "UpdateBakerStakeThreshold", + "UpdateAddAnonymityRevoker", + "UpdateAddIdentityProvider", + "UpdateRootKeys", + "UpdateLevel1Keys", + "UpdateLevel2Keys", + "UpdatePoolParameters", + "UpdateCooldownParameters", + "UpdateTimeParameters", + "MintDistributionCpv1Update", + "GasRewardsCpv2Update", + "TimeoutParametersUpdate", + "MinBlockTimeUpdate", + "BlockEnergyLimitUpdate", + "FinalizationCommitteeParametersUpdate" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "success", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "events: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "reject: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Bpchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "296663cefb009b3c6dd1c056f4b76d2ffe2878c1f2f5373c24db7a55055bf33e" +} diff --git a/backend-rust/.sqlx/query-b31f45f67b49b721b5cd23e6d757659b083f029f472180cbd27b342abf2611a3.json b/backend-rust/.sqlx/query-b31f45f67b49b721b5cd23e6d757659b083f029f472180cbd27b342abf2611a3.json new file mode 100644 index 00000000..8c947cc9 --- /dev/null +++ b/backend-rust/.sqlx/query-b31f45f67b49b721b5cd23e6d757659b083f029f472180cbd27b342abf2611a3.json @@ -0,0 +1,176 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n block_height,\n index,\n hash,\n ccd_cost,\n energy_cost,\n sender,\n type as \"tx_type: DbTransactionType\",\n type_account as \"type_account: AccountTransactionType\",\n type_credential_deployment as \"type_credential_deployment: CredentialDeploymentTransactionType\",\n type_update as \"type_update: UpdateTransactionType\",\n success,\n events as \"events: sqlx::types::Json>\",\n reject as \"reject: sqlx::types::Json\"\nFROM transactions\nWHERE block_height=$1 AND index=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "ccd_cost", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "energy_cost", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "sender", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "tx_type: DbTransactionType", + "type_info": { + "Custom": { + "name": "transaction_type", + "kind": { + "Enum": [ + "Account", + "CredentialDeployment", + "Update" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "type_account: AccountTransactionType", + "type_info": { + "Custom": { + "name": "account_transaction_type", + "kind": { + "Enum": [ + "InitializeSmartContractInstance", + "UpdateSmartContractInstance", + "SimpleTransfer", + "EncryptedTransfer", + "SimpleTransferWithMemo", + "EncryptedTransferWithMemo", + "TransferWithScheduleWithMemo", + "DeployModule", + "AddBaker", + "RemoveBaker", + "UpdateBakerStake", + "UpdateBakerRestakeEarnings", + "UpdateBakerKeys", + "UpdateCredentialKeys", + "TransferToEncrypted", + "TransferToPublic", + "TransferWithSchedule", + "UpdateCredentials", + "RegisterData", + "ConfigureBaker", + "ConfigureDelegation" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "type_credential_deployment: CredentialDeploymentTransactionType", + "type_info": { + "Custom": { + "name": "credential_deployment_transaction_type", + "kind": { + "Enum": [ + "Initial", + "Normal" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "type_update: UpdateTransactionType", + "type_info": { + "Custom": { + "name": "update_transaction_type", + "kind": { + "Enum": [ + "UpdateProtocol", + "UpdateElectionDifficulty", + "UpdateEuroPerEnergy", + "UpdateMicroGtuPerEuro", + "UpdateFoundationAccount", + "UpdateMintDistribution", + "UpdateTransactionFeeDistribution", + "UpdateGasRewards", + "UpdateBakerStakeThreshold", + "UpdateAddAnonymityRevoker", + "UpdateAddIdentityProvider", + "UpdateRootKeys", + "UpdateLevel1Keys", + "UpdateLevel2Keys", + "UpdatePoolParameters", + "UpdateCooldownParameters", + "UpdateTimeParameters", + "MintDistributionCpv1Update", + "GasRewardsCpv2Update", + "TimeoutParametersUpdate", + "MinBlockTimeUpdate", + "BlockEnergyLimitUpdate", + "FinalizationCommitteeParametersUpdate" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "success", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "events: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "reject: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "b31f45f67b49b721b5cd23e6d757659b083f029f472180cbd27b342abf2611a3" +} diff --git a/backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json b/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json similarity index 55% rename from backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json rename to backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json index a7c3bf12..a9d30774 100644 --- a/backend-rust/.sqlx/query-945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1.json +++ b/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json @@ -1,17 +1,17 @@ { "db_name": "PostgreSQL", - "query": "SELECT * FROM blocks WHERE hash=$1", + "query": "SELECT hash, height, slot_time, baker_id, total_amount FROM blocks WHERE hash=$1", "describe": { "columns": [ { "ordinal": 0, - "name": "height", - "type_info": "Int8" + "name": "hash", + "type_info": "Bpchar" }, { "ordinal": 1, - "name": "hash", - "type_info": "Bpchar" + "name": "height", + "type_info": "Int8" }, { "ordinal": 2, @@ -20,33 +20,13 @@ }, { "ordinal": 3, - "name": "block_time", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "finalization_time", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "finalized_by", - "type_info": "Int8" - }, - { - "ordinal": 6, "name": "baker_id", "type_info": "Int8" }, { - "ordinal": 7, + "ordinal": 4, "name": "total_amount", "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "total_staked", - "type_info": "Int8" } ], "parameters": { @@ -58,13 +38,9 @@ false, false, false, - false, true, - true, - true, - false, false ] }, - "hash": "945f2feb2e505df0a2ee5185493b205a17b7717121ef0c8eafae3ddf5d95cbb1" + "hash": "b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78" } diff --git a/backend-rust/.sqlx/query-c7b8450c1a116be7ec304a77852bb6ef9191868b471c65e4f4f8f68f80d48b7b.json b/backend-rust/.sqlx/query-c7b8450c1a116be7ec304a77852bb6ef9191868b471c65e4f4f8f68f80d48b7b.json new file mode 100644 index 00000000..dae1fdba --- /dev/null +++ b/backend-rust/.sqlx/query-c7b8450c1a116be7ec304a77852bb6ef9191868b471c65e4f4f8f68f80d48b7b.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n index, created_block, address, amount\nFROM accounts\nWHERE address=$1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_block", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bpchar" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "c7b8450c1a116be7ec304a77852bb6ef9191868b471c65e4f4f8f68f80d48b7b" +} diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 9aabdf82..9af696bb 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -4,13 +4,12 @@ //! - Enable GraphiQL through flag instead of always. #![allow(unused_variables)] -#![allow(unreachable_code)] // TODO remove this macro, when done with first iteration /// Short hand for returning API error with the message not implemented. macro_rules! todo_api { () => { - return Err(ApiError::InternalError(String::from("Not implemented"))) + Err(ApiError::InternalError(String::from("Not implemented"))) }; } @@ -167,9 +166,7 @@ mod monitor { } } impl async_graphql::extensions::ExtensionFactory for MonitorExtension { - fn create(&self) -> Arc { - return Arc::new(self.clone()); - } + fn create(&self) -> Arc { Arc::new(self.clone()) } } #[async_trait] impl async_graphql::extensions::Extension for MonitorExtension { @@ -273,10 +270,12 @@ impl From for ApiError { type ApiResult = Result; +/// Get the database pool from the context. fn get_pool<'a>(ctx: &Context<'a>) -> ApiResult<&'a PgPool> { ctx.data::().map_err(ApiError::NoDatabasePool) } +/// Get service configuration from the context. fn get_config<'a>(ctx: &Context<'a>) -> ApiResult<&'a ApiServiceConfig> { ctx.data::().map_err(ApiError::NoServiceConfig) } @@ -300,6 +299,7 @@ fn check_connection_query(first: &Option, last: &Option) -> ApiResult< pub struct Query; #[Object] +#[allow(clippy::too_many_arguments)] impl Query { async fn versions(&self) -> Versions { Versions { @@ -308,11 +308,8 @@ impl Query { } async fn block<'a>(&self, ctx: &Context<'a>, height_id: types::ID) -> ApiResult { - let height: i64 = height_id.clone().try_into().map_err(ApiError::InvalidIdInt)?; - sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", height) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + let height: BlockHeight = height_id.try_into().map_err(ApiError::InvalidIdInt)?; + Block::query_by_height(get_pool(ctx)?, height).await } async fn block_by_block_hash<'a>( @@ -320,10 +317,7 @@ impl Query { ctx: &Context<'a>, block_hash: BlockHash, ) -> ApiResult { - sqlx::query_as!(Block, "SELECT * FROM blocks WHERE hash=$1", block_hash) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + Block::query_by_hash(get_pool(ctx)?, block_hash).await } async fn blocks<'a>( @@ -397,12 +391,7 @@ impl Query { async fn transaction<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { let id = IdTransaction::try_from(id)?; - sqlx::query_as("SELECT * FROM transactions WHERE block=$1 AND index=$2") - .bind(id.block) - .bind(id.index) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + Transaction::query_by_id(get_pool(ctx)?, id).await?.ok_or(ApiError::NotFound) } async fn transaction_by_transaction_hash<'a>( @@ -410,9 +399,7 @@ impl Query { ctx: &Context<'a>, transaction_hash: TransactionHash, ) -> ApiResult { - sqlx::query_as("SELECT * FROM transactions WHERE hash=$1") - .bind(transaction_hash) - .fetch_optional(get_pool(ctx)?) + Transaction::query_by_hash(get_pool(ctx)?, transaction_hash) .await? .ok_or(ApiError::NotFound) } @@ -526,9 +513,8 @@ impl Query { } async fn account<'a>(&self, ctx: &Context<'a>, id: types::ID) -> ApiResult { - let index: i64 = id.clone().try_into().map_err(ApiError::InvalidIdInt)?; - let pool = get_pool(ctx)?; - Account::query_by_index(pool, index).await + let index: i64 = id.try_into().map_err(ApiError::InvalidIdInt)?; + Account::query_by_index(get_pool(ctx)?, index).await?.ok_or(ApiError::NotFound) } async fn account_by_address<'a>( @@ -536,11 +522,7 @@ impl Query { ctx: &Context<'a>, account_address: String, ) -> ApiResult { - sqlx::query_as("SELECT * FROM accounts WHERE address=$1") - .bind(account_address) - .fetch_optional(get_pool(ctx)?) - .await? - .ok_or(ApiError::NotFound) + Account::query_by_address(get_pool(ctx)?, account_address).await?.ok_or(ApiError::NotFound) } async fn accounts<'a>( @@ -859,10 +841,7 @@ impl SubscriptionContext { Self::BLOCK_ADDED_CHANNEL => { let block_height = BlockHeight::from_str(notification.payload()) .context("Failed to parse payload of block added")?; - let block = sqlx::query_as("SELECT * FROM blocks WHERE height=$1") - .bind(block_height) - .fetch_one(&pool) - .await?; + let block = Block::query_by_height(&pool, block_height).await?; self.block_added_sender.send(Arc::new(block))?; } unknown => { @@ -1018,36 +997,64 @@ struct Versions { backend_versions: String, } -#[derive(Debug, SimpleObject, sqlx::FromRow)] -#[graphql(complex)] +#[derive(Debug, sqlx::FromRow)] pub struct Block { - #[graphql(name = "blockHash")] - hash: BlockHash, - #[graphql(name = "blockHeight")] - height: BlockHeight, + hash: BlockHash, + height: BlockHeight, /// Time of the block being baked. - #[graphql(name = "blockSlotTime")] - slot_time: DateTime, - #[graphql(skip)] - block_time: i32, - #[graphql(skip)] - finalization_time: Option, - #[graphql(skip)] - finalized_by: Option, - baker_id: Option, - total_amount: Amount, - #[graphql(skip)] - total_staked: Amount, + slot_time: DateTime, + // block_time: i32, + // finalization_time: Option, + // finalized_by: Option, + baker_id: Option, + total_amount: Amount, + // total_staked: Amount, +} + +impl Block { + async fn query_by_height(pool: &PgPool, height: BlockHeight) -> ApiResult { + sqlx::query_as!( + Block, + "SELECT hash, height, slot_time, baker_id, total_amount FROM blocks WHERE height=$1", + height + ) + .fetch_optional(pool) + .await? + .ok_or(ApiError::NotFound) + } + + async fn query_by_hash(pool: &PgPool, block_hash: BlockHash) -> ApiResult { + sqlx::query_as!( + Block, + "SELECT hash, height, slot_time, baker_id, total_amount FROM blocks WHERE hash=$1", + block_hash + ) + .fetch_optional(pool) + .await? + .ok_or(ApiError::NotFound) + } +} + +#[Object] +impl Block { // chain_parameters: ChainParameters, // balance_statistics: BalanceStatistics, // block_statistics: BlockStatistics, -} -#[ComplexObject] -impl Block { /// Absolute block height. async fn id(&self) -> types::ID { types::ID::from(self.height) } + async fn block_hash(&self) -> &BlockHash { &self.hash } + + async fn block_height(&self) -> &BlockHeight { &self.height } + + async fn baker_id(&self) -> Option { self.baker_id } + + async fn total_amount(&self) -> &Amount { &self.total_amount } + + /// Time of the block being baked. + async fn block_slot_time(&self) -> &DateTime { &self.slot_time } + /// Whether the block is finalized. async fn finalized(&self) -> bool { true } @@ -1072,7 +1079,7 @@ impl Block { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn transactions( @@ -1084,7 +1091,7 @@ impl Block { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -1114,19 +1121,25 @@ struct Contract { } #[ComplexObject] impl Contract { - async fn contract_events(&self, skip: i32, take: i32) -> ContractEventsCollectionSegment { - todo!() + async fn contract_events( + &self, + skip: i32, + take: i32, + ) -> ApiResult { + todo_api!() } async fn contract_reject_events( &self, _skip: i32, _take: i32, - ) -> ContractRejectEventsCollectionSegment { - todo!() + ) -> ApiResult { + todo_api!() } - async fn tokens(&self, skip: i32, take: i32) -> TokensCollectionSegment { todo!() } + async fn tokens(&self, skip: i32, take: i32) -> ApiResult { + todo_api!() + } } /// A segment of a collection. @@ -1733,6 +1746,7 @@ struct AccountReward { } #[derive(Enum, Copy, Clone, PartialEq, Eq)] +#[allow(clippy::enum_variant_names)] enum RewardType { FinalizationReward, FoundationReward, @@ -1794,6 +1808,7 @@ struct Token { } #[derive(Union)] +#[allow(clippy::enum_variant_names)] enum SpecialEvent { MintSpecialEvent(MintSpecialEvent), FinalizationRewardsSpecialEvent(FinalizationRewardsSpecialEvent), @@ -1832,7 +1847,7 @@ impl FinalizationRewardsSpecialEvent { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -1865,7 +1880,7 @@ impl BakingRewardsSpecialEvent { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -1990,6 +2005,7 @@ struct BlockStatistics { } #[derive(Interface)] +#[allow(clippy::duplicated_attributes)] #[graphql( field(name = "euro_per_energy", ty = "&ExchangeRate"), field(name = "micro_ccd_per_euro", ty = "&ExchangeRate"), @@ -2087,6 +2103,7 @@ impl From for AccountAddress { } } +#[derive(Copy, Clone)] struct IdTransaction { block: BlockHeight, index: TransactionIndex, @@ -2129,44 +2146,102 @@ struct TransactionConnectionQuery { has_next: bool, } -#[derive(SimpleObject, sqlx::FromRow)] -#[graphql(complex)] +#[derive(sqlx::FromRow)] struct Transaction { - #[graphql(skip)] - block: BlockHeight, - #[graphql(name = "transactionIndex")] - index: i64, - #[graphql(name = "transactionHash")] + block_height: BlockHeight, + index: TransactionIndex, hash: TransactionHash, ccd_cost: Amount, energy_cost: Energy, - #[graphql(skip)] sender: Option, - #[graphql(skip)] - r#type: DbTransactionType, - #[graphql(skip)] + tx_type: DbTransactionType, type_account: Option, - #[graphql(skip)] type_credential_deployment: Option, - #[graphql(skip)] type_update: Option, - #[graphql(skip)] success: bool, - #[graphql(skip)] events: Option>>, - #[graphql(skip)] reject: Option>, } -#[ComplexObject] impl Transaction { - /// Transaction query ID, formatted as ":". + fn id_transaction(&self) -> IdTransaction { + IdTransaction { + block: self.block_height, + index: self.index, + } + } + + async fn query_by_id(pool: &PgPool, id: IdTransaction) -> ApiResult> { + let transaction = sqlx::query_as!( + Transaction, + r#"SELECT + block_height, + index, + hash, + ccd_cost, + energy_cost, + sender, + type as "tx_type: DbTransactionType", + type_account as "type_account: AccountTransactionType", + type_credential_deployment as "type_credential_deployment: CredentialDeploymentTransactionType", + type_update as "type_update: UpdateTransactionType", + success, + events as "events: sqlx::types::Json>", + reject as "reject: sqlx::types::Json" +FROM transactions +WHERE block_height=$1 AND index=$2"#, + id.block, + id.index + ) + .fetch_optional(pool) + .await?; + Ok(transaction) + } + + async fn query_by_hash( + pool: &PgPool, + transaction_hash: TransactionHash, + ) -> ApiResult> { + let transaction = sqlx::query_as!( + Transaction, + r#"SELECT + block_height, + index, + hash, + ccd_cost, + energy_cost, + sender, + type as "tx_type: DbTransactionType", + type_account as "type_account: AccountTransactionType", + type_credential_deployment as "type_credential_deployment: CredentialDeploymentTransactionType", + type_update as "type_update: UpdateTransactionType", + success, + events as "events: sqlx::types::Json>", + reject as "reject: sqlx::types::Json" +FROM transactions +WHERE hash=$1"#, + transaction_hash + ) + .fetch_optional(pool) + .await?; + Ok(transaction) + } +} + +#[Object] +impl Transaction { + /// Transaction query ID, formatted as ":". async fn id(&self) -> types::ID { self.id_transaction().into() } + async fn transaction_index(&self) -> TransactionIndex { self.index } + + async fn transaction_hash(&self) -> &TransactionHash { &self.hash } + + async fn ccd_cost(&self) -> Amount { self.ccd_cost } + + async fn energy_cost(&self) -> Energy { self.energy_cost } + async fn block<'a>(&self, ctx: &Context<'a>) -> ApiResult { - let result = sqlx::query_as!(Block, "SELECT * FROM blocks WHERE height=$1", self.block) - .fetch_one(get_pool(ctx)?) - .await?; - Ok(result) + Block::query_by_height(get_pool(ctx)?, self.block_height).await } async fn sender_account_address<'a>( @@ -2183,7 +2258,7 @@ impl Transaction { } async fn transaction_type(&self) -> ApiResult { - let tt = match self.r#type { + let tt = match self.tx_type { DbTransactionType::Account => TransactionType::AccountTransaction(AccountTransaction { account_transaction_type: self.type_account, }), @@ -2229,16 +2304,9 @@ impl Transaction { } } } -impl Transaction { - fn id_transaction(&self) -> IdTransaction { - IdTransaction { - block: self.block, - index: self.index, - } - } -} #[derive(Union)] +#[allow(clippy::enum_variant_names)] enum TransactionType { AccountTransaction(AccountTransaction), CredentialDeploymentTransaction(CredentialDeploymentTransaction), @@ -2408,7 +2476,6 @@ enum TransactionResult<'a> { struct Success<'a> { events: &'a Vec, } - #[Object] impl Success<'_> { async fn events( @@ -2470,19 +2537,12 @@ struct AccountConnectionQuery { has_next: bool, } -#[derive(SimpleObject, sqlx::FromRow)] -#[graphql(complex)] +#[derive(sqlx::FromRow)] struct Account { // release_schedule: AccountReleaseSchedule, - #[graphql(skip)] index: i64, /// Height of the block with the transaction creating this account. - #[graphql(skip)] created_block: BlockHeight, - /// Index of transaction creating this account within a block. Only Null for - /// genesis accounts. - #[graphql(skip)] - created_index: Option, /// The address of the account in Base58Check. #[sqlx(try_from = "String")] address: AccountAddress, @@ -2492,20 +2552,50 @@ struct Account { // baker: Option, // delegation: Option, } - impl Account { - async fn query_by_index(pool: &PgPool, index: AccountIndex) -> ApiResult { - sqlx::query_as!(Account, "SELECT * FROM accounts WHERE index=$1", index) - .fetch_optional(pool) - .await? - .ok_or(ApiError::NotFound) + async fn query_by_index(pool: &PgPool, index: AccountIndex) -> ApiResult> { + let account = sqlx::query_as!( + Account, + r#" +SELECT + index, created_block, address, amount +FROM accounts +WHERE index=$1 +"#, + index + ) + .fetch_optional(pool) + .await?; + Ok(account) + } + + async fn query_by_address(pool: &PgPool, address: String) -> ApiResult> { + let account = sqlx::query_as!( + Account, + r#" +SELECT + index, created_block, address, amount +FROM accounts +WHERE address=$1 +"#, + address + ) + .fetch_optional(pool) + .await?; + Ok(account) } } -#[ComplexObject] +#[Object] impl Account { async fn id(&self) -> types::ID { types::ID::from(self.index) } + /// The address of the account in Base58Check. + async fn address(&self) -> &AccountAddress { &self.address } + + /// The total amount of CCD hold by the account. + async fn amount(&self) -> Amount { self.amount } + /// Timestamp of the block where this account was created. async fn created_at<'a>(&self, ctx: &Context<'a>) -> ApiResult { let rec = sqlx::query!("SELECT slot_time FROM blocks WHERE height=$1", self.created_block) @@ -2531,7 +2621,7 @@ impl Account { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } async fn transactions( @@ -2543,7 +2633,7 @@ impl Account { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } async fn account_statement( @@ -2555,7 +2645,7 @@ impl Account { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } async fn rewards( @@ -2567,7 +2657,7 @@ impl Account { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -2587,7 +2677,7 @@ impl AccountReleaseSchedule { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: String, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -2684,7 +2774,7 @@ impl Baker { } async fn account<'a>(&self, ctx: &Context<'a>) -> ApiResult { - Account::query_by_index(get_pool(ctx)?, self.id).await + Account::query_by_index(get_pool(ctx)?, self.id).await?.ok_or(ApiError::NotFound) } // transactions("Returns the first _n_ elements from the list." first: Int @@ -2983,7 +3073,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } // async fn modules( @@ -2997,7 +3087,7 @@ impl SearchResult { // specified cursor." )] // _before: Option, // ) -> ApiResult> { - // todo!() + // todo_api!() // } async fn blocks( @@ -3009,7 +3099,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn transactions( @@ -3021,7 +3111,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn tokens( @@ -3033,7 +3123,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn accounts( @@ -3045,7 +3135,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn bakers( @@ -3057,7 +3147,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } async fn node_statuses( @@ -3069,7 +3159,7 @@ impl SearchResult { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] _before: Option, ) -> ApiResult> { - todo!() + todo_api!() } } @@ -3386,7 +3476,7 @@ pub fn events_from_summary( .sum::() .try_into()?, }), - Event::TransferMemo(memo.try_into()?), + Event::TransferMemo(memo.into()), ] } AccountTransactionEffects::CredentialKeysUpdated { @@ -3527,7 +3617,7 @@ pub fn events_from_summary( BakerEvent::DelegationRemoved { delegator_id, } => { - todo!() + unimplemented!() } } }) @@ -3589,7 +3679,7 @@ pub fn events_from_summary( DelegationEvent::BakerRemoved { baker_id, } => { - todo!(); + unimplemented!(); } }) .collect::>>()? @@ -3975,7 +4065,9 @@ pub struct BakerAdded { } #[ComplexObject] impl BakerAdded { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -3988,7 +4080,9 @@ pub struct BakerKeysUpdated { } #[ComplexObject] impl BakerKeysUpdated { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -3998,7 +4092,9 @@ pub struct BakerRemoved { } #[ComplexObject] impl BakerRemoved { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -4009,7 +4105,9 @@ pub struct BakerSetRestakeEarnings { } #[ComplexObject] impl BakerSetRestakeEarnings { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -4020,7 +4118,9 @@ pub struct BakerStakeDecreased { } #[ComplexObject] impl BakerStakeDecreased { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] @@ -4031,7 +4131,9 @@ pub struct BakerStakeIncreased { } #[ComplexObject] impl BakerStakeIncreased { - async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { todo!() } + async fn account_address<'a>(&self, _ctx: &Context<'a>) -> ApiResult { + todo_api!() + } } #[derive(SimpleObject, serde::Serialize, serde::Deserialize)] diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 0d8b53fd..ccec023b 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1,3 +1,6 @@ +#![allow(unused_variables)] // TODO Remove before first release +#![allow(dead_code)] // TODO Remove before first release + use crate::graphql_api::{ events_from_summary, AccountTransactionType, BakerPoolOpenStatus, CredentialDeploymentTransactionType, DbTransactionType, UpdateTransactionType, @@ -322,8 +325,7 @@ impl Indexer for BlockPreProcessor { tokenomics_info: tokenomics_info.response, total_staked_capital, }; - let prepared_block = - PreparedBlock::prepare(&data).map_err(|err| RPCError::ParseError(err))?; + let prepared_block = PreparedBlock::prepare(&data).map_err(RPCError::ParseError)?; Ok(prepared_block) } .await; @@ -816,7 +818,7 @@ impl PreparedBlockItem { (None, Some(reject)) }; - let prepared_event = PreparedEvent::prepare(&data, &block_item)?; + let prepared_event = PreparedEvent::prepare(data, block_item)?; Ok(Self { block_item_index, @@ -882,9 +884,7 @@ impl PreparedEvent { let prepared_event = match &block_item.details { BlockItemSummaryDetails::AccountCreation(details) => { Some(PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( - data, - &block_item, - details, + data, block_item, details, )?)) } BlockItemSummaryDetails::AccountTransaction(details) => match &details.effects { @@ -969,7 +969,7 @@ impl PreparedEvent { } => Some(PreparedEvent::BakerEvents( events .iter() - .map(|event| PreparedBakerEvent::prepare(event)) + .map(PreparedBakerEvent::prepare) .collect::>>()?, )), From 310d66c11ce66e14e1636aecbb19597461093c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 10 Oct 2024 19:53:06 +0200 Subject: [PATCH 27/50] Only use compile-time checked queries --- ...5141ad88d42bb22e4d6e1b5c1eab654c03c63.json | 43 ++ ...4b02c03df94ac28852b1c6d8c47dc15f389dd.json | 49 ++ ...baa307d687ed10c77b668510869a3bed4ff07.json | 180 +++++++ backend-rust/src/graphql_api.rs | 450 ++++++++---------- 4 files changed, 471 insertions(+), 251 deletions(-) create mode 100644 backend-rust/.sqlx/query-5384fa76bb898ea40ec7d8679c65141ad88d42bb22e4d6e1b5c1eab654c03c63.json create mode 100644 backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json create mode 100644 backend-rust/.sqlx/query-c7ecc4ede6cb4db3fca1da8a4dcbaa307d687ed10c77b668510869a3bed4ff07.json diff --git a/backend-rust/.sqlx/query-5384fa76bb898ea40ec7d8679c65141ad88d42bb22e4d6e1b5c1eab654c03c63.json b/backend-rust/.sqlx/query-5384fa76bb898ea40ec7d8679c65141ad88d42bb22e4d6e1b5c1eab654c03c63.json new file mode 100644 index 00000000..30f58a2f --- /dev/null +++ b/backend-rust/.sqlx/query-5384fa76bb898ea40ec7d8679c65141ad88d42bb22e4d6e1b5c1eab654c03c63.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT * FROM (\n SELECT\n index,\n created_block,\n address,\n amount\n FROM accounts\n WHERE index > $1 AND index < $2\n ORDER BY\n (CASE WHEN $4 THEN index END) DESC,\n (CASE WHEN NOT $4 THEN index END) ASC\n LIMIT $3\n) ORDER BY index ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_block", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "address", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5384fa76bb898ea40ec7d8679c65141ad88d42bb22e4d6e1b5c1eab654c03c63" +} diff --git a/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json b/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json new file mode 100644 index 00000000..42943e74 --- /dev/null +++ b/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT * FROM (\n SELECT\n hash, height, slot_time, baker_id, total_amount\n FROM blocks\n WHERE height > $1 AND height < $2\n ORDER BY\n (CASE WHEN $4 THEN height END) DESC,\n (CASE WHEN NOT $4 THEN height END) ASC\n LIMIT $3\n) ORDER BY height ASC\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 1, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 3, + "name": "baker_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "total_amount", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd" +} diff --git a/backend-rust/.sqlx/query-c7ecc4ede6cb4db3fca1da8a4dcbaa307d687ed10c77b668510869a3bed4ff07.json b/backend-rust/.sqlx/query-c7ecc4ede6cb4db3fca1da8a4dcbaa307d687ed10c77b668510869a3bed4ff07.json new file mode 100644 index 00000000..034cfb55 --- /dev/null +++ b/backend-rust/.sqlx/query-c7ecc4ede6cb4db3fca1da8a4dcbaa307d687ed10c77b668510869a3bed4ff07.json @@ -0,0 +1,180 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT * FROM (\n SELECT\n block_height,\n index,\n hash,\n ccd_cost,\n energy_cost,\n sender,\n type as \"tx_type: DbTransactionType\",\n type_account as \"type_account: AccountTransactionType\",\n type_credential_deployment as \"type_credential_deployment: CredentialDeploymentTransactionType\",\n type_update as \"type_update: UpdateTransactionType\",\n success,\n events as \"events: sqlx::types::Json>\",\n reject as \"reject: sqlx::types::Json\"\n FROM transactions\n WHERE block_height > $1 AND block_height < $2\n OR ((block_height = $1 AND index > $3) AND NOT (block_height = $2 AND index >= $4))\n OR ((block_height = $2 AND index < $4) AND NOT (block_height = $1 AND index <= $3))\n ORDER BY (CASE WHEN $6 THEN block_height END) DESC,\n (CASE WHEN $6 THEN index END) DESC,\n (CASE WHEN NOT $6 THEN block_height END) ASC,\n (CASE WHEN NOT $6 THEN index END) ASC\n LIMIT $5\n) ORDER BY block_height ASC, index ASC\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "hash", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "ccd_cost", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "energy_cost", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "sender", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "tx_type: DbTransactionType", + "type_info": { + "Custom": { + "name": "transaction_type", + "kind": { + "Enum": [ + "Account", + "CredentialDeployment", + "Update" + ] + } + } + } + }, + { + "ordinal": 7, + "name": "type_account: AccountTransactionType", + "type_info": { + "Custom": { + "name": "account_transaction_type", + "kind": { + "Enum": [ + "InitializeSmartContractInstance", + "UpdateSmartContractInstance", + "SimpleTransfer", + "EncryptedTransfer", + "SimpleTransferWithMemo", + "EncryptedTransferWithMemo", + "TransferWithScheduleWithMemo", + "DeployModule", + "AddBaker", + "RemoveBaker", + "UpdateBakerStake", + "UpdateBakerRestakeEarnings", + "UpdateBakerKeys", + "UpdateCredentialKeys", + "TransferToEncrypted", + "TransferToPublic", + "TransferWithSchedule", + "UpdateCredentials", + "RegisterData", + "ConfigureBaker", + "ConfigureDelegation" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "type_credential_deployment: CredentialDeploymentTransactionType", + "type_info": { + "Custom": { + "name": "credential_deployment_transaction_type", + "kind": { + "Enum": [ + "Initial", + "Normal" + ] + } + } + } + }, + { + "ordinal": 9, + "name": "type_update: UpdateTransactionType", + "type_info": { + "Custom": { + "name": "update_transaction_type", + "kind": { + "Enum": [ + "UpdateProtocol", + "UpdateElectionDifficulty", + "UpdateEuroPerEnergy", + "UpdateMicroGtuPerEuro", + "UpdateFoundationAccount", + "UpdateMintDistribution", + "UpdateTransactionFeeDistribution", + "UpdateGasRewards", + "UpdateBakerStakeThreshold", + "UpdateAddAnonymityRevoker", + "UpdateAddIdentityProvider", + "UpdateRootKeys", + "UpdateLevel1Keys", + "UpdateLevel2Keys", + "UpdatePoolParameters", + "UpdateCooldownParameters", + "UpdateTimeParameters", + "MintDistributionCpv1Update", + "GasRewardsCpv2Update", + "TimeoutParametersUpdate", + "MinBlockTimeUpdate", + "BlockEnergyLimitUpdate", + "FinalizationCommitteeParametersUpdate" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "success", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "events: sqlx::types::Json>", + "type_info": "Jsonb" + }, + { + "ordinal": 12, + "name": "reject: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "c7ecc4ede6cb4db3fca1da8a4dcbaa307d687ed10c77b668510869a3bed4ff07" +} diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 9af696bb..aa1321bf 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -25,7 +25,7 @@ use chrono::Duration; use concordium_rust_sdk::{id::types as sdk_types, types::AmountFraction}; use futures::prelude::*; use prometheus_client::registry::Registry; -use sqlx::{postgres::types::PgInterval, PgPool, Postgres}; +use sqlx::{postgres::types::PgInterval, PgPool}; use std::{error::Error, str::FromStr as _, sync::Arc}; use tokio::{net::TcpListener, sync::broadcast}; use tokio_util::sync::CancellationToken; @@ -36,10 +36,20 @@ const VERSION: &str = clap::crate_version!(); pub struct ApiServiceConfig { /// Account(s) that should not be considered in circulation. #[arg(long, env = "CCDSCAN_API_CONFIG_NON_CIRCULATING_ACCOUNTS", value_delimiter = ',')] - non_circulating_account: Vec, + non_circulating_account: Vec, /// The most transactions which can be queried at once. #[arg(long, env = "CCDSCAN_API_CONFIG_TRANSACTION_CONNECTION_LIMIT", default_value = "100")] - transaction_connection_limit: i64, + transaction_connection_limit: i64, + #[arg(long, env = "CCDSCAN_API_CONFIG_BLOCK_CONNECTION_LIMIT", default_value = "100")] + block_connection_limit: i64, + #[arg(long, env = "CCDSCAN_API_CONFIG_ACCOUNT_CONNECTION_LIMIT", default_value = "100")] + account_connection_limit: i64, + #[arg( + long, + env = "CCDSCAN_API_CONFIG_TRANSACTION_EVENT_CONNECTION_LIMIT", + default_value = "100" + )] + transaction_event_connection_limit: i64, } pub struct Service { @@ -280,21 +290,67 @@ fn get_config<'a>(ctx: &Context<'a>) -> ApiResult<&'a ApiServiceConfig> { ctx.data::().map_err(ApiError::NoServiceConfig) } -fn check_connection_query(first: &Option, last: &Option) -> ApiResult<()> { - if first.is_some() && last.is_some() { - return Err(ApiError::QueryConnectionFirstLast); - } - if let Some(first) = first { - if first < &0 { - return Err(ApiError::QueryConnectionNegativeFirst); - } - }; - if let Some(last) = last { - if last < &0 { - return Err(ApiError::QueryConnectionNegativeLast); +trait ConnectionCursor { + const MIN: Self; + const MAX: Self; +} +impl ConnectionCursor for i64 { + const MAX: i64 = i64::MAX; + const MIN: i64 = i64::MIN; +} +impl ConnectionCursor for usize { + const MAX: usize = usize::MAX; + const MIN: usize = usize::MIN; +} + +struct ConnectionQuery { + from: A, + to: A, + limit: i64, + desc: bool, +} +impl ConnectionQuery { + fn new( + first: Option, + after: Option, + last: Option, + before: Option, + connection_limit: i64, + ) -> ApiResult + where + A: std::str::FromStr + ConnectionCursor, + E: Into, { + if first.is_some() && last.is_some() { + return Err(ApiError::QueryConnectionFirstLast); } - }; - Ok(()) + if let Some(first) = first { + if first < 0 { + return Err(ApiError::QueryConnectionNegativeFirst); + } + }; + if let Some(last) = last { + if last < 0 { + return Err(ApiError::QueryConnectionNegativeLast); + } + }; + let from = if let Some(a) = after { + a.parse::().map_err(|e| e.into())? + } else { + A::MIN + }; + let to = if let Some(b) = before { + b.parse::().map_err(|e| e.into())? + } else { + A::MAX + }; + let limit = first.or(last).map_or(connection_limit, |limit| connection_limit.min(limit)); + Ok(Self { + from, + to, + limit, + desc: last.is_some(), + }) + } } pub struct Query; @@ -330,52 +386,37 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { - check_connection_query(&first, &last)?; - - let mut builder = - sqlx::QueryBuilder::<'_, Postgres>::new("SELECT * FROM (SELECT * FROM blocks"); - - match (after, before) { - (None, None) => {} - (None, Some(before)) => { - builder - .push(" WHERE height < ") - .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } - (Some(after), None) => { - builder - .push(" WHERE height > ") - .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - } - (Some(after), Some(before)) => { - builder - .push(" WHERE height > ") - .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) - .push(" AND height < ") - .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } - } - - match (first, &last) { - (None, None) => { - builder.push(" ORDER BY height ASC)"); - } - (None, Some(last)) => { - builder - .push(" ORDER BY height DESC LIMIT ") - .push_bind(last) - .push(") ORDER BY height ASC "); - } - (Some(first), None) => { - builder.push(" ORDER BY height ASC LIMIT ").push_bind(first).push(")"); - } - (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), - } - - let mut block_stream = builder.build_query_as::().fetch(get_pool(ctx)?); - + let config = get_config(ctx)?; + let pool = get_pool(ctx)?; + let query = ConnectionQuery::::new( + first, + after, + last, + before, + config.block_connection_limit, + )?; + let mut row_stream = sqlx::query_as!( + Block, + r#" +SELECT * FROM ( + SELECT + hash, height, slot_time, baker_id, total_amount + FROM blocks + WHERE height > $1 AND height < $2 + ORDER BY + (CASE WHEN $4 THEN height END) DESC, + (CASE WHEN NOT $4 THEN height END) ASC + LIMIT $3 +) ORDER BY height ASC +"#, + query.from, + query.to, + query.limit, + query.desc + ) + .fetch(pool); let mut connection = connection::Connection::new(true, true); - while let Some(block) = block_stream.try_next().await? { + while let Some(block) = row_stream.try_next().await? { connection.edges.push(connection::Edge::new(block.height.to_string(), block)); } if last.is_some() { @@ -415,100 +456,56 @@ impl Query { before: Option, ) -> ApiResult> { let config = get_config(ctx)?; - - check_connection_query(&first, &last)?; - let after_id = after.as_deref().map(IdTransaction::from_str).transpose()?; - let before_id = before.as_deref().map(IdTransaction::from_str).transpose()?; - - let mut builder = sqlx::QueryBuilder::<'_, Postgres>::new(""); - if last.is_some() { - builder.push("SELECT * FROM ("); - } - builder.push( - "SELECT * FROM ( + let pool = get_pool(ctx)?; + let query = ConnectionQuery::::new( + first, + after, + last, + before, + config.transaction_connection_limit, + )?; + let mut row_stream = sqlx::query_as!( + Transaction, + r#" +SELECT * FROM ( SELECT - block, index, hash, ccd_cost, energy_cost, sender, type, type_account, \ - type_credential_deployment, - type_update, success, events, reject, - LAG(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_prev, - LEAD(TRUE, 1, FALSE) OVER (ORDER BY block ASC, index ASC) as has_next - FROM - transactions -)", - ); - match (after_id, before_id) { - (None, None) => {} - (None, Some(before_id)) => { - builder - .push(" WHERE block < ") - .push_bind(before_id.block) - .push(" OR block = ") - .push_bind(before_id.block) - .push(" AND index < ") - .push_bind(before_id.index); - } - (Some(after_id), None) => { - builder - .push(" WHERE block > ") - .push_bind(after_id.block) - .push(" OR block = ") - .push_bind(after_id.block) - .push(" AND index > ") - .push_bind(after_id.index); - } - (Some(after_id), Some(before_id)) => { - builder - .push(" WHERE (block > ") - .push_bind(after_id.block) - .push(" OR block = ") - .push_bind(after_id.block) - .push(" AND index > ") - .push_bind(after_id.index) - .push(") AND (block < ") - .push_bind(before_id.block) - .push(" OR block = ") - .push_bind(before_id.block) - .push(" AND index < ") - .push_bind(before_id.index) - .push(")"); - } - } - - match (first, last) { - (None, None) => { - builder - .push(" ORDER BY block ASC, index ASC LIMIT ") - .push_bind(config.transaction_connection_limit); - } - (None, Some(last)) => { - builder - .push(" ORDER BY block DESC, index DESC LIMIT ") - .push_bind(last.min(config.transaction_connection_limit)) - .push(") ORDER BY block ASC, index ASC"); - } - (Some(first), None) => { - builder - .push(" ORDER BY block ASC, index ASC LIMIT ") - .push_bind(first.min(config.transaction_connection_limit)); - } - (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), - } - let mut row_stream = - builder.build_query_as::().fetch(get_pool(ctx)?); + block_height, + index, + hash, + ccd_cost, + energy_cost, + sender, + type as "tx_type: DbTransactionType", + type_account as "type_account: AccountTransactionType", + type_credential_deployment as "type_credential_deployment: CredentialDeploymentTransactionType", + type_update as "type_update: UpdateTransactionType", + success, + events as "events: sqlx::types::Json>", + reject as "reject: sqlx::types::Json" + FROM transactions + WHERE block_height > $1 AND block_height < $2 + OR ((block_height = $1 AND index > $3) AND NOT (block_height = $2 AND index >= $4)) + OR ((block_height = $2 AND index < $4) AND NOT (block_height = $1 AND index <= $3)) + ORDER BY (CASE WHEN $6 THEN block_height END) DESC, + (CASE WHEN $6 THEN index END) DESC, + (CASE WHEN NOT $6 THEN block_height END) ASC, + (CASE WHEN NOT $6 THEN index END) ASC + LIMIT $5 +) ORDER BY block_height ASC, index ASC +"#, + query.from.block, + query.to.block, + query.from.index, + query.to.index, + query.limit, + query.desc + ) + .fetch(pool); + // TODO Update page prev/next let mut connection = connection::Connection::new(true, true); - let mut first_row = true; while let Some(row) = row_stream.try_next().await? { - if first_row { - connection.has_previous_page = row.has_prev; - first_row = false; - } - connection.edges.push(connection::Edge::new( - row.transaction.id_transaction().to_string(), - row.transaction, - )); - connection.has_next_page = row.has_next; + connection.edges.push(connection::Edge::new(row.id_transaction().to_string(), row)); } - Ok(connection) } @@ -537,82 +534,44 @@ impl Query { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { - check_connection_query(&first, &last)?; - // TODO: include sort and filter - - let mut builder = sqlx::QueryBuilder::<'_, Postgres>::new(""); - if last.is_some() { - builder.push("SELECT * FROM ("); - } - - builder.push( - "SELECT * FROM ( + let pool = get_pool(ctx)?; + let config = get_config(ctx)?; + let query = ConnectionQuery::::new( + first, + after, + last, + before, + config.account_connection_limit, + )?; + let mut row_stream = sqlx::query_as!( + Account, + r#" +SELECT * FROM ( SELECT index, created_block, - created_index, address, - amount, - LAG(TRUE, 1, FALSE) OVER (ORDER BY index ASC) as has_prev, - LEAD(TRUE, 1, FALSE) OVER (ORDER BY index ASC) as has_next + amount FROM accounts -)", - ); - - match (after, before) { - (None, None) => {} - (None, Some(before)) => { - builder - .push(" WHERE index < ") - .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } - (Some(after), None) => { - builder - .push(" WHERE index > ") - .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?); - } - (Some(after), Some(before)) => { - builder - .push(" WHERE index > ") - .push_bind(after.parse::().map_err(ApiError::InvalidIdInt)?) - .push(" AND index < ") - .push_bind(before.parse::().map_err(ApiError::InvalidIdInt)?); - } - } - - match (first, &last) { - (None, None) => { - builder.push(" ORDER BY index ASC"); - } - (None, Some(last)) => { - builder - .push(" ORDER BY index DESC LIMIT ") - .push_bind(last) - .push(") ORDER BY index ASC "); - } - (Some(first), None) => { - builder.push(" ORDER BY index ASC LIMIT ").push_bind(first); - } - (Some(_), Some(_)) => return Err(ApiError::QueryConnectionFirstLast), - } - - let mut row_stream = - builder.build_query_as::().fetch(get_pool(ctx)?); - + WHERE index > $1 AND index < $2 + ORDER BY + (CASE WHEN $4 THEN index END) DESC, + (CASE WHEN NOT $4 THEN index END) ASC + LIMIT $3 +) ORDER BY index ASC + "#, + query.from, + query.to, + query.limit, + query.desc + ) + .fetch(pool); + // TODO Update page prev/next let mut connection = connection::Connection::new(true, true); - let mut first_row = true; while let Some(row) = row_stream.try_next().await? { - if first_row { - connection.has_previous_page = row.has_prev; - first_row = false; - } - connection - .edges - .push(connection::Edge::new(row.account.index.to_string(), row.account)); - connection.has_next_page = row.has_next; + connection.edges.push(connection::Edge::new(row.index.to_string(), row)); } - Ok(connection) } @@ -2108,6 +2067,16 @@ struct IdTransaction { block: BlockHeight, index: TransactionIndex, } +impl ConnectionCursor for IdTransaction { + const MAX: IdTransaction = IdTransaction { + block: BlockHeight::MAX, + index: TransactionIndex::MAX, + }; + const MIN: IdTransaction = IdTransaction { + block: BlockHeight::MIN, + index: TransactionIndex::MIN, + }; +} impl std::str::FromStr for IdTransaction { type Err = ApiError; @@ -2126,27 +2095,12 @@ impl TryFrom for IdTransaction { fn try_from(value: types::ID) -> Result { value.0.parse() } } -// impl From for types::ID { -// fn from(value: IdTransaction) -> Self { -// types::ID::from(value.to_string()) -// } -// } - impl std::fmt::Display for IdTransaction { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}:{}", self.block, self.index) } } -#[derive(sqlx::FromRow)] -struct TransactionConnectionQuery { - #[sqlx(flatten)] - transaction: Transaction, - has_prev: bool, - has_next: bool, -} - -#[derive(sqlx::FromRow)] struct Transaction { block_height: BlockHeight, index: TransactionIndex, @@ -2487,39 +2441,41 @@ impl Success<'_> { #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] before: Option, ) -> ApiResult> { - check_connection_query(&first, &last)?; - - let mut start = if let Some(after) = after { + if first.is_some() && last.is_some() { + return Err(ApiError::QueryConnectionFirstLast); + } + let mut start = if let Some(after) = after.as_ref() { usize::from_str(after.as_str())? } else { 0 }; - - let mut end = if let Some(before) = before { + let mut end = if let Some(before) = before.as_ref() { usize::from_str(before.as_str())? } else { self.events.len() }; - if let Some(first) = first { + if first < 0 { + return Err(ApiError::QueryConnectionNegativeFirst); + } let first = usize::try_from(first)?; end = usize::min(end, start + first); } - if let Some(last) = last { + if last < 0 { + return Err(ApiError::QueryConnectionNegativeLast); + } let last = usize::try_from(last)?; if let Some(new_end) = end.checked_sub(last) { start = usize::max(start, new_end); } } - let mut connection = connection::Connection::new(start == 0, end == self.events.len()); connection.edges = self.events[start..end] .iter() .enumerate() .map(|(i, event)| connection::Edge::new(i.to_string(), event)) .collect(); - Ok(connection) } } @@ -2529,14 +2485,6 @@ struct Rejected<'a> { reason: &'a TransactionRejectReason, } -#[derive(sqlx::FromRow)] -struct AccountConnectionQuery { - #[sqlx(flatten)] - account: Account, - has_prev: bool, - has_next: bool, -} - #[derive(sqlx::FromRow)] struct Account { // release_schedule: AccountReleaseSchedule, From 91883901a733d8219c93d538e70a9e1eb48889be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Fri, 11 Oct 2024 09:28:03 +0200 Subject: [PATCH 28/50] Use dotenvy and update documentation --- backend-rust/Cargo.lock | 8 +------- backend-rust/Cargo.toml | 6 +++++- backend-rust/src/bin/ccdscan-api.rs | 10 +++++----- backend-rust/src/bin/ccdscan-indexer.rs | 7 ++++--- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 87fa8065..75aa025d 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -900,7 +900,7 @@ dependencies = [ "clap", "concordium-rust-sdk", "derive_more", - "dotenv", + "dotenvy", "futures", "hex", "iso8601-duration", @@ -1237,12 +1237,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - [[package]] name = "dotenvy" version = "0.15.7" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index 747e89a7..b153bb1a 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -19,7 +19,7 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.5", features = ["derive", "env", "cargo"] } concordium-rust-sdk = { path = "./concordium-rust-sdk" } derive_more = "0.99" -dotenv = "0.15" +dotenvy = "0.15" futures = "0.3" hex = "0.4" serde = "1.0" @@ -35,3 +35,7 @@ iso8601-duration = { version = "0.2", features = ["chrono"] } tokio-util = "0.7" prometheus-client = "0.22" tonic = "0.10.2" + +# Recommended by SQLx to speed up incremental builds +[profile.dev.package.sqlx-macros] +opt-level = 3 diff --git a/backend-rust/src/bin/ccdscan-api.rs b/backend-rust/src/bin/ccdscan-api.rs index 2fa13029..5c975f88 100644 --- a/backend-rust/src/bin/ccdscan-api.rs +++ b/backend-rust/src/bin/ccdscan-api.rs @@ -2,7 +2,6 @@ use anyhow::Context; use async_graphql::SDLExportOptions; use clap::Parser; use concordium_scan::{graphql_api, metrics}; -use dotenv::dotenv; use prometheus_client::registry::Registry; use sqlx::PgPool; use std::{net::SocketAddr, path::PathBuf}; @@ -10,11 +9,12 @@ use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use tracing::{error, info}; -// TODO add env for remaining args. #[derive(Parser)] struct Cli { - /// The URL used for the database, something of the form - /// "postgres://postgres:example@localhost/ccd-scan" + /// The URL used for the database, something of the form: + /// "postgres://postgres:example@localhost/ccd-scan". + /// Use environmental variable when the connection contains a password, as + /// command line arguments are visible across OS processes. #[arg(long, env = "DATABASE_URL")] database_url: String, /// Output the GraphQL Schema for the API to this path. @@ -32,7 +32,7 @@ struct Cli { #[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv().ok(); + let _ = dotenvy::dotenv(); let cli = Cli::parse(); tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); let pool = PgPool::connect(&cli.database_url) diff --git a/backend-rust/src/bin/ccdscan-indexer.rs b/backend-rust/src/bin/ccdscan-indexer.rs index f1eed5c7..a9a375d7 100644 --- a/backend-rust/src/bin/ccdscan-indexer.rs +++ b/backend-rust/src/bin/ccdscan-indexer.rs @@ -5,7 +5,6 @@ use concordium_scan::{ indexer::{self, IndexerServiceConfig}, metrics, }; -use dotenv::dotenv; use prometheus_client::registry::Registry; use sqlx::PgPool; use std::net::SocketAddr; @@ -17,7 +16,9 @@ use tracing::{error, info}; #[command(version, author, about)] struct Cli { /// The URL used for the database, something of the form - /// "postgres://postgres:example@localhost/ccd-scan" + /// "postgres://postgres:example@localhost/ccd-scan". + /// Use environmental variable when the connection contains a password, as + /// command line arguments are visible across OS processes. #[arg(long, env = "DATABASE_URL")] database_url: String, /// gRPC interface of the node. Several can be provided. @@ -38,7 +39,7 @@ struct Cli { #[tokio::main] async fn main() -> anyhow::Result<()> { - dotenv().ok(); + let _ = dotenvy::dotenv(); let cli = Cli::parse(); tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init(); let pool = PgPool::connect(&cli.database_url) From bd8874616a05063018831d0d7a5d6207055c8e84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Fri, 11 Oct 2024 14:33:32 +0200 Subject: [PATCH 29/50] Add Query::module_reference_event without schema and comments --- .../migrations/0001_initialize.up.sql | 18 +++++ backend-rust/src/graphql_api.rs | 39 +++++++++- backend-rust/src/indexer.rs | 72 ++++++++++++++++++- 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index c1a474d9..0d0074b6 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -247,6 +247,24 @@ CREATE TABLE bakers( BIGINT ); +CREATE TABLE smart_contract_modules ( + index + BIGINT + PRIMARY KEY, + -- Module reference of the wasm module. + module_reference + CHAR(64) + UNIQUE + NOT NULL, + deployment_block_height + BIGINT + NOT NULL, + deployment_transaction_index + BIGINT + NOT NULL, + schema BYTEA, + FOREIGN KEY (deployment_block_height, deployment_transaction_index) REFERENCES transactions(block_height, index) +); CREATE OR REPLACE FUNCTION block_added_notify_trigger_function() RETURNS trigger AS $trigger$ DECLARE diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index aa1321bf..12f017e0 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -749,7 +749,42 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) // after the specified cursor." after: String "Returns the last _n_ elements // from the list." last: Int "Returns the elements in the list that come // before the specified cursor." before: String): ContractsConnection - // moduleReferenceEvent(moduleReference: String!): ModuleReferenceEvent + + async fn module_reference_event<'a>( + &self, + ctx: &Context<'a>, + module_reference: String, + ) -> ApiResult { + let pool = get_pool(ctx)?; + + let row = sqlx::query!( + r#" +SELECT + deployment_block_height as block_height, + deployment_transaction_index, + schema as display_schema, + blocks.slot_time as block_slot_time, + transactions.hash as transaction_hash, + accounts.address as sender +FROM smart_contract_modules +JOIN blocks ON deployment_block_height=blocks.height +JOIN transactions ON deployment_block_height=transactions.block_height AND deployment_transaction_index=transactions.index +JOIN accounts ON transactions.sender=accounts.index +WHERE module_reference=$1 +"#, + module_reference + ).fetch_optional(pool).await? + .ok_or(ApiError::NotFound)?; + + Ok(ModuleReferenceEvent { + module_reference, + sender: row.sender.into(), + block_height: row.block_height, + transaction_hash: row.transaction_hash, + block_slot_time: row.block_slot_time, + display_schema: None, // TODO print the actual schema + }) + } } pub struct Subscription { @@ -4257,7 +4292,7 @@ pub struct ModuleReferenceEvent { block_height: BlockHeight, transaction_hash: String, block_slot_time: DateTime, - display_schema: String, + display_schema: Option, // TODO: // moduleReferenceRejectEvents(skip: Int take: Int): // ModuleReferenceRejectEventsCollectionSegment moduleReferenceContractLinkEvents(skip: Int diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index ccec023b..a5bb090b 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -876,6 +876,8 @@ enum PreparedEvent { AccountCreation(PreparedAccountCreation), /// Changes related to validators (previously referred to as bakers). BakerEvents(Vec), + /// Smart contract module got deployed. + ModuleDeployed(PreparedModuleDeployed), /// No changes in the database was caused by this event. NoOperation, } @@ -894,7 +896,11 @@ impl PreparedEvent { } => None, AccountTransactionEffects::ModuleDeployed { module_ref, - } => None, + } => Some(PreparedEvent::ModuleDeployed(PreparedModuleDeployed::prepare( + data, + block_item, + *module_ref, + )?)), AccountTransactionEffects::ContractInitialized { data, } => None, @@ -1034,6 +1040,7 @@ impl PreparedEvent { } Ok(()) } + PreparedEvent::ModuleDeployed(event) => event.save(tx).await, PreparedEvent::NoOperation => Ok(()), } } @@ -1340,3 +1347,66 @@ impl PreparedBakerEvent { Ok(()) } } + +struct PreparedModuleDeployed { + block_height: i64, + deployment_transaction_index: i64, + module_reference: String, + schema: Option>, +} + +impl PreparedModuleDeployed { + fn prepare( + data: &BlockData, + block_item: &BlockItemSummary, + module_reference: sdk_types::hashes::ModuleReference, + ) -> anyhow::Result { + let block_height = data.finalized_block_info.height; + let deployment_transaction_index = block_item.index.index.try_into()?; + + // let wasm_module = node_client + // .get_module_source(&module_reference, + // BlockIdentifier::AbsoluteHeight(block_height)) .await? + // .response; + // let schema = match wasm_module.version { + // WasmVersion::V0 => + // utils::get_embedded_schema_v0(wasm_module.source.as_ref()), + // WasmVersion::V1 => + // utils::get_embedded_schema_v1(wasm_module.source.as_ref()), } + // .ok(); + // let schema = to_bytes(&schema); + + Ok(Self { + block_height: i64::try_from(block_height.height)?, + deployment_transaction_index, + module_reference: module_reference.into(), + schema: None, + }) + } + + async fn save( + &self, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + sqlx::query!( + r#" +INSERT INTO smart_contract_modules ( + index, + module_reference, + deployment_block_height, + deployment_transaction_index, + schema +) VALUES ( + (SELECT COALESCE(MAX(index) + 1, 0) FROM smart_contract_modules), + $1, $2, $3, $4 +)"#, + self.module_reference, + self.block_height, + self.deployment_transaction_index, + self.schema + ) + .execute(tx.as_mut()) + .await?; + Ok(()) + } +} From 2550131cfbd19ba8e6cbfca5f111dab71268bac4 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Fri, 11 Oct 2024 20:27:20 +0300 Subject: [PATCH 30/50] Make module schema query complete --- ...a68c70e14052dc9f0e476a22d487d3b4060ea.json | 17 +++++ ...0f956292ba2e0b0f759e6ab98c9fd6662185c.json | 52 ++++++++++++++++ .../migrations/0001_initialize.up.sql | 3 +- backend-rust/src/graphql_api.rs | 2 +- backend-rust/src/indexer.rs | 62 +++++++++++-------- 5 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json create mode 100644 backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json diff --git a/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json b/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json new file mode 100644 index 00000000..c7a6ce6e --- /dev/null +++ b/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO smart_contract_modules (\n index,\n module_reference,\n deployment_block_height,\n deployment_transaction_index,\n schema\n) VALUES (\n (SELECT COALESCE(MAX(index) + 1, 0) FROM smart_contract_modules),\n $1, $2, $3, $4\n)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bpchar", + "Int8", + "Int8", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea" +} diff --git a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json new file mode 100644 index 00000000..729e26b8 --- /dev/null +++ b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n deployment_block_height as block_height,\n deployment_transaction_index,\n schema as display_schema,\n blocks.slot_time as block_slot_time,\n transactions.hash as transaction_hash,\n accounts.address as sender\nFROM smart_contract_modules\nJOIN blocks ON deployment_block_height=blocks.height\nJOIN transactions ON deployment_block_height=transactions.block_height AND deployment_transaction_index=transactions.index\nJOIN accounts ON transactions.sender=accounts.index\nWHERE module_reference=$1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "deployment_transaction_index", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "display_schema", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "block_slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 4, + "name": "transaction_hash", + "type_info": "Bpchar" + }, + { + "ordinal": 5, + "name": "sender", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [ + "Bpchar" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + false + ] + }, + "hash": "4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c" +} diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index 0d0074b6..4ffd02a5 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -262,7 +262,8 @@ CREATE TABLE smart_contract_modules ( deployment_transaction_index BIGINT NOT NULL, - schema BYTEA, + -- TODO: Would be nice to use BYTEA here (should be propagated to front end) + schema TEXT, FOREIGN KEY (deployment_block_height, deployment_transaction_index) REFERENCES transactions(block_height, index) ); diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 12f017e0..a10b521e 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -782,7 +782,7 @@ WHERE module_reference=$1 block_height: row.block_height, transaction_hash: row.transaction_hash, block_slot_time: row.block_slot_time, - display_schema: None, // TODO print the actual schema + display_schema: row.display_schema, }) } } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index a5bb090b..919f8d08 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -8,8 +8,10 @@ use crate::graphql_api::{ use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ + base::smart_contracts::WasmVersion, common::types::Amount, indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, + smart_contracts::engine::utils::{get_embedded_schema_v0, get_embedded_schema_v1}, types::{ self as sdk_types, queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, @@ -325,7 +327,8 @@ impl Indexer for BlockPreProcessor { tokenomics_info: tokenomics_info.response, total_staked_capital, }; - let prepared_block = PreparedBlock::prepare(&data).map_err(RPCError::ParseError)?; + let prepared_block = + PreparedBlock::prepare(&mut client, &data).await.map_err(RPCError::ParseError)?; Ok(prepared_block) } .await; @@ -643,7 +646,7 @@ struct PreparedBlock { } impl PreparedBlock { - fn prepare(data: &BlockData) -> anyhow::Result { + async fn prepare(node_client: &mut v2::Client, data: &BlockData) -> anyhow::Result { let height = i64::try_from(data.finalized_block_info.height.height)?; let hash = data.finalized_block_info.block_hash.to_string(); let block_last_finalized = data.block_info.block_last_finalized.to_string(); @@ -658,7 +661,8 @@ impl PreparedBlock { let total_staked = i64::try_from(data.total_staked_capital.micro_ccd())?; let mut prepared_block_items = Vec::new(); for block_item in data.events.iter() { - prepared_block_items.push(PreparedBlockItem::prepare(data, block_item)?) + prepared_block_items + .push(PreparedBlockItem::prepare(node_client, data, block_item).await?) } Ok(Self { hash, @@ -769,7 +773,11 @@ struct PreparedBlockItem { } impl PreparedBlockItem { - fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result { + async fn prepare( + node_client: &mut v2::Client, + data: &BlockData, + block_item: &BlockItemSummary, + ) -> anyhow::Result { let block_height = i64::try_from(data.finalized_block_info.height.height)?; let block_item_index = i64::try_from(block_item.index.index)?; let block_item_hash = block_item.hash.to_string(); @@ -818,7 +826,7 @@ impl PreparedBlockItem { (None, Some(reject)) }; - let prepared_event = PreparedEvent::prepare(data, block_item)?; + let prepared_event = PreparedEvent::prepare(node_client, data, block_item).await?; Ok(Self { block_item_index, @@ -882,7 +890,11 @@ enum PreparedEvent { NoOperation, } impl PreparedEvent { - fn prepare(data: &BlockData, block_item: &BlockItemSummary) -> anyhow::Result> { + async fn prepare( + node_client: &mut v2::Client, + data: &BlockData, + block_item: &BlockItemSummary, + ) -> anyhow::Result> { let prepared_event = match &block_item.details { BlockItemSummaryDetails::AccountCreation(details) => { Some(PreparedEvent::AccountCreation(PreparedAccountCreation::prepare( @@ -896,11 +908,10 @@ impl PreparedEvent { } => None, AccountTransactionEffects::ModuleDeployed { module_ref, - } => Some(PreparedEvent::ModuleDeployed(PreparedModuleDeployed::prepare( - data, - block_item, - *module_ref, - )?)), + } => Some(PreparedEvent::ModuleDeployed( + PreparedModuleDeployed::prepare(node_client, data, block_item, *module_ref) + .await?, + )), AccountTransactionEffects::ContractInitialized { data, } => None, @@ -1352,11 +1363,12 @@ struct PreparedModuleDeployed { block_height: i64, deployment_transaction_index: i64, module_reference: String, - schema: Option>, + schema: Option, } impl PreparedModuleDeployed { - fn prepare( + async fn prepare( + node_client: &mut v2::Client, data: &BlockData, block_item: &BlockItemSummary, module_reference: sdk_types::hashes::ModuleReference, @@ -1364,23 +1376,23 @@ impl PreparedModuleDeployed { let block_height = data.finalized_block_info.height; let deployment_transaction_index = block_item.index.index.try_into()?; - // let wasm_module = node_client - // .get_module_source(&module_reference, - // BlockIdentifier::AbsoluteHeight(block_height)) .await? - // .response; - // let schema = match wasm_module.version { - // WasmVersion::V0 => - // utils::get_embedded_schema_v0(wasm_module.source.as_ref()), - // WasmVersion::V1 => - // utils::get_embedded_schema_v1(wasm_module.source.as_ref()), } - // .ok(); - // let schema = to_bytes(&schema); + let wasm_module = node_client + .get_module_source(&module_reference, BlockIdentifier::AbsoluteHeight(block_height)) + .await? + .response; + let schema = match wasm_module.version { + WasmVersion::V0 => get_embedded_schema_v0(wasm_module.source.as_ref()), + WasmVersion::V1 => get_embedded_schema_v1(wasm_module.source.as_ref()), + } + .ok(); + + let schema = schema.as_ref().map(|s| s.to_string()); Ok(Self { block_height: i64::try_from(block_height.height)?, deployment_transaction_index, module_reference: module_reference.into(), - schema: None, + schema, }) } From 095f48888ed242b3c5b14ae474c8e9234cb89322 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Fri, 11 Oct 2024 22:04:33 +0300 Subject: [PATCH 31/50] Add contract initialized --- ...a68c70e14052dc9f0e476a22d487d3b4060ea.json | 2 +- ...0f956292ba2e0b0f759e6ab98c9fd6662185c.json | 2 +- ...0e9114c23ff5ef665a692d2ba1c3c31a50151.json | 59 ++++++++++++++ ...abe9c9f4c75a7ed8d70298e544d739b7ea2f6.json | 20 +++++ .../migrations/0001_initialize.up.sql | 48 ++++++++++- backend-rust/src/graphql_api.rs | 59 ++++++++++++-- backend-rust/src/indexer.rs | 80 ++++++++++++++++++- 7 files changed, 252 insertions(+), 18 deletions(-) create mode 100644 backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json create mode 100644 backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json diff --git a/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json b/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json index c7a6ce6e..7eaabd65 100644 --- a/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json +++ b/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json @@ -8,7 +8,7 @@ "Bpchar", "Int8", "Int8", - "Bytea" + "Text" ] }, "nullable": [] diff --git a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json index 729e26b8..ac9593ec 100644 --- a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json +++ b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json @@ -16,7 +16,7 @@ { "ordinal": 2, "name": "display_schema", - "type_info": "Bytea" + "type_info": "Text" }, { "ordinal": 3, diff --git a/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json new file mode 100644 index 00000000..5daea882 --- /dev/null +++ b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n module_reference,\n name as contract_name,\n contracts.amount,\n blocks.slot_time as block_slot_time,\n init_block_height as block_height,\n transactions.hash as transaction_hash,\n accounts.address as creator\nFROM contracts\nJOIN blocks ON init_block_height=blocks.height\nJOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index\nJOIN accounts ON transactions.sender=accounts.index\nWHERE contracts.index=$1 AND contracts.sub_index=$2\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "module_reference", + "type_info": "Bpchar" + }, + { + "ordinal": 1, + "name": "contract_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "block_slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 4, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "transaction_hash", + "type_info": "Bpchar" + }, + { + "ordinal": 6, + "name": "creator", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151" +} diff --git a/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json b/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json new file mode 100644 index 00000000..624c4bb2 --- /dev/null +++ b/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO contracts (\n index,\n sub_index,\n module_reference,\n name,\n amount,\n init_block_height,\n init_transaction_index\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Bpchar", + "Text", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6" +} diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index 4ffd02a5..38d69f8a 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -247,7 +247,8 @@ CREATE TABLE bakers( BIGINT ); -CREATE TABLE smart_contract_modules ( +-- Every WASM module on chain. +CREATE TABLE smart_contract_modules( index BIGINT PRIMARY KEY, @@ -256,15 +257,54 @@ CREATE TABLE smart_contract_modules ( CHAR(64) UNIQUE NOT NULL, + -- The absolute block height when the module was deployed. deployment_block_height BIGINT NOT NULL, + -- Transaction index in the block deploying the module. deployment_transaction_index BIGINT NOT NULL, - -- TODO: Would be nice to use BYTEA here (should be propagated to front end) - schema TEXT, - FOREIGN KEY (deployment_block_height, deployment_transaction_index) REFERENCES transactions(block_height, index) + -- TODO: Would be nice to use BYTEA here (should be propagated to the front end). + -- Embedded schema in the wasm module if present. + schema TEXT +); + +-- Every contract instance on chain. +CREATE TABLE contracts( + -- Index of the contract. + index + BIGINT + NOT NULL, + -- Sub index of the contract. + sub_index + BIGINT + NOT NULL, + -- TODO: It might be better to use `module_reference_index` which would save storage space but would require more work in inserting/querying by the indexer. + -- Module reference of the wasm module. + module_reference + CHAR(64) + UNIQUE + NOT NULL, + -- The contract name. + name + TEXT + NOT NULL, + -- The total balance of the contract in micro CCD. + amount + BIGINT + NOT NULL, + -- The absolute block height when the module was initialized. + init_block_height + BIGINT + NOT NULL, + -- Transaction index in the block initializing the contract. + init_transaction_index + BIGINT + NOT NULL, + + -- Make the contract index and subindex the primary key. + PRIMARY KEY (index, sub_index) ); CREATE OR REPLACE FUNCTION block_added_notify_trigger_function() RETURNS trigger AS $trigger$ diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index a10b521e..f8215d46 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -743,12 +743,55 @@ LIMIT 30", // WHERE slot_time > (LOCALTIMESTAMP - $1::interval) // the elements in the list that come before the specified cursor." before: // String): TokensConnection token(contractIndex: UnsignedLong! // contractSubIndex: UnsignedLong! tokenId: String!): Token! - // contract(contractAddressIndex: UnsignedLong! contractAddressSubIndex: - // UnsignedLong!): Contract contracts("Returns the first _n_ elements from - // the list." first: Int "Returns the elements in the list that come - // after the specified cursor." after: String "Returns the last _n_ elements - // from the list." last: Int "Returns the elements in the list that come - // before the specified cursor." before: String): ContractsConnection + + async fn contract<'a>( + &self, + ctx: &Context<'a>, + contract_address_index: ContractIndex, + contract_address_sub_index: ContractIndex, + ) -> ApiResult { + let pool = get_pool(ctx)?; + + let row = sqlx::query!( + r#" +SELECT + module_reference, + name as contract_name, + contracts.amount, + blocks.slot_time as block_slot_time, + init_block_height as block_height, + transactions.hash as transaction_hash, + accounts.address as creator +FROM contracts +JOIN blocks ON init_block_height=blocks.height +JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index +JOIN accounts ON transactions.sender=accounts.index +WHERE contracts.index=$1 AND contracts.sub_index=$2 +"#, +contract_address_index.0 as i64,contract_address_sub_index.0 as i64 + ).fetch_optional(pool).await? + .ok_or(ApiError::NotFound)?; + + let snapshot = ContractSnapshot { + block_height: row.block_height, + contract_address_index: contract_address_index.clone(), + contract_address_sub_index: contract_address_sub_index.clone(), + contract_name: row.contract_name, + module_reference: row.module_reference, + amount: row.amount, + }; + + Ok(Contract { + contract_address_index, + contract_address_sub_index, + contract_address: "<>".to_string(), + creator: row.creator.into(), + block_height: row.block_height, + transaction_hash: row.transaction_hash, + block_slot_time: row.block_slot_time, + snapshot, + }) + } async fn module_reference_event<'a>( &self, @@ -854,7 +897,7 @@ impl SubscriptionContext { /// The UnsignedLong scalar type represents a unsigned 64-bit numeric /// non-fractional value greater than or equal to 0. -#[derive(serde::Serialize, serde::Deserialize, derive_more::From)] +#[derive(Clone, serde::Serialize, serde::Deserialize, derive_more::From)] #[repr(transparent)] #[serde(transparent)] struct UnsignedLong(u64); @@ -1999,7 +2042,7 @@ struct BlockStatistics { } #[derive(Interface)] -#[allow(clippy::duplicated_attributes)] +// #[allow(clippy::duplicated_attributes)] #[graphql( field(name = "euro_per_energy", ty = "&ExchangeRate"), field(name = "micro_ccd_per_euro", ty = "&ExchangeRate"), diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 919f8d08..23adfdf6 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -15,7 +15,7 @@ use concordium_rust_sdk::{ types::{ self as sdk_types, queries::BlockInfo, AccountStakingInfo, AccountTransactionDetails, AccountTransactionEffects, BlockItemSummary, BlockItemSummaryDetails, - PartsPerHundredThousands, RewardsOverview, + ContractInitializedEvent, PartsPerHundredThousands, RewardsOverview, }, v2::{ self, BlockIdentifier, ChainParameters, FinalizedBlockInfo, QueryError, QueryResult, @@ -804,7 +804,7 @@ impl PreparedBlockItem { }; let success = block_item.is_success(); let (events, reject) = if success { - let events = serde_json::to_value(&events_from_summary(block_item.details.clone())?)?; + let events = serde_json::to_value(events_from_summary(block_item.details.clone())?)?; (Some(events), None) } else { let reject = @@ -886,6 +886,8 @@ enum PreparedEvent { BakerEvents(Vec), /// Smart contract module got deployed. ModuleDeployed(PreparedModuleDeployed), + /// Contract got initialized. + ContractInitialized(PreparedContractInitialized), /// No changes in the database was caused by this event. NoOperation, } @@ -913,8 +915,10 @@ impl PreparedEvent { .await?, )), AccountTransactionEffects::ContractInitialized { - data, - } => None, + data: event_data, + } => Some(PreparedEvent::ContractInitialized( + PreparedContractInitialized::prepare(data, block_item, event_data)?, + )), AccountTransactionEffects::ContractUpdateIssued { effects, } => None, @@ -1052,6 +1056,7 @@ impl PreparedEvent { Ok(()) } PreparedEvent::ModuleDeployed(event) => event.save(tx).await, + PreparedEvent::ContractInitialized(event) => event.save(tx).await, PreparedEvent::NoOperation => Ok(()), } } @@ -1422,3 +1427,70 @@ INSERT INTO smart_contract_modules ( Ok(()) } } + +struct PreparedContractInitialized { + index: i64, + sub_index: i64, + module_reference: String, + name: String, + amount: i64, + height: i64, + tx_index: i64, +} + +impl PreparedContractInitialized { + fn prepare( + data: &BlockData, + block_item: &BlockItemSummary, + event: &ContractInitializedEvent, + ) -> anyhow::Result { + let height = i64::try_from(data.finalized_block_info.height.height)?; + let tx_index = block_item.index.index.try_into()?; + + let index = i64::try_from(event.address.index)?; + let sub_index = i64::try_from(event.address.subindex)?; + let amount = i64::try_from(event.amount.micro_ccd)?; + let module_reference = event.origin_ref; + let name = event.init_name.to_string(); + + Ok(Self { + index, + sub_index, + module_reference: module_reference.into(), + amount, + name, + height, + tx_index, + }) + } + + async fn save( + &self, + tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, + ) -> anyhow::Result<()> { + sqlx::query!( + r#"INSERT INTO contracts ( + index, + sub_index, + module_reference, + name, + amount, + init_block_height, + init_transaction_index + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7 + )"#, + self.index, + self.sub_index, + self.module_reference, + self.name, + self.amount, + self.height, + self.tx_index, + ) + .execute(tx.as_mut()) + .await?; + Ok(()) + } +} From e8625e10fe3356ce1a5713948cc2e320909ef013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Sat, 12 Oct 2024 13:25:47 +0200 Subject: [PATCH 32/50] Fix in-flight query metrics --- backend-rust/src/graphql_api.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index aa1321bf..a3b5a18c 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -189,6 +189,7 @@ mod monitor { let label = QueryLabels { query: operation_name.unwrap_or("").to_owned(), }; + self.in_flight_requests.get_or_create(&label).inc(); self.total_requests.get_or_create(&label).inc(); let start = Instant::now(); let response = next.run(ctx, operation_name).await; From c7c3e2edfedfed24829a6bdbc4c32dc931299117 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Sun, 13 Oct 2024 11:55:12 +0300 Subject: [PATCH 33/50] Add contracts query --- ...85f713224a9248d76eac2b12d1c4dc539312a.json | 73 +++++++++ .../migrations/0001_initialize.up.sql | 1 - backend-rust/src/graphql_api.rs | 147 ++++++++++++++++-- 3 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json diff --git a/backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json b/backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json new file mode 100644 index 00000000..6e145d20 --- /dev/null +++ b/backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json @@ -0,0 +1,73 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT * FROM (\n SELECT\n contracts.index as index,\n sub_index,\n module_reference,\n name as contract_name,\n contracts.amount,\n blocks.slot_time as block_slot_time,\n init_block_height as block_height,\n transactions.hash as transaction_hash,\n accounts.address as creator\n FROM contracts\n JOIN blocks ON init_block_height=blocks.height\n JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index\n JOIN accounts ON transactions.sender=accounts.index\n WHERE contracts.index > $1 AND contracts.index < $2\n ORDER BY\n (CASE WHEN $4 THEN contracts.index END) DESC,\n (CASE WHEN NOT $4 THEN contracts.index END) ASC\n LIMIT $3\n) AS contract_data\nORDER BY contract_data.index ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "index", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "sub_index", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "module_reference", + "type_info": "Bpchar" + }, + { + "ordinal": 3, + "name": "contract_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "block_slot_time", + "type_info": "Timestamp" + }, + { + "ordinal": 6, + "name": "block_height", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "transaction_hash", + "type_info": "Bpchar" + }, + { + "ordinal": 8, + "name": "creator", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a" +} diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index 38d69f8a..cd4b36ae 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -284,7 +284,6 @@ CREATE TABLE contracts( -- Module reference of the wasm module. module_reference CHAR(64) - UNIQUE NOT NULL, -- The contract name. name diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index f8215d46..9696a1a7 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -26,7 +26,7 @@ use concordium_rust_sdk::{id::types as sdk_types, types::AmountFraction}; use futures::prelude::*; use prometheus_client::registry::Registry; use sqlx::{postgres::types::PgInterval, PgPool}; -use std::{error::Error, str::FromStr as _, sync::Arc}; +use std::{error::Error, str::FromStr, sync::Arc}; use tokio::{net::TcpListener, sync::broadcast}; use tokio_util::sync::CancellationToken; @@ -44,6 +44,8 @@ pub struct ApiServiceConfig { block_connection_limit: i64, #[arg(long, env = "CCDSCAN_API_CONFIG_ACCOUNT_CONNECTION_LIMIT", default_value = "100")] account_connection_limit: i64, + #[arg(long, env = "CCDSCAN_API_CONFIG_CONTRACT_CONNECTION_LIMIT", default_value = "100")] + contract_connection_limit: i64, #[arg( long, env = "CCDSCAN_API_CONFIG_TRANSACTION_EVENT_CONNECTION_LIMIT", @@ -272,6 +274,8 @@ enum ApiError { InvalidInt(#[from] std::num::TryFromIntError), #[error("Invalid integer: {0}")] InvalidIntString(#[from] std::num::ParseIntError), + #[error("Parse error: {0}")] + UnsignedLongParse(#[from] UnsignedLongParseError), } impl From for ApiError { @@ -773,18 +777,21 @@ contract_address_index.0 as i64,contract_address_sub_index.0 as i64 .ok_or(ApiError::NotFound)?; let snapshot = ContractSnapshot { - block_height: row.block_height, - contract_address_index: contract_address_index.clone(), - contract_address_sub_index: contract_address_sub_index.clone(), - contract_name: row.contract_name, - module_reference: row.module_reference, - amount: row.amount, + block_height: row.block_height, + contract_address_index, + contract_address_sub_index, + contract_name: row.contract_name, + module_reference: row.module_reference, + amount: row.amount, }; Ok(Contract { contract_address_index, contract_address_sub_index, - contract_address: "<>".to_string(), + contract_address: format!( + "<{},{}>", + contract_address_index, contract_address_sub_index + ), creator: row.creator.into(), block_height: row.block_height, transaction_hash: row.transaction_hash, @@ -793,6 +800,101 @@ contract_address_index.0 as i64,contract_address_sub_index.0 as i64 }) } + async fn contracts<'a>( + &self, + ctx: &Context<'a>, + #[graphql(desc = "Returns the first _n_ elements from the list.")] first: Option, + #[graphql(desc = "Returns the elements in the list that come after the specified cursor.")] + after: Option, + #[graphql(desc = "Returns the last _n_ elements from the list.")] last: Option, + #[graphql(desc = "Returns the elements in the list that come before the specified cursor.")] + before: Option, + ) -> ApiResult> { + let config = get_config(ctx)?; + let pool = get_pool(ctx)?; + let query = ConnectionQuery::::new( + first, + after, + last, + before, + config.contract_connection_limit, + )?; + + let mut row_stream = sqlx::query!( + r#" +SELECT * FROM ( + SELECT + contracts.index as index, + sub_index, + module_reference, + name as contract_name, + contracts.amount, + blocks.slot_time as block_slot_time, + init_block_height as block_height, + transactions.hash as transaction_hash, + accounts.address as creator + FROM contracts + JOIN blocks ON init_block_height=blocks.height + JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index + JOIN accounts ON transactions.sender=accounts.index + WHERE contracts.index > $1 AND contracts.index < $2 + ORDER BY + (CASE WHEN $4 THEN contracts.index END) DESC, + (CASE WHEN NOT $4 THEN contracts.index END) ASC + LIMIT $3 +) AS contract_data +ORDER BY contract_data.index ASC"#, + query.from, + query.to, + query.limit, + query.desc + ) + .fetch(pool); + + let mut connection = connection::Connection::new(true, true); + + while let Some(row) = row_stream.try_next().await? { + let contract_address_index = row.index.try_into()?; + let contract_address_sub_index = row.sub_index.try_into()?; + + let snapshot = ContractSnapshot { + block_height: row.block_height, + contract_address_index, + contract_address_sub_index, + contract_name: row.contract_name, + module_reference: row.module_reference, + amount: row.amount, + }; + + let contract = Contract { + contract_address_index, + contract_address_sub_index, + contract_address: format!( + "<{},{}>", + contract_address_index, contract_address_sub_index + ), + creator: row.creator.into(), + block_height: row.block_height, + transaction_hash: row.transaction_hash, + block_slot_time: row.block_slot_time, + snapshot, + }; + connection + .edges + .push(connection::Edge::new(contract.contract_address_index.to_string(), contract)); + } + + if last.is_some() { + if let Some(edge) = connection.edges.last() { + connection.has_previous_page = edge.node.contract_address_index.0 != 0; + } + } else if let Some(edge) = connection.edges.first() { + connection.has_previous_page = edge.node.contract_address_index.0 != 0; + } + + Ok(connection) + } + async fn module_reference_event<'a>( &self, ctx: &Context<'a>, @@ -897,7 +999,16 @@ impl SubscriptionContext { /// The UnsignedLong scalar type represents a unsigned 64-bit numeric /// non-fractional value greater than or equal to 0. -#[derive(Clone, serde::Serialize, serde::Deserialize, derive_more::From)] +#[derive( + Clone, + Copy, + derive_more::Display, + Debug, + serde::Serialize, + serde::Deserialize, + derive_more::From, + derive_more::FromStr, +)] #[repr(transparent)] #[serde(transparent)] struct UnsignedLong(u64); @@ -917,6 +1028,24 @@ impl ScalarType for UnsignedLong { fn to_value(&self) -> Value { Value::Number(self.0.into()) } } +#[derive(Debug, thiserror::Error, Clone)] +enum UnsignedLongParseError { + #[error("Negative number cannot be converted to UnsignedLong.")] + NotNegative, +} + +impl TryFrom for UnsignedLong { + type Error = UnsignedLongParseError; + + fn try_from(number: i64) -> Result { + if number < 0 { + Err(UnsignedLongParseError::NotNegative)? + } else { + Ok(UnsignedLong(number as u64)) + } + } +} + /// The `Long` scalar type represents non-fractional signed whole 64-bit numeric /// values. Long can represent values between -(2^63) and 2^63 - 1. #[derive(serde::Serialize, serde::Deserialize, derive_more::From)] From b4a0dcae1bdb15e3d92036d9f8168df589b2e101 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Mon, 14 Oct 2024 12:55:12 +0300 Subject: [PATCH 34/50] Update Readme --- backend-rust/ExampleQuery.png | Bin 0 -> 206330 bytes backend-rust/README.md | 99 ++++++++++++++++++++++++++++++-- backend-rust/src/graphql_api.rs | 2 +- 3 files changed, 94 insertions(+), 7 deletions(-) create mode 100644 backend-rust/ExampleQuery.png diff --git a/backend-rust/ExampleQuery.png b/backend-rust/ExampleQuery.png new file mode 100644 index 0000000000000000000000000000000000000000..eef4cd7eec284e4d85db3a90481684f801bc2590 GIT binary patch literal 206330 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 ## Run the GraphQL API Service -For instructions how to use the API service run: +For instructions on how to use the API service run: ``` -ccdscan-api --help +cargo run --bin ccdscan-api -- --help +``` + +Example: + +``` +cargo run --bin ccdscan-api ``` +### GraphiQL IDE + +Starting the GraphQL API Service above will provide you an interface +(usually at 127.0.0.1:8000) to execute graphQL querries. + +An examples is shown below: + +Query: + +``` +query ($after: String, $before: String, $first: Int, $last: Int) { + blocks(after: $after, before: $before, first: $first, last: $last) { + nodes { + id + bakerId + blockHash + blockHeight + blockSlotTime + finalized + transactionCount + __typename + } + pageInfo { + startCursor + endCursor + hasPreviousPage + hasNextPage + __typename + } + __typename + } +} + +``` + +Variables: + +``` +{"first": 5} +``` + +![ExampleQuery](./ExampleQuery.png) ## Setup for development @@ -43,7 +102,7 @@ To develop this service the following tools are required, besides the dependenci - [Rust and cargo](https://rustup.rs/) - [sqlx-cli](https://crates.io/crates/sqlx-cli) -This project have some dependencies tracked as Git submodules, so make sure to initialize these: +This project has some dependencies tracked as Git submodules, so make sure to initialize these: ``` git submodule update --init --recursive @@ -57,6 +116,12 @@ Both services depend on having a PostgreSQL server running, this can be done in docker run -p 5432:5432 -e 'POSTGRES_PASSWORD=example' -e 'POSTGRES_DB=ccd-scan' postgres:16 ``` +Alternatively set up the database from the `docker-compose` file with the command below: + +``` +docker compose up +``` + ### Initializing a database Then set the environment variable `DATABASE_URL` pointing to the location of the SQL database, this can be done by creating a `.env` file within this directory. @@ -73,7 +138,7 @@ With the environment variable `DATABASE_URL` set, use the `sqlx` CLI to setup th sqlx migrate run ``` -The project can now be build using `cargo build` +The project can now be built using `cargo build` ### Database migrations @@ -91,3 +156,25 @@ This will create two files in the directory: - `_.up.sql` for the SQL code to bring the database up from the previous version. - `_.down.sql` for the SQL code reverting back to the previous version. + +### Database deletion + +If you started the database with the `docker-compose` file and you want to restart the database with fresh data, +delete the data in the `data` folder with the command: + +``` +sudo rm -r data/ +``` + +### `sqlx` features + +The tool validates database queries at compile-time, ensuring they are both syntactically +correct and type-safe. To take advantage of this feature, you should run the following +command every time you update the database schema. +Run a live database with the new schema and execute the command: + +``` +cargo sqlx prepare +``` + +This will generate type metadata for the queries in the `.sqlx` folder. diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 9696a1a7..53d7cc47 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -2171,7 +2171,7 @@ struct BlockStatistics { } #[derive(Interface)] -// #[allow(clippy::duplicated_attributes)] +#[allow(clippy::duplicated_attributes)] #[graphql( field(name = "euro_per_energy", ty = "&ExchangeRate"), field(name = "micro_ccd_per_euro", ty = "&ExchangeRate"), From ffc40257e4f0d2be01cf3cc66f8bf3546032022a Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Mon, 14 Oct 2024 13:13:10 +0300 Subject: [PATCH 35/50] Add TLS support --- backend-rust/Cargo.lock | 136 ++++++++++++++++++ backend-rust/Cargo.toml | 2 +- backend-rust/README.md | 1 + .../migrations/0001_initialize.up.sql | 3 +- backend-rust/src/indexer.rs | 19 ++- 5 files changed, 157 insertions(+), 4 deletions(-) diff --git a/backend-rust/Cargo.lock b/backend-rust/Cargo.lock index 75aa025d..e3dc8f90 100644 --- a/backend-rust/Cargo.lock +++ b/backend-rust/Cargo.lock @@ -1019,6 +1019,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -2268,6 +2278,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + [[package]] name = "overload" version = "0.1.1" @@ -2713,6 +2729,21 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin 0.9.8", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rkyv" version = "0.7.44" @@ -2806,6 +2837,49 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.16" @@ -2818,12 +2892,31 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "seahash" version = "4.1.0" @@ -2848,6 +2941,29 @@ dependencies = [ "cc", ] +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags 2.5.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.23" @@ -3498,6 +3614,16 @@ dependencies = [ "syn 2.0.61", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -3583,7 +3709,11 @@ dependencies = [ "percent-encoding", "pin-project", "prost", + "rustls", + "rustls-native-certs", + "rustls-pemfile", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -3773,6 +3903,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.0" diff --git a/backend-rust/Cargo.toml b/backend-rust/Cargo.toml index b153bb1a..68ed637c 100644 --- a/backend-rust/Cargo.toml +++ b/backend-rust/Cargo.toml @@ -34,7 +34,7 @@ rust_decimal = "1.35" iso8601-duration = { version = "0.2", features = ["chrono"] } tokio-util = "0.7" prometheus-client = "0.22" -tonic = "0.10.2" +tonic = { version = "0.10.2", features = ["tls-roots", "tls"]} # Recommended by SQLx to speed up incremental builds [profile.dev.package.sqlx-macros] diff --git a/backend-rust/README.md b/backend-rust/README.md index 1643b17e..e4f35155 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -178,3 +178,4 @@ cargo sqlx prepare ``` This will generate type metadata for the queries in the `.sqlx` folder. + diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index cd4b36ae..23b43e4c 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -265,7 +265,6 @@ CREATE TABLE smart_contract_modules( deployment_transaction_index BIGINT NOT NULL, - -- TODO: Would be nice to use BYTEA here (should be propagated to the front end). -- Embedded schema in the wasm module if present. schema TEXT ); @@ -280,7 +279,7 @@ CREATE TABLE contracts( sub_index BIGINT NOT NULL, - -- TODO: It might be better to use `module_reference_index` which would save storage space but would require more work in inserting/querying by the indexer. + -- Note: It might be better to use `module_reference_index` which would save storage space but would require more work in inserting/querying by the indexer. -- Module reference of the wasm module. module_reference CHAR(64) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 23adfdf6..5884f52a 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -130,7 +130,24 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 /// Run the service. This future will only stop when signaled by the /// `cancel_token`. pub async fn run(self, cancel_token: CancellationToken) -> anyhow::Result<()> { - let traverse_config = TraverseConfig::new(self.endpoints, self.start_height.into()) + // Set up endpoints to the node. + let mut endpoints_with_schema = Vec::new(); + for endpoint in self.endpoints { + if endpoint + .uri() + .scheme() + .map_or(false, |x| x == &concordium_rust_sdk::v2::Scheme::HTTPS) + { + let new_endpoint = endpoint + .tls_config(tonic::transport::ClientTlsConfig::new()) + .context("Unable to construct TLS configuration for the Concordium node.")?; + endpoints_with_schema.push(new_endpoint); + } else { + endpoints_with_schema.push(endpoint); + } + } + + let traverse_config = TraverseConfig::new(endpoints_with_schema, self.start_height.into()) .context("Failed setting up TraverseConfig")? .set_max_parallel(self.config.max_parallel_block_preprocessors) .set_max_behind(std::time::Duration::from_secs(self.config.node_max_behind)); From 9b781a1749162a69777c208ed8add1c1631b5d5c Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Tue, 15 Oct 2024 18:49:42 +0300 Subject: [PATCH 36/50] Address comments --- backend-rust/README.md | 23 +++++++++++------------ backend-rust/src/graphql_api.rs | 10 +++++----- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/backend-rust/README.md b/backend-rust/README.md index e4f35155..0a56d7b5 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -55,9 +55,9 @@ cargo run --bin ccdscan-api ### GraphiQL IDE Starting the GraphQL API Service above will provide you an interface -(usually at 127.0.0.1:8000) to execute graphQL querries. +(usually at 127.0.0.1:8000) to execute GraphQL queries. -An examples is shown below: +An example is shown below: Query: @@ -156,18 +156,10 @@ This will create two files in the directory: - `_.up.sql` for the SQL code to bring the database up from the previous version. - `_.down.sql` for the SQL code reverting back to the previous version. - -### Database deletion - -If you started the database with the `docker-compose` file and you want to restart the database with fresh data, -delete the data in the `data` folder with the command: - -``` -sudo rm -r data/ -``` - ### `sqlx` features +- Feature 1: + The tool validates database queries at compile-time, ensuring they are both syntactically correct and type-safe. To take advantage of this feature, you should run the following command every time you update the database schema. @@ -179,3 +171,10 @@ cargo sqlx prepare This will generate type metadata for the queries in the `.sqlx` folder. +- Feature 2: + +If you want to update your database to a new schema, execute the command: + +``` +sqlx database reset --force +``` diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 53d7cc47..3f823f49 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -837,11 +837,11 @@ SELECT * FROM ( JOIN blocks ON init_block_height=blocks.height JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index JOIN accounts ON transactions.sender=accounts.index - WHERE contracts.index > $1 AND contracts.index < $2 - ORDER BY - (CASE WHEN $4 THEN contracts.index END) DESC, - (CASE WHEN NOT $4 THEN contracts.index END) ASC - LIMIT $3 + WHERE contracts.index > $1 AND contracts.index < $2 + ORDER BY + (CASE WHEN $4 THEN contracts.index END) DESC, + (CASE WHEN NOT $4 THEN contracts.index END) ASC + LIMIT $3 ) AS contract_data ORDER BY contract_data.index ASC"#, query.from, From e0b4e15c980f6f8ee111ef095271ac82e01b0c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 21 Oct 2024 09:19:24 +0200 Subject: [PATCH 37/50] Temporarily enable CI checks PRs on the rust-backend --- .github/workflows/check-format-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-format-build.yml b/.github/workflows/check-format-build.yml index fcdef409..d3193692 100644 --- a/.github/workflows/check-format-build.yml +++ b/.github/workflows/check-format-build.yml @@ -4,7 +4,7 @@ on: push: branches: main pull_request: - branches: [ main ] + branches: [ main, rust-backend ] env: RUST_FMT: "nightly-2023-04-01" From 871dd00c45a15f6a326f1274c893fcce7dd1efa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 17 Oct 2024 21:17:32 +0200 Subject: [PATCH 38/50] Batch insert blocks --- backend-rust/src/indexer.rs | 173 ++++++++++++++++++++++-------------- 1 file changed, 108 insertions(+), 65 deletions(-) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index ccec023b..6981c773 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -399,12 +399,11 @@ impl BlockProcessor { max_successive_failures: u32, registry: &mut Registry, ) -> anyhow::Result { - let starting_context = sqlx::query_as!( - BlockProcessingContext, + let last_finalized_block = sqlx::query!( r#" SELECT - height as last_finalized_height, - hash as last_finalized_hash + height, + hash FROM blocks WHERE finalization_time IS NULL ORDER BY height ASC @@ -415,6 +414,24 @@ LIMIT 1 .await .context("Failed to query data for save context")?; + let last_block = sqlx::query!( + r#" +SELECT + slot_time +FROM blocks +ORDER BY height DESC +LIMIT 1 +"# + ) + .fetch_one(&pool) + .await + .context("Failed to query data for save context")?; + + let starting_context = BlockProcessingContext { + last_finalized_hash: last_finalized_block.hash, + last_block_slot_time: last_block.slot_time, + }; + let blocks_processed = Counter::default(); registry.register( "blocks_processed", @@ -466,22 +483,20 @@ impl ProcessEvent for BlockProcessor { let start_time = Instant::now(); let mut out = format!("Processed {} blocks:", batch.len()); let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; - let mut override_context = None; - for data in batch { - let context = override_context.as_ref().unwrap_or(self.current_context.borrow()); - let new_context = data.save(context, &mut tx).await.context("Failed saving block")?; - if new_context.is_some() { - override_context = new_context; + + let new_context = + PreparedBlock::batch_save(batch, self.current_context.borrow(), &mut tx).await?; + for block in batch { + for item in block.prepared_block_items.iter() { + item.save(&mut tx).await?; } - out.push_str(format!("\n- {}:{}", data.height, data.hash).as_str()) + out.push_str(format!("\n- {}:{}", block.height, block.hash).as_str()) } tx.commit().await.context("Failed to commit SQL transaction")?; - if let Some(context) = override_context { - self.current_context = context; - } let duration = start_time.elapsed(); - self.processing_duration_seconds.observe(duration.as_secs_f64()); + self.processing_duration_seconds.observe(duration.as_secs_f64() / batch.len() as f64); self.blocks_processed.inc_by(u64::try_from(batch.len())?); + self.current_context = new_context; Ok(out) } @@ -505,13 +520,14 @@ impl ProcessEvent for BlockProcessor { } } +#[derive(Clone)] struct BlockProcessingContext { - /// The last finalized block height according to the latest indexed block. - /// This is needed in order to compute the finalization time of blocks. - last_finalized_height: i64, /// The last finalized block hash according to the latest indexed block. - /// This is needed in order to compute the finalization time of blocks. - last_finalized_hash: String, + /// This is used when computing the finalization time. + last_finalized_hash: String, + /// The slot time of the last processed block. + /// This is used when computing the block time. + last_block_slot_time: NaiveDateTime, } /// Raw block information fetched from a Concordium Node. @@ -672,59 +688,86 @@ impl PreparedBlock { }) } - async fn save( - &self, + async fn batch_save( + batch: &Vec, context: &BlockProcessingContext, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, - ) -> anyhow::Result> { + ) -> anyhow::Result { + let mut height = Vec::new(); + let mut hash = Vec::new(); + let mut slot_time = Vec::new(); + let mut baker_id = Vec::new(); + let mut total_amount = Vec::new(); + let mut total_staked = Vec::new(); + let mut block_time = Vec::new(); + + let mut finalizers = Vec::new(); + let mut last_finalized = Vec::new(); + let mut finalizers_slot_time = Vec::new(); + + let mut new_context = context.clone(); + for block in batch { + height.push(block.height); + hash.push(block.hash.clone()); + slot_time.push(block.slot_time); + baker_id.push(block.baker_id); + total_amount.push(block.total_amount); + total_staked.push(block.total_staked); + block_time.push( + block + .slot_time + .signed_duration_since(new_context.last_block_slot_time) + .num_milliseconds(), + ); + new_context.last_block_slot_time = block.slot_time; + + if block.block_last_finalized != new_context.last_finalized_hash { + finalizers.push(block.height); + finalizers_slot_time.push(block.slot_time); + last_finalized.push(block.block_last_finalized.clone()); + + new_context.last_finalized_hash = block.block_last_finalized.clone(); + } + } + sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) -VALUES ($1, $2, $3, - (SELECT EXTRACT("MILLISECONDS" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)), - $4, $5, $6);"#, - self.height, - self.hash, - self.slot_time, - self.baker_id, - self.total_amount, - self.total_staked + r#"INSERT INTO blocks + (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) +SELECT * FROM UNNEST( + $1::BIGINT[], + $2::text[], + $3::timestamp[], + $4::bigint[], + $5::bigint[], + $6::bigint[], + $7::bigint[] +);"#, + &height, + &hash, + &slot_time, + &block_time, + &baker_id as &[Option], + &total_amount, + &total_staked ) .execute(tx.as_mut()) - .await?; - - // Check if this block knows of a new finalized block. - // If so, mark the blocks since last time as finalized by this block. - let new_context = if self.block_last_finalized != context.last_finalized_hash { - let last_height = context.last_finalized_height; + .await?; - let rec = sqlx::query!( - r#" -WITH finalizer - AS (SELECT height FROM blocks WHERE hash = $1) + let rec = sqlx::query!( + r#" UPDATE blocks b - SET finalization_time = EXTRACT("MILLISECONDS" FROM $3 - b.slot_time), + SET finalization_time = EXTRACT("MILLISECONDS" FROM finalizer.slot_time - b.slot_time), finalized_by = finalizer.height -FROM finalizer -WHERE $2 <= b.height AND b.height < finalizer.height -RETURNING finalizer.height"#, - self.block_last_finalized, - last_height, - self.slot_time - ) - .fetch_one(tx.as_mut()) - .await - .context("Failed updating finalization_time")?; - let new_context = BlockProcessingContext { - last_finalized_hash: self.block_last_finalized.clone(), - last_finalized_height: rec.height, - }; - Some(new_context) - } else { - None - }; - for item in self.prepared_block_items.iter() { - item.save(tx).await?; - } +FROM UNNEST($1::BIGINT[], $2::text[], $3::timestamp[]) AS finalizer(height, finalized, slot_time) +JOIN blocks l ON finalizer.finalized = l.hash +WHERE b.finalization_time IS NULL AND b.height <= l.height +"#, + &finalizers, + &last_finalized, + &finalizers_slot_time + ) + .execute(tx.as_mut()) + .await?; Ok(new_context) } } From b8ee77b7c188095c30ff8b29a2ceedd8e95dcc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 17 Oct 2024 22:10:31 +0200 Subject: [PATCH 39/50] Allow preprocessing and processing to be done in parallel --- backend-rust/src/indexer.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 6981c773..5a28d405 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -138,11 +138,14 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 let (sender, receiver) = tokio::sync::mpsc::channel(self.config.max_processing_batch); let receiver = tokio_stream::wrappers::ReceiverStream::from(receiver) .ready_chunks(self.config.max_processing_batch); - let traverse_future = traverse_config.traverse(self.block_pre_processor, sender); - let process_future = processor_config.process_event_stream(self.block_processor, receiver); + let traverse_future = + tokio::spawn(traverse_config.traverse(self.block_pre_processor, sender)); + let process_future = + tokio::spawn(processor_config.process_event_stream(self.block_processor, receiver)); info!("Indexing from block height {}", self.start_height); - let (result, ()) = futures::join!(traverse_future, process_future); - Ok(result?) + let (traverse_result, process_result) = futures::join!(traverse_future, process_future); + process_result?; + Ok(traverse_result??) } } From 8c7f5149244807d049a3e96bce021055163ac91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 21 Oct 2024 09:23:17 +0200 Subject: [PATCH 40/50] Update query cache --- ...41eda3d128a04340dc9eaa8fb688a39a71fbe.json | 24 ----------------- ...5be2ea50b1725bd8ac6b65f37cbfb35235057.json | 26 +++++++++++++++++++ ...d3b629221191b75441b53d1f1d2d949a4250b.json | 20 ++++++++++++++ ...3bf3cb6f4fd2799a1925a9a28907f78385043.json | 20 ++++++++++++++ ...d870b37bf770fafe1f30aa2c28fd3f9262eaa.json | 16 ++++++++++++ ...78d250ea046851b2446c61d88ad2b1c97849c.json | 19 -------------- ...3513fb0b1f40abc01a4ba48b6c861925cdc03.json | 26 ------------------- 7 files changed, 82 insertions(+), 69 deletions(-) delete mode 100644 backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json create mode 100644 backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json create mode 100644 backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json create mode 100644 backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json create mode 100644 backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json delete mode 100644 backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json delete mode 100644 backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json diff --git a/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json b/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json deleted file mode 100644 index 4c0b3021..00000000 --- a/backend-rust/.sqlx/query-1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nWITH finalizer\n AS (SELECT height FROM blocks WHERE hash = $1)\nUPDATE blocks b\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM $3 - b.slot_time),\n finalized_by = finalizer.height\nFROM finalizer\nWHERE $2 <= b.height AND b.height < finalizer.height\nRETURNING finalizer.height", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "height", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Bpchar", - "Int8", - "Timestamp" - ] - }, - "nullable": [ - false - ] - }, - "hash": "1c8291787209470cd9bf025bc9641eda3d128a04340dc9eaa8fb688a39a71fbe" -} diff --git a/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json b/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json new file mode 100644 index 00000000..83f19a69 --- /dev/null +++ b/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n height,\n hash\nFROM blocks\nWHERE finalization_time IS NULL\nORDER BY height ASC\nLIMIT 1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false + ] + }, + "hash": "2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057" +} diff --git a/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json b/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json new file mode 100644 index 00000000..81017710 --- /dev/null +++ b/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n slot_time\nFROM blocks\nORDER BY height DESC\nLIMIT 1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "slot_time", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b" +} diff --git a/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json b/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json new file mode 100644 index 00000000..2326acf5 --- /dev/null +++ b/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO blocks\n (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nSELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::text[],\n $3::timestamp[],\n $4::bigint[],\n $5::bigint[],\n $6::bigint[],\n $7::bigint[]\n);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TimestampArray", + "Int8Array", + "Int8Array", + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043" +} diff --git a/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json b/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json new file mode 100644 index 00000000..dbcf78ca --- /dev/null +++ b/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE blocks b\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM finalizer.slot_time - b.slot_time),\n finalized_by = finalizer.height\nFROM UNNEST($1::BIGINT[], $2::text[], $3::timestamp[]) AS finalizer(height, finalized, slot_time)\nJOIN blocks l ON finalizer.finalized = l.hash\nWHERE b.finalization_time IS NULL AND b.height <= l.height\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TimestampArray" + ] + }, + "nullable": [] + }, + "hash": "a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa" +} diff --git a/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json b/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json deleted file mode 100644 index a4282e2a..00000000 --- a/backend-rust/.sqlx/query-c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO blocks (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nVALUES ($1, $2, $3,\n (SELECT EXTRACT(\"MILLISECONDS\" FROM $3 - b.slot_time) FROM blocks b WHERE b.height=($1 - 1::bigint)),\n $4, $5, $6);", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Bpchar", - "Timestamp", - "Int8", - "Int8", - "Int8" - ] - }, - "nullable": [] - }, - "hash": "c5dc978a5e0838b2f97696c0ef178d250ea046851b2446c61d88ad2b1c97849c" -} diff --git a/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json b/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json deleted file mode 100644 index f54a4ed4..00000000 --- a/backend-rust/.sqlx/query-e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nSELECT\n height as last_finalized_height,\n hash as last_finalized_hash\nFROM blocks\nWHERE finalization_time IS NULL\nORDER BY height ASC\nLIMIT 1\n", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "last_finalized_height", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "last_finalized_hash", - "type_info": "Bpchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "e80b5d3738ba93255f5f2b564983513fb0b1f40abc01a4ba48b6c861925cdc03" -} From 7604c7ee466405ecfc2bc312035cc14dcd4bd214 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Mon, 21 Oct 2024 10:52:47 +0300 Subject: [PATCH 41/50] Fix CI pipeline --- ...f145ef7018b81733954f0569eab5207afb47c8f.json} | 4 ++-- backend-rust/src/graphql_api.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) rename backend-rust/.sqlx/{query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json => query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json} (83%) diff --git a/backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json similarity index 83% rename from backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json rename to backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json index 6e145d20..0e39d8af 100644 --- a/backend-rust/.sqlx/query-e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a.json +++ b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT * FROM (\n SELECT\n contracts.index as index,\n sub_index,\n module_reference,\n name as contract_name,\n contracts.amount,\n blocks.slot_time as block_slot_time,\n init_block_height as block_height,\n transactions.hash as transaction_hash,\n accounts.address as creator\n FROM contracts\n JOIN blocks ON init_block_height=blocks.height\n JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index\n JOIN accounts ON transactions.sender=accounts.index\n WHERE contracts.index > $1 AND contracts.index < $2\n ORDER BY\n (CASE WHEN $4 THEN contracts.index END) DESC,\n (CASE WHEN NOT $4 THEN contracts.index END) ASC\n LIMIT $3\n) AS contract_data\nORDER BY contract_data.index ASC", + "query": "\nSELECT * FROM (\n SELECT\n contracts.index as index,\n sub_index,\n module_reference,\n name as contract_name,\n contracts.amount,\n blocks.slot_time as block_slot_time,\n init_block_height as block_height,\n transactions.hash as transaction_hash,\n accounts.address as creator\n FROM contracts\n JOIN blocks ON init_block_height=blocks.height\n JOIN transactions ON init_block_height=transactions.block_height AND init_transaction_index=transactions.index\n JOIN accounts ON transactions.sender=accounts.index\n WHERE contracts.index > $1 AND contracts.index < $2\n ORDER BY\n (CASE WHEN $4 THEN contracts.index END) DESC,\n (CASE WHEN NOT $4 THEN contracts.index END) ASC\n LIMIT $3\n) AS contract_data\nORDER BY contract_data.index ASC", "describe": { "columns": [ { @@ -69,5 +69,5 @@ false ] }, - "hash": "e20a43a933bcc5c2855d8acca8785f713224a9248d76eac2b12d1c4dc539312a" + "hash": "fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f" } diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 1a308c56..634d0a93 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -400,6 +400,10 @@ impl Query { before, config.block_connection_limit, )?; + // The CCDScan front-end currently expects an ASC order of the nodes/edges + // returned (outer `ORDER BY`), while the inner `ORDER BY` is a trick to + // get the correct nodes/edges selected based on the `after/before` key + // specified. let mut row_stream = sqlx::query_as!( Block, r#" @@ -469,6 +473,10 @@ SELECT * FROM ( before, config.transaction_connection_limit, )?; + // The CCDScan front-end currently expects an ASC order of the nodes/edges + // returned (outer `ORDER BY`), while the inner `ORDER BY` is a trick to + // get the correct nodes/edges selected based on the `after/before` key + // specified. let mut row_stream = sqlx::query_as!( Transaction, r#" @@ -549,6 +557,10 @@ SELECT * FROM ( before, config.account_connection_limit, )?; + // The CCDScan front-end currently expects an ASC order of the nodes/edges + // returned (outer `ORDER BY`), while the inner `ORDER BY` is a trick to + // get the correct nodes/edges selected based on the `after/before` key + // specified. let mut row_stream = sqlx::query_as!( Account, r#" @@ -821,6 +833,10 @@ contract_address_index.0 as i64,contract_address_sub_index.0 as i64 config.contract_connection_limit, )?; + // The CCDScan front-end currently expects an ASC order of the nodes/edges + // returned (outer `ORDER BY`), while the inner `ORDER BY` is a trick to + // get the correct nodes/edges selected based on the `after/before` key + // specified. let mut row_stream = sqlx::query!( r#" SELECT * FROM ( From 32edfecd6841008ba07fda0a9c4e85b8291c24a8 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Mon, 21 Oct 2024 13:00:57 +0300 Subject: [PATCH 42/50] Address comments --- ...a68c70e14052dc9f0e476a22d487d3b4060ea.json | 17 ----------------- ...0e9114c23ff5ef665a692d2ba1c3c31a50151.json | 2 +- ...abe9c9f4c75a7ed8d70298e544d739b7ea2f6.json | 2 +- ...c60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json | 17 +++++++++++++++++ ...45ef7018b81733954f0569eab5207afb47c8f.json | 2 +- backend-rust/README.md | 19 +++++++++++-------- .../migrations/0001_initialize.up.sql | 14 ++++++-------- backend-rust/src/graphql_api.rs | 18 +++++------------- backend-rust/src/indexer.rs | 2 -- 9 files changed, 42 insertions(+), 51 deletions(-) delete mode 100644 backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json create mode 100644 backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json diff --git a/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json b/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json deleted file mode 100644 index 7eaabd65..00000000 --- a/backend-rust/.sqlx/query-3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nINSERT INTO smart_contract_modules (\n index,\n module_reference,\n deployment_block_height,\n deployment_transaction_index,\n schema\n) VALUES (\n (SELECT COALESCE(MAX(index) + 1, 0) FROM smart_contract_modules),\n $1, $2, $3, $4\n)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bpchar", - "Int8", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "3efc8697b3ef3b60868a17ce678a68c70e14052dc9f0e476a22d487d3b4060ea" -} diff --git a/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json index 5daea882..7ae813ba 100644 --- a/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json +++ b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "contract_name", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, diff --git a/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json b/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json index 624c4bb2..17094415 100644 --- a/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json +++ b/backend-rust/.sqlx/query-8ab8b8b37231fc403415d98d54fabe9c9f4c75a7ed8d70298e544d739b7ea2f6.json @@ -8,7 +8,7 @@ "Int8", "Int8", "Bpchar", - "Text", + "Varchar", "Int8", "Int8", "Int8" diff --git a/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json b/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json new file mode 100644 index 00000000..8c35895d --- /dev/null +++ b/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\nINSERT INTO smart_contract_modules (\n module_reference,\n deployment_block_height,\n deployment_transaction_index,\n schema\n) VALUES (\n $1, $2, $3, $4\n)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bpchar", + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82" +} diff --git a/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json index 0e39d8af..bdbdf919 100644 --- a/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json +++ b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "contract_name", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 4, diff --git a/backend-rust/README.md b/backend-rust/README.md index 0a56d7b5..2b60d376 100644 --- a/backend-rust/README.md +++ b/backend-rust/README.md @@ -20,19 +20,20 @@ To run the services, the following dependencies are required to be available on For instructions on how to use the indexer run: ``` -cargo run --bin ccdscan-indexer -- --help +ccdscan-indexer --help ``` -Example: +## Run the Indexer Service during development + +Examples: ``` cargo run --bin ccdscan-indexer -- --node http://localhost:20001 --max-parallel-block-preprocessors 20 --max-processing-batch 20 -cargo run --bin ccdscan-indexer -- --node http://node.testnet.concordium.com:20000 -cargo run --bin ccdscan-indexer -- --node https://grpc.testnet.concordium.com:20000 +cargo run --bin ccdscan-indexer -- --node https://grpc.testnet.concordium.com:20000 --max-parallel-block-preprocessors 20 --max-processing-batch 20 ``` -Note: Queries like `getSourceModule` might timeout (are disabled) on our public-facing nodes. The recommendation is to run -your own local node during development. +Note: Since the indexer puts a lot of load on the node, use your own local node whenever possible. +If using the public nodes, run the indexer as short as possible. @@ -41,9 +42,11 @@ your own local node during development. For instructions on how to use the API service run: ``` -cargo run --bin ccdscan-api -- --help +ccdscan-api --help ``` +## Run the GraphQL API Service during development + Example: ``` @@ -173,7 +176,7 @@ This will generate type metadata for the queries in the `.sqlx` folder. - Feature 2: -If you want to update your database to a new schema, execute the command: +If you want to drop the entire database and start with an empty database that uses the current schema, execute the command: ``` sqlx database reset --force diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index 23b43e4c..4041b622 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -247,15 +247,13 @@ CREATE TABLE bakers( BIGINT ); --- Every WASM module on chain. +-- Every module on chain. CREATE TABLE smart_contract_modules( - index - BIGINT - PRIMARY KEY, - -- Module reference of the wasm module. + -- Module reference of the module. module_reference CHAR(64) UNIQUE + PRIMARY KEY NOT NULL, -- The absolute block height when the module was deployed. deployment_block_height @@ -265,7 +263,7 @@ CREATE TABLE smart_contract_modules( deployment_transaction_index BIGINT NOT NULL, - -- Embedded schema in the wasm module if present. + -- Embedded schema in the module if present. schema TEXT ); @@ -280,13 +278,13 @@ CREATE TABLE contracts( BIGINT NOT NULL, -- Note: It might be better to use `module_reference_index` which would save storage space but would require more work in inserting/querying by the indexer. - -- Module reference of the wasm module. + -- Module reference of the module. module_reference CHAR(64) NOT NULL, -- The contract name. name - TEXT + VARCHAR(100) NOT NULL, -- The total balance of the contract in micro CCD. amount diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 634d0a93..724cf582 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -276,7 +276,7 @@ enum ApiError { #[error("Invalid integer: {0}")] InvalidIntString(#[from] std::num::ParseIntError), #[error("Parse error: {0}")] - UnsignedLongParse(#[from] UnsignedLongParseError), + UnsignedLongNotNegative(#[from] UnsignedLongNotNegativeError), } impl From for ApiError { @@ -1046,21 +1046,13 @@ impl ScalarType for UnsignedLong { } #[derive(Debug, thiserror::Error, Clone)] -enum UnsignedLongParseError { - #[error("Negative number cannot be converted to UnsignedLong.")] - NotNegative, -} +#[error("Negative number cannot be converted to UnsignedLong.")] +struct UnsignedLongNotNegativeError; impl TryFrom for UnsignedLong { - type Error = UnsignedLongParseError; + type Error = >::Error; - fn try_from(number: i64) -> Result { - if number < 0 { - Err(UnsignedLongParseError::NotNegative)? - } else { - Ok(UnsignedLong(number as u64)) - } - } + fn try_from(number: i64) -> Result { Ok(UnsignedLong(number.try_into()?)) } } /// The `Long` scalar type represents non-fractional signed whole 64-bit numeric diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 5884f52a..7572204c 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -1425,13 +1425,11 @@ impl PreparedModuleDeployed { sqlx::query!( r#" INSERT INTO smart_contract_modules ( - index, module_reference, deployment_block_height, deployment_transaction_index, schema ) VALUES ( - (SELECT COALESCE(MAX(index) + 1, 0) FROM smart_contract_modules), $1, $2, $3, $4 )"#, self.module_reference, From f8d0659b18b66d579c20d4054b621a9b006ed153 Mon Sep 17 00:00:00 2001 From: Doris Benda Date: Mon, 21 Oct 2024 17:33:44 +0300 Subject: [PATCH 43/50] Use bytes for the schema --- ...f670f956292ba2e0b0f759e6ab98c9fd6662185c.json | 2 +- ...3aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json | 2 +- backend-rust/migrations/0001_initialize.up.sql | 2 +- backend-rust/src/graphql_api.rs | 16 ++++++++++++++-- backend-rust/src/indexer.rs | 6 +++--- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json index ac9593ec..729e26b8 100644 --- a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json +++ b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json @@ -16,7 +16,7 @@ { "ordinal": 2, "name": "display_schema", - "type_info": "Text" + "type_info": "Bytea" }, { "ordinal": 3, diff --git a/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json b/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json index 8c35895d..f87d3000 100644 --- a/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json +++ b/backend-rust/.sqlx/query-b039b622b29f180f689c7cb23aec60ab2a6f705d7c3455a4ba4051d0cd3f2d82.json @@ -8,7 +8,7 @@ "Bpchar", "Int8", "Int8", - "Text" + "Bytea" ] }, "nullable": [] diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index 4041b622..ee960470 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -264,7 +264,7 @@ CREATE TABLE smart_contract_modules( BIGINT NOT NULL, -- Embedded schema in the module if present. - schema TEXT + schema BYTEA ); -- Every contract instance on chain. diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 724cf582..1eabeb83 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -22,7 +22,11 @@ use async_graphql::{ }; use async_graphql_axum::GraphQLSubscription; use chrono::Duration; -use concordium_rust_sdk::{id::types as sdk_types, types::AmountFraction}; +use concordium_rust_sdk::{ + base::contracts_common::{from_bytes, schema::VersionedModuleSchema}, + id::types as sdk_types, + types::AmountFraction, +}; use futures::prelude::*; use prometheus_client::registry::Registry; use sqlx::{postgres::types::PgInterval, PgPool}; @@ -277,6 +281,8 @@ enum ApiError { InvalidIntString(#[from] std::num::ParseIntError), #[error("Parse error: {0}")] UnsignedLongNotNegative(#[from] UnsignedLongNotNegativeError), + #[error("Schema in database should be valid")] + InvalidModuleSchema, } impl From for ApiError { @@ -938,13 +944,19 @@ WHERE module_reference=$1 ).fetch_optional(pool).await? .ok_or(ApiError::NotFound)?; + let display_schema = row.display_schema.as_ref().map_or(Ok(None), |s| { + from_bytes::(s) + .map(|opt_schema| Some(opt_schema.to_string())) + .map_err(|_| ApiError::InvalidModuleSchema) + })?; + Ok(ModuleReferenceEvent { module_reference, sender: row.sender.into(), block_height: row.block_height, transaction_hash: row.transaction_hash, block_slot_time: row.block_slot_time, - display_schema: row.display_schema, + display_schema, }) } } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 7572204c..ae8689e6 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -8,7 +8,7 @@ use crate::graphql_api::{ use anyhow::Context; use chrono::NaiveDateTime; use concordium_rust_sdk::{ - base::smart_contracts::WasmVersion, + base::{contracts_common::to_bytes, smart_contracts::WasmVersion}, common::types::Amount, indexer::{async_trait, Indexer, ProcessEvent, TraverseConfig, TraverseError}, smart_contracts::engine::utils::{get_embedded_schema_v0, get_embedded_schema_v1}, @@ -1385,7 +1385,7 @@ struct PreparedModuleDeployed { block_height: i64, deployment_transaction_index: i64, module_reference: String, - schema: Option, + schema: Option>, } impl PreparedModuleDeployed { @@ -1408,7 +1408,7 @@ impl PreparedModuleDeployed { } .ok(); - let schema = schema.as_ref().map(|s| s.to_string()); + let schema = schema.as_ref().map(to_bytes); Ok(Self { block_height: i64::try_from(block_height.height)?, From 45d8c3b77ec650dce975cca235dc38fd3069061b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Mon, 21 Oct 2024 20:51:13 +0200 Subject: [PATCH 44/50] Address review comments --- ...5be2ea50b1725bd8ac6b65f37cbfb35235057.json | 26 ----- ...3bf3cb6f4fd2799a1925a9a28907f78385043.json | 20 ---- ...d870b37bf770fafe1f30aa2c28fd3f9262eaa.json | 16 --- backend-rust/src/indexer.rs | 106 ++++++++++-------- 4 files changed, 58 insertions(+), 110 deletions(-) delete mode 100644 backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json delete mode 100644 backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json delete mode 100644 backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json diff --git a/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json b/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json deleted file mode 100644 index 83f19a69..00000000 --- a/backend-rust/.sqlx/query-2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nSELECT\n height,\n hash\nFROM blocks\nWHERE finalization_time IS NULL\nORDER BY height ASC\nLIMIT 1\n", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "height", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "hash", - "type_info": "Bpchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "2ec7be1490136c5b79782b836595be2ea50b1725bd8ac6b65f37cbfb35235057" -} diff --git a/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json b/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json deleted file mode 100644 index 2326acf5..00000000 --- a/backend-rust/.sqlx/query-5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO blocks\n (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nSELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::text[],\n $3::timestamp[],\n $4::bigint[],\n $5::bigint[],\n $6::bigint[],\n $7::bigint[]\n);", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TimestampArray", - "Int8Array", - "Int8Array", - "Int8Array", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "5a464e4af840d6f4f480a17dd403bf3cb6f4fd2799a1925a9a28907f78385043" -} diff --git a/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json b/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json deleted file mode 100644 index dbcf78ca..00000000 --- a/backend-rust/.sqlx/query-a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\nUPDATE blocks b\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM finalizer.slot_time - b.slot_time),\n finalized_by = finalizer.height\nFROM UNNEST($1::BIGINT[], $2::text[], $3::timestamp[]) AS finalizer(height, finalized, slot_time)\nJOIN blocks l ON finalizer.finalized = l.hash\nWHERE b.finalization_time IS NULL AND b.height <= l.height\n", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8Array", - "TextArray", - "TimestampArray" - ] - }, - "nullable": [] - }, - "hash": "a41cc786813cd5538be83a0d90ed870b37bf770fafe1f30aa2c28fd3f9262eaa" -} diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 5a28d405..fadc54ba 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -31,7 +31,6 @@ use prometheus_client::{ registry::Registry, }; use sqlx::PgPool; -use std::borrow::Borrow; use tokio::{time::Instant, try_join}; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -405,11 +404,10 @@ impl BlockProcessor { let last_finalized_block = sqlx::query!( r#" SELECT - height, hash FROM blocks -WHERE finalization_time IS NULL -ORDER BY height ASC +WHERE finalization_time IS NOT NULL +ORDER BY height DESC LIMIT 1 "# ) @@ -486,9 +484,10 @@ impl ProcessEvent for BlockProcessor { let start_time = Instant::now(); let mut out = format!("Processed {} blocks:", batch.len()); let mut tx = self.pool.begin().await.context("Failed to create SQL transaction")?; - - let new_context = - PreparedBlock::batch_save(batch, self.current_context.borrow(), &mut tx).await?; + // Clone the context, to avoid mutating the current context until we are certain + // nothing fails. + let mut new_context = self.current_context.clone(); + PreparedBlock::batch_save(batch, &mut new_context, &mut tx).await?; for block in batch { for item in block.prepared_block_items.iter() { item.save(&mut tx).await?; @@ -496,9 +495,12 @@ impl ProcessEvent for BlockProcessor { out.push_str(format!("\n- {}:{}", block.height, block.hash).as_str()) } tx.commit().await.context("Failed to commit SQL transaction")?; + // Update metrics. let duration = start_time.elapsed(); self.processing_duration_seconds.observe(duration.as_secs_f64() / batch.len() as f64); self.blocks_processed.inc_by(u64::try_from(batch.len())?); + // Update the current context when we are certain that nothing failed during + // processing. self.current_context = new_context; Ok(out) } @@ -692,44 +694,46 @@ impl PreparedBlock { } async fn batch_save( - batch: &Vec, - context: &BlockProcessingContext, + batch: &[Self], + context: &mut BlockProcessingContext, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, - ) -> anyhow::Result { - let mut height = Vec::new(); - let mut hash = Vec::new(); - let mut slot_time = Vec::new(); - let mut baker_id = Vec::new(); - let mut total_amount = Vec::new(); + ) -> anyhow::Result<()> { + let mut heights = Vec::new(); + let mut hashes = Vec::new(); + let mut slot_times = Vec::new(); + let mut baker_ids = Vec::new(); + let mut total_amounts = Vec::new(); let mut total_staked = Vec::new(); - let mut block_time = Vec::new(); + let mut block_times = Vec::new(); let mut finalizers = Vec::new(); - let mut last_finalized = Vec::new(); + let mut last_finalizeds = Vec::new(); let mut finalizers_slot_time = Vec::new(); - let mut new_context = context.clone(); for block in batch { - height.push(block.height); - hash.push(block.hash.clone()); - slot_time.push(block.slot_time); - baker_id.push(block.baker_id); - total_amount.push(block.total_amount); + heights.push(block.height); + hashes.push(block.hash.clone()); + slot_times.push(block.slot_time); + baker_ids.push(block.baker_id); + total_amounts.push(block.total_amount); total_staked.push(block.total_staked); - block_time.push( + block_times.push( block .slot_time - .signed_duration_since(new_context.last_block_slot_time) + .signed_duration_since(context.last_block_slot_time) .num_milliseconds(), ); - new_context.last_block_slot_time = block.slot_time; + context.last_block_slot_time = block.slot_time; - if block.block_last_finalized != new_context.last_finalized_hash { + // Check if this block knows of a new finalized block. + // If so, note it down so we can mark the blocks since last time as finalized by + // this block. + if block.block_last_finalized != context.last_finalized_hash { finalizers.push(block.height); finalizers_slot_time.push(block.slot_time); - last_finalized.push(block.block_last_finalized.clone()); + last_finalizeds.push(block.block_last_finalized.clone()); - new_context.last_finalized_hash = block.block_last_finalized.clone(); + context.last_finalized_hash = block.block_last_finalized.clone(); } } @@ -738,40 +742,46 @@ impl PreparedBlock { (height, hash, slot_time, block_time, baker_id, total_amount, total_staked) SELECT * FROM UNNEST( $1::BIGINT[], - $2::text[], - $3::timestamp[], - $4::bigint[], - $5::bigint[], - $6::bigint[], - $7::bigint[] + $2::TEXT[], + $3::TIMESTAMP[], + $4::BIGINT[], + $5::BIGINT[], + $6::BIGINT[], + $7::BIGINT[] );"#, - &height, - &hash, - &slot_time, - &block_time, - &baker_id as &[Option], - &total_amount, + &heights, + &hashes, + &slot_times, + &block_times, + &baker_ids as &[Option], + &total_amounts, &total_staked ) .execute(tx.as_mut()) .await?; + // With all blocks in the batch inserted we update blocks which we now can + // compute the finalization time for. Using the list of finalizer blocks + // (those containing a last finalized block different from its predecessor) + // we update the blocks below which does not contain finalization time and + // compute it to be the difference between the slot_time of the block and the + // finalizer block. let rec = sqlx::query!( r#" -UPDATE blocks b - SET finalization_time = EXTRACT("MILLISECONDS" FROM finalizer.slot_time - b.slot_time), +UPDATE blocks + SET finalization_time = EXTRACT("MILLISECONDS" FROM finalizer.slot_time - blocks.slot_time), finalized_by = finalizer.height -FROM UNNEST($1::BIGINT[], $2::text[], $3::timestamp[]) AS finalizer(height, finalized, slot_time) -JOIN blocks l ON finalizer.finalized = l.hash -WHERE b.finalization_time IS NULL AND b.height <= l.height +FROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMP[]) AS finalizer(height, finalized, slot_time) +JOIN blocks last ON finalizer.finalized = last.hash +WHERE blocks.finalization_time IS NULL AND blocks.height <= last.height "#, &finalizers, - &last_finalized, + &last_finalizeds, &finalizers_slot_time ) .execute(tx.as_mut()) .await?; - Ok(new_context) + Ok(()) } } From 97d3ec2ebd0ef7c40eb1fbb36e8ca4d9234ff35a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 23 Oct 2024 09:13:37 +0200 Subject: [PATCH 45/50] Add untracked sqlx queries --- ...c4bf7d7759696e9bf167a010d8d81d4a0f676.json | 20 +++++++++++++++++++ ...78e1737a302b81df505d07ec57d1e3c10d554.json | 20 +++++++++++++++++++ ...6f895551d46196c7343e4142a127dff9ad191.json | 16 +++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 backend-rust/.sqlx/query-1098f561527a09b752d50168e35c4bf7d7759696e9bf167a010d8d81d4a0f676.json create mode 100644 backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json create mode 100644 backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json diff --git a/backend-rust/.sqlx/query-1098f561527a09b752d50168e35c4bf7d7759696e9bf167a010d8d81d4a0f676.json b/backend-rust/.sqlx/query-1098f561527a09b752d50168e35c4bf7d7759696e9bf167a010d8d81d4a0f676.json new file mode 100644 index 00000000..b024904c --- /dev/null +++ b/backend-rust/.sqlx/query-1098f561527a09b752d50168e35c4bf7d7759696e9bf167a010d8d81d4a0f676.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\nSELECT\n hash\nFROM blocks\nWHERE finalization_time IS NOT NULL\nORDER BY height DESC\nLIMIT 1\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Bpchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "1098f561527a09b752d50168e35c4bf7d7759696e9bf167a010d8d81d4a0f676" +} diff --git a/backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json b/backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json new file mode 100644 index 00000000..0b30a75e --- /dev/null +++ b/backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO blocks\n (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nSELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::TEXT[],\n $3::TIMESTAMP[],\n $4::BIGINT[],\n $5::BIGINT[],\n $6::BIGINT[],\n $7::BIGINT[]\n);", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TimestampArray", + "Int8Array", + "Int8Array", + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554" +} diff --git a/backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json b/backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json new file mode 100644 index 00000000..08828671 --- /dev/null +++ b/backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE blocks\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM finalizer.slot_time - blocks.slot_time),\n finalized_by = finalizer.height\nFROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMP[]) AS finalizer(height, finalized, slot_time)\nJOIN blocks last ON finalizer.finalized = last.hash\nWHERE blocks.finalization_time IS NULL AND blocks.height <= last.height\n", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TimestampArray" + ] + }, + "nullable": [] + }, + "hash": "c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191" +} From 4b347c6a708ebf8c68a27ff954b9f4bdf1eb3706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 23 Oct 2024 11:10:00 +0200 Subject: [PATCH 46/50] Fix missing finalization_time for genesis block --- ...b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json} | 4 ++-- backend-rust/migrations/0001_initialize.up.sql | 5 +++-- backend-rust/src/indexer.rs | 5 ++++- 3 files changed, 9 insertions(+), 5 deletions(-) rename backend-rust/.sqlx/{query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json => query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json} (63%) diff --git a/backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json b/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json similarity index 63% rename from backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json rename to backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json index b136add8..f7132182 100644 --- a/backend-rust/.sqlx/query-4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635.json +++ b/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO blocks (height, hash, slot_time, block_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5);", + "query": "INSERT INTO blocks (height, hash, slot_time, block_time, finalization_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, 0, $4, $5);", "describe": { "columns": [], "parameters": { @@ -14,5 +14,5 @@ }, "nullable": [] }, - "hash": "4bf3a743a28e01f648bfa45be8e699f563b1d097925615b2a85dcf5852ad0635" + "hash": "fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f" } diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index c1a474d9..ed2760f2 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -86,11 +86,12 @@ CREATE TABLE blocks( block_time INTEGER NOT NULL, - -- Milliseconds between the slot_time of this block and the block above causing this block to be finalized. + -- Milliseconds between the slot_time of this block and the first block above where this was + -- recorded as finalized. -- This is NULL until the indexer have processed the block marking this a finalized. finalization_time INTEGER, - -- Block causing this block to become finalized. + -- Block where this block was first recorded as finalized. -- This is NULL until the indexer have processed the block marking this a finalized. finalized_by BIGINT diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index fadc54ba..4e806da9 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -142,6 +142,9 @@ SELECT height FROM blocks ORDER BY height DESC LIMIT 1 let process_future = tokio::spawn(processor_config.process_event_stream(self.block_processor, receiver)); info!("Indexing from block height {}", self.start_height); + // Wait for both processes to exit, in case one of them results in an error, + // wait for the other which then eventually will stop gracefully as either end + // of their channel will get dropped. let (traverse_result, process_result) = futures::join!(traverse_future, process_future); process_result?; Ok(traverse_result??) @@ -572,7 +575,7 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res let total_amount = i64::try_from(genesis_tokenomics.common_reward_data().total_amount.micro_ccd())?; sqlx::query!( - r#"INSERT INTO blocks (height, hash, slot_time, block_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, $4, $5);"#, + r#"INSERT INTO blocks (height, hash, slot_time, block_time, finalization_time, total_amount, total_staked) VALUES ($1, $2, $3, 0, 0, $4, $5);"#, 0, block_hash, slot_time, From e3327896eb125ae0efc4584f24689d280697aca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Wed, 23 Oct 2024 11:18:27 +0200 Subject: [PATCH 47/50] Use with_capacity for fields during block batching --- backend-rust/src/indexer.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 4e806da9..5e74ab97 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -701,17 +701,17 @@ impl PreparedBlock { context: &mut BlockProcessingContext, tx: &mut sqlx::Transaction<'static, sqlx::Postgres>, ) -> anyhow::Result<()> { - let mut heights = Vec::new(); - let mut hashes = Vec::new(); - let mut slot_times = Vec::new(); - let mut baker_ids = Vec::new(); - let mut total_amounts = Vec::new(); - let mut total_staked = Vec::new(); - let mut block_times = Vec::new(); + let mut heights = Vec::with_capacity(batch.len()); + let mut hashes = Vec::with_capacity(batch.len()); + let mut slot_times = Vec::with_capacity(batch.len()); + let mut baker_ids = Vec::with_capacity(batch.len()); + let mut total_amounts = Vec::with_capacity(batch.len()); + let mut total_staked = Vec::with_capacity(batch.len()); + let mut block_times = Vec::with_capacity(batch.len()); - let mut finalizers = Vec::new(); - let mut last_finalizeds = Vec::new(); - let mut finalizers_slot_time = Vec::new(); + let mut finalizers = Vec::with_capacity(batch.len()); + let mut last_finalizeds = Vec::with_capacity(batch.len()); + let mut finalizers_slot_time = Vec::with_capacity(batch.len()); for block in batch { heights.push(block.height); From 017f4bc7c9bc8886bb4549eeadfae9cddcfe30b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 24 Oct 2024 07:28:40 +0200 Subject: [PATCH 48/50] Revert "Use rust-backend for block metrics" This reverts commit 1ecf0892db20b59b5039dbc36be308396cc578bc. --- frontend/nuxt.config.ts | 11 ------- frontend/src/queries/useChartBlockMetrics.ts | 6 +++- .../queries/useRewardMetricsForBakerQuery.ts | 33 +++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) create mode 100644 frontend/src/queries/useRewardMetricsForBakerQuery.ts diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 5d302a31..875d055e 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -35,17 +35,6 @@ const getConfig = (env: Environment): Config => { } export default defineNuxtConfig({ - vite: { - server: { - proxy: { - '/api': 'http://127.0.0.1:8000', - '/ws': { - target: 'ws://127.0.0.1:8000', - ws: true, - }, - }, - }, - }, srcDir: 'src/', components: [ '~/components', diff --git a/frontend/src/queries/useChartBlockMetrics.ts b/frontend/src/queries/useChartBlockMetrics.ts index 534704dd..64e2c769 100644 --- a/frontend/src/queries/useChartBlockMetrics.ts +++ b/frontend/src/queries/useChartBlockMetrics.ts @@ -21,8 +21,13 @@ const BlockMetricsQuery = gql` bucketWidth x_Time y_BlocksAdded + y_BlockTimeMin y_BlockTimeAvg + y_BlockTimeMax + y_LastTotalMicroCcd y_FinalizationTimeAvg + y_MinTotalMicroCcdStaked + y_MaxTotalMicroCcdStaked y_LastTotalMicroCcdStaked } } @@ -31,7 +36,6 @@ const BlockMetricsQuery = gql` export const useBlockMetricsQuery = (period: Ref) => { const { data, executeQuery, fetching } = useQuery({ - context: { url: '/api/graphql' }, query: BlockMetricsQuery, requestPolicy: 'cache-and-network', variables: { period }, diff --git a/frontend/src/queries/useRewardMetricsForBakerQuery.ts b/frontend/src/queries/useRewardMetricsForBakerQuery.ts new file mode 100644 index 00000000..e8101eb8 --- /dev/null +++ b/frontend/src/queries/useRewardMetricsForBakerQuery.ts @@ -0,0 +1,33 @@ +import { useQuery, gql } from '@urql/vue' +import { Ref } from 'vue' +import { RewardMetrics, MetricsPeriod } from '~/types/generated' + +export type RewardMetricsForBakerQueryResponse = { + rewardMetricsForBaker: RewardMetrics +} + +const RewardMetricsForBakerQuery = gql` + query ($bakerId: Long!, $period: MetricsPeriod!) { + rewardMetricsForBaker(bakerId: $bakerId, period: $period) { + sumRewardAmount + buckets { + bucketWidth + x_Time + y_SumRewards + } + } + } +` + +export const useRewardMetricsForBakerQueryQuery = ( + bakerId: Ref, + period: Ref +) => { + const { data, executeQuery, fetching } = useQuery({ + query: RewardMetricsForBakerQuery, + requestPolicy: 'cache-and-network', + variables: { bakerId, period }, + }) + + return { data, executeQuery, fetching } +} From e519e0048ba5e79802ebf16e1be179080147ce67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 24 Oct 2024 07:40:53 +0200 Subject: [PATCH 49/50] Remove rust-backend from CI and have CI run when ready for review --- .github/workflows/check-format-build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check-format-build.yml b/.github/workflows/check-format-build.yml index d3193692..8a7cf9b9 100644 --- a/.github/workflows/check-format-build.yml +++ b/.github/workflows/check-format-build.yml @@ -4,7 +4,8 @@ on: push: branches: main pull_request: - branches: [ main, rust-backend ] + types: [opened, synchronize, reopened, ready_for_review] + branches: [ main ] env: RUST_FMT: "nightly-2023-04-01" From 286d5c246664c7f7bf9ed5623258a1b328d6aa09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Holm=20Gj=C3=B8rup?= Date: Thu, 24 Oct 2024 08:27:10 +0200 Subject: [PATCH 50/50] Store timestamps with timezone --- ...477b41cbbc3f927ddd75448a6f0e28c51449256a63.json | 2 +- ...4541cd3b629221191b75441b53d1f1d2d949a4250b.json | 2 +- ...f9e4b688e911eeb8ba4f086872686636a14c55951.json} | 6 +++--- ...d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json | 2 +- ...db7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json | 2 +- ...ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json | 2 +- ...d8743794c49d0cc8ea41fec73ce43b4721de906d7.json} | 6 +++--- ...263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json | 2 +- ...4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json | 2 +- ...57603bd8ccbedacb81b627037b20484ab2a673587b.json | 2 +- ...4a9f145ef7018b81733954f0569eab5207afb47c8f.json | 2 +- ...1c21ee26fda687d61c7895fe97e2c090f5b621502f.json | 2 +- backend-rust/migrations/0001_initialize.up.sql | 2 +- backend-rust/src/graphql_api.rs | 7 +++---- backend-rust/src/indexer.rs | 14 +++++++------- 15 files changed, 27 insertions(+), 28 deletions(-) rename backend-rust/.sqlx/{query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json => query-47a0003642a1f54a2e3efaff9e4b688e911eeb8ba4f086872686636a14c55951.json} (54%) rename backend-rust/.sqlx/{query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json => query-b257ada4e3c14ada8354048d8743794c49d0cc8ea41fec73ce43b4721de906d7.json} (62%) diff --git a/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json b/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json index 94b6476b..e24c14ff 100644 --- a/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json +++ b/backend-rust/.sqlx/query-268d46a3e3ff954e568395477b41cbbc3f927ddd75448a6f0e28c51449256a63.json @@ -16,7 +16,7 @@ { "ordinal": 2, "name": "slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 3, diff --git a/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json b/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json index 81017710..01648bf6 100644 --- a/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json +++ b/backend-rust/.sqlx/query-3ff270d4a647c8837eeb174541cd3b629221191b75441b53d1f1d2d949a4250b.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" } ], "parameters": { diff --git a/backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json b/backend-rust/.sqlx/query-47a0003642a1f54a2e3efaff9e4b688e911eeb8ba4f086872686636a14c55951.json similarity index 54% rename from backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json rename to backend-rust/.sqlx/query-47a0003642a1f54a2e3efaff9e4b688e911eeb8ba4f086872686636a14c55951.json index 08828671..b38c62b8 100644 --- a/backend-rust/.sqlx/query-c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191.json +++ b/backend-rust/.sqlx/query-47a0003642a1f54a2e3efaff9e4b688e911eeb8ba4f086872686636a14c55951.json @@ -1,16 +1,16 @@ { "db_name": "PostgreSQL", - "query": "\nUPDATE blocks\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM finalizer.slot_time - blocks.slot_time),\n finalized_by = finalizer.height\nFROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMP[]) AS finalizer(height, finalized, slot_time)\nJOIN blocks last ON finalizer.finalized = last.hash\nWHERE blocks.finalization_time IS NULL AND blocks.height <= last.height\n", + "query": "\nUPDATE blocks\n SET finalization_time = EXTRACT(\"MILLISECONDS\" FROM finalizer.slot_time - blocks.slot_time),\n finalized_by = finalizer.height\nFROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMPTZ[]) AS finalizer(height, finalized, slot_time)\nJOIN blocks last ON finalizer.finalized = last.hash\nWHERE blocks.finalization_time IS NULL AND blocks.height <= last.height\n", "describe": { "columns": [], "parameters": { "Left": [ "Int8Array", "TextArray", - "TimestampArray" + "TimestamptzArray" ] }, "nullable": [] }, - "hash": "c97c1cb3271378d4411f58645746f895551d46196c7343e4142a127dff9ad191" + "hash": "47a0003642a1f54a2e3efaff9e4b688e911eeb8ba4f086872686636a14c55951" } diff --git a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json index 729e26b8..a67a57c0 100644 --- a/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json +++ b/backend-rust/.sqlx/query-4b355bfd7da0b6d4c037f1d3f670f956292ba2e0b0f759e6ab98c9fd6662185c.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "block_slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 4, diff --git a/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json b/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json index 42943e74..54888aea 100644 --- a/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json +++ b/backend-rust/.sqlx/query-6f31c869599ab3e5ac749adb7c64b02c03df94ac28852b1c6d8c47dc15f389dd.json @@ -16,7 +16,7 @@ { "ordinal": 2, "name": "slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 3, diff --git a/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json index 7ae813ba..9c87d766 100644 --- a/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json +++ b/backend-rust/.sqlx/query-78e43ba23879bdc6f583e0ce2db0e9114c23ff5ef665a692d2ba1c3c31a50151.json @@ -21,7 +21,7 @@ { "ordinal": 3, "name": "block_slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 4, diff --git a/backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json b/backend-rust/.sqlx/query-b257ada4e3c14ada8354048d8743794c49d0cc8ea41fec73ce43b4721de906d7.json similarity index 62% rename from backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json rename to backend-rust/.sqlx/query-b257ada4e3c14ada8354048d8743794c49d0cc8ea41fec73ce43b4721de906d7.json index 0b30a75e..70daef21 100644 --- a/backend-rust/.sqlx/query-7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554.json +++ b/backend-rust/.sqlx/query-b257ada4e3c14ada8354048d8743794c49d0cc8ea41fec73ce43b4721de906d7.json @@ -1,13 +1,13 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO blocks\n (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nSELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::TEXT[],\n $3::TIMESTAMP[],\n $4::BIGINT[],\n $5::BIGINT[],\n $6::BIGINT[],\n $7::BIGINT[]\n);", + "query": "INSERT INTO blocks\n (height, hash, slot_time, block_time, baker_id, total_amount, total_staked)\nSELECT * FROM UNNEST(\n $1::BIGINT[],\n $2::TEXT[],\n $3::TIMESTAMPTZ[],\n $4::BIGINT[],\n $5::BIGINT[],\n $6::BIGINT[],\n $7::BIGINT[]\n);", "describe": { "columns": [], "parameters": { "Left": [ "Int8Array", "TextArray", - "TimestampArray", + "TimestamptzArray", "Int8Array", "Int8Array", "Int8Array", @@ -16,5 +16,5 @@ }, "nullable": [] }, - "hash": "7239974919e11d23c08750f1a9f78e1737a302b81df505d07ec57d1e3c10d554" + "hash": "b257ada4e3c14ada8354048d8743794c49d0cc8ea41fec73ce43b4721de906d7" } diff --git a/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json b/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json index a9d30774..e4034b82 100644 --- a/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json +++ b/backend-rust/.sqlx/query-b780b835089ecc8847ff47263c0f9c58a2c0c73e4f87c3e194ae11f5b0a38c78.json @@ -16,7 +16,7 @@ { "ordinal": 2, "name": "slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 3, diff --git a/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json b/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json index a7741fec..47ca06a0 100644 --- a/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json +++ b/backend-rust/.sqlx/query-bd740f9ff5d1cadd6dd51b4bd21a71ee5ea8cff7e7a091bf36070c039d365ca7.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" } ], "parameters": { diff --git a/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json b/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json index 56bb768a..0a8d2cd1 100644 --- a/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json +++ b/backend-rust/.sqlx/query-de4cd6d244d3fcce559dd957603bd8ccbedacb81b627037b20484ab2a673587b.json @@ -6,7 +6,7 @@ { "ordinal": 0, "name": "time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 1, diff --git a/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json index bdbdf919..cfbb6fb6 100644 --- a/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json +++ b/backend-rust/.sqlx/query-fc2f6d6323e1214d286c384a9f145ef7018b81733954f0569eab5207afb47c8f.json @@ -31,7 +31,7 @@ { "ordinal": 5, "name": "block_slot_time", - "type_info": "Timestamp" + "type_info": "Timestamptz" }, { "ordinal": 6, diff --git a/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json b/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json index f7132182..2ce24c2e 100644 --- a/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json +++ b/backend-rust/.sqlx/query-fc560cd7aff38b6379b1321c21ee26fda687d61c7895fe97e2c090f5b621502f.json @@ -7,7 +7,7 @@ "Left": [ "Int8", "Bpchar", - "Timestamp", + "Timestamptz", "Int8", "Int8" ] diff --git a/backend-rust/migrations/0001_initialize.up.sql b/backend-rust/migrations/0001_initialize.up.sql index d9430b70..7569ff09 100644 --- a/backend-rust/migrations/0001_initialize.up.sql +++ b/backend-rust/migrations/0001_initialize.up.sql @@ -79,7 +79,7 @@ CREATE TABLE blocks( NOT NULL, -- Timestamp for when the block was baked. slot_time - TIMESTAMP + TIMESTAMPTZ NOT NULL, -- Milliseconds between the slot_time of this block and the block below (height - 1). -- For the genesis block it will be 0. diff --git a/backend-rust/src/graphql_api.rs b/backend-rust/src/graphql_api.rs index 1eabeb83..65a12d73 100644 --- a/backend-rust/src/graphql_api.rs +++ b/backend-rust/src/graphql_api.rs @@ -1174,7 +1174,7 @@ type AccountIndex = i64; type TransactionIndex = i64; type Amount = i64; // TODO: should be UnsignedLong in graphQL type Energy = i64; // TODO: should be UnsignedLong in graphQL -type DateTime = chrono::NaiveDateTime; // TODO check format matches. +type DateTime = chrono::DateTime; // TODO check format matches. type ContractIndex = UnsignedLong; // TODO check format. type BigInteger = u64; // TODO check format. type MetadataUrl = String; @@ -3869,12 +3869,11 @@ pub fn events_from_summary( } BlockItemSummaryDetails::Update(details) => { vec![Event::ChainUpdateEnqueued(ChainUpdateEnqueued { - effective_time: chrono::DateTime::from_timestamp( + effective_time: DateTime::from_timestamp( details.effective_time.seconds.try_into()?, 0, ) - .context("Failed to parse effective time")? - .naive_utc(), + .context("Failed to parse effective time")?, payload: true, // placeholder })] } diff --git a/backend-rust/src/indexer.rs b/backend-rust/src/indexer.rs index 580828bf..6e8c3518 100644 --- a/backend-rust/src/indexer.rs +++ b/backend-rust/src/indexer.rs @@ -6,7 +6,7 @@ use crate::graphql_api::{ CredentialDeploymentTransactionType, DbTransactionType, UpdateTransactionType, }; use anyhow::Context; -use chrono::NaiveDateTime; +use chrono::{DateTime, Utc}; use concordium_rust_sdk::{ base::{contracts_common::to_bytes, smart_contracts::WasmVersion}, common::types::Amount, @@ -555,7 +555,7 @@ struct BlockProcessingContext { last_finalized_hash: String, /// The slot time of the last processed block. /// This is used when computing the block time. - last_block_slot_time: NaiveDateTime, + last_block_slot_time: DateTime, } /// Raw block information fetched from a Concordium Node. @@ -577,7 +577,7 @@ async fn save_genesis_data(endpoint: v2::Endpoint, pool: &PgPool) -> anyhow::Res { let genesis_block_info = client.get_block_info(genesis_height).await?.response; let block_hash = genesis_block_info.block_hash.to_string(); - let slot_time = genesis_block_info.block_slot_time.naive_utc(); + let slot_time = genesis_block_info.block_slot_time; let genesis_tokenomics = client.get_tokenomics_info(genesis_height).await?.response; let total_staked = match genesis_tokenomics { RewardsOverview::V0 { @@ -672,7 +672,7 @@ struct PreparedBlock { /// Absolute height of the block. height: i64, /// Block slot time (UTC). - slot_time: NaiveDateTime, + slot_time: DateTime, /// Id of the validator which constructed the block. Is only None for the /// genesis block. baker_id: Option, @@ -691,7 +691,7 @@ impl PreparedBlock { let height = i64::try_from(data.finalized_block_info.height.height)?; let hash = data.finalized_block_info.block_hash.to_string(); let block_last_finalized = data.block_info.block_last_finalized.to_string(); - let slot_time = data.block_info.block_slot_time.naive_utc(); + let slot_time = data.block_info.block_slot_time; let baker_id = if let Some(index) = data.block_info.block_baker { Some(i64::try_from(index.id.index)?) } else { @@ -767,7 +767,7 @@ impl PreparedBlock { SELECT * FROM UNNEST( $1::BIGINT[], $2::TEXT[], - $3::TIMESTAMP[], + $3::TIMESTAMPTZ[], $4::BIGINT[], $5::BIGINT[], $6::BIGINT[], @@ -795,7 +795,7 @@ SELECT * FROM UNNEST( UPDATE blocks SET finalization_time = EXTRACT("MILLISECONDS" FROM finalizer.slot_time - blocks.slot_time), finalized_by = finalizer.height -FROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMP[]) AS finalizer(height, finalized, slot_time) +FROM UNNEST($1::BIGINT[], $2::TEXT[], $3::TIMESTAMPTZ[]) AS finalizer(height, finalized, slot_time) JOIN blocks last ON finalizer.finalized = last.hash WHERE blocks.finalization_time IS NULL AND blocks.height <= last.height "#,