diff --git a/Cargo.lock b/Cargo.lock index b40f1c32..9518d358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -361,6 +361,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "any_spawner" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d" +dependencies = [ + "futures", + "thiserror 2.0.17", + "tokio", + "wasm-bindgen-futures", +] + [[package]] name = "anyhow" version = "1.0.100" @@ -782,6 +794,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + [[package]] name = "async-stream" version = "0.3.6" @@ -821,6 +850,47 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.111", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -834,7 +904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", - "axum-core", + "axum-core 0.4.5", "bytes", "futures-util", "http 1.4.0", @@ -843,7 +913,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -861,6 +931,43 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core 0.5.6", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "axum-core" version = "0.4.5" @@ -882,12 +989,37 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base-x" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" + [[package]] name = "base16ct" version = "0.2.0" @@ -900,7 +1032,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" dependencies = [ - "const-str", + "const-str 0.4.3", "match-lookup", ] @@ -1170,6 +1302,25 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "cbindgen" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da6bc11b07529f16944307272d5bd9b22530bc7d05751717c9d416586cedab49" +dependencies = [ + "clap 3.2.25", + "heck 0.4.1", + "indexmap 1.9.3", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", + "tempfile", + "toml 0.5.11", +] + [[package]] name = "cc" version = "1.2.49" @@ -1266,6 +1417,21 @@ dependencies = [ "libloading", ] +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_lex 0.2.4", + "indexmap 1.9.3", + "strsim 0.10.0", + "termcolor", + "textwrap", +] + [[package]] name = "clap" version = "4.5.53" @@ -1284,8 +1450,8 @@ checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", - "clap_lex", - "strsim", + "clap_lex 0.7.6", + "strsim 0.11.1", ] [[package]] @@ -1294,12 +1460,21 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.111", ] +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "clap_lex" version = "0.7.6" @@ -1315,6 +1490,23 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "codee" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1351,6 +1543,28 @@ dependencies = [ "url", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.15.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" +dependencies = [ + "convert_case 0.6.0", + "pathdiff", + "serde_core", + "toml 0.9.11+spec-1.1.0", + "winnow", +] + [[package]] name = "console" version = "0.16.2" @@ -1364,6 +1578,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + [[package]] name = "const-hex" version = "1.17.0" @@ -1388,12 +1622,53 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "const_str_slice_concat" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b" + [[package]] name = "convert_case" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "convert_case" version = "0.8.0" @@ -1578,7 +1853,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.111", ] @@ -1592,7 +1867,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.111", ] @@ -1631,6 +1906,20 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -1654,7 +1943,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 1.0.109", ] [[package]] @@ -1689,6 +1978,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive-where" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1812,13 +2112,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + [[package]] name = "duplicate" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "proc-macro2-diagnostics", ] @@ -1887,6 +2193,16 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "either_of" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216d23e0ec69759a17f05e1c553f3a6870e5ec73420fbb07807a6f34d5d1d5a4" +dependencies = [ + "paste", + "pin-project-lite", +] + [[package]] name = "elf" version = "0.7.4" @@ -1980,6 +2296,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472" + [[package]] name = "errno" version = "0.3.14" @@ -1990,6 +2312,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "example_program_deployment_methods" version = "0.1.0" @@ -2007,6 +2350,33 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "explorer_service" +version = "0.1.0" +dependencies = [ + "axum 0.8.8", + "chrono", + "clap 4.5.53", + "console_error_panic_hook", + "console_log", + "env_logger", + "hex", + "indexer_service_protocol", + "indexer_service_rpc", + "jsonrpsee", + "leptos", + "leptos_axum", + "leptos_meta", + "leptos_router", + "log", + "serde", + "tokio", + "url", + "urlencoding", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2148,6 +2518,7 @@ dependencies = [ "futures-core", "futures-task", "futures-util", + "num_cpus", ] [[package]] @@ -2186,7 +2557,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" dependencies = [ "gloo-timers", - "send_wrapper", + "send_wrapper 0.4.0", ] [[package]] @@ -2325,7 +2696,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" dependencies = [ "cfg-if", - "dashmap", + "dashmap 5.5.3", "futures", "futures-timer", "no-std-compat", @@ -2349,6 +2720,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "guardian" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f" + [[package]] name = "h2" version = "0.3.27" @@ -2433,12 +2810,27 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[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.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2496,6 +2888,15 @@ version = "1.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280" +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + [[package]] name = "http" version = "0.2.12" @@ -2540,6 +2941,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2558,6 +2965,22 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hydration_context" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8714ae4adeaa846d838f380fbd72f049197de629948f91bf045329e0cf0a283" +dependencies = [ + "futures", + "js-sys", + "once_cell", + "or_poisoned", + "pin-project-lite", + "serde", + "throw_error", + "wasm-bindgen", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2784,6 +3207,7 @@ name = "indexer_core" version = "0.1.0" dependencies = [ "anyhow", + "async-stream", "bedrock_client", "borsh", "common", @@ -2805,12 +3229,16 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clap", + "clap 4.5.53", "env_logger", + "futures", + "indexer_core", "indexer_service_protocol", "indexer_service_rpc", "jsonrpsee", "log", + "serde", + "serde_json", "tokio", "tokio-util", ] @@ -2896,7 +3324,7 @@ dependencies = [ "env_logger", "futures", "hex", - "indexer_core", + "indexer_service", "key_protocol", "log", "nssa", @@ -2909,6 +3337,21 @@ dependencies = [ "wallet", ] +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "inventory" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2931,7 +3374,7 @@ version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", "windows-sys 0.61.2", ] @@ -3142,7 +3585,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2da3f8ab5ce1bb124b6d082e62dffe997578ceaf0aeb9f3174a214589dc00f07" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro-crate", "proc-macro2", "quote", @@ -3297,6 +3740,228 @@ dependencies = [ "spin", ] +[[package]] +name = "leptos" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9569fc37575a5d64c0512145af7630bf651007237ef67a8a77328199d315bb" +dependencies = [ + "any_spawner", + "base64", + "cfg-if", + "either_of", + "futures", + "getrandom 0.3.4", + "hydration_context", + "leptos_config", + "leptos_dom", + "leptos_hot_reload", + "leptos_macro", + "leptos_server", + "oco_ref", + "or_poisoned", + "paste", + "rand 0.9.2", + "reactive_graph", + "rustc-hash", + "rustc_version", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "serde_qs", + "server_fn", + "slotmap", + "tachys", + "thiserror 2.0.17", + "throw_error", + "typed-builder 0.23.2", + "typed-builder-macro 0.23.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_split_helpers", + "web-sys", +] + +[[package]] +name = "leptos_axum" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0caa95760f87f3067e05025140becefdbdfd36cbc2adac4519f06e1f1edf4af" +dependencies = [ + "any_spawner", + "axum 0.8.8", + "dashmap 6.1.0", + "futures", + "hydration_context", + "leptos", + "leptos_integration_utils", + "leptos_macro", + "leptos_meta", + "leptos_router", + "parking_lot", + "server_fn", + "tachys", + "tokio", + "tower 0.5.2", + "tower-http", +] + +[[package]] +name = "leptos_config" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071fc40aeb9fcab885965bad1887990477253ad51f926cd19068f45a44c59e89" +dependencies = [ + "config", + "regex", + "serde", + "thiserror 2.0.17", + "typed-builder 0.21.2", +] + +[[package]] +name = "leptos_dom" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f4330c88694c5575e0bfe4eecf81b045d14e76a4f8b00d5fd2a63f8779f895" +dependencies = [ + "js-sys", + "or_poisoned", + "reactive_graph", + "send_wrapper 0.6.0", + "tachys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_hot_reload" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d61ec3e1ff8aaee8c5151688550c0363f85bc37845450764c31ff7584a33f38" +dependencies = [ + "anyhow", + "camino", + "indexmap 2.12.1", + "parking_lot", + "proc-macro2", + "quote", + "rstml", + "serde", + "syn 2.0.111", + "walkdir", +] + +[[package]] +name = "leptos_integration_utils" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13cccc9305df53757bae61bf15641bfa6a667b5f78456ace4879dfe0591ae0e8" +dependencies = [ + "futures", + "hydration_context", + "leptos", + "leptos_config", + "leptos_meta", + "leptos_router", + "reactive_graph", +] + +[[package]] +name = "leptos_macro" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86ffd2e9cf3e264e9b3e16bdb086cefa26bd0fa7bc6a26b0cc5f6c1fd3178ed" +dependencies = [ + "attribute-derive", + "cfg-if", + "convert_case 0.10.0", + "html-escape", + "itertools 0.14.0", + "leptos_hot_reload", + "prettyplease", + "proc-macro-error2", + "proc-macro2", + "quote", + "rstml", + "rustc_version", + "server_fn_macro", + "syn 2.0.111", + "uuid", +] + +[[package]] +name = "leptos_meta" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d489e38d3f541e9e43ecc2e3a815527840345a2afca629b3e23fcc1dd254578" +dependencies = [ + "futures", + "indexmap 2.12.1", + "leptos", + "or_poisoned", + "send_wrapper 0.6.0", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_router" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e573711f2fb9ab5d655ec38115220d359eaaf1dcb93cc0ea624543b6dba959" +dependencies = [ + "any_spawner", + "either_of", + "futures", + "gloo-net", + "js-sys", + "leptos", + "leptos_router_macro", + "or_poisoned", + "percent-encoding", + "reactive_graph", + "rustc_version", + "send_wrapper 0.6.0", + "tachys", + "thiserror 2.0.17", + "url", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "leptos_router_macro" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409c0bd99f986c3cfa1a4db2443c835bc602ded1a12784e22ecb28c3ed5a2ae2" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "leptos_server" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf1045af93050bf3388d1c138426393fc131f6d9e46a65519da884c033ed730" +dependencies = [ + "any_spawner", + "base64", + "codee", + "futures", + "hydration_context", + "or_poisoned", + "reactive_graph", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "server_fn", + "tachys", +] + [[package]] name = "libc" version = "0.2.178" @@ -3379,6 +4044,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "linear-map" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae20f6b19ad527b550c223fddc3077a547fc70cda94b9b566575423fd303ee" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -3638,7 +4309,7 @@ name = "logos-blockchain-http-api-common" version = "0.1.0" source = "git+https://github.com/logos-blockchain/logos-blockchain.git#451df112f8574aea2840d04fffb7e16e76d24f42" dependencies = [ - "axum", + "axum 0.7.9", "governor", "logos-blockchain-core", "logos-blockchain-key-management-system-keys", @@ -3915,6 +4586,29 @@ dependencies = [ "libc", ] +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + [[package]] name = "match-lookup" version = "0.1.1" @@ -3932,6 +4626,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "maybe-async" version = "0.2.10" @@ -3989,6 +4689,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4007,6 +4717,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "multiaddr" version = "0.18.2" @@ -4064,6 +4791,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "next_tuple" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28" + [[package]] name = "nimue" version = "0.1.1" @@ -4226,6 +4959,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi 0.5.2", + "libc", +] + [[package]] name = "num_enum" version = "0.7.5" @@ -4256,6 +4999,16 @@ dependencies = [ "malloc_buf", ] +[[package]] +name = "oco_ref" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d" +dependencies = [ + "serde", + "thiserror 2.0.17", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -4341,6 +5094,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "or_poisoned" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd" + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + [[package]] name = "overwatch" version = "0.1.0" @@ -4368,6 +5133,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -4397,6 +5168,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4525,6 +5302,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.111", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -4556,6 +5343,17 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -4575,13 +5373,14 @@ dependencies = [ "quote", "syn 2.0.111", "version_check", + "yansi", ] [[package]] name = "program_deployment" version = "0.1.0" dependencies = [ - "clap", + "clap 4.5.53", "nssa", "nssa_core", "tokio", @@ -4720,6 +5519,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "r-efi" version = "5.3.0" @@ -4803,6 +5624,60 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "reactive_graph" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f0df355582937223ea403e52490201d65295bd6981383c69bfae5a1f8730c2" +dependencies = [ + "any_spawner", + "async-lock", + "futures", + "guardian", + "hydration_context", + "indexmap 2.12.1", + "or_poisoned", + "paste", + "pin-project-lite", + "rustc-hash", + "rustc_version", + "send_wrapper 0.6.0", + "serde", + "slotmap", + "thiserror 2.0.17", + "web-sys", +] + +[[package]] +name = "reactive_stores" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35372f05664a62a3dd389503371a15b8feb3396f99f6ec000de651fddb030942" +dependencies = [ + "dashmap 6.1.0", + "guardian", + "itertools 0.14.0", + "or_poisoned", + "paste", + "reactive_graph", + "reactive_stores_macro", + "rustc-hash", + "send_wrapper 0.6.0", +] + +[[package]] +name = "reactive_stores_macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fa40919eb2975100283b2a70e68eafce1e8bcf81f0622ff168e4c2b3f8d46bb" +dependencies = [ + "convert_case 0.8.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -5239,6 +6114,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rstml" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56" +dependencies = [ + "derive-where", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.111", + "syn_derive", + "thiserror 2.0.17", +] + [[package]] name = "ruint" version = "1.17.0" @@ -5391,7 +6281,7 @@ dependencies = [ "strum", "tempfile", "thiserror 2.0.17", - "toml", + "toml 0.8.23", "yaml-rust2", ] @@ -5543,6 +6433,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f638d531eccd6e23b980caf34876660d38e265409d8e99b397ab71eb3612fad0" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + [[package]] name = "sequencer_core" version = "0.1.0" @@ -5554,6 +6453,7 @@ dependencies = [ "chrono", "common", "futures", + "jsonrpsee", "log", "logos-blockchain-core", "logos-blockchain-key-management-system-service", @@ -5567,6 +6467,7 @@ dependencies = [ "storage", "tempfile", "tokio", + "url", ] [[package]] @@ -5600,9 +6501,12 @@ dependencies = [ "actix", "actix-web", "anyhow", - "clap", + "clap 4.5.53", "common", "env_logger", + "futures", + "indexer_service_protocol", + "indexer_service_rpc", "log", "sequencer_core", "sequencer_rpc", @@ -5683,6 +6587,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -5692,6 +6607,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -5745,6 +6669,71 @@ dependencies = [ "serde", ] +[[package]] +name = "server_fn" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353d02fa2886cd8dae0b8da0965289fa8f2ecc7df633d1ce965f62fdf9644d29" +dependencies = [ + "axum 0.8.8", + "base64", + "bytes", + "const-str 0.7.1", + "const_format", + "dashmap 6.1.0", + "futures", + "gloo-net", + "http 1.4.0", + "http-body-util", + "hyper", + "inventory", + "js-sys", + "pin-project-lite", + "rustc_version", + "rustversion", + "send_wrapper 0.6.0", + "serde", + "serde_json", + "serde_qs", + "server_fn_macro_default", + "thiserror 2.0.17", + "throw_error", + "tokio", + "tower 0.5.2", + "tower-layer", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950b8cfc9ff5f39ca879c5a7c5e640de2695a199e18e424c3289d0964cabe642" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.111", + "xxhash-rust", +] + +[[package]] +name = "server_fn_macro_default" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00" +dependencies = [ + "server_fn_macro", + "syn 2.0.111", +] + [[package]] name = "sha1" version = "0.10.6" @@ -5798,6 +6787,15 @@ version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -5918,6 +6916,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[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" @@ -5939,7 +6943,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.111", @@ -5973,6 +6977,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6014,6 +7030,40 @@ dependencies = [ "libc", ] +[[package]] +name = "tachys" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b2db11e455f7e84e2cc3e76f8a3f3843f7956096265d5ecff781eabe235077" +dependencies = [ + "any_spawner", + "async-trait", + "const_str_slice_concat", + "drain_filter_polyfill", + "either_of", + "erased", + "futures", + "html-escape", + "indexmap 2.12.1", + "itertools 0.14.0", + "js-sys", + "linear-map", + "next_tuple", + "oco_ref", + "or_poisoned", + "parking_lot", + "paste", + "reactive_graph", + "reactive_stores", + "rustc-hash", + "rustc_version", + "send_wrapper 0.6.0", + "slotmap", + "throw_error", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -6084,6 +7134,12 @@ dependencies = [ "risc0-zkvm", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" + [[package]] name = "thiserror" version = "1.0.69" @@ -6124,6 +7180,15 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "throw_error" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "time" version = "0.3.44" @@ -6260,6 +7325,18 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -6274,6 +7351,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "0.8.23" @@ -6281,11 +7367,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -6297,9 +7396,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.4+spec-1.0.0" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3cea6b2aa3b910092f6abd4053ea464fab5f9c170ba5e9a6aead16ec4af2b6" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -6312,7 +7411,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.12.1", "serde", - "serde_spanned", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", @@ -6325,16 +7424,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap 2.12.1", - "toml_datetime 0.7.4+spec-1.0.0", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.5+spec-1.0.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c03bee5ce3696f31250db0bbaff18bc43301ce0e8db2ed1f07cbb2acf89984c" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] @@ -6380,14 +7479,24 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -6408,7 +7517,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3790eac6ad3fb8d9d96c2b040ae06e2517aa24b067545d1078b96ae72f7bb9a7" dependencies = [ - "axum", + "axum 0.7.9", "forwarded-header-value", "governor", "http 1.4.0", @@ -6482,6 +7591,63 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.17", + "utf-8", +] + +[[package]] +name = "typed-builder" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef81aec2ca29576f9f6ae8755108640d0a86dd3161b2e8bca6cfa554e98f77d" +dependencies = [ + "typed-builder-macro 0.21.2", +] + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro 0.23.2", +] + +[[package]] +name = "typed-builder-macro" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ecb9ecf7799210407c14a8cfdfe0173365780968dc57973ed082211958e0b18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "typenum" version = "1.19.0" @@ -6494,6 +7660,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6567,6 +7739,24 @@ dependencies = [ "serde", ] +[[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 = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6579,6 +7769,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6617,7 +7818,7 @@ dependencies = [ "base64", "borsh", "bytemuck", - "clap", + "clap 4.5.53", "common", "env_logger", "futures", @@ -6638,6 +7839,17 @@ dependencies = [ "url", ] +[[package]] +name = "wallet-ffi" +version = "0.1.0" +dependencies = [ + "cbindgen", + "common", + "nssa", + "tokio", + "wallet", +] + [[package]] name = "want" version = "0.3.1" @@ -6733,6 +7945,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_split_helpers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a114b3073258dd5de3d812cdd048cca6842342755e828a14dbf15f843f2d1b84" +dependencies = [ + "async-once-cell", + "wasm_split_macros", +] + +[[package]] +name = "wasm_split_macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56481f8ed1a9f9ae97ea7b08a5e2b12e8adf9a7818a6ba952b918e09c7be8bf0" +dependencies = [ + "base16", + "quote", + "sha2", + "syn 2.0.111", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -7136,6 +8370,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -7147,6 +8387,12 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 6d40c7fc..cc078836 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,15 +6,18 @@ members = [ "key_protocol", "mempool", "wallet", + "wallet-ffi", "common", "nssa", "nssa/core", "sequencer_core", "sequencer_rpc", "sequencer_runner", - "indexer_service", - "indexer_service/protocol", - "indexer_service/rpc", + "indexer/core", + "indexer/service", + "indexer/service/protocol", + "indexer/service/rpc", + "explorer_service", "program_methods", "program_methods/guest", "test_program_methods", @@ -23,7 +26,6 @@ members = [ "examples/program_deployment/methods", "examples/program_deployment/methods/guest", "bedrock_client", - "indexer_core", ] [workspace.dependencies] @@ -36,13 +38,14 @@ key_protocol = { path = "key_protocol" } sequencer_core = { path = "sequencer_core" } sequencer_rpc = { path = "sequencer_rpc" } sequencer_runner = { path = "sequencer_runner" } -indexer_service = { path = "indexer_service" } -indexer_service_protocol = { path = "indexer_service/protocol" } -indexer_service_rpc = { path = "indexer_service/rpc" } +indexer_core = { path = "indexer/core" } +indexer_service = { path = "indexer/service" } +indexer_service_protocol = { path = "indexer/service/protocol" } +indexer_service_rpc = { path = "indexer/service/rpc" } wallet = { path = "wallet" } +wallet-ffi = { path = "wallet-ffi" } test_program_methods = { path = "test_program_methods" } bedrock_client = { path = "bedrock_client" } -indexer_core = { path = "indexer_core" } tokio = { version = "1.28.2", features = [ "net", @@ -90,6 +93,7 @@ itertools = "0.14.0" url = { version = "2.5.4", features = ["serde"] } tokio-retry = "0.3.0" schemars = "1.2.0" +async-stream = "0.3.6" logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } logos-blockchain-key-management-system-service = { git = "https://github.com/logos-blockchain/logos-blockchain.git" } @@ -114,3 +118,10 @@ actix-web = { version = "=4.1.0", default-features = false, features = [ ] } clap = { version = "4.5.42", features = ["derive", "env"] } reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] } + +# Profile for leptos WASM release builds +[profile.wasm-release] +inherits = "release" +opt-level = 'z' +lto = true +codegen-units = 1 diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 01efd324..3786adb0 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index 14176e55..8dc49825 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index 20d22bc7..5f8041af 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index 8ba4f1dd..f57e2539 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index b937d66f..a413c966 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index 2e5fca77..2a30289a 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index 8b739241..a2240d62 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index e72262b2..a9a19497 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index 86c3e695..3c15821b 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 5c23dfe4..dc7fce46 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index ee2d8f2b..b5efbe89 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index 39c09989..160e9800 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index cee9a3b9..09660236 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index 285cae2f..ec3b6de0 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index 5b8fb311..afd0e22f 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index 5bb5fbbd..3f20fc54 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index ff63175f..478ddd60 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index 0fc49a55..0a0ce7c3 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 7268da57..afb3917b 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index f597d8ba..6a20fa17 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/common/src/communication/indexer.rs b/common/src/communication/indexer.rs deleted file mode 100644 index a0edc176..00000000 --- a/common/src/communication/indexer.rs +++ /dev/null @@ -1,6 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum Message { - L2BlockFinalized { l2_block_height: u64 }, -} diff --git a/common/src/communication/mod.rs b/common/src/communication/mod.rs deleted file mode 100644 index d99eb481..00000000 --- a/common/src/communication/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod indexer; diff --git a/common/src/config.rs b/common/src/config.rs new file mode 100644 index 00000000..3850f08c --- /dev/null +++ b/common/src/config.rs @@ -0,0 +1,55 @@ +//! Common configuration structures and utilities. + +use std::str::FromStr; + +use logos_blockchain_common_http_client::BasicAuthCredentials; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BasicAuth { + pub username: String, + pub password: Option, +} + +impl std::fmt::Display for BasicAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.username)?; + if let Some(password) = &self.password { + write!(f, ":{password}")?; + } + + Ok(()) + } +} + +impl FromStr for BasicAuth { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let parse = || { + let mut parts = s.splitn(2, ':'); + let username = parts.next()?; + let password = parts.next().filter(|p| !p.is_empty()); + if parts.next().is_some() { + return None; + } + + Some((username, password)) + }; + + let (username, password) = parse().ok_or_else(|| { + anyhow::anyhow!("Invalid auth format. Expected 'user' or 'user:password'") + })?; + + Ok(Self { + username: username.to_string(), + password: password.map(|p| p.to_string()), + }) + } +} + +impl From for BasicAuthCredentials { + fn from(value: BasicAuth) -> Self { + BasicAuthCredentials::new(value.username, value.password) + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 68902811..d44fd30f 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -1,5 +1,5 @@ pub mod block; -pub mod communication; +pub mod config; pub mod error; pub mod rpc_primitives; pub mod sequencer_client; @@ -8,6 +8,7 @@ pub mod transaction; // Module for tests utility functions // TODO: Compile only for tests pub mod test_utils; -pub type HashType = [u8; 32]; pub const PINATA_BASE58: &str = "EfQhKQAkX2FJiwNii2WFQsGndjvF1Mzd7RuVe7QdPLw7"; + +pub type HashType = [u8; 32]; diff --git a/common/src/rpc_primitives/requests.rs b/common/src/rpc_primitives/requests.rs index da2a8b3a..526cdb67 100644 --- a/common/src/rpc_primitives/requests.rs +++ b/common/src/rpc_primitives/requests.rs @@ -76,11 +76,6 @@ pub struct GetProofForCommitmentRequest { #[derive(Serialize, Deserialize, Debug)] pub struct GetProgramIdsRequest {} -#[derive(Serialize, Deserialize, Debug)] -pub struct PostIndexerMessageRequest { - pub message: crate::communication::indexer::Message, -} - parse_request!(HelloRequest); parse_request!(RegisterAccountRequest); parse_request!(SendTxRequest); @@ -231,8 +226,3 @@ pub struct GetInitialTestnetAccountsResponse { pub account_id: String, pub balance: u64, } - -#[derive(Serialize, Deserialize, Debug)] -pub struct PostIndexerMessageResponse { - pub status: String, -} diff --git a/common/src/sequencer_client.rs b/common/src/sequencer_client.rs index f280745e..8d846421 100644 --- a/common/src/sequencer_client.rs +++ b/common/src/sequencer_client.rs @@ -1,10 +1,9 @@ -use std::{collections::HashMap, ops::RangeInclusive, str::FromStr}; +use std::{collections::HashMap, ops::RangeInclusive}; use anyhow::Result; -use logos_blockchain_common_http_client::BasicAuthCredentials; use nssa_core::program::ProgramId; use reqwest::Client; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use serde_json::Value; use url::Url; @@ -14,71 +13,22 @@ use super::rpc_primitives::requests::{ }; use crate::{ block::Block, + config::BasicAuth, error::{SequencerClientError, SequencerRpcError}, rpc_primitives::{ self, requests::{ GetAccountRequest, GetAccountResponse, GetAccountsNoncesRequest, GetAccountsNoncesResponse, GetBlockRangeDataRequest, GetBlockRangeDataResponse, - GetGenesisBlockRequest, GetGenesisBlockResponse, GetInitialTestnetAccountsResponse, - GetLastBlockRequest, GetLastBlockResponse, GetProgramIdsRequest, GetProgramIdsResponse, - GetProofForCommitmentRequest, GetProofForCommitmentResponse, - GetTransactionByHashRequest, GetTransactionByHashResponse, PostIndexerMessageRequest, - PostIndexerMessageResponse, SendTxRequest, SendTxResponse, + GetInitialTestnetAccountsResponse, GetLastBlockRequest, GetLastBlockResponse, + GetProgramIdsRequest, GetProgramIdsResponse, GetProofForCommitmentRequest, + GetProofForCommitmentResponse, GetTransactionByHashRequest, + GetTransactionByHashResponse, SendTxRequest, SendTxResponse, }, }, transaction::{EncodedTransaction, NSSATransaction}, }; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BasicAuth { - pub username: String, - pub password: Option, -} - -impl std::fmt::Display for BasicAuth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.username)?; - if let Some(password) = &self.password { - write!(f, ":{password}")?; - } - - Ok(()) - } -} - -impl FromStr for BasicAuth { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result { - let parse = || { - let mut parts = s.splitn(2, ':'); - let username = parts.next()?; - let password = parts.next().filter(|p| !p.is_empty()); - if parts.next().is_some() { - return None; - } - - Some((username, password)) - }; - - let (username, password) = parse().ok_or_else(|| { - anyhow::anyhow!("Invalid auth format. Expected 'user' or 'user:password'") - })?; - - Ok(Self { - username: username.to_string(), - password: password.map(|p| p.to_string()), - }) - } -} - -impl From for BasicAuthCredentials { - fn from(value: BasicAuth) -> Self { - BasicAuthCredentials::new(value.username, value.password) - } -} - #[derive(Clone)] pub struct SequencerClient { pub client: reqwest::Client, @@ -415,23 +365,4 @@ impl SequencerClient { Ok(resp_deser) } - - /// Post indexer into sequencer - pub async fn post_indexer_message( - &self, - message: crate::communication::indexer::Message, - ) -> Result { - let last_req = PostIndexerMessageRequest { message }; - - let req = serde_json::to_value(last_req).unwrap(); - - let resp = self - .call_method_with_payload("post_indexer_message", req) - .await - .unwrap(); - - let resp_deser = serde_json::from_value(resp).unwrap(); - - Ok(resp_deser) - } } diff --git a/explorer_service/.gitignore b/explorer_service/.gitignore new file mode 100644 index 00000000..49015de2 --- /dev/null +++ b/explorer_service/.gitignore @@ -0,0 +1,11 @@ +# Leptos build outputs +/target +/pkg +/site + +# WASM artifacts +*.wasm + +# Environment +.env +.env.local diff --git a/explorer_service/Cargo.toml b/explorer_service/Cargo.toml new file mode 100644 index 00000000..2bfd5cfb --- /dev/null +++ b/explorer_service/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "explorer_service" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +indexer_service_protocol.workspace = true + +# Leptos framework +leptos = "0.8.15" +leptos_meta = "0.8.5" +leptos_router = "0.8.11" + +# Serialization +serde.workspace = true + +# Logging +log.workspace = true +console_error_panic_hook = "0.1" +console_log = "1.0" + +# Date/Time +chrono.workspace = true + +# Hex encoding/decoding +hex.workspace = true + +# URL encoding +urlencoding = "2.1" + +# WASM-specific +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = [ + "Window", + "Document", + "Location", + "HtmlInputElement", +] } + +# Server-side dependencies (optional, enabled by features) +indexer_service_rpc = { workspace = true, features = [ + "client", +], optional = true } +jsonrpsee = { workspace = true, features = ["http-client"], optional = true } +tokio = { workspace = true, optional = true } +axum = { version = "0.8.8", optional = true } +leptos_axum = { version = "0.8.7", optional = true } +clap = { workspace = true, features = ["derive"], optional = true } +url = { workspace = true, optional = true } +env_logger = { workspace = true, optional = true } + +[features] +hydrate = ["leptos/hydrate"] +ssr = [ + "leptos/ssr", + "dep:indexer_service_rpc", + "dep:jsonrpsee", + "dep:tokio", + "dep:axum", + "dep:leptos_axum", + "dep:clap", + "dep:url", + "dep:env_logger", +] + +[package.metadata.leptos] +bin-features = ["ssr"] +lib-features = ["hydrate"] +assets-dir = "public" diff --git a/explorer_service/Dockerfile b/explorer_service/Dockerfile new file mode 100644 index 00000000..e10c5ebe --- /dev/null +++ b/explorer_service/Dockerfile @@ -0,0 +1,52 @@ +FROM rust:1.91.1-trixie AS builder + +# Install cargo-binstall, which makes it easier to install other +# cargo extensions like cargo-leptos +RUN wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz +RUN tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz +RUN cp cargo-binstall /usr/local/cargo/bin + +# Install required tools +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends clang + +# Install cargo-leptos +RUN cargo binstall cargo-leptos -y + +# Add the WASM target +RUN rustup target add wasm32-unknown-unknown + +# Make an /explorer_service dir, which everything will eventually live in +RUN mkdir -p /explorer_service +WORKDIR /explorer_service +COPY . . + +# Build the app +RUN cargo leptos build --release -vv + +FROM debian:trixie-slim AS runtime +WORKDIR /explorer_service +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +# Copy the server binary to the /explorer_service directory +COPY --from=builder /explorer_service/target/release/explorer_service /explorer_service/ + +# /target/site contains our JS/WASM/CSS, etc. +COPY --from=builder /explorer_service/target/site /explorer_service/site + +# Copy Cargo.toml as it’s needed at runtime +COPY --from=builder /explorer_service/Cargo.toml /explorer_service/ + +# Set any required env variables +ENV RUST_LOG="info" +ENV LEPTOS_SITE_ADDR="0.0.0.0:8080" +ENV LEPTOS_SITE_ROOT="site" +ENV INDEXER_RPC_URL="http://localhost:8779" +EXPOSE 8080 + +# Run the server +CMD ["/explorer_service/explorer_service"] diff --git a/explorer_service/README.md b/explorer_service/README.md new file mode 100644 index 00000000..6f118dbb --- /dev/null +++ b/explorer_service/README.md @@ -0,0 +1,71 @@ +# LEE Blockchain Explorer + +A web-based UI for exploring the blockchain state, built with Rust and Leptos framework. + +## Features + +- **Main Page**: Search for blocks, transactions, or accounts by hash/ID. View recent blocks. +- **Block Page**: View detailed block information and all transactions within a block. +- **Transaction Page**: View transaction details including type, accounts involved, and proofs. +- **Account Page**: View account state and transaction history. + +## Architecture + +- **Framework**: Leptos 0.8 with SSR (Server-Side Rendering) and hydration +- **Data Source**: Indexer Service JSON-RPC API +- **Components**: Reusable BlockPreview, TransactionPreview, and AccountPreview components +- **Styling**: Custom CSS with responsive design + +## Development + +### Prerequisites + +- Rust (stable or nightly) +- `cargo-leptos` tool: `cargo install cargo-leptos` +- Running indexer service at `http://localhost:8080/rpc` (or configure via `INDEXER_RPC_URL`) + +### Build and Run + +```bash +# Development mode (with hot-reload) +cargo leptos watch + +# Production build +cargo leptos build --release + +# Run production build +cargo leptos serve --release +``` + +The explorer will be available at `http://localhost:3000` by default. + +### Configuration + +Set the `INDEXER_RPC_URL` environment variable to point to your indexer service: + +```bash +export INDEXER_RPC_URL=http://localhost:8080/rpc +cargo leptos watch +``` + +## Features + +### Search + +The search bar supports: +- Block IDs (numeric) +- Block hashes (64-character hex) +- Transaction hashes (64-character hex) +- Account IDs (64-character hex) + +### Real-time Updates + +The main page loads recent blocks and can be extended to subscribe to new blocks via WebSocket. + +### Responsive Design + +The UI is mobile-friendly and adapts to different screen sizes. + +## License + +See LICENSE file in the repository root. diff --git a/explorer_service/docker-compose.yml b/explorer_service/docker-compose.yml new file mode 100644 index 00000000..28c4c9c7 --- /dev/null +++ b/explorer_service/docker-compose.yml @@ -0,0 +1,11 @@ +services: + explorer_service: + image: lssa/explorer_service + build: + context: .. + dockerfile: explorer_service/Dockerfile + container_name: explorer_service + environment: + INDEXER_RPC_URL: ${INDEXER_RPC_URL:-http://localhost:8779} + ports: + - "8080:8080" diff --git a/explorer_service/public/explorer.css b/explorer_service/public/explorer.css new file mode 100644 index 00000000..a6415ed5 --- /dev/null +++ b/explorer_service/public/explorer.css @@ -0,0 +1,516 @@ +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #f5f7fa; + color: #2c3e50; + line-height: 1.6; +} + +/* App layout */ +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app-header { + background-color: #2c3e50; + color: white; + padding: 1rem 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.app-nav { + max-width: 1200px; + margin: 0 auto; +} + +.nav-logo { + color: white; + text-decoration: none; + font-size: 1.5rem; + font-weight: bold; +} + +.nav-logo:hover { + color: #3498db; +} + +.app-main { + flex: 1; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; +} + +.app-footer { + background-color: #34495e; + color: white; + text-align: center; + padding: 1.5rem; + margin-top: 2rem; +} + +/* Page headers */ +.page-header h1 { + font-size: 2rem; + margin-bottom: 1.5rem; + color: #2c3e50; +} + +/* Search section */ +.search-section { + margin-bottom: 3rem; +} + +.search-form { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; +} + +.search-input { + flex: 1; + padding: 0.75rem 1rem; + border: 2px solid #dde4ed; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.3s; +} + +.search-input:focus { + outline: none; + border-color: #3498db; +} + +.search-button { + padding: 0.75rem 2rem; + background-color: #3498db; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s; +} + +.search-button:hover { + background-color: #2980b9; +} + +/* Block preview */ +.block-preview { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; +} + +.block-preview:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.block-preview-link { + text-decoration: none; + color: inherit; +} + +.block-preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.block-id .label { + color: #7f8c8d; + font-size: 0.9rem; +} + +.block-id .value { + font-size: 1.5rem; + font-weight: bold; + color: #2c3e50; +} + +.block-status { + padding: 0.5rem 1rem; + border-radius: 20px; + font-size: 0.85rem; + font-weight: 600; +} + +.status-pending { + background-color: #fff3cd; + color: #856404; +} + +.status-safe { + background-color: #d1ecf1; + color: #0c5460; +} + +.status-finalized { + background-color: #d4edda; + color: #155724; +} + +.block-preview-body { + display: grid; + gap: 0.5rem; +} + +.block-field { + display: flex; + gap: 0.5rem; +} + +.field-label { + color: #7f8c8d; + font-weight: 500; +} + +.field-value { + color: #2c3e50; +} + +.hash { + font-family: "Courier New", monospace; + font-size: 0.9rem; + word-break: break-all; +} + +/* Transaction preview */ +.transaction-preview { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; +} + +.transaction-preview:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.transaction-preview-link { + text-decoration: none; + color: inherit; +} + +.transaction-preview-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid #eee; +} + +.tx-type { + padding: 0.4rem 0.8rem; + border-radius: 16px; + font-size: 0.85rem; + font-weight: 600; + border: 2px solid; +} + +.tx-type-public { + background-color: #e3f2fd; + color: #0d47a1; + border-color: #1976d2; + border-style: solid; +} + +.tx-type-private { + background-color: #ffe0f0; + color: #880e4f; + border-color: #c2185b; + border-style: dashed; + font-style: italic; +} + +.tx-type-deployment { + background-color: #fff3e0; + color: #e65100; + border-color: #ff9800; + border-style: dotted; +} + +.tx-hash { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.transaction-preview-body { + color: #7f8c8d; + font-size: 0.9rem; +} + +/* Account preview */ +.account-preview { + background-color: white; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: transform 0.2s, box-shadow 0.2s; +} + +.account-preview:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.account-preview-link { + text-decoration: none; + color: inherit; +} + +.account-preview-header { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.account-id .label { + color: #7f8c8d; + font-size: 0.9rem; +} + +.account-id .value { + font-size: 1.2rem; + font-weight: 600; + color: #2c3e50; +} + +.account-preview-body { + display: grid; + gap: 0.5rem; +} + +.account-field { + display: flex; + gap: 0.5rem; +} + +.account-not-found { + color: #e74c3c; + font-style: italic; +} + +/* Detail pages */ +.block-detail, +.transaction-detail, +.account-detail { + background-color: white; + border-radius: 8px; + padding: 2rem; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.block-info, +.transaction-info, +.account-info, +.transaction-details { + margin-bottom: 2rem; +} + +.block-info h2, +.transaction-info h2, +.account-info h2, +.transaction-details h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: #2c3e50; +} + +.info-grid { + display: grid; + gap: 1rem; +} + +.info-row { + display: flex; + gap: 1rem; + padding: 0.75rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.info-label { + color: #7f8c8d; + font-weight: 600; + min-width: 150px; +} + +.info-value { + color: #2c3e50; + word-break: break-all; +} + +.signature { + font-size: 0.75rem; +} + +/* Transactions list */ +.block-transactions, +.account-transactions { + margin-top: 2rem; +} + +.block-transactions h2, +.account-transactions h2 { + font-size: 1.5rem; + margin-bottom: 1rem; + color: #2c3e50; +} + +.transactions-list { + display: grid; + gap: 1rem; +} + +.no-transactions { + padding: 2rem; + text-align: center; + color: #7f8c8d; + background-color: #f8f9fa; + border-radius: 8px; +} + +/* Accounts list */ +.accounts-list { + display: grid; + gap: 0.5rem; + margin-top: 1rem; +} + +.account-item { + padding: 0.75rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.account-item a { + color: #3498db; + text-decoration: none; +} + +.account-item a:hover { + text-decoration: underline; +} + +.nonce { + color: #7f8c8d; + font-size: 0.9rem; + margin-left: 0.5rem; +} + +/* Loading and error states */ +.loading, +.loading-more { + text-align: center; + padding: 2rem; + color: #7f8c8d; + font-style: italic; +} + +.error, +.error-page { + background-color: #f8d7da; + color: #721c24; + padding: 1rem; + border-radius: 8px; + margin: 1rem 0; +} + +.not-found, +.not-found-page { + text-align: center; + padding: 3rem; + color: #7f8c8d; +} + +.not-found-page h1 { + font-size: 4rem; + color: #e74c3c; + margin-bottom: 1rem; +} + +.not-found-page a { + color: #3498db; + text-decoration: none; + font-weight: 600; +} + +.not-found-page a:hover { + text-decoration: underline; +} + +/* Load more button */ +.load-more-button { + display: block; + width: 100%; + padding: 1rem; + margin-top: 1rem; + background-color: #3498db; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.3s; +} + +.load-more-button:hover { + background-color: #2980b9; +} + +/* Responsive design */ +@media (max-width: 768px) { + .app-main { + padding: 1rem; + } + + .search-form { + flex-direction: column; + } + + .search-button { + width: 100%; + } + + .block-preview-header, + .transaction-preview-header { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .info-row { + flex-direction: column; + gap: 0.25rem; + } + + .info-label { + min-width: auto; + } +} diff --git a/explorer_service/src/api.rs b/explorer_service/src/api.rs new file mode 100644 index 00000000..fe84033f --- /dev/null +++ b/explorer_service/src/api.rs @@ -0,0 +1,158 @@ +use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction}; +use leptos::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Search results structure +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SearchResults { + pub blocks: Vec, + pub transactions: Vec, + pub accounts: Vec<(AccountId, Option)>, +} + +/// RPC client type +#[cfg(feature = "ssr")] +pub type IndexerRpcClient = jsonrpsee::http_client::HttpClient; + +/// Get account information by ID +#[server] +pub async fn get_account(account_id: AccountId) -> Result { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_account(account_id) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Parse hex string to bytes +#[cfg(feature = "ssr")] +fn parse_hex(s: &str) -> Option> { + let s = s.trim().trim_start_matches("0x"); + hex::decode(s).ok() +} + +/// Search for a block, transaction, or account by query string +#[server] +pub async fn search(query: String) -> Result { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + + let mut blocks = Vec::new(); + let mut transactions = Vec::new(); + let mut accounts = Vec::new(); + + // Try to parse as hash (32 bytes) + if let Some(bytes) = parse_hex(&query) + && let Ok(hash_array) = <[u8; 32]>::try_from(bytes) + { + let hash = Hash(hash_array); + + // Try as block hash + if let Ok(block) = client.get_block_by_hash(hash).await { + blocks.push(block); + } + + // Try as transaction hash + if let Ok(tx) = client.get_transaction(hash).await { + transactions.push(tx); + } + + // Try as account ID + let account_id = AccountId { value: hash_array }; + match client.get_account(account_id).await { + Ok(account) => { + accounts.push((account_id, Some(account))); + } + Err(_) => { + // Account might not exist yet, still add it to results + accounts.push((account_id, None)); + } + } + } + + // Try as block ID + if let Ok(block_id) = query.parse::() + && let Ok(block) = client.get_block_by_id(block_id).await + { + blocks.push(block); + } + + Ok(SearchResults { + blocks, + transactions, + accounts, + }) +} + +/// Get block by ID +#[server] +pub async fn get_block_by_id(block_id: BlockId) -> Result { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_block_by_id(block_id) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Get block by hash +#[server] +pub async fn get_block_by_hash(block_hash: Hash) -> Result { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_block_by_hash(block_hash) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Get transaction by hash +#[server] +pub async fn get_transaction(tx_hash: Hash) -> Result { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_transaction(tx_hash) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Get blocks with pagination +#[server] +pub async fn get_blocks(offset: u32, limit: u32) -> Result, ServerFnError> { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_blocks(offset, limit) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Get transactions by account +#[server] +pub async fn get_transactions_by_account( + account_id: AccountId, + limit: u32, + offset: u32, +) -> Result, ServerFnError> { + use indexer_service_rpc::RpcClient as _; + let client = expect_context::(); + client + .get_transactions_by_account(account_id, limit, offset) + .await + .map_err(|e| ServerFnError::ServerError(format!("RPC error: {}", e))) +} + +/// Create the RPC client for the indexer service (server-side only) +#[cfg(feature = "ssr")] +pub fn create_indexer_rpc_client(url: &url::Url) -> Result { + use jsonrpsee::http_client::HttpClientBuilder; + use log::info; + + info!("Connecting to Indexer RPC on URL: {url}"); + + HttpClientBuilder::default() + .build(url.as_str()) + .map_err(|e| format!("Failed to create RPC client: {e}")) +} diff --git a/explorer_service/src/components/account_preview.rs b/explorer_service/src/components/account_preview.rs new file mode 100644 index 00000000..30bbae5b --- /dev/null +++ b/explorer_service/src/components/account_preview.rs @@ -0,0 +1,63 @@ +use indexer_service_protocol::{Account, AccountId}; +use leptos::prelude::*; +use leptos_router::components::A; + +use crate::format_utils; + +/// Account preview component +#[component] +pub fn AccountPreview(account_id: AccountId, account: Option) -> impl IntoView { + let account_id_str = format_utils::format_account_id(&account_id); + + view! { + + } +} diff --git a/explorer_service/src/components/block_preview.rs b/explorer_service/src/components/block_preview.rs new file mode 100644 index 00000000..b577cceb --- /dev/null +++ b/explorer_service/src/components/block_preview.rs @@ -0,0 +1,77 @@ +use indexer_service_protocol::{BedrockStatus, Block, BlockBody, BlockHeader}; +use leptos::prelude::*; +use leptos_router::components::A; + +use crate::format_utils; + +/// Get CSS class for bedrock status +fn status_class(status: &BedrockStatus) -> &'static str { + match status { + BedrockStatus::Pending => "status-pending", + BedrockStatus::Safe => "status-safe", + BedrockStatus::Finalized => "status-finalized", + } +} + +/// Block preview component +#[component] +pub fn BlockPreview(block: Block) -> impl IntoView { + let Block { + header: + BlockHeader { + block_id, + prev_block_hash, + hash, + timestamp, + signature: _, + }, + body: BlockBody { transactions }, + bedrock_status, + bedrock_parent_id: _, + } = block; + + let tx_count = transactions.len(); + + let hash_str = hex::encode(hash.0); + let prev_hash_str = hex::encode(prev_block_hash.0); + let time_str = format_utils::format_timestamp(timestamp); + let status_str = match &bedrock_status { + BedrockStatus::Pending => "Pending", + BedrockStatus::Safe => "Safe", + BedrockStatus::Finalized => "Finalized", + }; + + view! { + + } +} diff --git a/explorer_service/src/components/mod.rs b/explorer_service/src/components/mod.rs new file mode 100644 index 00000000..a0032b10 --- /dev/null +++ b/explorer_service/src/components/mod.rs @@ -0,0 +1,7 @@ +pub mod account_preview; +pub mod block_preview; +pub mod transaction_preview; + +pub use account_preview::AccountPreview; +pub use block_preview::BlockPreview; +pub use transaction_preview::TransactionPreview; diff --git a/explorer_service/src/components/transaction_preview.rs b/explorer_service/src/components/transaction_preview.rs new file mode 100644 index 00000000..a08abb30 --- /dev/null +++ b/explorer_service/src/components/transaction_preview.rs @@ -0,0 +1,72 @@ +use indexer_service_protocol::Transaction; +use leptos::prelude::*; +use leptos_router::components::A; + +/// Get transaction type name and CSS class +fn transaction_type_info(tx: &Transaction) -> (&'static str, &'static str) { + match tx { + Transaction::Public(_) => ("Public", "tx-type-public"), + Transaction::PrivacyPreserving(_) => ("Privacy-Preserving", "tx-type-private"), + Transaction::ProgramDeployment(_) => ("Program Deployment", "tx-type-deployment"), + } +} + +/// Transaction preview component +#[component] +pub fn TransactionPreview(transaction: Transaction) -> impl IntoView { + let hash = transaction.hash(); + let hash_str = hex::encode(hash.0); + let (type_name, type_class) = transaction_type_info(&transaction); + + // Get additional metadata based on transaction type + let metadata = match &transaction { + Transaction::Public(tx) => { + let indexer_service_protocol::PublicTransaction { + hash: _, + message, + witness_set: _, + } = tx; + format!("{} accounts involved", message.account_ids.len()) + } + Transaction::PrivacyPreserving(tx) => { + let indexer_service_protocol::PrivacyPreservingTransaction { + hash: _, + message, + witness_set: _, + } = tx; + format!( + "{} public accounts, {} commitments", + message.public_account_ids.len(), + message.new_commitments.len() + ) + } + Transaction::ProgramDeployment(tx) => { + let indexer_service_protocol::ProgramDeploymentTransaction { hash: _, message } = tx; + format!("{} bytes", message.bytecode.len()) + } + }; + + view! { + + } +} diff --git a/explorer_service/src/format_utils.rs b/explorer_service/src/format_utils.rs new file mode 100644 index 00000000..6f5378de --- /dev/null +++ b/explorer_service/src/format_utils.rs @@ -0,0 +1,33 @@ +//! Formatting utilities for the explorer + +use indexer_service_protocol::{AccountId, ProgramId}; + +/// Format timestamp to human-readable string +pub fn format_timestamp(timestamp: u64) -> String { + let seconds = timestamp / 1000; + let datetime = chrono::DateTime::from_timestamp(seconds as i64, 0) + .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap()); + datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +/// Format hash (32 bytes) to hex string +pub fn format_hash(hash: &[u8; 32]) -> String { + hex::encode(hash) +} + +/// Format account ID to hex string +pub fn format_account_id(account_id: &AccountId) -> String { + hex::encode(account_id.value) +} + +/// Format program ID to hex string +pub fn format_program_id(program_id: &ProgramId) -> String { + let bytes: Vec = program_id.iter().flat_map(|n| n.to_be_bytes()).collect(); + hex::encode(bytes) +} + +/// Parse hex string to bytes +pub fn parse_hex(s: &str) -> Option> { + let s = s.trim().trim_start_matches("0x"); + hex::decode(s).ok() +} diff --git a/explorer_service/src/lib.rs b/explorer_service/src/lib.rs new file mode 100644 index 00000000..489636fd --- /dev/null +++ b/explorer_service/src/lib.rs @@ -0,0 +1,102 @@ +use leptos::prelude::*; +use leptos_meta::{Meta, Stylesheet, Title, provide_meta_context}; +use leptos_router::{ + ParamSegment, StaticSegment, + components::{Route, Router, Routes}, +}; +use pages::{AccountPage, BlockPage, MainPage, TransactionPage}; + +pub mod api; +mod components; +mod format_utils; +mod pages; + +/// Main application component with routing setup. +/// +/// # Routes +/// +/// - `/` - Main page with search and recent blocks +/// - `/block/:id` - Block detail page (`:id` is the numeric block ID) +/// - `/transaction/:hash` - Transaction detail page (`:hash` is the hex-encoded transaction hash) +/// - `/account/:id` - Account detail page (`:id` is the hex-encoded account ID) +/// +/// All other routes will show a 404 Not Found page. +#[component] +pub fn App() -> impl IntoView { + // Provides context that manages stylesheets, titles, meta tags, etc. + provide_meta_context(); + + view! { + + + <Meta name="description" content="Explore the blockchain - view blocks, transactions, and accounts" /> + + <Router> + <div class="app"> + <header class="app-header"> + <nav class="app-nav"> + <a href="/" class="nav-logo"> + "LEE Blockchain Explorer" + </a> + </nav> + </header> + + <main class="app-main"> + // Route definitions: + // - MainPage: Home with search and recent blocks + // - BlockPage: Detailed block view with all transactions + // - TransactionPage: Detailed transaction view + // - AccountPage: Account state and transaction history + <Routes fallback=|| view! { <NotFound /> }> + // Main page - search and recent blocks + <Route path=StaticSegment("") view=MainPage /> + + // Block detail page - /block/123 + <Route path=(StaticSegment("block"), ParamSegment("id")) view=BlockPage /> + + // Transaction detail page - /transaction/0abc123... + <Route + path=(StaticSegment("transaction"), ParamSegment("hash")) + view=TransactionPage + /> + + // Account detail page - /account/0def456... + <Route + path=(StaticSegment("account"), ParamSegment("id")) + view=AccountPage + /> + </Routes> + </main> + + <footer class="app-footer"> + <p>"LEE Blockchain Explorer © 2026"</p> + </footer> + </div> + </Router> + } +} + +/// 404 Not Found page component. +/// +/// Displayed when a user navigates to a route that doesn't exist. +#[component] +fn NotFound() -> impl IntoView { + view! { + <div class="not-found-page"> + <h1>"404"</h1> + <p>"Page not found"</p> + <a href="/">"Go back to home"</a> + </div> + } +} + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + use leptos::mount::hydrate_body; + + console_error_panic_hook::set_once(); + console_log::init_with_level(log::Level::Debug).expect("error initializing logger"); + + hydrate_body(App); +} diff --git a/explorer_service/src/main.rs b/explorer_service/src/main.rs new file mode 100644 index 00000000..63d54d70 --- /dev/null +++ b/explorer_service/src/main.rs @@ -0,0 +1,79 @@ +#[cfg(feature = "ssr")] +#[tokio::main] +async fn main() { + use axum::Router; + use clap::Parser; + use explorer_service::App; + use leptos::prelude::*; + use leptos_axum::{LeptosRoutes, generate_route_list}; + use leptos_meta::MetaTags; + + env_logger::init(); + + /// LEE Blockchain Explorer Server CLI arguments. + #[derive(Parser, Debug)] + #[command(version, about, long_about = None)] + struct Args { + /// Indexer RPC URL + #[arg(long, env = "INDEXER_RPC_URL", default_value = "http://localhost:8779")] + indexer_rpc_url: url::Url, + } + + let args = Args::parse(); + + let conf = get_configuration(None).unwrap(); + let leptos_options = conf.leptos_options; + let addr = leptos_options.site_addr; + let routes = generate_route_list(App); + + // Create RPC client once + let rpc_client = explorer_service::api::create_indexer_rpc_client(&args.indexer_rpc_url) + .expect("Failed to create RPC client"); + + // Build our application with routes + let app = Router::new() + .leptos_routes_with_context( + &leptos_options, + routes, + { + let rpc_client = rpc_client.clone(); + move || provide_context(rpc_client.clone()) + }, + { + let leptos_options = leptos_options.clone(); + move || { + view! { + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <AutoReload options=leptos_options.clone() /> + <HydrationScripts options=leptos_options.clone() /> + <MetaTags /> + </head> + <body> + <App /> + </body> + </html> + } + } + }, + ) + .fallback(leptos_axum::file_and_error_handler(|_| { + view! { "Page not found" } + })) + .with_state(leptos_options); + + // Run the server + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + println!("Listening on http://{}", &addr); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} + +#[cfg(not(feature = "ssr"))] +fn main() { + // Client-only main - no-op since hydration is done via wasm_bindgen +} diff --git a/explorer_service/src/pages/account_page.rs b/explorer_service/src/pages/account_page.rs new file mode 100644 index 00000000..efd9ae81 --- /dev/null +++ b/explorer_service/src/pages/account_page.rs @@ -0,0 +1,229 @@ +use indexer_service_protocol::{Account, AccountId}; +use leptos::prelude::*; +use leptos_router::hooks::use_params_map; + +use crate::{api, components::TransactionPreview, format_utils}; + +/// Account page component +#[component] +pub fn AccountPage() -> impl IntoView { + let params = use_params_map(); + let (tx_offset, set_tx_offset) = signal(0u32); + let (all_transactions, set_all_transactions) = signal(Vec::new()); + let (is_loading, set_is_loading) = signal(false); + let (has_more, set_has_more) = signal(true); + let tx_limit = 10u32; + + // Parse account ID from URL params + let account_id = move || { + let account_id_str = params.read().get("id").unwrap_or_default(); + format_utils::parse_hex(&account_id_str).and_then(|bytes| { + if bytes.len() == 32 { + let account_id_array: [u8; 32] = bytes.try_into().ok()?; + Some(AccountId { + value: account_id_array, + }) + } else { + None + } + }) + }; + + // Load account data + let account_resource = Resource::new(account_id, |acc_id_opt| async move { + match acc_id_opt { + Some(acc_id) => api::get_account(acc_id).await, + None => Err(leptos::prelude::ServerFnError::ServerError( + "Invalid account ID".to_string(), + )), + } + }); + + // Load initial transactions + let transactions_resource = Resource::new(account_id, move |acc_id_opt| async move { + match acc_id_opt { + Some(acc_id) => api::get_transactions_by_account(acc_id, tx_limit, 0).await, + None => Err(leptos::prelude::ServerFnError::ServerError( + "Invalid account ID".to_string(), + )), + } + }); + + // Update all_transactions when initial load completes + Effect::new(move || { + if let Some(Ok(txs)) = transactions_resource.get() { + set_all_transactions.set(txs.clone()); + set_has_more.set(txs.len() as u32 == tx_limit); + } + }); + + // Load more transactions handler + let load_more = move |_| { + let Some(acc_id) = account_id() else { + return; + }; + + set_is_loading.set(true); + let current_offset = tx_offset.get() + tx_limit; + set_tx_offset.set(current_offset); + + leptos::task::spawn_local(async move { + match api::get_transactions_by_account(acc_id, tx_limit, current_offset).await { + Ok(new_txs) => { + let txs_count = new_txs.len() as u32; + set_all_transactions.update(|txs| txs.extend(new_txs)); + set_has_more.set(txs_count == tx_limit); + } + Err(e) => { + log::error!("Failed to load more transactions: {}", e); + } + } + set_is_loading.set(false); + }); + }; + + view! { + <div class="account-page"> + <Suspense fallback=move || view! { <div class="loading">"Loading account..."</div> }> + {move || { + account_resource + .get() + .map(|result| match result { + Ok(acc) => { + let Account { + program_owner, + balance, + data, + nonce, + } = acc; + + let acc_id = account_id().expect("Account ID should be set"); + let account_id_str = format_utils::format_account_id(&acc_id); + let program_id = format_utils::format_program_id(&program_owner); + let balance_str = balance.to_string(); + let nonce_str = nonce.to_string(); + let data_len = data.0.len(); + view! { + <div class="account-detail"> + <div class="page-header"> + <h1>"Account"</h1> + </div> + + <div class="account-info"> + <h2>"Account Information"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Account ID:"</span> + <span class="info-value hash">{account_id_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Balance:"</span> + <span class="info-value">{balance_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Program Owner:"</span> + <span class="info-value hash">{program_id}</span> + </div> + <div class="info-row"> + <span class="info-label">"Nonce:"</span> + <span class="info-value">{nonce_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Data:"</span> + <span class="info-value">{format!("{} bytes", data_len)}</span> + </div> + </div> + </div> + + <div class="account-transactions"> + <h2>"Transactions"</h2> + <Suspense fallback=move || { + view! { <div class="loading">"Loading transactions..."</div> } + }> + + {move || { + transactions_resource + .get() + .map(|result| match result { + Ok(_) => { + let txs = all_transactions.get(); + if txs.is_empty() { + view! { + <div class="no-transactions"> + "No transactions found" + </div> + } + .into_any() + } else { + view! { + <div> + <div class="transactions-list"> + {txs + .into_iter() + .map(|tx| { + view! { <TransactionPreview transaction=tx /> } + }) + .collect::<Vec<_>>()} + </div> + {move || { + if has_more.get() { + view! { + <button + class="load-more-button" + on:click=load_more + disabled=move || is_loading.get() + > + {move || { + if is_loading.get() { + "Loading..." + } else { + "Load More" + } + }} + + </button> + } + .into_any() + } else { + ().into_any() + } + }} + + </div> + } + .into_any() + } + } + Err(e) => { + view! { + <div class="error"> + {format!("Failed to load transactions: {}", e)} + </div> + } + .into_any() + } + }) + }} + + </Suspense> + </div> + </div> + } + .into_any() + } + Err(e) => { + view! { + <div class="error-page"> + <h1>"Error"</h1> + <p>{format!("Failed to load account: {}", e)}</p> + </div> + } + .into_any() + } + }) + }} + + </Suspense> + </div> + } +} diff --git a/explorer_service/src/pages/block_page.rs b/explorer_service/src/pages/block_page.rs new file mode 100644 index 00000000..988bc5e3 --- /dev/null +++ b/explorer_service/src/pages/block_page.rs @@ -0,0 +1,159 @@ +use indexer_service_protocol::{BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Hash}; +use leptos::prelude::*; +use leptos_router::{components::A, hooks::use_params_map}; + +use crate::{api, components::TransactionPreview, format_utils}; + +#[derive(Clone, PartialEq, Eq)] +enum BlockIdOrHash { + BlockId(BlockId), + Hash(Hash), +} + +/// Block page component +#[component] +pub fn BlockPage() -> impl IntoView { + let params = use_params_map(); + + let block_resource = Resource::new( + move || { + let id_str = params.read().get("id").unwrap_or_default(); + + // Try to parse as block ID (number) + if let Ok(block_id) = id_str.parse::<BlockId>() { + return Some(BlockIdOrHash::BlockId(block_id)); + } + + // Try to parse as block hash (hex string) + let id_str = id_str.trim().trim_start_matches("0x"); + if let Some(bytes) = format_utils::parse_hex(id_str) + && let Ok(hash_array) = <[u8; 32]>::try_from(bytes) + { + return Some(BlockIdOrHash::Hash(Hash(hash_array))); + } + + None + }, + |block_id_or_hash| async move { + match block_id_or_hash { + Some(BlockIdOrHash::BlockId(id)) => api::get_block_by_id(id).await, + Some(BlockIdOrHash::Hash(hash)) => api::get_block_by_hash(hash).await, + None => Err(leptos::prelude::ServerFnError::ServerError( + "Invalid block ID or hash".to_string(), + )), + } + }, + ); + + view! { + <div class="block-page"> + <Suspense fallback=move || view! { <div class="loading">"Loading block..."</div> }> + {move || { + block_resource + .get() + .map(|result| match result { + Ok(blk) => { + let Block { + header: BlockHeader { + block_id, + prev_block_hash, + hash, + timestamp, + signature, + }, + body: BlockBody { + transactions, + }, + bedrock_status, + bedrock_parent_id: _, + } = blk; + + let hash_str = format_utils::format_hash(&hash.0); + let prev_hash = format_utils::format_hash(&prev_block_hash.0); + let timestamp_str = format_utils::format_timestamp(timestamp); + let signature_str = hex::encode(signature.0); + let status = match &bedrock_status { + BedrockStatus::Pending => "Pending", + BedrockStatus::Safe => "Safe", + BedrockStatus::Finalized => "Finalized", + }; + view! { + <div class="block-detail"> + <div class="page-header"> + <h1>"Block " {block_id.to_string()}</h1> + </div> + + <div class="block-info"> + <h2>"Block Information"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Block ID: "</span> + <span class="info-value">{block_id.to_string()}</span> + </div> + <div class="info-row"> + <span class="info-label">"Hash: "</span> + <span class="info-value hash">{hash_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Previous Block Hash: "</span> + <A href=format!("/block/{}", prev_hash) attr:class="info-value hash"> + {prev_hash} + </A> + </div> + <div class="info-row"> + <span class="info-label">"Timestamp: "</span> + <span class="info-value">{timestamp_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Status: "</span> + <span class="info-value">{status}</span> + </div> + <div class="info-row"> + <span class="info-label">"Signature: "</span> + <span class="info-value hash signature">{signature_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Transaction Count: "</span> + <span class="info-value">{transactions.len().to_string()}</span> + </div> + </div> + </div> + + <div class="block-transactions"> + <h2>"Transactions"</h2> + {if transactions.is_empty() { + view! { <div class="no-transactions">"No transactions"</div> } + .into_any() + } else { + view! { + <div class="transactions-list"> + {transactions + .into_iter() + .map(|tx| view! { <TransactionPreview transaction=tx /> }) + .collect::<Vec<_>>()} + </div> + } + .into_any() + }} + + </div> + </div> + } + .into_any() + } + Err(e) => { + view! { + <div class="error-page"> + <h1>"Error"</h1> + <p>{format!("Failed to load block: {}", e)}</p> + </div> + } + .into_any() + } + }) + }} + + </Suspense> + </div> + } +} diff --git a/explorer_service/src/pages/main_page.rs b/explorer_service/src/pages/main_page.rs new file mode 100644 index 00000000..ffd625c8 --- /dev/null +++ b/explorer_service/src/pages/main_page.rs @@ -0,0 +1,208 @@ +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_query_map}; +use web_sys::SubmitEvent; + +use crate::{ + api::{self, SearchResults}, + components::{AccountPreview, BlockPreview, TransactionPreview}, +}; + +/// Main page component +#[component] +pub fn MainPage() -> impl IntoView { + let query_map = use_query_map(); + let navigate = use_navigate(); + + // Read search query from URL parameter + let url_query = move || query_map.read().get("q").unwrap_or_default(); + + let (search_query, set_search_query) = signal(url_query()); + + // Sync search input with URL parameter + Effect::new(move || { + set_search_query.set(url_query()); + }); + + // Search results resource based on URL query parameter + let search_resource = Resource::new(url_query, |query| async move { + if query.is_empty() { + return None; + } + match api::search(query).await { + Ok(result) => Some(result), + Err(e) => { + log::error!("Search error: {}", e); + None + } + } + }); + + // Load recent blocks on mount + let recent_blocks_resource = Resource::new(|| (), |_| async { api::get_blocks(0, 10).await }); + + // Handle search - update URL parameter + let on_search = move |ev: SubmitEvent| { + ev.prevent_default(); + let query = search_query.get(); + if query.is_empty() { + navigate("?", Default::default()); + return; + } + + navigate( + &format!("?q={}", urlencoding::encode(&query)), + Default::default(), + ); + }; + + view! { + <div class="main-page"> + <div class="page-header"> + <h1>"LEE Blockchain Explorer"</h1> + </div> + + <div class="search-section"> + <form on:submit=on_search class="search-form"> + <input + type="text" + class="search-input" + placeholder="Search by block ID, block hash, transaction hash, or account ID..." + prop:value=move || search_query.get() + on:input=move |ev| set_search_query.set(event_target_value(&ev)) + /> + <button type="submit" class="search-button"> + "Search" + </button> + </form> + + <Suspense fallback=move || view! { <div class="loading">"Searching..."</div> }> + {move || { + search_resource + .get() + .and_then(|opt_results| opt_results) + .map(|results| { + let SearchResults { + blocks, + transactions, + accounts, + } = results; + let has_results = !blocks.is_empty() + || !transactions.is_empty() + || !accounts.is_empty(); + view! { + <div class="search-results"> + <h2>"Search Results"</h2> + {if !has_results { + view! { <div class="not-found">"No results found"</div> } + .into_any() + } else { + view! { + <div class="results-container"> + {if !blocks.is_empty() { + view! { + <div class="results-section"> + <h3>"Blocks"</h3> + <div class="results-list"> + {blocks + .into_iter() + .map(|block| { + view! { <BlockPreview block=block /> } + }) + .collect::<Vec<_>>()} + </div> + </div> + } + .into_any() + } else { + ().into_any() + }} + + {if !transactions.is_empty() { + view! { + <div class="results-section"> + <h3>"Transactions"</h3> + <div class="results-list"> + {transactions + .into_iter() + .map(|tx| { + view! { <TransactionPreview transaction=tx /> } + }) + .collect::<Vec<_>>()} + </div> + </div> + } + .into_any() + } else { + ().into_any() + }} + + {if !accounts.is_empty() { + view! { + <div class="results-section"> + <h3>"Accounts"</h3> + <div class="results-list"> + {accounts + .into_iter() + .map(|(id, account)| { + view! { + <AccountPreview + account_id=id + account=account + /> + } + }) + .collect::<Vec<_>>()} + </div> + </div> + } + .into_any() + } else { + ().into_any() + }} + + </div> + } + .into_any() + }} + </div> + } + .into_any() + }) + }} + + </Suspense> + </div> + + <div class="blocks-section"> + <h2>"Recent Blocks"</h2> + <Suspense fallback=move || view! { <div class="loading">"Loading blocks..."</div> }> + {move || { + recent_blocks_resource + .get() + .map(|result| match result { + Ok(blocks) if !blocks.is_empty() => { + view! { + <div class="blocks-list"> + {blocks + .into_iter() + .map(|block| view! { <BlockPreview block=block /> }) + .collect::<Vec<_>>()} + </div> + } + .into_any() + } + Ok(_) => { + view! { <div class="no-blocks">"No blocks found"</div> }.into_any() + } + Err(e) => { + view! { <div class="error">{format!("Error: {}", e)}</div> } + .into_any() + } + }) + }} + + </Suspense> + </div> + </div> + } +} diff --git a/explorer_service/src/pages/mod.rs b/explorer_service/src/pages/mod.rs new file mode 100644 index 00000000..f4220145 --- /dev/null +++ b/explorer_service/src/pages/mod.rs @@ -0,0 +1,9 @@ +pub mod account_page; +pub mod block_page; +pub mod main_page; +pub mod transaction_page; + +pub use account_page::AccountPage; +pub use block_page::BlockPage; +pub use main_page::MainPage; +pub use transaction_page::TransactionPage; diff --git a/explorer_service/src/pages/transaction_page.rs b/explorer_service/src/pages/transaction_page.rs new file mode 100644 index 00000000..a8571a7a --- /dev/null +++ b/explorer_service/src/pages/transaction_page.rs @@ -0,0 +1,262 @@ +use indexer_service_protocol::{ + Hash, PrivacyPreservingMessage, PrivacyPreservingTransaction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, PublicMessage, PublicTransaction, Transaction, WitnessSet, +}; +use leptos::prelude::*; +use leptos_router::{components::A, hooks::use_params_map}; + +use crate::{api, format_utils}; + +/// Transaction page component +#[component] +pub fn TransactionPage() -> impl IntoView { + let params = use_params_map(); + + let transaction_resource = Resource::new( + move || { + let tx_hash_str = params.read().get("hash").unwrap_or_default(); + format_utils::parse_hex(&tx_hash_str).and_then(|bytes| { + if bytes.len() == 32 { + let hash_array: [u8; 32] = bytes.try_into().ok()?; + Some(Hash(hash_array)) + } else { + None + } + }) + }, + |hash_opt| async move { + match hash_opt { + Some(hash) => api::get_transaction(hash).await, + None => Err(leptos::prelude::ServerFnError::ServerError( + "Invalid transaction hash".to_string(), + )), + } + }, + ); + + view! { + <div class="transaction-page"> + <Suspense fallback=move || view! { <div class="loading">"Loading transaction..."</div> }> + {move || { + transaction_resource + .get() + .map(|result| match result { + Ok(tx) => { + let tx_hash = format_utils::format_hash(&tx.hash().0); + let tx_type = match &tx { + Transaction::Public(_) => "Public Transaction", + Transaction::PrivacyPreserving(_) => "Privacy-Preserving Transaction", + Transaction::ProgramDeployment(_) => "Program Deployment Transaction", + }; + view! { + <div class="transaction-detail"> + <div class="page-header"> + <h1>"Transaction"</h1> + </div> + + <div class="transaction-info"> + <h2>"Transaction Information"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Hash:"</span> + <span class="info-value hash">{tx_hash}</span> + </div> + <div class="info-row"> + <span class="info-label">"Type:"</span> + <span class="info-value">{tx_type}</span> + </div> + </div> + </div> + + {match tx { + Transaction::Public(ptx) => { + let PublicTransaction { + hash: _, + message, + witness_set, + } = ptx; + let PublicMessage { + program_id, + account_ids, + nonces, + instruction_data, + } = message; + let WitnessSet { + signatures_and_public_keys, + proof, + } = witness_set; + + let program_id_str = program_id + .iter() + .map(|n| format!("{:08x}", n)) + .collect::<String>(); + let proof_len = proof.0.len(); + let signatures_count = signatures_and_public_keys.len(); + + view! { + <div class="transaction-details"> + <h2>"Public Transaction Details"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Program ID:"</span> + <span class="info-value hash">{program_id_str}</span> + </div> + <div class="info-row"> + <span class="info-label">"Instruction Data:"</span> + <span class="info-value"> + {format!("{} u32 values", instruction_data.len())} + </span> + </div> + <div class="info-row"> + <span class="info-label">"Proof Size:"</span> + <span class="info-value">{format!("{} bytes", proof_len)}</span> + </div> + <div class="info-row"> + <span class="info-label">"Signatures:"</span> + <span class="info-value">{signatures_count.to_string()}</span> + </div> + </div> + + <h3>"Accounts"</h3> + <div class="accounts-list"> + {account_ids + .into_iter() + .zip(nonces.into_iter()) + .map(|(account_id, nonce)| { + let account_id_str = format_utils::format_account_id(&account_id); + view! { + <div class="account-item"> + <A href=format!("/account/{}", account_id_str)> + <span class="hash">{account_id_str}</span> + </A> + <span class="nonce"> + " (nonce: " {nonce.to_string()} ")" + </span> + </div> + } + }) + .collect::<Vec<_>>()} + </div> + </div> + } + .into_any() + } + Transaction::PrivacyPreserving(pptx) => { + let PrivacyPreservingTransaction { + hash: _, + message, + witness_set, + } = pptx; + let PrivacyPreservingMessage { + public_account_ids, + nonces, + public_post_states: _, + encrypted_private_post_states, + new_commitments, + new_nullifiers, + } = message; + let WitnessSet { + signatures_and_public_keys: _, + proof, + } = witness_set; + + let proof_len = proof.0.len(); + view! { + <div class="transaction-details"> + <h2>"Privacy-Preserving Transaction Details"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Public Accounts:"</span> + <span class="info-value"> + {public_account_ids.len().to_string()} + </span> + </div> + <div class="info-row"> + <span class="info-label">"New Commitments:"</span> + <span class="info-value">{new_commitments.len().to_string()}</span> + </div> + <div class="info-row"> + <span class="info-label">"Nullifiers:"</span> + <span class="info-value">{new_nullifiers.len().to_string()}</span> + </div> + <div class="info-row"> + <span class="info-label">"Encrypted States:"</span> + <span class="info-value"> + {encrypted_private_post_states.len().to_string()} + </span> + </div> + <div class="info-row"> + <span class="info-label">"Proof Size:"</span> + <span class="info-value">{format!("{} bytes", proof_len)}</span> + </div> + </div> + + <h3>"Public Accounts"</h3> + <div class="accounts-list"> + {public_account_ids + .into_iter() + .zip(nonces.into_iter()) + .map(|(account_id, nonce)| { + let account_id_str = format_utils::format_account_id(&account_id); + view! { + <div class="account-item"> + <A href=format!("/account/{}", account_id_str)> + <span class="hash">{account_id_str}</span> + </A> + <span class="nonce"> + " (nonce: " {nonce.to_string()} ")" + </span> + </div> + } + }) + .collect::<Vec<_>>()} + </div> + </div> + } + .into_any() + } + Transaction::ProgramDeployment(pdtx) => { + let ProgramDeploymentTransaction { + hash: _, + message, + } = pdtx; + let ProgramDeploymentMessage { bytecode } = message; + + let bytecode_len = bytecode.len(); + view! { + <div class="transaction-details"> + <h2>"Program Deployment Transaction Details"</h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">"Bytecode Size:"</span> + <span class="info-value"> + {format!("{} bytes", bytecode_len)} + </span> + </div> + </div> + </div> + } + .into_any() + } + }} + + </div> + } + .into_any() + } + Err(e) => { + view! { + <div class="error-page"> + <h1>"Error"</h1> + <p>{format!("Failed to load transaction: {}", e)}</p> + </div> + } + .into_any() + } + }) + }} + + </Suspense> + </div> + } +} diff --git a/indexer_core/Cargo.toml b/indexer/core/Cargo.toml similarity index 92% rename from indexer_core/Cargo.toml rename to indexer/core/Cargo.toml index 0713330c..263731fa 100644 --- a/indexer_core/Cargo.toml +++ b/indexer/core/Cargo.toml @@ -19,7 +19,4 @@ futures.workspace = true url.workspace = true logos-blockchain-core.workspace = true serde_json.workspace = true - -[features] -default = [] -testnet = [] +async-stream.workspace = true diff --git a/indexer_core/src/block_store.rs b/indexer/core/src/block_store.rs similarity index 100% rename from indexer_core/src/block_store.rs rename to indexer/core/src/block_store.rs diff --git a/indexer_core/src/config.rs b/indexer/core/src/config.rs similarity index 74% rename from indexer_core/src/config.rs rename to indexer/core/src/config.rs index 65a9c0df..0127b30b 100644 --- a/indexer_core/src/config.rs +++ b/indexer/core/src/config.rs @@ -15,14 +15,12 @@ use serde::{Deserialize, Serialize}; use url::Url; #[derive(Debug, Clone, Serialize, Deserialize)] -/// ToDo: Expand if necessary -pub struct ClientConfig { +pub struct BedrockClientConfig { pub addr: Url, pub auth: Option<BasicAuth>, } #[derive(Debug, Clone, Serialize, Deserialize)] -/// Note: For individual RPC requests we use Fibonacci backoff retry strategy pub struct IndexerConfig { /// Home dir of sequencer storage pub home: PathBuf, @@ -31,19 +29,19 @@ pub struct IndexerConfig { /// List of initial commitments pub initial_commitments: Vec<CommitmentsInitialData>, pub resubscribe_interval_millis: u64, + /// For individual RPC requests we use Fibonacci backoff retry strategy. pub backoff: BackoffConfig, - pub bedrock_client_config: ClientConfig, - pub sequencer_client_config: ClientConfig, + pub bedrock_client_config: BedrockClientConfig, pub channel_id: ChannelId, } impl IndexerConfig { - pub fn from_path(config_home: &Path) -> Result<IndexerConfig> { - let file = File::open(config_home) - .with_context(|| format!("Failed to open indexer config at {config_home:?}"))?; + pub fn from_path(config_path: &Path) -> Result<IndexerConfig> { + let file = File::open(config_path) + .with_context(|| format!("Failed to open indexer config at {config_path:?}"))?; let reader = BufReader::new(file); serde_json::from_reader(reader) - .with_context(|| format!("Failed to parse indexer config at {config_home:?}")) + .with_context(|| format!("Failed to parse indexer config at {config_path:?}")) } } diff --git a/indexer/core/src/lib.rs b/indexer/core/src/lib.rs new file mode 100644 index 00000000..78c1e5ce --- /dev/null +++ b/indexer/core/src/lib.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use anyhow::Result; +use bedrock_client::BedrockClient; +use common::block::Block; +use futures::StreamExt; +use log::info; +use logos_blockchain_core::mantle::{ + Op, SignedMantleTx, + ops::channel::{ChannelId, inscribe::InscriptionOp}, +}; +use tokio::sync::RwLock; + +use crate::{config::IndexerConfig, state::IndexerState}; + +pub mod config; +pub mod state; + +#[derive(Clone)] +pub struct IndexerCore { + bedrock_client: BedrockClient, + config: IndexerConfig, + state: IndexerState, +} + +impl IndexerCore { + pub fn new(config: IndexerConfig) -> Result<Self> { + Ok(Self { + bedrock_client: BedrockClient::new( + config.bedrock_client_config.auth.clone().map(Into::into), + config.bedrock_client_config.addr.clone(), + )?, + config, + // No state setup for now, future task. + state: IndexerState { + latest_seen_block: Arc::new(RwLock::new(0)), + }, + }) + } + + pub async fn subscribe_parse_block_stream(&self) -> impl futures::Stream<Item = Result<Block>> { + async_stream::stream! { + loop { + let mut stream_pinned = Box::pin(self.bedrock_client.get_lib_stream().await?); + + info!("Block stream joined"); + + while let Some(block_info) = stream_pinned.next().await { + let header_id = block_info.header_id; + + info!("Observed L1 block at height {}", block_info.height); + + if let Some(l1_block) = self + .bedrock_client + .get_block_by_id(header_id, &self.config.backoff) + .await? + { + info!("Extracted L1 block at height {}", block_info.height); + + let l2_blocks_parsed = parse_blocks( + l1_block.into_transactions().into_iter(), + &self.config.channel_id, + ).collect::<Vec<_>>(); + + info!("Parsed {} L2 blocks", l2_blocks_parsed.len()); + + for l2_block in l2_blocks_parsed { + // State modification, will be updated in future + { + let mut guard = self.state.latest_seen_block.write().await; + if l2_block.header.block_id > *guard { + *guard = l2_block.header.block_id; + } + } + + yield Ok(l2_block); + } + } + } + + // Refetch stream after delay + tokio::time::sleep(std::time::Duration::from_millis( + self.config.resubscribe_interval_millis, + )) + .await; + } + } + } +} + +fn parse_blocks( + block_txs: impl Iterator<Item = SignedMantleTx>, + decoded_channel_id: &ChannelId, +) -> impl Iterator<Item = Block> { + block_txs.flat_map(|tx| { + tx.mantle_tx.ops.into_iter().filter_map(|op| match op { + Op::ChannelInscribe(InscriptionOp { + channel_id, + inscription, + .. + }) if channel_id == *decoded_channel_id => { + borsh::from_slice::<Block>(&inscription).ok() + } + _ => None, + }) + }) +} diff --git a/indexer_core/src/state.rs b/indexer/core/src/state.rs similarity index 100% rename from indexer_core/src/state.rs rename to indexer/core/src/state.rs diff --git a/indexer/service/Cargo.toml b/indexer/service/Cargo.toml new file mode 100644 index 00000000..f41a9afd --- /dev/null +++ b/indexer/service/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "indexer_service" +version = "0.1.0" +edition = "2024" + +[dependencies] +indexer_service_protocol = { workspace = true, features = ["convert"] } +indexer_service_rpc = { workspace = true, features = ["server"] } +indexer_core.workspace = true + +clap = { workspace = true, features = ["derive"] } +anyhow.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal"] } +tokio-util.workspace = true +env_logger.workspace = true +log.workspace = true +jsonrpsee.workspace = true +serde.workspace = true +serde_json.workspace = true +futures.workspace = true +async-trait = "0.1.89" + +[features] +# Return mock responses with generated data for testing purposes +mock-responses = [] diff --git a/indexer_service/Dockerfile b/indexer/service/Dockerfile similarity index 100% rename from indexer_service/Dockerfile rename to indexer/service/Dockerfile diff --git a/indexer_service/docker-compose.yml b/indexer/service/docker-compose.yml similarity index 100% rename from indexer_service/docker-compose.yml rename to indexer/service/docker-compose.yml diff --git a/indexer_service/protocol/Cargo.toml b/indexer/service/protocol/Cargo.toml similarity index 100% rename from indexer_service/protocol/Cargo.toml rename to indexer/service/protocol/Cargo.toml diff --git a/indexer_service/protocol/src/convert.rs b/indexer/service/protocol/src/convert.rs similarity index 95% rename from indexer_service/protocol/src/convert.rs rename to indexer/service/protocol/src/convert.rs index 8c6de2f4..179a25ec 100644 --- a/indexer_service/protocol/src/convert.rs +++ b/indexer/service/protocol/src/convert.rs @@ -381,11 +381,17 @@ impl TryFrom<WitnessSet> for nssa::privacy_preserving_transaction::witness_set:: impl From<nssa::PublicTransaction> for PublicTransaction { fn from(value: nssa::PublicTransaction) -> Self { + let hash = Hash(value.hash()); + let nssa::PublicTransaction { + message, + witness_set, + } = value; + Self { - message: value.message().clone().into(), + hash, + message: message.into(), witness_set: WitnessSet { - signatures_and_public_keys: value - .witness_set() + signatures_and_public_keys: witness_set .signatures_and_public_keys() .iter() .map(|(sig, pk)| (sig.clone().into(), pk.clone().into())) @@ -401,6 +407,7 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction { fn try_from(value: PublicTransaction) -> Result<Self, Self::Error> { let PublicTransaction { + hash: _, message, witness_set, } = value; @@ -408,6 +415,7 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction { signatures_and_public_keys, proof: _, } = witness_set; + Ok(Self::new( message.into(), nssa::public_transaction::WitnessSet::from_raw_parts( @@ -422,9 +430,16 @@ impl TryFrom<PublicTransaction> for nssa::PublicTransaction { impl From<nssa::PrivacyPreservingTransaction> for PrivacyPreservingTransaction { fn from(value: nssa::PrivacyPreservingTransaction) -> Self { + let hash = Hash(value.hash()); + let nssa::PrivacyPreservingTransaction { + message, + witness_set, + } = value; + Self { - message: value.message().clone().into(), - witness_set: value.witness_set().clone().into(), + hash, + message: message.into(), + witness_set: witness_set.into(), } } } @@ -434,13 +449,17 @@ impl TryFrom<PrivacyPreservingTransaction> for nssa::PrivacyPreservingTransactio fn try_from(value: PrivacyPreservingTransaction) -> Result<Self, Self::Error> { let PrivacyPreservingTransaction { + hash: _, message, witness_set, } = value; + Ok(Self::new( - message.try_into().map_err(|_| { - nssa::error::NssaError::InvalidInput("Data too big error".to_string()) - })?, + message + .try_into() + .map_err(|err: nssa_core::account::data::DataTooBigError| { + nssa::error::NssaError::InvalidInput(err.to_string()) + })?, witness_set.try_into()?, )) } @@ -448,15 +467,19 @@ impl TryFrom<PrivacyPreservingTransaction> for nssa::PrivacyPreservingTransactio impl From<nssa::ProgramDeploymentTransaction> for ProgramDeploymentTransaction { fn from(value: nssa::ProgramDeploymentTransaction) -> Self { + let hash = Hash(value.hash()); + let nssa::ProgramDeploymentTransaction { message } = value; + Self { - message: value.into_message().into(), + hash, + message: message.into(), } } } impl From<ProgramDeploymentTransaction> for nssa::ProgramDeploymentTransaction { fn from(value: ProgramDeploymentTransaction) -> Self { - let ProgramDeploymentTransaction { message } = value; + let ProgramDeploymentTransaction { hash: _, message } = value; Self::new(message.into()) } } diff --git a/indexer_service/protocol/src/lib.rs b/indexer/service/protocol/src/lib.rs similarity index 89% rename from indexer_service/protocol/src/lib.rs rename to indexer/service/protocol/src/lib.rs index f12bdf5b..c354aa59 100644 --- a/indexer_service/protocol/src/lib.rs +++ b/indexer/service/protocol/src/lib.rs @@ -67,14 +67,27 @@ pub enum Transaction { ProgramDeployment(ProgramDeploymentTransaction), } +impl Transaction { + /// Get the hash of the transaction + pub fn hash(&self) -> &self::Hash { + match self { + Transaction::Public(tx) => &tx.hash, + Transaction::PrivacyPreserving(tx) => &tx.hash, + Transaction::ProgramDeployment(tx) => &tx.hash, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct PublicTransaction { + pub hash: Hash, pub message: PublicMessage, pub witness_set: WitnessSet, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct PrivacyPreservingTransaction { + pub hash: Hash, pub message: PrivacyPreservingMessage, pub witness_set: WitnessSet, } @@ -121,6 +134,7 @@ pub struct EncryptedAccountData { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct ProgramDeploymentTransaction { + pub hash: Hash, pub message: ProgramDeploymentMessage, } @@ -133,7 +147,7 @@ pub struct Ciphertext( pub Vec<u8>, ); -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct PublicKey( #[serde(with = "base64::arr")] #[schemars(with = "String", description = "base64-encoded public key")] @@ -147,21 +161,21 @@ pub struct EphemeralPublicKey( pub Vec<u8>, ); -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Commitment( #[serde(with = "base64::arr")] #[schemars(with = "String", description = "base64-encoded commitment")] pub [u8; 32], ); -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Nullifier( #[serde(with = "base64::arr")] #[schemars(with = "String", description = "base64-encoded nullifier")] pub [u8; 32], ); -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct CommitmentSetDigest( #[serde(with = "base64::arr")] #[schemars(with = "String", description = "base64-encoded commitment set digest")] @@ -182,7 +196,7 @@ pub struct Data( pub Vec<u8>, ); -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct Hash( #[serde(with = "base64::arr")] #[schemars(with = "String", description = "base64-encoded hash")] diff --git a/indexer_service/rpc/Cargo.toml b/indexer/service/rpc/Cargo.toml similarity index 100% rename from indexer_service/rpc/Cargo.toml rename to indexer/service/rpc/Cargo.toml diff --git a/indexer_service/rpc/src/lib.rs b/indexer/service/rpc/src/lib.rs similarity index 63% rename from indexer_service/rpc/src/lib.rs rename to indexer/service/rpc/src/lib.rs index c1c4a560..52c5f0fb 100644 --- a/indexer_service/rpc/src/lib.rs +++ b/indexer/service/rpc/src/lib.rs @@ -1,11 +1,14 @@ use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction}; -use jsonrpsee::{core::SubscriptionResult, proc_macros::rpc, types::ErrorObjectOwned}; +use jsonrpsee::proc_macros::rpc; +#[cfg(feature = "server")] +use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned}; #[cfg(all(not(feature = "server"), not(feature = "client")))] compile_error!("At least one of `server` or `client` features must be enabled."); -#[cfg_attr(feature = "server", rpc(server))] -#[cfg_attr(feature = "client", rpc(client))] +#[cfg_attr(all(feature = "server", not(feature = "client")), rpc(server))] +#[cfg_attr(all(feature = "client", not(feature = "server")), rpc(client))] +#[cfg_attr(all(feature = "server", feature = "client"), rpc(server, client))] pub trait Rpc { #[method(name = "get_schema")] fn get_schema(&self) -> Result<serde_json::Value, ErrorObjectOwned> { @@ -20,8 +23,8 @@ pub trait Rpc { Ok(serde_json::to_value(block_schema).expect("Schema serialization should not fail")) } - #[subscription(name = "subscribeToBlocks", item = Vec<Block>)] - async fn subscribe_to_blocks(&self, from: BlockId) -> SubscriptionResult; + #[subscription(name = "subscribeToFinalizedBlocks", item = BlockId)] + async fn subscribe_to_finalized_blocks(&self) -> SubscriptionResult; #[method(name = "getBlockById")] async fn get_block_by_id(&self, block_id: BlockId) -> Result<Block, ErrorObjectOwned>; @@ -29,12 +32,20 @@ pub trait Rpc { #[method(name = "getBlockByHash")] async fn get_block_by_hash(&self, block_hash: Hash) -> Result<Block, ErrorObjectOwned>; - #[method(name = "getLastBlockId")] - async fn get_last_block_id(&self) -> Result<BlockId, ErrorObjectOwned>; - #[method(name = "getAccount")] async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned>; #[method(name = "getTransaction")] async fn get_transaction(&self, tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned>; + + #[method(name = "getBlocks")] + async fn get_blocks(&self, offset: u32, limit: u32) -> Result<Vec<Block>, ErrorObjectOwned>; + + #[method(name = "getTransactionsByAccount")] + async fn get_transactions_by_account( + &self, + account_id: AccountId, + limit: u32, + offset: u32, + ) -> Result<Vec<Transaction>, ErrorObjectOwned>; } diff --git a/indexer/service/src/lib.rs b/indexer/service/src/lib.rs new file mode 100644 index 00000000..8185cb40 --- /dev/null +++ b/indexer/service/src/lib.rs @@ -0,0 +1,82 @@ +use std::net::SocketAddr; + +use anyhow::{Context as _, Result}; +pub use indexer_core::config::*; +use indexer_service_rpc::RpcServer as _; +use jsonrpsee::server::Server; +use log::{error, info}; + +pub mod service; + +#[cfg(feature = "mock-responses")] +pub mod mock_service; + +pub struct IndexerHandle { + addr: SocketAddr, + server_handle: Option<jsonrpsee::server::ServerHandle>, +} +impl IndexerHandle { + fn new(addr: SocketAddr, server_handle: jsonrpsee::server::ServerHandle) -> Self { + Self { + addr, + server_handle: Some(server_handle), + } + } + + pub fn addr(&self) -> SocketAddr { + self.addr + } + + pub async fn stopped(mut self) { + let handle = self + .server_handle + .take() + .expect("Indexer server handle is set"); + + handle.stopped().await + } +} + +impl Drop for IndexerHandle { + fn drop(&mut self) { + let Self { + addr: _, + server_handle, + } = self; + + let Some(handle) = server_handle else { + return; + }; + + if let Err(err) = handle.stop() { + error!("An error occurred while stopping Indexer RPC server: {err}"); + } + } +} + +pub async fn run_server(config: IndexerConfig, port: u16) -> Result<IndexerHandle> { + #[cfg(feature = "mock-responses")] + let _ = config; + + let server = Server::builder() + .build(SocketAddr::from(([0, 0, 0, 0], port))) + .await + .context("Failed to build RPC server")?; + + let addr = server + .local_addr() + .context("Failed to get local address of RPC server")?; + + info!("Starting Indexer Service RPC server on {addr}"); + + #[cfg(not(feature = "mock-responses"))] + let handle = { + let service = + service::IndexerService::new(config).context("Failed to initialize indexer service")?; + server.start(service.into_rpc()) + }; + #[cfg(feature = "mock-responses")] + let handle = server.start(mock_service::MockIndexerService::new_with_mock_blocks().into_rpc()); + + Ok(IndexerHandle::new(addr, handle)) +} diff --git a/indexer_service/src/main.rs b/indexer/service/src/main.rs similarity index 57% rename from indexer_service/src/main.rs rename to indexer/service/src/main.rs index bfdd3259..e4d18feb 100644 --- a/indexer_service/src/main.rs +++ b/indexer/service/src/main.rs @@ -1,15 +1,15 @@ -use std::net::SocketAddr; +use std::path::PathBuf; -use anyhow::{Context as _, Result}; +use anyhow::Result; use clap::Parser; -use indexer_service_rpc::RpcServer as _; -use jsonrpsee::server::Server; use log::{error, info}; use tokio_util::sync::CancellationToken; #[derive(Debug, Parser)] #[clap(version)] struct Args { + #[clap(name = "config")] + config_path: PathBuf, #[clap(short, long, default_value = "8779")] port: u16, } @@ -18,18 +18,18 @@ struct Args { async fn main() -> Result<()> { env_logger::init(); - let args = Args::parse(); + let Args { config_path, port } = Args::parse(); let cancellation_token = listen_for_shutdown_signal(); - let handle = run_server(args.port).await?; - let handle_clone = handle.clone(); + let config = indexer_service::IndexerConfig::from_path(&config_path)?; + let indexer_handle = indexer_service::run_server(config, port).await?; tokio::select! { _ = cancellation_token.cancelled() => { info!("Shutting down server..."); } - _ = handle_clone.stopped() => { + _ = indexer_handle.stopped() => { error!("Server stopped unexpectedly"); } } @@ -39,22 +39,6 @@ async fn main() -> Result<()> { Ok(()) } -async fn run_server(port: u16) -> Result<jsonrpsee::server::ServerHandle> { - let server = Server::builder() - .build(SocketAddr::from(([0, 0, 0, 0], port))) - .await - .context("Failed to build RPC server")?; - - let addr = server - .local_addr() - .context("Failed to get local address of RPC server")?; - - info!("Starting Indexer Service RPC server on {addr}"); - - let handle = server.start(indexer_service::service::IndexerService.into_rpc()); - Ok(handle) -} - fn listen_for_shutdown_signal() -> CancellationToken { let cancellation_token = CancellationToken::new(); let cancellation_token_clone = cancellation_token.clone(); diff --git a/indexer/service/src/mock_service.rs b/indexer/service/src/mock_service.rs new file mode 100644 index 00000000..e7afda18 --- /dev/null +++ b/indexer/service/src/mock_service.rs @@ -0,0 +1,270 @@ +use std::collections::HashMap; + +use indexer_service_protocol::{ + Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, + CommitmentSetDigest, Data, EncryptedAccountData, Hash, MantleMsgId, PrivacyPreservingMessage, + PrivacyPreservingTransaction, ProgramDeploymentMessage, ProgramDeploymentTransaction, + PublicMessage, PublicTransaction, Signature, Transaction, WitnessSet, +}; +use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned}; + +/// A mock implementation of the IndexerService RPC for testing purposes. +pub struct MockIndexerService { + blocks: Vec<Block>, + accounts: HashMap<AccountId, Account>, + transactions: HashMap<Hash, (Transaction, BlockId)>, +} + +impl MockIndexerService { + pub fn new_with_mock_blocks() -> Self { + let mut blocks = Vec::new(); + let mut accounts = HashMap::new(); + let mut transactions = HashMap::new(); + + // Create some mock accounts + let account_ids: Vec<AccountId> = (0..5) + .map(|i| { + let mut value = [0u8; 32]; + value[0] = i; + AccountId { value } + }) + .collect(); + + for (i, account_id) in account_ids.iter().enumerate() { + accounts.insert( + *account_id, + Account { + program_owner: [i as u32; 8], + balance: 1000 * (i as u128 + 1), + data: Data(vec![0xaa, 0xbb, 0xcc]), + nonce: i as u128, + }, + ); + } + + // Create 10 blocks with transactions + let mut prev_hash = Hash([0u8; 32]); + + for block_id in 0..10 { + let block_hash = { + let mut hash = [0u8; 32]; + hash[0] = block_id as u8; + hash[1] = 0xff; + Hash(hash) + }; + + // Create 2-4 transactions per block (mix of Public, PrivacyPreserving, and + // ProgramDeployment) + let num_txs = 2 + (block_id % 3); + let mut block_transactions = Vec::new(); + + for tx_idx in 0..num_txs { + let tx_hash = { + let mut hash = [0u8; 32]; + hash[0] = block_id as u8; + hash[1] = tx_idx as u8; + Hash(hash) + }; + + // Vary transaction types: Public, PrivacyPreserving, or ProgramDeployment + let tx = match (block_id + tx_idx) % 5 { + // Public transactions (most common) + 0 | 1 => Transaction::Public(PublicTransaction { + hash: tx_hash, + message: PublicMessage { + program_id: [1u32; 8], + account_ids: vec![ + account_ids[tx_idx as usize % account_ids.len()], + account_ids[(tx_idx as usize + 1) % account_ids.len()], + ], + nonces: vec![block_id as u128, (block_id + 1) as u128], + instruction_data: vec![1, 2, 3, 4], + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: indexer_service_protocol::Proof(vec![0; 32]), + }, + }), + // PrivacyPreserving transactions + 2 | 3 => Transaction::PrivacyPreserving(PrivacyPreservingTransaction { + hash: tx_hash, + message: PrivacyPreservingMessage { + public_account_ids: vec![ + account_ids[tx_idx as usize % account_ids.len()], + ], + nonces: vec![block_id as u128], + public_post_states: vec![Account { + program_owner: [1u32; 8], + balance: 500, + data: Data(vec![0xdd, 0xee]), + nonce: block_id as u128, + }], + encrypted_private_post_states: vec![EncryptedAccountData { + ciphertext: indexer_service_protocol::Ciphertext(vec![ + 0x01, 0x02, 0x03, 0x04, + ]), + epk: indexer_service_protocol::EphemeralPublicKey(vec![0xaa; 32]), + view_tag: 42, + }], + new_commitments: vec![Commitment([block_id as u8; 32])], + new_nullifiers: vec![( + indexer_service_protocol::Nullifier([tx_idx as u8; 32]), + CommitmentSetDigest([0xff; 32]), + )], + }, + witness_set: WitnessSet { + signatures_and_public_keys: vec![], + proof: indexer_service_protocol::Proof(vec![0; 32]), + }, + }), + // ProgramDeployment transactions (rare) + _ => Transaction::ProgramDeployment(ProgramDeploymentTransaction { + hash: tx_hash, + message: ProgramDeploymentMessage { + bytecode: vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00], /* WASM magic number */ + }, + }), + }; + + transactions.insert(tx_hash, (tx.clone(), block_id)); + block_transactions.push(tx); + } + + let block = Block { + header: BlockHeader { + block_id, + prev_block_hash: prev_hash, + hash: block_hash, + timestamp: 1704067200000 + (block_id * 12000), // ~12 seconds per block + signature: Signature([0u8; 64]), + }, + body: BlockBody { + transactions: block_transactions, + }, + bedrock_status: match block_id { + 0..=5 => BedrockStatus::Finalized, + 6..=8 => BedrockStatus::Safe, + _ => BedrockStatus::Pending, + }, + bedrock_parent_id: MantleMsgId([0; 32]), + }; + + prev_hash = block_hash; + blocks.push(block); + } + + Self { + blocks, + accounts, + transactions, + } + } +} + +#[async_trait::async_trait] +impl indexer_service_rpc::RpcServer for MockIndexerService { + async fn subscribe_to_finalized_blocks( + &self, + subscription_sink: jsonrpsee::PendingSubscriptionSink, + ) -> SubscriptionResult { + let sink = subscription_sink.accept().await?; + for block in self + .blocks + .iter() + .filter(|b| b.bedrock_status == BedrockStatus::Finalized) + { + let json = serde_json::value::to_raw_value(block).unwrap(); + sink.send(json).await?; + } + Ok(()) + } + + async fn get_block_by_id(&self, block_id: BlockId) -> Result<Block, ErrorObjectOwned> { + self.blocks + .iter() + .find(|b| b.header.block_id == block_id) + .cloned() + .ok_or_else(|| { + ErrorObjectOwned::owned( + -32001, + format!("Block with ID {} not found", block_id), + None::<()>, + ) + }) + } + + async fn get_block_by_hash(&self, block_hash: Hash) -> Result<Block, ErrorObjectOwned> { + self.blocks + .iter() + .find(|b| b.header.hash == block_hash) + .cloned() + .ok_or_else(|| ErrorObjectOwned::owned(-32001, "Block with hash not found", None::<()>)) + } + + async fn get_account(&self, account_id: AccountId) -> Result<Account, ErrorObjectOwned> { + self.accounts + .get(&account_id) + .cloned() + .ok_or_else(|| ErrorObjectOwned::owned(-32001, "Account not found", None::<()>)) + } + + async fn get_transaction(&self, tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> { + self.transactions + .get(&tx_hash) + .map(|(tx, _)| tx.clone()) + .ok_or_else(|| ErrorObjectOwned::owned(-32001, "Transaction not found", None::<()>)) + } + + async fn get_blocks(&self, offset: u32, limit: u32) -> Result<Vec<Block>, ErrorObjectOwned> { + let offset = offset as usize; + let limit = limit as usize; + let total = self.blocks.len(); + + // Return blocks in reverse order (newest first), with pagination + let start = offset.min(total); + let end = (offset + limit).min(total); + + Ok(self + .blocks + .iter() + .rev() + .skip(start) + .take(end - start) + .cloned() + .collect()) + } + + async fn get_transactions_by_account( + &self, + account_id: AccountId, + limit: u32, + offset: u32, + ) -> Result<Vec<Transaction>, ErrorObjectOwned> { + let mut account_txs: Vec<_> = self + .transactions + .values() + .filter(|(tx, _)| match tx { + Transaction::Public(pub_tx) => pub_tx.message.account_ids.contains(&account_id), + Transaction::PrivacyPreserving(priv_tx) => { + priv_tx.message.public_account_ids.contains(&account_id) + } + Transaction::ProgramDeployment(_) => false, + }) + .collect(); + + // Sort by block ID descending (most recent first) + account_txs.sort_by(|a, b| b.1.cmp(&a.1)); + + let start = offset as usize; + if start >= account_txs.len() { + return Ok(Vec::new()); + } + + let end = (start + limit as usize).min(account_txs.len()); + + Ok(account_txs[start..end] + .iter() + .map(|(tx, _)| tx.clone()) + .collect()) + } +} diff --git a/indexer/service/src/service.rs b/indexer/service/src/service.rs new file mode 100644 index 00000000..d7b14c4d --- /dev/null +++ b/indexer/service/src/service.rs @@ -0,0 +1,162 @@ +use std::{pin::pin, sync::Arc}; + +use anyhow::{Context as _, Result, bail}; +use futures::StreamExt as _; +use indexer_core::{IndexerCore, config::IndexerConfig}; +use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction}; +use jsonrpsee::{ + SubscriptionSink, + core::{Serialize, SubscriptionResult}, + types::ErrorObjectOwned, +}; +use tokio::sync::{Mutex, mpsc::UnboundedSender}; + +pub struct IndexerService { + subscription_service: SubscriptionService, + + #[expect( + dead_code, + reason = "Will be used in future implementations of RPC methods" + )] + indexer: IndexerCore, +} + +impl IndexerService { + pub fn new(config: IndexerConfig) -> Result<Self> { + let indexer = IndexerCore::new(config)?; + let subscription_service = SubscriptionService::spawn_new(indexer.clone()); + + Ok(Self { + subscription_service, + indexer, + }) + } +} + +#[async_trait::async_trait] +impl indexer_service_rpc::RpcServer for IndexerService { + async fn subscribe_to_finalized_blocks( + &self, + subscription_sink: jsonrpsee::PendingSubscriptionSink, + ) -> SubscriptionResult { + let sink = subscription_sink.accept().await?; + self.subscription_service + .add_subscription(Subscription::new(sink))?; + + Ok(()) + } + + async fn get_block_by_id(&self, _block_id: BlockId) -> Result<Block, ErrorObjectOwned> { + todo!() + } + + async fn get_block_by_hash(&self, _block_hash: Hash) -> Result<Block, ErrorObjectOwned> { + todo!() + } + + async fn get_account(&self, _account_id: AccountId) -> Result<Account, ErrorObjectOwned> { + todo!() + } + + async fn get_transaction(&self, _tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> { + todo!() + } + + async fn get_blocks(&self, _offset: u32, _limit: u32) -> Result<Vec<Block>, ErrorObjectOwned> { + todo!() + } + + async fn get_transactions_by_account( + &self, + _account_id: AccountId, + _limit: u32, + _offset: u32, + ) -> Result<Vec<Transaction>, ErrorObjectOwned> { + todo!() + } +} + +struct SubscriptionService { + respond_subscribers_loop_handle: tokio::task::JoinHandle<Result<()>>, + new_subscription_sender: UnboundedSender<Subscription<BlockId>>, +} + +impl SubscriptionService { + pub fn spawn_new(indexer: IndexerCore) -> Self { + let (new_subscription_sender, mut sub_receiver) = + tokio::sync::mpsc::unbounded_channel::<Subscription<BlockId>>(); + + let subscriptions = Arc::new(Mutex::new(Vec::new())); + + let respond_subscribers_loop_handle = tokio::spawn(async move { + let mut block_stream = pin!(indexer.subscribe_parse_block_stream().await); + + loop { + tokio::select! { + sub = sub_receiver.recv() => { + let Some(subscription) = sub else { + bail!("Subscription receiver closed unexpectedly"); + }; + subscriptions.lock().await.push(subscription); + } + block_opt = block_stream.next() => { + let Some(block) = block_opt else { + bail!("Block stream ended unexpectedly"); + }; + let block = block.context("Failed to get L2 block data")?; + let block: indexer_service_protocol::Block = block + .try_into() + .context("Failed to convert L2 Block into protocol Block")?; + + // Cloning subscriptions to avoid holding the lock while sending + let subscriptions = subscriptions.lock().await.clone(); + for sink in subscriptions { + sink.send(&block.header.block_id).await?; + } + } + } + } + }); + + Self { + respond_subscribers_loop_handle, + new_subscription_sender, + } + } + + pub fn add_subscription(&self, subscription: Subscription<BlockId>) -> Result<()> { + self.new_subscription_sender.send(subscription)?; + Ok(()) + } +} + +impl Drop for SubscriptionService { + fn drop(&mut self) { + self.respond_subscribers_loop_handle.abort(); + } +} + +#[derive(Clone)] +struct Subscription<T> { + sink: SubscriptionSink, + _marker: std::marker::PhantomData<T>, +} + +impl<T> Subscription<T> { + fn new(sink: SubscriptionSink) -> Self { + Self { + sink, + _marker: std::marker::PhantomData, + } + } + + async fn send(&self, item: &T) -> Result<()> + where + T: Serialize, + { + let json = serde_json::value::to_raw_value(item) + .context("Failed to serialize item for subscription")?; + self.sink.send(json).await?; + Ok(()) + } +} diff --git a/indexer_core/src/lib.rs b/indexer_core/src/lib.rs deleted file mode 100644 index acf2dd7e..00000000 --- a/indexer_core/src/lib.rs +++ /dev/null @@ -1,148 +0,0 @@ -use anyhow::Result; -use bedrock_client::BedrockClient; -// ToDo: Remove after testnet -use common::PINATA_BASE58; -use common::{ - block::Block, communication::indexer::Message, - rpc_primitives::requests::PostIndexerMessageResponse, sequencer_client::SequencerClient, -}; -use futures::StreamExt; -use log::info; -use logos_blockchain_core::mantle::{ - Op, SignedMantleTx, - ops::channel::{ChannelId, inscribe::InscriptionOp}, -}; - -use crate::{block_store::IndexerStore, config::IndexerConfig}; - -pub mod block_store; -pub mod config; -pub mod state; - -pub struct IndexerCore { - pub bedrock_client: BedrockClient, - pub sequencer_client: SequencerClient, - pub config: IndexerConfig, - pub store: IndexerStore, -} - -impl IndexerCore { - pub async fn new(config: IndexerConfig) -> Result<Self> { - let sequencer_client = SequencerClient::new_with_auth( - config.sequencer_client_config.addr.clone(), - config.sequencer_client_config.auth.clone(), - )?; - - let start_block = sequencer_client.get_genesis_block().await?; - - let initial_commitments: Vec<nssa_core::Commitment> = config - .initial_commitments - .iter() - .map(|init_comm_data| { - let npk = &init_comm_data.npk; - - let mut acc = init_comm_data.account.clone(); - - acc.program_owner = nssa::program::Program::authenticated_transfer_program().id(); - - nssa_core::Commitment::new(npk, &acc) - }) - .collect(); - - let init_accs: Vec<(nssa::AccountId, u128)> = config - .initial_accounts - .iter() - .map(|acc_data| (acc_data.account_id.parse().unwrap(), acc_data.balance)) - .collect(); - - let mut state = nssa::V02State::new_with_genesis_accounts(&init_accs, &initial_commitments); - - // ToDo: Remove after testnet - state.add_pinata_program(PINATA_BASE58.parse().unwrap()); - - let home = config.home.clone(); - - Ok(Self { - bedrock_client: BedrockClient::new( - config.bedrock_client_config.auth.clone().map(Into::into), - config.bedrock_client_config.addr.clone(), - )?, - sequencer_client, - config, - // ToDo: Implement restarts - store: IndexerStore::open_db_with_genesis(&home, Some((start_block, state)))?, - }) - } - - pub async fn subscribe_parse_block_stream(&self) -> Result<()> { - loop { - let mut stream_pinned = Box::pin(self.bedrock_client.get_lib_stream().await?); - - info!("Block stream joined"); - - while let Some(block_info) = stream_pinned.next().await { - let header_id = block_info.header_id; - - info!("Observed L1 block at height {}", block_info.height); - - if let Some(l1_block) = self - .bedrock_client - .get_block_by_id(header_id, &self.config.backoff) - .await? - { - info!("Extracted L1 block at height {}", block_info.height); - - let l2_blocks_parsed = parse_blocks( - l1_block.into_transactions().into_iter(), - &self.config.channel_id, - ); - - for l2_block in l2_blocks_parsed { - let l2_block_height = l2_block.header.block_id; - - // State modification, will be updated in future - self.store.put_block(l2_block)?; - - // Sending data into sequencer, may need to be expanded. - let message = Message::L2BlockFinalized { l2_block_height }; - - let status = self.send_message_to_sequencer(message.clone()).await?; - - info!("Sent message {message:#?} to sequencer; status {status:#?}"); - } - } - } - - // Refetch stream after delay - tokio::time::sleep(std::time::Duration::from_millis( - self.config.resubscribe_interval_millis, - )) - .await; - } - } - - pub async fn send_message_to_sequencer( - &self, - message: Message, - ) -> Result<PostIndexerMessageResponse> { - Ok(self.sequencer_client.post_indexer_message(message).await?) - } -} - -fn parse_blocks( - block_txs: impl Iterator<Item = SignedMantleTx>, - decoded_channel_id: &ChannelId, -) -> impl Iterator<Item = Block> { - block_txs.flat_map(|tx| { - tx.mantle_tx.ops.into_iter().filter_map(|op| match op { - Op::ChannelInscribe(InscriptionOp { - channel_id, - inscription, - .. - }) if channel_id == *decoded_channel_id => { - borsh::from_slice::<Block>(&inscription).ok() - } - _ => None, - }) - }) -} diff --git a/indexer_service/Cargo.toml b/indexer_service/Cargo.toml deleted file mode 100644 index d3f31de8..00000000 --- a/indexer_service/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "indexer_service" -version = "0.1.0" -edition = "2024" - -[dependencies] -indexer_service_protocol.workspace = true -indexer_service_rpc = { workspace = true, features = ["server"] } - -clap = { workspace = true, features = ["derive"] } -anyhow.workspace = true -tokio.workspace = true -tokio-util.workspace = true -env_logger.workspace = true -log.workspace = true -jsonrpsee.workspace = true -async-trait = "0.1.89" diff --git a/indexer_service/src/lib.rs b/indexer_service/src/lib.rs deleted file mode 100644 index 1f278a4d..00000000 --- a/indexer_service/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod service; diff --git a/indexer_service/src/service.rs b/indexer_service/src/service.rs deleted file mode 100644 index 46c5fb2d..00000000 --- a/indexer_service/src/service.rs +++ /dev/null @@ -1,36 +0,0 @@ -use indexer_service_protocol::{Account, AccountId, Block, BlockId, Hash, Transaction}; -use jsonrpsee::{core::SubscriptionResult, types::ErrorObjectOwned}; - -pub struct IndexerService; - -// `async_trait` is required by `jsonrpsee` -#[async_trait::async_trait] -impl indexer_service_rpc::RpcServer for IndexerService { - async fn subscribe_to_blocks( - &self, - _subscription_sink: jsonrpsee::PendingSubscriptionSink, - _from: BlockId, - ) -> SubscriptionResult { - todo!() - } - - async fn get_block_by_id(&self, _block_id: BlockId) -> Result<Block, ErrorObjectOwned> { - todo!() - } - - async fn get_block_by_hash(&self, _block_hash: Hash) -> Result<Block, ErrorObjectOwned> { - todo!() - } - - async fn get_last_block_id(&self) -> Result<BlockId, ErrorObjectOwned> { - todo!() - } - - async fn get_account(&self, _account_id: AccountId) -> Result<Account, ErrorObjectOwned> { - todo!() - } - - async fn get_transaction(&self, _tx_hash: Hash) -> Result<Transaction, ErrorObjectOwned> { - todo!() - } -} diff --git a/integration_tests/Cargo.toml b/integration_tests/Cargo.toml index b7ca13dd..74fbd557 100644 --- a/integration_tests/Cargo.toml +++ b/integration_tests/Cargo.toml @@ -11,7 +11,7 @@ sequencer_runner.workspace = true wallet.workspace = true common.workspace = true key_protocol.workspace = true -indexer_core.workspace = true +indexer_service.workspace = true url.workspace = true anyhow.workspace = true diff --git a/integration_tests/src/config.rs b/integration_tests/src/config.rs new file mode 100644 index 00000000..16d577b5 --- /dev/null +++ b/integration_tests/src/config.rs @@ -0,0 +1,39 @@ +use std::net::SocketAddr; + +use anyhow::Result; +use indexer_service::{BackoffConfig, BedrockClientConfig, ChannelId, IndexerConfig}; +use url::Url; + +pub fn indexer_config(bedrock_addr: SocketAddr) -> IndexerConfig { + let channel_id: [u8; 32] = [0u8, 1] + .repeat(16) + .try_into() + .unwrap_or_else(|_| unreachable!()); + let channel_id = ChannelId::try_from(channel_id).expect("Failed to create channel ID"); + + IndexerConfig { + resubscribe_interval_millis: 1000, + backoff: BackoffConfig { + start_delay_millis: 100, + max_retries: 10, + }, + bedrock_client_config: BedrockClientConfig { + addr: addr_to_http_url(bedrock_addr).expect("Failed to convert bedrock addr to URL"), + auth: None, + }, + channel_id, + } +} + +fn addr_to_http_url(addr: SocketAddr) -> Result<Url> { + // Convert 0.0.0.0 to 127.0.0.1 for client connections + // When binding to port 0, the server binds to 0.0.0.0:<random_port> + // but clients need to connect to 127.0.0.1:<port> to work reliably + let url_string = if addr.ip().is_unspecified() { + format!("http://127.0.0.1:{}", addr.port()) + } else { + format!("http://{addr}") + }; + + url_string.parse().map_err(Into::into) +} diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index abd653b0..818d8f1a 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -2,24 +2,24 @@ use std::{net::SocketAddr, path::PathBuf, sync::LazyLock}; -use actix_web::dev::ServerHandle; use anyhow::{Context, Result}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use common::{ sequencer_client::SequencerClient, transaction::{EncodedTransaction, NSSATransaction}, }; -use futures::FutureExt as _; -use indexer_core::{IndexerCore, config::IndexerConfig}; +use indexer_service::IndexerHandle; use log::debug; use nssa::PrivacyPreservingTransaction; use nssa_core::Commitment; use sequencer_core::config::SequencerConfig; +use sequencer_runner::SequencerHandle; use tempfile::TempDir; -use tokio::task::JoinHandle; use url::Url; use wallet::{WalletCore, config::WalletConfigOverrides}; +mod config; + // TODO: Remove this and control time from tests pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12; @@ -38,19 +38,17 @@ static LOGGER: LazyLock<()> = LazyLock::new(env_logger::init); /// It's memory and logically safe to create multiple instances of this struct in parallel tests, /// as each instance uses its own temporary directories for sequencer and wallet data. pub struct TestContext { - sequencer_server_handle: ServerHandle, - sequencer_loop_handle: JoinHandle<Result<()>>, - sequencer_retry_pending_blocks_handle: JoinHandle<Result<()>>, - indexer_loop_handle: Option<JoinHandle<Result<()>>>, sequencer_client: SequencerClient, wallet: WalletCore, + _sequencer_handle: SequencerHandle, + _indexer_handle: IndexerHandle, _temp_sequencer_dir: TempDir, _temp_wallet_dir: TempDir, _temp_indexer_dir: Option<TempDir>, } impl TestContext { - /// Create new test context in detached mode. Default. + /// Create new test context. pub async fn new() -> Result<Self> { let manifest_dir = env!("CARGO_MANIFEST_DIR"); @@ -60,51 +58,26 @@ impl TestContext { let sequencer_config = SequencerConfig::from_path(&sequencer_config_path) .context("Failed to create sequencer config from file")?; - Self::new_with_sequencer_and_maybe_indexer_configs(sequencer_config, None).await + Self::new_with_sequencer_config(sequencer_config).await } - /// Create new test context in local bedrock node attached mode. - pub async fn new_bedrock_local_attached() -> Result<Self> { - let manifest_dir = env!("CARGO_MANIFEST_DIR"); - - let sequencer_config_path = PathBuf::from(manifest_dir) - .join("configs/sequencer/bedrock_local_attached/sequencer_config.json"); - - let sequencer_config = SequencerConfig::from_path(&sequencer_config_path) - .context("Failed to create sequencer config from file")?; - - let indexer_config_path = - PathBuf::from(manifest_dir).join("configs/indexer/indexer_config.json"); - - let indexer_config = IndexerConfig::from_path(&indexer_config_path) - .context("Failed to create indexer config from file")?; - - Self::new_with_sequencer_and_maybe_indexer_configs(sequencer_config, Some(indexer_config)) - .await - } - - /// Create new test context with custom sequencer config and maybe indexer config. + /// Create new test context with custom sequencer config. /// /// `home` and `port` fields of the provided config will be overridden to meet tests parallelism /// requirements. - pub async fn new_with_sequencer_and_maybe_indexer_configs( - sequencer_config: SequencerConfig, - indexer_config: Option<IndexerConfig>, - ) -> Result<Self> { + pub async fn new_with_sequencer_config(sequencer_config: SequencerConfig) -> Result<Self> { // Ensure logger is initialized only once *LOGGER; debug!("Test context setup"); - let ( - sequencer_server_handle, - sequencer_addr, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - temp_sequencer_dir, - ) = Self::setup_sequencer(sequencer_config) - .await - .context("Failed to setup sequencer")?; + let bedrock_addr = todo!(); + let indexer_config = config::indexer_config(bedrock_addr); + + let (_sequencer_handle, sequencer_addr, temp_sequencer_dir) = + Self::setup_sequencer(sequencer_config) + .await + .context("Failed to setup sequencer")?; // Convert 0.0.0.0 to 127.0.0.1 for client connections // When binding to port 0, the server binds to 0.0.0.0:<random_port> @@ -124,57 +97,23 @@ impl TestContext { ) .context("Failed to create sequencer client")?; - if let Some(mut indexer_config) = indexer_config { - indexer_config.sequencer_client_config.addr = - Url::parse(&sequencer_addr).context("Failed to parse sequencer addr")?; + let _indexer_handle = indexer_service::run_server(indexer_config, 0) + .await + .context("Failed to run Indexer Service")?; - let temp_indexer_dir = - tempfile::tempdir().context("Failed to create temp dir for indexer home")?; - - debug!("Using temp indexer home at {:?}", temp_indexer_dir.path()); - indexer_config.home = temp_indexer_dir.path().to_owned(); - - let indexer_core = IndexerCore::new(indexer_config).await?; - - let indexer_loop_handle = Some(tokio::spawn(async move { - indexer_core.subscribe_parse_block_stream().await - })); - - Ok(Self { - sequencer_server_handle, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - indexer_loop_handle, - sequencer_client, - wallet, - _temp_sequencer_dir: temp_sequencer_dir, - _temp_wallet_dir: temp_wallet_dir, - _temp_indexer_dir: Some(temp_indexer_dir), - }) - } else { - Ok(Self { - sequencer_server_handle, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - indexer_loop_handle: None, - sequencer_client, - wallet, - _temp_sequencer_dir: temp_sequencer_dir, - _temp_wallet_dir: temp_wallet_dir, - _temp_indexer_dir: None, - }) - } + Ok(Self { + sequencer_client, + wallet, + _sequencer_handle, + _indexer_handle, + _temp_sequencer_dir: temp_sequencer_dir, + _temp_wallet_dir: temp_wallet_dir, + }) } async fn setup_sequencer( mut config: SequencerConfig, - ) -> Result<( - ServerHandle, - SocketAddr, - JoinHandle<Result<()>>, - JoinHandle<Result<()>>, - TempDir, - )> { + ) -> Result<(SequencerHandle, SocketAddr, TempDir)> { let temp_sequencer_dir = tempfile::tempdir().context("Failed to create temp dir for sequencer home")?; @@ -186,20 +125,10 @@ impl TestContext { // Setting port to 0 lets the OS choose a free port for us config.port = 0; - let ( - sequencer_server_handle, - sequencer_addr, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - ) = sequencer_runner::startup_sequencer(config).await?; + let (sequencer_handle, sequencer_addr) = + sequencer_runner::startup_sequencer(config).await?; - Ok(( - sequencer_server_handle, - sequencer_addr, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - temp_sequencer_dir, - )) + Ok((sequencer_handle, sequencer_addr, temp_sequencer_dir)) } async fn setup_wallet(sequencer_addr: String) -> Result<(WalletCore, TempDir)> { @@ -251,33 +180,6 @@ impl TestContext { } } -impl Drop for TestContext { - fn drop(&mut self) { - debug!("Test context cleanup"); - - let Self { - sequencer_server_handle, - sequencer_loop_handle, - sequencer_retry_pending_blocks_handle, - indexer_loop_handle, - sequencer_client: _, - wallet: _, - _temp_sequencer_dir, - _temp_wallet_dir, - _temp_indexer_dir, - } = self; - - sequencer_loop_handle.abort(); - sequencer_retry_pending_blocks_handle.abort(); - if let Some(indexer_loop_handle) = indexer_loop_handle { - indexer_loop_handle.abort(); - } - - // Can't wait here as Drop can't be async, but anyway stop signal should be sent - sequencer_server_handle.stop(true).now_or_never(); - } -} - pub fn format_public_account_id(account_id: &str) -> String { format!("Public/{account_id}") } diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index f73ec31e..fdbba823 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -192,6 +192,7 @@ impl TpsTestManager { signing_key: [37; 32], bedrock_config: None, retry_pending_blocks_timeout_millis: 1000 * 60 * 4, + indexer_rpc_url: "http://localhost:8779".parse().unwrap(), } } } diff --git a/nssa/src/privacy_preserving_transaction/transaction.rs b/nssa/src/privacy_preserving_transaction/transaction.rs index 34649d2d..8eb4236e 100644 --- a/nssa/src/privacy_preserving_transaction/transaction.rs +++ b/nssa/src/privacy_preserving_transaction/transaction.rs @@ -5,6 +5,7 @@ use nssa_core::{ Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput, account::{Account, AccountWithMetadata}, }; +use sha2::{Digest as _, digest::FixedOutput as _}; use super::{message::Message, witness_set::WitnessSet}; use crate::{ @@ -131,6 +132,13 @@ impl PrivacyPreservingTransaction { &self.witness_set } + pub fn hash(&self) -> [u8; 32] { + let bytes = self.to_bytes(); + let mut hasher = sha2::Sha256::new(); + hasher.update(&bytes); + hasher.finalize_fixed().into() + } + pub(crate) fn signer_account_ids(&self) -> Vec<AccountId> { self.witness_set .signatures_and_public_keys() diff --git a/nssa/src/program_deployment_transaction/transaction.rs b/nssa/src/program_deployment_transaction/transaction.rs index 6002aded..188b73ea 100644 --- a/nssa/src/program_deployment_transaction/transaction.rs +++ b/nssa/src/program_deployment_transaction/transaction.rs @@ -1,4 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; +use sha2::{Digest as _, digest::FixedOutput as _}; use crate::{ V02State, error::NssaError, program::Program, program_deployment_transaction::message::Message, @@ -6,7 +7,7 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct ProgramDeploymentTransaction { - pub(crate) message: Message, + pub message: Message, } impl ProgramDeploymentTransaction { @@ -30,4 +31,11 @@ impl ProgramDeploymentTransaction { Ok(program) } } + + pub fn hash(&self) -> [u8; 32] { + let bytes = self.to_bytes(); + let mut hasher = sha2::Sha256::new(); + hasher.update(&bytes); + hasher.finalize_fixed().into() + } } diff --git a/nssa/src/public_transaction/transaction.rs b/nssa/src/public_transaction/transaction.rs index f5badb6a..7d42dccc 100644 --- a/nssa/src/public_transaction/transaction.rs +++ b/nssa/src/public_transaction/transaction.rs @@ -17,8 +17,8 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct PublicTransaction { - message: Message, - witness_set: WitnessSet, + pub message: Message, + pub witness_set: WitnessSet, } impl PublicTransaction { diff --git a/sequencer_core/Cargo.toml b/sequencer_core/Cargo.toml index 528fa16f..dfe2c27e 100644 --- a/sequencer_core/Cargo.toml +++ b/sequencer_core/Cargo.toml @@ -9,6 +9,7 @@ nssa_core.workspace = true common.workspace = true storage.workspace = true mempool.workspace = true +bedrock_client.workspace = true base58.workspace = true anyhow.workspace = true @@ -18,12 +19,13 @@ tempfile.workspace = true chrono.workspace = true log.workspace = true tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } -bedrock_client.workspace = true logos-blockchain-key-management-system-service.workspace = true logos-blockchain-core.workspace = true rand.workspace = true reqwest.workspace = true borsh.workspace = true +url.workspace = true +jsonrpsee = { workspace = true, features = ["http-client"] } [features] default = [] diff --git a/sequencer_core/src/block_store.rs b/sequencer_core/src/block_store.rs index 0c2f0d79..05c412a0 100644 --- a/sequencer_core/src/block_store.rs +++ b/sequencer_core/src/block_store.rs @@ -20,7 +20,7 @@ impl SequencerStore { /// ATTENTION: Will overwrite genesis block. pub fn open_db_with_genesis( location: &Path, - genesis_block: Option<Block>, + genesis_block: Option<&Block>, signing_key: nssa::PrivateKey, ) -> Result<Self> { let tx_hash_to_block_map = if let Some(block) = &genesis_block { @@ -84,8 +84,8 @@ impl SequencerStore { self.dbio.get_all_blocks().map(|res| Ok(res?)) } - pub(crate) fn update(&mut self, block: Block, state: &V02State) -> Result<()> { - let new_transactions_map = block_to_transactions_map(&block); + pub(crate) fn update(&mut self, block: &Block, state: &V02State) -> Result<()> { + let new_transactions_map = block_to_transactions_map(block); self.dbio.atomic_update(block, state)?; self.tx_hash_to_block_map.extend(new_transactions_map); Ok(()) @@ -129,7 +129,7 @@ mod tests { let genesis_block = genesis_block_hashable_data.into_pending_block(&signing_key, [0; 32]); // Start an empty node store let mut node_store = - SequencerStore::open_db_with_genesis(path, Some(genesis_block), signing_key).unwrap(); + SequencerStore::open_db_with_genesis(path, Some(&genesis_block), signing_key).unwrap(); let tx = common::test_utils::produce_dummy_empty_transaction(); let block = common::test_utils::produce_dummy_block(1, None, vec![tx.clone()]); @@ -139,7 +139,7 @@ mod tests { assert_eq!(None, retrieved_tx); // Add the block with the transaction let dummy_state = V02State::new_with_genesis_accounts(&[], &[]); - node_store.update(block, &dummy_state).unwrap(); + node_store.update(&block, &dummy_state).unwrap(); // Try again let retrieved_tx = node_store.get_transaction_by_hash(tx.hash()); assert_eq!(Some(tx), retrieved_tx); diff --git a/sequencer_core/src/config.rs b/sequencer_core/src/config.rs index bda0129d..4dc1ed1c 100644 --- a/sequencer_core/src/config.rs +++ b/sequencer_core/src/config.rs @@ -11,6 +11,7 @@ use common::{ }; use logos_blockchain_core::mantle::ops::channel::ChannelId; use serde::{Deserialize, Serialize}; +use url::Url; // TODO: Provide default values #[derive(Clone, Serialize, Deserialize)] @@ -41,6 +42,8 @@ pub struct SequencerConfig { pub signing_key: [u8; 32], /// Bedrock configuration options pub bedrock_config: Option<BedrockConfig>, + /// Indexer RPC URL + pub indexer_rpc_url: Url, } #[derive(Clone, Serialize, Deserialize)] @@ -48,7 +51,7 @@ pub struct BedrockConfig { /// Bedrock channel ID pub channel_id: ChannelId, /// Bedrock Url - pub node_url: String, + pub node_url: Url, /// Bedrock auth pub auth: Option<BasicAuth>, } diff --git a/sequencer_core/src/lib.rs b/sequencer_core/src/lib.rs index a3bc87a1..552f9bbd 100644 --- a/sequencer_core/src/lib.rs +++ b/sequencer_core/src/lib.rs @@ -1,4 +1,4 @@ -use std::time::Instant; +use std::{fmt::Display, sync::Arc, time::Instant}; use anyhow::Result; #[cfg(feature = "testnet")] @@ -17,6 +17,8 @@ mod block_settlement_client; pub mod block_store; pub mod config; +type IndexerClient = Arc<jsonrpsee::ws_client::WsClient>; + pub struct SequencerCore { state: nssa::V02State, store: SequencerStore, @@ -24,6 +26,7 @@ pub struct SequencerCore { sequencer_config: SequencerConfig, chain_height: u64, block_settlement_client: Option<BlockSettlementClient>, + indexer_client: IndexerClient, last_bedrock_msg_id: MantleMsgId, } @@ -33,7 +36,9 @@ impl SequencerCore { /// assumed to represent the correct latest state consistent with Bedrock-finalized data. /// If no database is found, the sequencer performs a fresh start from genesis, /// initializing its state with the accounts defined in the configuration file. - pub fn start_from_config(config: SequencerConfig) -> (Self, MemPoolHandle<EncodedTransaction>) { + pub async fn start_from_config( + config: SequencerConfig, + ) -> (Self, MemPoolHandle<EncodedTransaction>) { let hashable_data = HashableBlockData { block_id: config.genesis_id, transactions: vec![], @@ -49,7 +54,7 @@ impl SequencerCore { // as fixing this issue may require actions non-native to program scope let store = SequencerStore::open_db_with_genesis( &config.home.join("rocksdb"), - Some(genesis_block), + Some(&genesis_block), signing_key, ) .unwrap(); @@ -97,6 +102,22 @@ impl SequencerCore { .expect("Block settlement client should be constructible") }); + let last_bedrock_msg_id = if let Some(client) = block_settlement_client.as_ref() { + let (_, msg_id) = client + .create_inscribe_tx(&genesis_block) + .expect("Inscription transaction with genesis block should be constructible"); + msg_id.into() + } else { + channel_genesis_msg_id + }; + + let indexer_client = Arc::new( + jsonrpsee::ws_client::WsClientBuilder::default() + .build(config.indexer_rpc_url.clone()) + .await + .expect("Failed to create Indexer client"), + ); + let sequencer_core = Self { state, store, @@ -104,7 +125,8 @@ impl SequencerCore { chain_height: config.genesis_id, sequencer_config: config, block_settlement_client, - last_bedrock_msg_id: channel_genesis_msg_id, + indexer_client, + last_bedrock_msg_id, }; (sequencer_core, mempool_handle) @@ -129,11 +151,9 @@ impl SequencerCore { } pub async fn produce_new_block_and_post_to_settlement_layer(&mut self) -> Result<u64> { - let block_data = self.produce_new_block_with_mempool_transactions()?; + let block = self.produce_new_block_with_mempool_transactions()?; if let Some(client) = self.block_settlement_client.as_mut() { - let block = - block_data.into_pending_block(self.store.signing_key(), self.last_bedrock_msg_id); let msg_id = client.submit_block_to_bedrock(&block).await?; self.last_bedrock_msg_id = msg_id.into(); log::info!("Posted block data to Bedrock"); @@ -143,7 +163,7 @@ impl SequencerCore { } /// Produces new block from transactions in mempool - pub fn produce_new_block_with_mempool_transactions(&mut self) -> Result<HashableBlockData> { + pub fn produce_new_block_with_mempool_transactions(&mut self) -> Result<Block> { let now = Instant::now(); let new_block_height = self.chain_height + 1; @@ -180,7 +200,7 @@ impl SequencerCore { .clone() .into_pending_block(self.store.signing_key(), self.last_bedrock_msg_id); - self.store.update(block, &self.state)?; + self.store.update(&block, &self.state)?; self.chain_height = new_block_height; @@ -199,7 +219,7 @@ impl SequencerCore { hashable_data.transactions.len(), now.elapsed().as_secs() ); - Ok(hashable_data) + Ok(block) } pub fn state(&self) -> &nssa::V02State { @@ -229,6 +249,10 @@ impl SequencerCore { .map(|block| block.header.block_id) .min() { + info!( + "Clearing pending blocks up to id: {}", + last_finalized_block_id + ); (first_pending_block_id..=last_finalized_block_id) .try_for_each(|id| self.store.delete_block_at_id(id)) } else { @@ -250,6 +274,10 @@ impl SequencerCore { pub fn block_settlement_client(&self) -> Option<BlockSettlementClient> { self.block_settlement_client.clone() } + + pub fn indexer_client(&self) -> IndexerClient { + Arc::clone(&self.indexer_client) + } } #[cfg(test)] @@ -291,6 +319,7 @@ mod tests { signing_key: *sequencer_sign_key_for_testing().value(), bedrock_config: None, retry_pending_blocks_timeout_millis: 1000 * 60 * 4, + indexer_rpc_url: "http://localhost:8779".parse().unwrap(), } } @@ -336,7 +365,7 @@ mod tests { async fn common_setup_with_config( config: SequencerConfig, ) -> (SequencerCore, MemPoolHandle<EncodedTransaction>) { - let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config); + let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config).await; let tx = common::test_utils::produce_dummy_empty_transaction(); mempool_handle.push(tx).await.unwrap(); @@ -348,10 +377,10 @@ mod tests { (sequencer, mempool_handle) } - #[test] - fn test_start_from_config() { + #[tokio::test] + async fn test_start_from_config() { let config = setup_sequencer_config(); - let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()); + let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await; assert_eq!(sequencer.chain_height, config.genesis_id); assert_eq!(sequencer.sequencer_config.max_num_tx_in_block, 10); @@ -385,8 +414,8 @@ mod tests { assert_eq!(20000, balance_acc_2); } - #[test] - fn test_start_different_intial_accounts_balances() { + #[tokio::test] + async fn test_start_different_intial_accounts_balances() { let acc1_account_id: Vec<u8> = vec![ 27, 132, 197, 86, 123, 18, 100, 64, 153, 93, 62, 213, 170, 186, 5, 101, 215, 30, 24, 52, 96, 72, 25, 255, 156, 23, 245, 233, 213, 221, 7, 143, @@ -410,7 +439,7 @@ mod tests { let initial_accounts = vec![initial_acc1, initial_acc2]; let config = setup_sequencer_config_variable_initial_accounts(initial_accounts); - let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()); + let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await; let acc1_account_id = config.initial_accounts[0] .account_id @@ -635,7 +664,7 @@ mod tests { let block = sequencer.produce_new_block_with_mempool_transactions(); assert!(block.is_ok()); - assert_eq!(block.unwrap().block_id, genesis_height + 1); + assert_eq!(block.unwrap().header.block_id, genesis_height + 1); } #[tokio::test] @@ -673,6 +702,7 @@ mod tests { let current_height = sequencer .produce_new_block_with_mempool_transactions() .unwrap() + .header .block_id; let block = sequencer.store.get_block_at_id(current_height).unwrap(); @@ -710,6 +740,7 @@ mod tests { let current_height = sequencer .produce_new_block_with_mempool_transactions() .unwrap() + .header .block_id; let block = sequencer.store.get_block_at_id(current_height).unwrap(); assert_eq!(block.body.transactions, vec![tx.clone()]); @@ -719,6 +750,7 @@ mod tests { let current_height = sequencer .produce_new_block_with_mempool_transactions() .unwrap() + .header .block_id; let block = sequencer.store.get_block_at_id(current_height).unwrap(); assert!(block.body.transactions.is_empty()); @@ -737,7 +769,8 @@ mod tests { // from `acc_1` to `acc_2`. The block created with that transaction will be kept stored in // the temporary directory for the block storage of this test. { - let (mut sequencer, mempool_handle) = SequencerCore::start_from_config(config.clone()); + let (mut sequencer, mempool_handle) = + SequencerCore::start_from_config(config.clone()).await; let signing_key = PrivateKey::try_new([1; 32]).unwrap(); let tx = common::test_utils::create_transaction_native_token_transfer( @@ -752,6 +785,7 @@ mod tests { let current_height = sequencer .produce_new_block_with_mempool_transactions() .unwrap() + .header .block_id; let block = sequencer.store.get_block_at_id(current_height).unwrap(); assert_eq!(block.body.transactions, vec![tx.clone()]); @@ -759,7 +793,7 @@ mod tests { // Instantiating a new sequencer from the same config. This should load the existing block // with the above transaction and update the state to reflect that. - let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()); + let (sequencer, _mempool_handle) = SequencerCore::start_from_config(config.clone()).await; let balance_acc_1 = sequencer.state.get_account_by_id(&acc1_account_id).balance; let balance_acc_2 = sequencer.state.get_account_by_id(&acc2_account_id).balance; @@ -774,10 +808,10 @@ mod tests { ); } - #[test] - fn test_get_pending_blocks() { + #[tokio::test] + async fn test_get_pending_blocks() { let config = setup_sequencer_config(); - let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config); + let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config).await; sequencer .produce_new_block_with_mempool_transactions() .unwrap(); @@ -790,10 +824,10 @@ mod tests { assert_eq!(sequencer.get_pending_blocks().unwrap().len(), 4); } - #[test] - fn test_delete_blocks() { + #[tokio::test] + async fn test_delete_blocks() { let config = setup_sequencer_config(); - let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config); + let (mut sequencer, _mempool_handle) = SequencerCore::start_from_config(config).await; sequencer .produce_new_block_with_mempool_transactions() .unwrap(); diff --git a/sequencer_rpc/src/process.rs b/sequencer_rpc/src/process.rs index b5f88513..a03ac32e 100644 --- a/sequencer_rpc/src/process.rs +++ b/sequencer_rpc/src/process.rs @@ -14,12 +14,12 @@ use common::{ GetAccountBalanceRequest, GetAccountBalanceResponse, GetAccountRequest, GetAccountResponse, GetAccountsNoncesRequest, GetAccountsNoncesResponse, GetBlockDataRequest, GetBlockDataResponse, GetBlockRangeDataRequest, - GetBlockRangeDataResponse, GetGenesisBlockRequest, GetGenesisBlockResponse, - GetGenesisIdRequest, GetGenesisIdResponse, GetInitialTestnetAccountsRequest, - GetLastBlockRequest, GetLastBlockResponse, GetProgramIdsRequest, GetProgramIdsResponse, - GetProofForCommitmentRequest, GetProofForCommitmentResponse, - GetTransactionByHashRequest, GetTransactionByHashResponse, HelloRequest, HelloResponse, - PostIndexerMessageRequest, PostIndexerMessageResponse, SendTxRequest, SendTxResponse, + GetBlockRangeDataResponse, GetGenesisIdRequest, GetGenesisIdResponse, + GetInitialTestnetAccountsRequest, GetLastBlockRequest, GetLastBlockResponse, + GetProgramIdsRequest, GetProgramIdsResponse, GetProofForCommitmentRequest, + GetProofForCommitmentResponse, GetTransactionByHashRequest, + GetTransactionByHashResponse, HelloRequest, HelloResponse, SendTxRequest, + SendTxResponse, }, }, transaction::{ @@ -46,7 +46,6 @@ pub const GET_ACCOUNTS_NONCES: &str = "get_accounts_nonces"; pub const GET_ACCOUNT: &str = "get_account"; pub const GET_PROOF_FOR_COMMITMENT: &str = "get_proof_for_commitment"; pub const GET_PROGRAM_IDS: &str = "get_program_ids"; -pub const POST_INDEXER_MESSAGE: &str = "post_indexer_message"; pub const HELLO_FROM_SEQUENCER: &str = "HELLO_FROM_SEQUENCER"; @@ -336,18 +335,6 @@ impl JsonHandler { respond(response) } - async fn process_indexer_message(&self, request: Request) -> Result<Value, RpcErr> { - let _indexer_post_req = PostIndexerMessageRequest::parse(Some(request.params))?; - - // ToDo: Add indexer messages handling - - let response = PostIndexerMessageResponse { - status: "Success".to_string(), - }; - - respond(response) - } - pub async fn process_request_internal(&self, request: Request) -> Result<Value, RpcErr> { match request.method.as_ref() { HELLO => self.process_temp_hello(request).await, @@ -364,7 +351,6 @@ impl JsonHandler { GET_TRANSACTION_BY_HASH => self.process_get_transaction_by_hash(request).await, GET_PROOF_FOR_COMMITMENT => self.process_get_proof_by_commitment(request).await, GET_PROGRAM_IDS => self.process_get_program_ids(request).await, - POST_INDEXER_MESSAGE => self.process_indexer_message(request).await, _ => Err(RpcErr(RpcError::method_not_found(request.method))), } } @@ -377,8 +363,8 @@ mod tests { use base58::ToBase58; use base64::{Engine, engine::general_purpose}; use common::{ - block::AccountInitialData, sequencer_client::BasicAuth, - test_utils::sequencer_sign_key_for_testing, transaction::EncodedTransaction, + config::BasicAuth, test_utils::sequencer_sign_key_for_testing, + transaction::EncodedTransaction, }; use sequencer_core::{ SequencerCore, @@ -430,19 +416,20 @@ mod tests { retry_pending_blocks_timeout_millis: 1000 * 60 * 4, bedrock_config: Some(BedrockConfig { channel_id: [42; 32].into(), - node_url: "http://localhost:8080".to_string(), + node_url: "http://localhost:8080".parse().unwrap(), auth: Some(BasicAuth { username: "user".to_string(), password: None, }), }), + indexer_rpc_url: "http://localhost:8779".parse().unwrap(), } } async fn components_for_tests() -> (JsonHandler, Vec<AccountInitialData>, EncodedTransaction) { let config = sequencer_config_for_tests(); - let (mut sequencer_core, mempool_handle) = SequencerCore::start_from_config(config); + let (mut sequencer_core, mempool_handle) = SequencerCore::start_from_config(config).await; let initial_accounts = sequencer_core.sequencer_config().initial_accounts.clone(); let signing_key = nssa::PrivateKey::try_new([1; 32]).unwrap(); diff --git a/sequencer_runner/Cargo.toml b/sequencer_runner/Cargo.toml index 55f56dec..f840317c 100644 --- a/sequencer_runner/Cargo.toml +++ b/sequencer_runner/Cargo.toml @@ -7,6 +7,8 @@ edition = "2024" common.workspace = true sequencer_core = { workspace = true, features = ["testnet"] } sequencer_rpc.workspace = true +indexer_service_protocol.workspace = true +indexer_service_rpc = { workspace = true, features = ["client"] } clap = { workspace = true, features = ["derive", "env"] } anyhow.workspace = true @@ -15,3 +17,4 @@ log.workspace = true actix.workspace = true actix-web.workspace = true tokio.workspace = true +futures.workspace = true diff --git a/sequencer_runner/configs/debug/sequencer_config.json b/sequencer_runner/configs/debug/sequencer_config.json index 80bfe0a4..9ab5d98d 100644 --- a/sequencer_runner/configs/debug/sequencer_config.json +++ b/sequencer_runner/configs/debug/sequencer_config.json @@ -162,5 +162,6 @@ "auth": { "username": "user" } - } + }, + "indexer_rpc_url": "ws://localhost:8779" } diff --git a/sequencer_runner/src/lib.rs b/sequencer_runner/src/lib.rs index 8dbea525..c74a943e 100644 --- a/sequencer_runner/src/lib.rs +++ b/sequencer_runner/src/lib.rs @@ -1,10 +1,11 @@ -use std::{net::SocketAddr, path::PathBuf, sync::Arc}; +use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use actix_web::dev::ServerHandle; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Parser; use common::rpc_primitives::RpcConfig; -use log::{info, warn}; +use futures::{FutureExt as _, never::Never}; +use log::{error, info, warn}; use sequencer_core::{SequencerCore, config::SequencerConfig}; use sequencer_rpc::new_http_server; use tokio::{sync::Mutex, task::JoinHandle}; @@ -18,19 +19,75 @@ struct Args { home_dir: PathBuf, } +/// Handle to manage the sequencer and its tasks. +/// +/// Implements `Drop` to ensure all tasks are aborted and the HTTP server is stopped when dropped. +pub struct SequencerHandle { + http_server_handle: ServerHandle, + main_loop_handle: JoinHandle<Result<Never>>, + retry_pending_blocks_loop_handle: JoinHandle<Result<Never>>, + listen_for_bedrock_blocks_loop_handle: JoinHandle<Result<Never>>, +} + +impl SequencerHandle { + /// Runs the sequencer indefinitely, monitoring its tasks. + /// + /// If no error occurs, this function will never return. + async fn run_forever(&mut self) -> Result<Never> { + let Self { + http_server_handle: _, + main_loop_handle, + retry_pending_blocks_loop_handle, + listen_for_bedrock_blocks_loop_handle, + } = self; + + tokio::select! { + res = main_loop_handle => { + res + .context("Main loop task panicked")? + .context("Main loop exited unexpectedly") + } + res = retry_pending_blocks_loop_handle => { + res + .context("Retry pending blocks loop task panicked")? + .context("Retry pending blocks loop exited unexpectedly") + } + res = listen_for_bedrock_blocks_loop_handle => { + res + .context("Listen for bedrock blocks loop task panicked")? + .context("Listen for bedrock blocks loop exited unexpectedly") + } + } + } +} + +impl Drop for SequencerHandle { + fn drop(&mut self) { + let Self { + http_server_handle, + main_loop_handle, + retry_pending_blocks_loop_handle, + listen_for_bedrock_blocks_loop_handle, + } = self; + + main_loop_handle.abort(); + retry_pending_blocks_loop_handle.abort(); + listen_for_bedrock_blocks_loop_handle.abort(); + + // Can't wait here as Drop can't be async, but anyway stop signal should be sent + http_server_handle.stop(true).now_or_never(); + } +} + pub async fn startup_sequencer( app_config: SequencerConfig, -) -> Result<( - ServerHandle, - SocketAddr, - JoinHandle<Result<()>>, - JoinHandle<Result<()>>, -)> { - let block_timeout = app_config.block_create_timeout_millis; - let retry_pending_blocks_timeout = app_config.retry_pending_blocks_timeout_millis; +) -> Result<(SequencerHandle, SocketAddr)> { + let block_timeout = Duration::from_millis(app_config.block_create_timeout_millis); + let retry_pending_blocks_timeout = + Duration::from_millis(app_config.retry_pending_blocks_timeout_millis); let port = app_config.port; - let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(app_config); + let (sequencer_core, mempool_handle) = SequencerCore::start_from_config(app_config).await; info!("Sequencer core set up"); @@ -45,69 +102,115 @@ pub async fn startup_sequencer( let http_server_handle = http_server.handle(); tokio::spawn(http_server); - info!("Starting pending block retry loop"); - let seq_core_wrapped_for_block_retry = seq_core_wrapped.clone(); - let retry_pending_blocks_handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis( - retry_pending_blocks_timeout, - )) - .await; - - let (pending_blocks, block_settlement_client) = { - let sequencer_core = seq_core_wrapped_for_block_retry.lock().await; - let client = sequencer_core.block_settlement_client(); - let pending_blocks = sequencer_core - .get_pending_blocks() - .expect("Sequencer should be able to retrieve pending blocks"); - (pending_blocks, client) - }; - - let Some(client) = block_settlement_client else { - continue; - }; - - info!("Resubmitting {} pending blocks", pending_blocks.len()); - for block in &pending_blocks { - if let Err(e) = client.submit_block_to_bedrock(block).await { - warn!( - "Failed to resubmit block with id {} with error {}", - block.header.block_id, e - ); - } - } - } - }); - info!("Starting main sequencer loop"); - let main_loop_handle = tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(block_timeout)).await; + let main_loop_handle = tokio::spawn(main_loop(Arc::clone(&seq_core_wrapped), block_timeout)); - info!("Collecting transactions from mempool, block creation"); + info!("Starting pending block retry loop"); + let retry_pending_blocks_loop_handle = tokio::spawn(retry_pending_blocks_loop( + Arc::clone(&seq_core_wrapped), + retry_pending_blocks_timeout, + )); - let id = { - let mut state = seq_core_wrapped.lock().await; - - state - .produce_new_block_and_post_to_settlement_layer() - .await? - }; - - info!("Block with id {id} created"); - - info!("Waiting for new transactions"); - } - }); + info!("Starting bedrock block listening loop"); + let listen_for_bedrock_blocks_loop_handle = + tokio::spawn(listen_for_bedrock_blocks_loop(seq_core_wrapped)); Ok(( - http_server_handle, + SequencerHandle { + http_server_handle, + main_loop_handle, + retry_pending_blocks_loop_handle, + listen_for_bedrock_blocks_loop_handle, + }, addr, - main_loop_handle, - retry_pending_blocks_handle, )) } +async fn main_loop(seq_core: Arc<Mutex<SequencerCore>>, block_timeout: Duration) -> Result<Never> { + loop { + tokio::time::sleep(block_timeout).await; + + info!("Collecting transactions from mempool, block creation"); + + let id = { + let mut state = seq_core.lock().await; + + state + .produce_new_block_and_post_to_settlement_layer() + .await? + }; + + info!("Block with id {id} created"); + + info!("Waiting for new transactions"); + } +} + +async fn retry_pending_blocks_loop( + seq_core: Arc<Mutex<SequencerCore>>, + retry_pending_blocks_timeout: Duration, +) -> Result<Never> { + loop { + tokio::time::sleep(retry_pending_blocks_timeout).await; + + let (pending_blocks, block_settlement_client) = { + let sequencer_core = seq_core.lock().await; + let client = sequencer_core.block_settlement_client(); + let pending_blocks = sequencer_core + .get_pending_blocks() + .expect("Sequencer should be able to retrieve pending blocks"); + (pending_blocks, client) + }; + + let Some(client) = block_settlement_client else { + continue; + }; + + info!("Resubmitting {} pending blocks", pending_blocks.len()); + for block in &pending_blocks { + if let Err(e) = client.submit_block_to_bedrock(block).await { + warn!( + "Failed to resubmit block with id {} with error {}", + block.header.block_id, e + ); + } + } + } +} + +async fn listen_for_bedrock_blocks_loop(seq_core: Arc<Mutex<SequencerCore>>) -> Result<Never> { + use indexer_service_rpc::RpcClient as _; + + let indexer_client = seq_core.lock().await.indexer_client(); + + loop { + // TODO: Subscribe from the first pending block ID? + let mut subscription = indexer_client + .subscribe_to_finalized_blocks() + .await + .context("Failed to subscribe to finalized blocks")?; + + while let Some(block_id) = subscription.next().await { + let block_id = block_id.context("Failed to get next block from subscription")?; + + info!("Received new L2 block with ID {block_id}"); + + seq_core + .lock() + .await + .clean_finalized_blocks_from_db(block_id) + .with_context(|| { + format!("Failed to clean finalized blocks from DB for block ID {block_id}") + })?; + } + + warn!( + "Block subscription closed unexpectedly, reason: {:?}", + subscription.close_reason() + ); + } +} + pub async fn main_runner() -> Result<()> { env_logger::init(); @@ -125,24 +228,12 @@ pub async fn main_runner() -> Result<()> { } // ToDo: Add restart on failures - let (_, _, main_loop_handle, retry_loop_handle) = startup_sequencer(app_config).await?; + let (mut sequencer_handle, _addr) = startup_sequencer(app_config).await?; info!("Sequencer running. Monitoring concurrent tasks..."); - tokio::select! { - res = main_loop_handle => { - match res { - Ok(inner_res) => warn!("Main loop exited unexpectedly: {:?}", inner_res), - Err(e) => warn!("Main loop task panicked: {:?}", e), - } - } - res = retry_loop_handle => { - match res { - Ok(inner_res) => warn!("Retry loop exited unexpectedly: {:?}", inner_res), - Err(e) => warn!("Retry loop task panicked: {:?}", e), - } - } - } + let Err(err) = sequencer_handle.run_forever().await; + error!("Sequencer failed: {err:?}"); info!("Shutting down sequencer..."); diff --git a/storage/src/sequencer.rs b/storage/src/sequencer.rs index 6d6af918..40959eab 100644 --- a/storage/src/sequencer.rs +++ b/storage/src/sequencer.rs @@ -1,13 +1,3 @@ -use std::{path::Path, sync::Arc}; - -use common::block::Block; -use nssa::V02State; -use rocksdb::{ - BoundColumnFamily, ColumnFamilyDescriptor, DBWithThreadMode, MultiThreaded, Options, WriteBatch, -}; - -use crate::error::DbError; - /// Maximal size of stored blocks in base /// /// Used to control db size @@ -46,7 +36,7 @@ pub struct RocksDBIO { } impl RocksDBIO { - pub fn open_or_create(path: &Path, start_block: Option<Block>) -> DbResult<Self> { + pub fn open_or_create(path: &Path, start_block: Option<&Block>) -> DbResult<Self> { let mut cf_opts = Options::default(); cf_opts.set_max_write_buffer_number(16); // ToDo: Add more column families for different data @@ -207,7 +197,7 @@ impl RocksDBIO { Ok(()) } - pub fn put_meta_first_block_in_db(&self, block: Block) -> DbResult<()> { + pub fn put_meta_first_block_in_db(&self, block: &Block) -> DbResult<()> { let cf_meta = self.meta_column(); self.db .put_cf( @@ -300,7 +290,7 @@ impl RocksDBIO { Ok(()) } - pub fn put_block(&self, block: Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> { + pub fn put_block(&self, block: &Block, first: bool, batch: &mut WriteBatch) -> DbResult<()> { let cf_block = self.block_column(); if !first { @@ -316,7 +306,7 @@ impl RocksDBIO { borsh::to_vec(&block.header.block_id).map_err(|err| { DbError::borsh_cast_message(err, Some("Failed to serialize block id".to_string())) })?, - borsh::to_vec(&block).map_err(|err| { + borsh::to_vec(block).map_err(|err| { DbError::borsh_cast_message(err, Some("Failed to serialize block data".to_string())) })?, ); @@ -426,7 +416,7 @@ impl RocksDBIO { }) } - pub fn atomic_update(&self, block: Block, state: &V02State) -> DbResult<()> { + pub fn atomic_update(&self, block: &Block, state: &V02State) -> DbResult<()> { let block_id = block.header.block_id; let mut batch = WriteBatch::default(); self.put_block(block, false, &mut batch)?; @@ -438,4 +428,4 @@ impl RocksDBIO { ) }) } -} +} \ No newline at end of file diff --git a/wallet-ffi/Cargo.toml b/wallet-ffi/Cargo.toml new file mode 100644 index 00000000..bc989fea --- /dev/null +++ b/wallet-ffi/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "wallet-ffi" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +wallet.workspace = true +nssa.workspace = true +common.workspace = true +tokio.workspace = true + +[build-dependencies] +cbindgen = "0.26" diff --git a/wallet-ffi/build.rs b/wallet-ffi/build.rs new file mode 100644 index 00000000..63ee0d9e --- /dev/null +++ b/wallet-ffi/build.rs @@ -0,0 +1,13 @@ +fn main() { + let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config = + cbindgen::Config::from_file("cbindgen.toml").expect("Unable to read cbindgen.toml"); + + cbindgen::Builder::new() + .with_crate(crate_dir) + .with_config(config) + .generate() + .expect("Unable to generate bindings") + .write_to_file("wallet_ffi.h"); +} diff --git a/wallet-ffi/cbindgen.toml b/wallet-ffi/cbindgen.toml new file mode 100644 index 00000000..42c46543 --- /dev/null +++ b/wallet-ffi/cbindgen.toml @@ -0,0 +1,40 @@ +language = "C" +header = """ +/** + * NSSA Wallet FFI Bindings + * + * Thread Safety: All functions are thread-safe. The wallet handle can be + * shared across threads, but operations are serialized internally. + * + * Memory Management: + * - Functions returning pointers allocate memory that must be freed + * - Use the corresponding wallet_ffi_free_* function to free memory + * - Never free memory returned by FFI using standard C free() + * + * Error Handling: + * - Functions return WalletFfiError codes + * - On error, call wallet_ffi_get_last_error() for detailed message + * - The error string must be freed with wallet_ffi_free_error_string() + * + * Initialization: + * 1. Call wallet_ffi_init_runtime() before any other function + * 2. Create wallet with wallet_ffi_create_new() or wallet_ffi_open() + * 3. Destroy wallet with wallet_ffi_destroy() when done + */ +""" + +include_guard = "WALLET_FFI_H" +include_version = true +no_includes = false + +[export] +include = ["Ffi.*", "WalletFfiError", "WalletHandle"] + +[enum] +rename_variants = "ScreamingSnakeCase" + +[fn] +rename_args = "None" + +[struct] +rename_fields = "None" diff --git a/wallet-ffi/src/account.rs b/wallet-ffi/src/account.rs new file mode 100644 index 00000000..b99d10cf --- /dev/null +++ b/wallet-ffi/src/account.rs @@ -0,0 +1,395 @@ +//! Account management functions. + +use std::ptr; + +use nssa::AccountId; + +use crate::{ + block_on, + error::{print_error, WalletFfiError}, + types::{ + FfiAccount, FfiAccountList, FfiAccountListEntry, FfiBytes32, FfiProgramId, WalletHandle, + }, + wallet::get_wallet, +}; + +/// Create a new public account. +/// +/// Public accounts use standard transaction signing and are suitable for +/// non-private operations. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_account_id`: Output pointer for the new account ID (32 bytes) +/// +/// # Returns +/// - `Success` on successful creation +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_create_account_public( + handle: *mut WalletHandle, + out_account_id: *mut FfiBytes32, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_account_id.is_null() { + print_error("Null output pointer for account_id"); + return WalletFfiError::NullPointer; + } + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let (account_id, _chain_index) = wallet.create_new_account_public(None); + + unsafe { + (*out_account_id).data = *account_id.value(); + } + + WalletFfiError::Success +} + +/// Create a new private account. +/// +/// Private accounts use privacy-preserving transactions with nullifiers +/// and commitments. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_account_id`: Output pointer for the new account ID (32 bytes) +/// +/// # Returns +/// - `Success` on successful creation +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_create_account_private( + handle: *mut WalletHandle, + out_account_id: *mut FfiBytes32, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_account_id.is_null() { + print_error("Null output pointer for account_id"); + return WalletFfiError::NullPointer; + } + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let (account_id, _chain_index) = wallet.create_new_account_private(None); + + unsafe { + (*out_account_id).data = *account_id.value(); + } + + WalletFfiError::Success +} + +/// List all accounts in the wallet. +/// +/// Returns both public and private accounts managed by this wallet. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_list`: Output pointer for the account list +/// +/// # Returns +/// - `Success` on successful listing +/// - Error code on failure +/// +/// # Memory +/// The returned list must be freed with `wallet_ffi_free_account_list()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_list` must be a valid pointer to a `FfiAccountList` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_list_accounts( + handle: *mut WalletHandle, + out_list: *mut FfiAccountList, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_list.is_null() { + print_error("Null output pointer for account list"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let user_data = &wallet.storage().user_data; + let mut entries = Vec::new(); + + // Public accounts from default signing keys (preconfigured) + for account_id in user_data.default_pub_account_signing_keys.keys() { + entries.push(FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: true, + }); + } + + // Public accounts from key tree (generated) + for account_id in user_data.public_key_tree.account_id_map.keys() { + entries.push(FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: true, + }); + } + + // Private accounts from default accounts (preconfigured) + for account_id in user_data.default_user_private_accounts.keys() { + entries.push(FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: false, + }); + } + + // Private accounts from key tree (generated) + for account_id in user_data.private_key_tree.account_id_map.keys() { + entries.push(FfiAccountListEntry { + account_id: FfiBytes32::from_account_id(account_id), + is_public: false, + }); + } + + let count = entries.len(); + + if count == 0 { + unsafe { + (*out_list).entries = ptr::null_mut(); + (*out_list).count = 0; + } + } else { + let entries_boxed = entries.into_boxed_slice(); + let entries_ptr = Box::into_raw(entries_boxed) as *mut FfiAccountListEntry; + + unsafe { + (*out_list).entries = entries_ptr; + (*out_list).count = count; + } + } + + WalletFfiError::Success +} + +/// Free an account list returned by `wallet_ffi_list_accounts`. +/// +/// # Safety +/// The list must be either null or a valid list returned by `wallet_ffi_list_accounts`. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_account_list(list: *mut FfiAccountList) { + if list.is_null() { + return; + } + + unsafe { + let list = &*list; + if !list.entries.is_null() && list.count > 0 { + let slice = std::slice::from_raw_parts_mut(list.entries, list.count); + drop(Box::from_raw(slice as *mut [FfiAccountListEntry])); + } + } +} + +/// Get account balance. +/// +/// For public accounts, this fetches the balance from the network. +/// For private accounts, this returns the locally cached balance. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id`: The account ID (32 bytes) +/// - `is_public`: Whether this is a public account +/// - `out_balance`: Output for balance as little-endian [u8; 16] +/// +/// # Returns +/// - `Success` on successful query +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +/// - `out_balance` must be a valid pointer to a `[u8; 16]` array +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_balance( + handle: *mut WalletHandle, + account_id: *const FfiBytes32, + is_public: bool, + out_balance: *mut [u8; 16], +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if account_id.is_null() || out_balance.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let account_id = AccountId::new(unsafe { (*account_id).data }); + + let balance = if is_public { + match block_on(wallet.get_account_balance(account_id)) { + Ok(Ok(b)) => b, + Ok(Err(e)) => { + print_error(format!("Failed to get balance: {}", e)); + return WalletFfiError::NetworkError; + } + Err(e) => return e, + } + } else { + match wallet.get_account_private(&account_id) { + Some(account) => account.balance, + None => { + print_error("Private account not found"); + return WalletFfiError::AccountNotFound; + } + } + }; + + unsafe { + *out_balance = balance.to_le_bytes(); + } + + WalletFfiError::Success +} + +/// Get full public account data from the network. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id`: The account ID (32 bytes) +/// - `out_account`: Output pointer for account data +/// +/// # Returns +/// - `Success` on successful query +/// - Error code on failure +/// +/// # Memory +/// The account data must be freed with `wallet_ffi_free_account_data()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +/// - `out_account` must be a valid pointer to a `FfiAccount` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_account_public( + handle: *mut WalletHandle, + account_id: *const FfiBytes32, + out_account: *mut FfiAccount, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if account_id.is_null() || out_account.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let account_id = AccountId::new(unsafe { (*account_id).data }); + + let account = match block_on(wallet.get_account_public(account_id)) { + Ok(Ok(a)) => a, + Ok(Err(e)) => { + print_error(format!("Failed to get account: {}", e)); + return WalletFfiError::NetworkError; + } + Err(e) => return e, + }; + + // Convert account data to FFI type + let data_vec: Vec<u8> = account.data.into(); + let data_len = data_vec.len(); + let data_ptr = if data_len > 0 { + let data_boxed = data_vec.into_boxed_slice(); + Box::into_raw(data_boxed) as *const u8 + } else { + ptr::null() + }; + + let program_owner = FfiProgramId { + data: account.program_owner, + }; + + unsafe { + (*out_account).program_owner = program_owner; + (*out_account).balance = account.balance.to_le_bytes(); + (*out_account).nonce = account.nonce.to_le_bytes(); + (*out_account).data = data_ptr; + (*out_account).data_len = data_len; + } + + WalletFfiError::Success +} + +/// Free account data returned by `wallet_ffi_get_account_public`. +/// +/// # Safety +/// The account must be either null or a valid account returned by +/// `wallet_ffi_get_account_public`. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_account_data(account: *mut FfiAccount) { + if account.is_null() { + return; + } + + unsafe { + let account = &*account; + if !account.data.is_null() && account.data_len > 0 { + let slice = std::slice::from_raw_parts_mut(account.data as *mut u8, account.data_len); + drop(Box::from_raw(slice as *mut [u8])); + } + } +} diff --git a/wallet-ffi/src/error.rs b/wallet-ffi/src/error.rs new file mode 100644 index 00000000..ea366475 --- /dev/null +++ b/wallet-ffi/src/error.rs @@ -0,0 +1,46 @@ +//! Error handling for the FFI layer. +//! +//! Uses numeric error codes with error messages printed to stderr. + +/// Error codes returned by FFI functions. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WalletFfiError { + /// Operation completed successfully + Success = 0, + /// A null pointer was passed where a valid pointer was expected + NullPointer = 1, + /// Invalid UTF-8 string + InvalidUtf8 = 2, + /// Wallet handle is not initialized + WalletNotInitialized = 3, + /// Configuration error + ConfigError = 4, + /// Storage/persistence error + StorageError = 5, + /// Network/RPC error + NetworkError = 6, + /// Account not found + AccountNotFound = 7, + /// Key not found for account + KeyNotFound = 8, + /// Insufficient funds for operation + InsufficientFunds = 9, + /// Invalid account ID format + InvalidAccountId = 10, + /// Tokio runtime error + RuntimeError = 11, + /// Password required but not provided + PasswordRequired = 12, + /// Block synchronization error + SyncError = 13, + /// Serialization/deserialization error + SerializationError = 14, + /// Internal error (catch-all) + InternalError = 99, +} + +/// Log an error message to stderr. +pub fn print_error(msg: impl Into<String>) { + eprintln!("[wallet-ffi] {}", msg.into()); +} diff --git a/wallet-ffi/src/keys.rs b/wallet-ffi/src/keys.rs new file mode 100644 index 00000000..e8309a81 --- /dev/null +++ b/wallet-ffi/src/keys.rs @@ -0,0 +1,253 @@ +//! Key retrieval functions. + +use std::ptr; + +use nssa::{AccountId, PublicKey}; + +use crate::{ + error::{print_error, WalletFfiError}, + types::{FfiBytes32, FfiPrivateAccountKeys, FfiPublicAccountKey, WalletHandle}, + wallet::get_wallet, +}; + +/// Get the public key for a public account. +/// +/// This returns the public key derived from the account's signing key. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id`: The account ID (32 bytes) +/// - `out_public_key`: Output pointer for the public key +/// +/// # Returns +/// - `Success` on successful retrieval +/// - `KeyNotFound` if the account's key is not in this wallet +/// - Error code on other failures +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +/// - `out_public_key` must be a valid pointer to a `FfiPublicAccountKey` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_public_account_key( + handle: *mut WalletHandle, + account_id: *const FfiBytes32, + out_public_key: *mut FfiPublicAccountKey, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if account_id.is_null() || out_public_key.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let account_id = AccountId::new(unsafe { (*account_id).data }); + + let private_key = match wallet.get_account_public_signing_key(&account_id) { + Some(k) => k, + None => { + print_error("Public account key not found in wallet"); + return WalletFfiError::KeyNotFound; + } + }; + + let public_key = PublicKey::new_from_private_key(private_key); + + unsafe { + (*out_public_key).public_key.data = *public_key.value(); + } + + WalletFfiError::Success +} + +/// Get keys for a private account. +/// +/// Returns the nullifier public key (NPK) and incoming viewing public key (IPK) +/// for the specified private account. These keys are safe to share publicly. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id`: The account ID (32 bytes) +/// - `out_keys`: Output pointer for the key data +/// +/// # Returns +/// - `Success` on successful retrieval +/// - `AccountNotFound` if the private account is not in this wallet +/// - Error code on other failures +/// +/// # Memory +/// The keys structure must be freed with `wallet_ffi_free_private_account_keys()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +/// - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_private_account_keys( + handle: *mut WalletHandle, + account_id: *const FfiBytes32, + out_keys: *mut FfiPrivateAccountKeys, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if account_id.is_null() || out_keys.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let account_id = AccountId::new(unsafe { (*account_id).data }); + + let (key_chain, _account) = match wallet.storage().user_data.get_private_account(&account_id) { + Some(k) => k, + None => { + print_error("Private account not found in wallet"); + return WalletFfiError::AccountNotFound; + } + }; + + // NPK is a 32-byte array + let npk_bytes = key_chain.nullifer_public_key.0; + + // IPK is a compressed secp256k1 point (33 bytes) + let ipk_bytes = key_chain.incoming_viewing_public_key.to_bytes(); + let ipk_len = ipk_bytes.len(); + let ipk_vec = ipk_bytes.to_vec(); + let ipk_boxed = ipk_vec.into_boxed_slice(); + let ipk_ptr = Box::into_raw(ipk_boxed) as *const u8; + + unsafe { + (*out_keys).nullifier_public_key.data = npk_bytes; + (*out_keys).incoming_viewing_public_key = ipk_ptr; + (*out_keys).incoming_viewing_public_key_len = ipk_len; + } + + WalletFfiError::Success +} + +/// Free private account keys returned by `wallet_ffi_get_private_account_keys`. +/// +/// # Safety +/// The keys must be either null or valid keys returned by +/// `wallet_ffi_get_private_account_keys`. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_private_account_keys(keys: *mut FfiPrivateAccountKeys) { + if keys.is_null() { + return; + } + + unsafe { + let keys = &*keys; + if !keys.incoming_viewing_public_key.is_null() && keys.incoming_viewing_public_key_len > 0 { + let slice = std::slice::from_raw_parts_mut( + keys.incoming_viewing_public_key as *mut u8, + keys.incoming_viewing_public_key_len, + ); + drop(Box::from_raw(slice as *mut [u8])); + } + } +} + +/// Convert an account ID to a Base58 string. +/// +/// # Parameters +/// - `account_id`: The account ID (32 bytes) +/// +/// # Returns +/// - Pointer to null-terminated Base58 string on success +/// - Null pointer on error +/// +/// # Memory +/// The returned string must be freed with `wallet_ffi_free_string()`. +/// +/// # Safety +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_account_id_to_base58( + account_id: *const FfiBytes32, +) -> *mut std::ffi::c_char { + if account_id.is_null() { + print_error("Null account_id pointer"); + return ptr::null_mut(); + } + + let account_id = AccountId::new(unsafe { (*account_id).data }); + let base58_str = account_id.to_string(); + + match std::ffi::CString::new(base58_str) { + Ok(s) => s.into_raw(), + Err(e) => { + print_error(format!("Failed to create C string: {}", e)); + ptr::null_mut() + } + } +} + +/// Parse a Base58 string into an account ID. +/// +/// # Parameters +/// - `base58_str`: Null-terminated Base58 string +/// - `out_account_id`: Output pointer for the account ID (32 bytes) +/// +/// # Returns +/// - `Success` on successful parsing +/// - `InvalidAccountId` if the string is not valid Base58 +/// - Error code on other failures +/// +/// # Safety +/// - `base58_str` must be a valid pointer to a null-terminated C string +/// - `out_account_id` must be a valid pointer to a `FfiBytes32` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_account_id_from_base58( + base58_str: *const std::ffi::c_char, + out_account_id: *mut FfiBytes32, +) -> WalletFfiError { + if base58_str.is_null() || out_account_id.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(base58_str) }; + let str_slice = match c_str.to_str() { + Ok(s) => s, + Err(e) => { + print_error(format!("Invalid UTF-8: {}", e)); + return WalletFfiError::InvalidUtf8; + } + }; + + let account_id: AccountId = match str_slice.parse() { + Ok(id) => id, + Err(e) => { + print_error(format!("Invalid Base58 account ID: {}", e)); + return WalletFfiError::InvalidAccountId; + } + }; + + unsafe { + (*out_account_id).data = *account_id.value(); + } + + WalletFfiError::Success +} diff --git a/wallet-ffi/src/lib.rs b/wallet-ffi/src/lib.rs new file mode 100644 index 00000000..75032300 --- /dev/null +++ b/wallet-ffi/src/lib.rs @@ -0,0 +1,70 @@ +//! NSSA Wallet FFI Library +//! +//! This crate provides C-compatible bindings for the NSSA wallet functionality. +//! +//! # Usage +//! +//! 1. Initialize the runtime with `wallet_ffi_init_runtime()` +//! 2. Create or open a wallet with `wallet_ffi_create_new()` or `wallet_ffi_open()` +//! 3. Use the wallet functions to manage accounts and transfers +//! 4. Destroy the wallet with `wallet_ffi_destroy()` when done +//! +//! # Thread Safety +//! +//! All functions are thread-safe. The wallet handle uses internal locking +//! to ensure safe concurrent access. +//! +//! # Memory Management +//! +//! - Functions returning pointers allocate memory that must be freed +//! - Use the corresponding `wallet_ffi_free_*` function to free memory +//! - Never free memory returned by FFI using standard C `free()` + +pub mod account; +pub mod error; +pub mod keys; +pub mod sync; +pub mod transfer; +pub mod types; +pub mod wallet; + +// Re-export public types for cbindgen +pub use error::WalletFfiError as FfiError; +use tokio::runtime::Handle; +pub use types::*; + +use crate::error::{print_error, WalletFfiError}; + +/// Get a reference to the global runtime. +pub(crate) fn get_runtime() -> Result<Handle, WalletFfiError> { + Handle::try_current().map_err(|_| WalletFfiError::RuntimeError) +} + +/// Run an async future on the global runtime, blocking until completion. +pub(crate) fn block_on<F: std::future::Future>(future: F) -> Result<F::Output, WalletFfiError> { + let runtime = get_runtime()?; + Ok(runtime.block_on(future)) +} + +/// Initialize the global Tokio runtime. +/// +/// This must be called before any async operations (like network calls). +/// Safe to call multiple times - subsequent calls are no-ops. +/// +/// # Returns +/// - `Success` if the runtime was initialized or already exists +/// - `RuntimeError` if runtime creation failed +#[no_mangle] +pub extern "C" fn wallet_ffi_init_runtime() -> WalletFfiError { + let result = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build(); + + match result { + Ok(_) => WalletFfiError::Success, + Err(e) => { + print_error(format!("Failed to initialize runtime: {}", e)); + WalletFfiError::RuntimeError + } + } +} diff --git a/wallet-ffi/src/sync.rs b/wallet-ffi/src/sync.rs new file mode 100644 index 00000000..3979f935 --- /dev/null +++ b/wallet-ffi/src/sync.rs @@ -0,0 +1,151 @@ +//! Block synchronization functions. + +use crate::{ + block_on, + error::{print_error, WalletFfiError}, + types::WalletHandle, + wallet::get_wallet, +}; + +/// Synchronize private accounts to a specific block. +/// +/// This scans the blockchain from the last synced block to the specified block, +/// updating private account balances based on any relevant transactions. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `block_id`: Target block number to sync to +/// +/// # Returns +/// - `Success` if synchronization completed +/// - `SyncError` if synchronization failed +/// - Error code on other failures +/// +/// # Note +/// This operation can take a while for large block ranges. The wallet +/// internally uses a progress bar which may output to stdout. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_sync_to_block( + handle: *mut WalletHandle, + block_id: u64, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let mut wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + match block_on(wallet.sync_to_block(block_id)) { + Ok(Ok(())) => WalletFfiError::Success, + Ok(Err(e)) => { + print_error(format!("Sync failed: {}", e)); + WalletFfiError::SyncError + } + Err(e) => e, + } +} + +/// Get the last synced block number. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_block_id`: Output pointer for the block number +/// +/// # Returns +/// - `Success` on success +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_block_id` must be a valid pointer to a `u64` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_last_synced_block( + handle: *mut WalletHandle, + out_block_id: *mut u64, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_block_id.is_null() { + print_error("Null output pointer"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + unsafe { + *out_block_id = wallet.last_synced_block; + } + + WalletFfiError::Success +} + +/// Get the current block height from the sequencer. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `out_block_height`: Output pointer for the current block height +/// +/// # Returns +/// - `Success` on success +/// - `NetworkError` if the sequencer is unreachable +/// - Error code on other failures +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `out_block_height` must be a valid pointer to a `u64` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_current_block_height( + handle: *mut WalletHandle, + out_block_height: *mut u64, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if out_block_height.is_null() { + print_error("Null output pointer"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + match block_on(wallet.sequencer_client.get_last_block()) { + Ok(Ok(response)) => { + unsafe { + *out_block_height = response.last_block; + } + WalletFfiError::Success + } + Ok(Err(e)) => { + print_error(format!("Failed to get block height: {:?}", e)); + WalletFfiError::NetworkError + } + Err(e) => e, + } +} diff --git a/wallet-ffi/src/transfer.rs b/wallet-ffi/src/transfer.rs new file mode 100644 index 00000000..055f0c32 --- /dev/null +++ b/wallet-ffi/src/transfer.rs @@ -0,0 +1,199 @@ +//! Token transfer functions. + +use std::{ffi::CString, ptr}; + +use common::error::ExecutionFailureKind; +use nssa::AccountId; +use wallet::program_facades::native_token_transfer::NativeTokenTransfer; + +use crate::{ + block_on, + error::{print_error, WalletFfiError}, + types::{FfiBytes32, FfiTransferResult, WalletHandle}, + wallet::get_wallet, +}; + +/// Send a public token transfer. +/// +/// Transfers tokens from one public account to another on the network. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `from`: Source account ID (must be owned by this wallet) +/// - `to`: Destination account ID +/// - `amount`: Amount to transfer as little-endian [u8; 16] +/// - `out_result`: Output pointer for transfer result +/// +/// # Returns +/// - `Success` if the transfer was submitted successfully +/// - `InsufficientFunds` if the source account doesn't have enough balance +/// - `KeyNotFound` if the source account's signing key is not in this wallet +/// - Error code on other failures +/// +/// # Memory +/// The result must be freed with `wallet_ffi_free_transfer_result()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `from` must be a valid pointer to a `FfiBytes32` struct +/// - `to` must be a valid pointer to a `FfiBytes32` struct +/// - `amount` must be a valid pointer to a `[u8; 16]` array +/// - `out_result` must be a valid pointer to a `FfiTransferResult` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_transfer_public( + handle: *mut WalletHandle, + from: *const FfiBytes32, + to: *const FfiBytes32, + amount: *const [u8; 16], + out_result: *mut FfiTransferResult, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if from.is_null() || to.is_null() || amount.is_null() || out_result.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let from_id = AccountId::new(unsafe { (*from).data }); + let to_id = AccountId::new(unsafe { (*to).data }); + let amount = u128::from_le_bytes(unsafe { *amount }); + + let transfer = NativeTokenTransfer(&wallet); + + match block_on(transfer.send_public_transfer(from_id, to_id, amount)) { + Ok(Ok(response)) => { + let tx_hash = CString::new(response.tx_hash) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()); + + unsafe { + (*out_result).tx_hash = tx_hash; + (*out_result).success = true; + } + WalletFfiError::Success + } + Ok(Err(e)) => { + print_error(format!("Transfer failed: {:?}", e)); + unsafe { + (*out_result).tx_hash = ptr::null_mut(); + (*out_result).success = false; + } + match e { + ExecutionFailureKind::InsufficientFundsError => WalletFfiError::InsufficientFunds, + ExecutionFailureKind::KeyNotFoundError => WalletFfiError::KeyNotFound, + ExecutionFailureKind::SequencerError => WalletFfiError::NetworkError, + ExecutionFailureKind::SequencerClientError(_) => WalletFfiError::NetworkError, + _ => WalletFfiError::InternalError, + } + } + Err(e) => e, + } +} + +/// Register a public account on the network. +/// +/// This initializes a public account on the blockchain. The account must be +/// owned by this wallet. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// - `account_id`: Account ID to register +/// - `out_result`: Output pointer for registration result +/// +/// # Returns +/// - `Success` if the registration was submitted successfully +/// - Error code on failure +/// +/// # Memory +/// The result must be freed with `wallet_ffi_free_transfer_result()`. +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +/// - `account_id` must be a valid pointer to a `FfiBytes32` struct +/// - `out_result` must be a valid pointer to a `FfiTransferResult` struct +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_register_public_account( + handle: *mut WalletHandle, + account_id: *const FfiBytes32, + out_result: *mut FfiTransferResult, +) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + if account_id.is_null() || out_result.is_null() { + print_error("Null pointer argument"); + return WalletFfiError::NullPointer; + } + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + let account_id = AccountId::new(unsafe { (*account_id).data }); + + let transfer = NativeTokenTransfer(&wallet); + + match block_on(transfer.register_account(account_id)) { + Ok(Ok(response)) => { + let tx_hash = CString::new(response.tx_hash) + .map(|s| s.into_raw()) + .unwrap_or(ptr::null_mut()); + + unsafe { + (*out_result).tx_hash = tx_hash; + (*out_result).success = true; + } + WalletFfiError::Success + } + Ok(Err(e)) => { + print_error(format!("Registration failed: {:?}", e)); + unsafe { + (*out_result).tx_hash = ptr::null_mut(); + (*out_result).success = false; + } + match e { + ExecutionFailureKind::KeyNotFoundError => WalletFfiError::KeyNotFound, + ExecutionFailureKind::SequencerError => WalletFfiError::NetworkError, + ExecutionFailureKind::SequencerClientError(_) => WalletFfiError::NetworkError, + _ => WalletFfiError::InternalError, + } + } + Err(e) => e, + } +} + +/// Free a transfer result returned by `wallet_ffi_transfer_public` or +/// `wallet_ffi_register_public_account`. +/// +/// # Safety +/// The result must be either null or a valid result from a transfer function. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_transfer_result(result: *mut FfiTransferResult) { + if result.is_null() { + return; + } + + unsafe { + let result = &*result; + if !result.tx_hash.is_null() { + drop(CString::from_raw(result.tx_hash)); + } + } +} diff --git a/wallet-ffi/src/types.rs b/wallet-ffi/src/types.rs new file mode 100644 index 00000000..3bcfd9fd --- /dev/null +++ b/wallet-ffi/src/types.rs @@ -0,0 +1,151 @@ +//! C-compatible type definitions for the FFI layer. + +use std::ffi::c_char; + +/// Opaque pointer to the Wallet instance. +/// +/// This type is never instantiated directly - it's used as an opaque handle +/// to hide the internal wallet structure from C code. +#[repr(C)] +pub struct WalletHandle { + _private: [u8; 0], +} + +/// 32-byte array type for AccountId, keys, hashes, etc. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiBytes32 { + pub data: [u8; 32], +} + +/// Program ID - 8 u32 values (32 bytes total). +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiProgramId { + pub data: [u32; 8], +} + +/// Account data structure - C-compatible version of nssa Account. +/// +/// Note: `balance` and `nonce` are u128 values represented as little-endian +/// byte arrays since C doesn't have native u128 support. +#[repr(C)] +pub struct FfiAccount { + pub program_owner: FfiProgramId, + /// Balance as little-endian [u8; 16] + pub balance: [u8; 16], + /// Pointer to account data bytes + pub data: *const u8, + /// Length of account data + pub data_len: usize, + /// Nonce as little-endian [u8; 16] + pub nonce: [u8; 16], +} + +impl Default for FfiAccount { + fn default() -> Self { + Self { + program_owner: FfiProgramId::default(), + balance: [0u8; 16], + data: std::ptr::null(), + data_len: 0, + nonce: [0u8; 16], + } + } +} + +/// Public keys for a private account (safe to expose). +#[repr(C)] +pub struct FfiPrivateAccountKeys { + /// Nullifier public key (32 bytes) + pub nullifier_public_key: FfiBytes32, + /// Incoming viewing public key (compressed secp256k1 point) + pub incoming_viewing_public_key: *const u8, + /// Length of incoming viewing public key (typically 33 bytes) + pub incoming_viewing_public_key_len: usize, +} + +impl Default for FfiPrivateAccountKeys { + fn default() -> Self { + Self { + nullifier_public_key: FfiBytes32::default(), + incoming_viewing_public_key: std::ptr::null(), + incoming_viewing_public_key_len: 0, + } + } +} + +/// Public key info for a public account. +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FfiPublicAccountKey { + pub public_key: FfiBytes32, +} + +/// Single entry in the account list. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct FfiAccountListEntry { + pub account_id: FfiBytes32, + pub is_public: bool, +} + +/// List of accounts returned by wallet_ffi_list_accounts. +#[repr(C)] +pub struct FfiAccountList { + pub entries: *mut FfiAccountListEntry, + pub count: usize, +} + +impl Default for FfiAccountList { + fn default() -> Self { + Self { + entries: std::ptr::null_mut(), + count: 0, + } + } +} + +/// Result of a transfer operation. +#[repr(C)] +pub struct FfiTransferResult { + /// Transaction hash (null-terminated string, or null on failure) + pub tx_hash: *mut c_char, + /// Whether the transfer succeeded + pub success: bool, +} + +impl Default for FfiTransferResult { + fn default() -> Self { + Self { + tx_hash: std::ptr::null_mut(), + success: false, + } + } +} + +// Helper functions to convert between Rust and FFI types + +impl FfiBytes32 { + /// Create from a 32-byte array. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self { data: bytes } + } + + /// Create from an AccountId. + pub fn from_account_id(id: &nssa::AccountId) -> Self { + Self { data: *id.value() } + } +} + +impl From<&nssa::AccountId> for FfiBytes32 { + fn from(id: &nssa::AccountId) -> Self { + Self::from_account_id(id) + } +} + +impl From<FfiBytes32> for nssa::AccountId { + fn from(bytes: FfiBytes32) -> Self { + nssa::AccountId::new(bytes.data) + } +} diff --git a/wallet-ffi/src/wallet.rs b/wallet-ffi/src/wallet.rs new file mode 100644 index 00000000..6f817f8e --- /dev/null +++ b/wallet-ffi/src/wallet.rs @@ -0,0 +1,279 @@ +//! Wallet lifecycle management functions. + +use std::{ + ffi::{c_char, CStr}, + path::PathBuf, + ptr, + sync::Mutex, +}; + +use wallet::WalletCore; + +use crate::{ + block_on, + error::{print_error, WalletFfiError}, + types::WalletHandle, +}; + +/// Internal wrapper around WalletCore with mutex for thread safety. +pub(crate) struct WalletWrapper { + pub core: Mutex<WalletCore>, +} + +/// Helper to get the wallet wrapper from an opaque handle. +pub(crate) fn get_wallet( + handle: *mut WalletHandle, +) -> Result<&'static WalletWrapper, WalletFfiError> { + if handle.is_null() { + print_error("Null wallet handle"); + return Err(WalletFfiError::NullPointer); + } + Ok(unsafe { &*(handle as *mut WalletWrapper) }) +} + +/// Helper to get a mutable reference to the wallet wrapper. +#[allow(dead_code)] +pub(crate) fn get_wallet_mut( + handle: *mut WalletHandle, +) -> Result<&'static mut WalletWrapper, WalletFfiError> { + if handle.is_null() { + print_error("Null wallet handle"); + return Err(WalletFfiError::NullPointer); + } + Ok(unsafe { &mut *(handle as *mut WalletWrapper) }) +} + +/// Helper to convert a C string to a Rust PathBuf. +fn c_str_to_path(ptr: *const c_char, name: &str) -> Result<PathBuf, WalletFfiError> { + if ptr.is_null() { + print_error(format!("Null pointer for {}", name)); + return Err(WalletFfiError::NullPointer); + } + + let c_str = unsafe { CStr::from_ptr(ptr) }; + match c_str.to_str() { + Ok(s) => Ok(PathBuf::from(s)), + Err(e) => { + print_error(format!("Invalid UTF-8 in {}: {}", name, e)); + Err(WalletFfiError::InvalidUtf8) + } + } +} + +/// Helper to convert a C string to a Rust String. +fn c_str_to_string(ptr: *const c_char, name: &str) -> Result<String, WalletFfiError> { + if ptr.is_null() { + print_error(format!("Null pointer for {}", name)); + return Err(WalletFfiError::NullPointer); + } + + let c_str = unsafe { CStr::from_ptr(ptr) }; + match c_str.to_str() { + Ok(s) => Ok(s.to_string()), + Err(e) => { + print_error(format!("Invalid UTF-8 in {}: {}", name, e)); + Err(WalletFfiError::InvalidUtf8) + } + } +} + +/// Create a new wallet with fresh storage. +/// +/// This initializes a new wallet with a new seed derived from the password. +/// Use this for first-time wallet creation. +/// +/// # Parameters +/// - `config_path`: Path to the wallet configuration file (JSON) +/// - `storage_path`: Path where wallet data will be stored +/// - `password`: Password for encrypting the wallet seed +/// +/// # Returns +/// - Opaque wallet handle on success +/// - Null pointer on error (call `wallet_ffi_get_last_error()` for details) +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_create_new( + config_path: *const c_char, + storage_path: *const c_char, + password: *const c_char, +) -> *mut WalletHandle { + let config_path = match c_str_to_path(config_path, "config_path") { + Ok(p) => p, + Err(_) => return ptr::null_mut(), + }; + + let storage_path = match c_str_to_path(storage_path, "storage_path") { + Ok(p) => p, + Err(_) => return ptr::null_mut(), + }; + + let password = match c_str_to_string(password, "password") { + Ok(s) => s, + Err(_) => return ptr::null_mut(), + }; + + match WalletCore::new_init_storage(config_path, storage_path, None, password) { + Ok(core) => { + let wrapper = Box::new(WalletWrapper { + core: Mutex::new(core), + }); + Box::into_raw(wrapper) as *mut WalletHandle + } + Err(e) => { + print_error(format!("Failed to create wallet: {}", e)); + ptr::null_mut() + } + } +} + +/// Open an existing wallet from storage. +/// +/// This loads a wallet that was previously created with `wallet_ffi_create_new()`. +/// +/// # Parameters +/// - `config_path`: Path to the wallet configuration file (JSON) +/// - `storage_path`: Path where wallet data is stored +/// +/// # Returns +/// - Opaque wallet handle on success +/// - Null pointer on error (call `wallet_ffi_get_last_error()` for details) +/// +/// # Safety +/// All string parameters must be valid null-terminated UTF-8 strings. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_open( + config_path: *const c_char, + storage_path: *const c_char, +) -> *mut WalletHandle { + let config_path = match c_str_to_path(config_path, "config_path") { + Ok(p) => p, + Err(_) => return ptr::null_mut(), + }; + + let storage_path = match c_str_to_path(storage_path, "storage_path") { + Ok(p) => p, + Err(_) => return ptr::null_mut(), + }; + + match WalletCore::new_update_chain(config_path, storage_path, None) { + Ok(core) => { + let wrapper = Box::new(WalletWrapper { + core: Mutex::new(core), + }); + Box::into_raw(wrapper) as *mut WalletHandle + } + Err(e) => { + print_error(format!("Failed to open wallet: {}", e)); + ptr::null_mut() + } + } +} + +/// Destroy a wallet handle and free its resources. +/// +/// After calling this function, the handle is invalid and must not be used. +/// +/// # Safety +/// - The handle must be either null or a valid handle from `wallet_ffi_create_new()` or +/// `wallet_ffi_open()`. +/// - The handle must not be used after this call. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_destroy(handle: *mut WalletHandle) { + if !handle.is_null() { + unsafe { + drop(Box::from_raw(handle as *mut WalletWrapper)); + } + } +} + +/// Save wallet state to persistent storage. +/// +/// This should be called periodically or after important operations to ensure +/// wallet data is persisted to disk. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// +/// # Returns +/// - `Success` on successful save +/// - Error code on failure +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_save(handle: *mut WalletHandle) -> WalletFfiError { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(e) => return e, + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return WalletFfiError::InternalError; + } + }; + + match block_on(wallet.store_persistent_data()) { + Ok(Ok(())) => WalletFfiError::Success, + Ok(Err(e)) => { + print_error(format!("Failed to save wallet: {}", e)); + WalletFfiError::StorageError + } + Err(e) => e, + } +} + +/// Get the sequencer address from the wallet configuration. +/// +/// # Parameters +/// - `handle`: Valid wallet handle +/// +/// # Returns +/// - Pointer to null-terminated string on success (caller must free with +/// `wallet_ffi_free_string()`) +/// - Null pointer on error +/// +/// # Safety +/// - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_get_sequencer_addr(handle: *mut WalletHandle) -> *mut c_char { + let wrapper = match get_wallet(handle) { + Ok(w) => w, + Err(_) => return ptr::null_mut(), + }; + + let wallet = match wrapper.core.lock() { + Ok(w) => w, + Err(e) => { + print_error(format!("Failed to lock wallet: {}", e)); + return ptr::null_mut(); + } + }; + + let addr = wallet.config().sequencer_addr.clone(); + + match std::ffi::CString::new(addr) { + Ok(s) => s.into_raw(), + Err(e) => { + print_error(format!("Invalid sequencer address: {}", e)); + ptr::null_mut() + } + } +} + +/// Free a string returned by wallet FFI functions. +/// +/// # Safety +/// The pointer must be either null or a valid string returned by an FFI function. +#[no_mangle] +pub unsafe extern "C" fn wallet_ffi_free_string(ptr: *mut c_char) { + if !ptr.is_null() { + unsafe { + drop(std::ffi::CString::from_raw(ptr)); + } + } +} diff --git a/wallet-ffi/wallet_ffi.h b/wallet-ffi/wallet_ffi.h new file mode 100644 index 00000000..4786e5cc --- /dev/null +++ b/wallet-ffi/wallet_ffi.h @@ -0,0 +1,676 @@ +/** + * NSSA Wallet FFI Bindings + * + * Thread Safety: All functions are thread-safe. The wallet handle can be + * shared across threads, but operations are serialized internally. + * + * Memory Management: + * - Functions returning pointers allocate memory that must be freed + * - Use the corresponding wallet_ffi_free_* function to free memory + * - Never free memory returned by FFI using standard C free() + * + * Error Handling: + * - Functions return WalletFfiError codes + * - On error, call wallet_ffi_get_last_error() for detailed message + * - The error string must be freed with wallet_ffi_free_error_string() + * + * Initialization: + * 1. Call wallet_ffi_init_runtime() before any other function + * 2. Create wallet with wallet_ffi_create_new() or wallet_ffi_open() + * 3. Destroy wallet with wallet_ffi_destroy() when done + */ + + +#ifndef WALLET_FFI_H +#define WALLET_FFI_H + +/* Generated with cbindgen:0.26.0 */ + +#include <stdarg.h> +#include <stdbool.h> +#include <stdint.h> +#include <stdlib.h> + +/** + * Error codes returned by FFI functions. + */ +typedef enum WalletFfiError { + /** + * Operation completed successfully + */ + SUCCESS = 0, + /** + * A null pointer was passed where a valid pointer was expected + */ + NULL_POINTER = 1, + /** + * Invalid UTF-8 string + */ + INVALID_UTF8 = 2, + /** + * Wallet handle is not initialized + */ + WALLET_NOT_INITIALIZED = 3, + /** + * Configuration error + */ + CONFIG_ERROR = 4, + /** + * Storage/persistence error + */ + STORAGE_ERROR = 5, + /** + * Network/RPC error + */ + NETWORK_ERROR = 6, + /** + * Account not found + */ + ACCOUNT_NOT_FOUND = 7, + /** + * Key not found for account + */ + KEY_NOT_FOUND = 8, + /** + * Insufficient funds for operation + */ + INSUFFICIENT_FUNDS = 9, + /** + * Invalid account ID format + */ + INVALID_ACCOUNT_ID = 10, + /** + * Tokio runtime error + */ + RUNTIME_ERROR = 11, + /** + * Password required but not provided + */ + PASSWORD_REQUIRED = 12, + /** + * Block synchronization error + */ + SYNC_ERROR = 13, + /** + * Serialization/deserialization error + */ + SERIALIZATION_ERROR = 14, + /** + * Internal error (catch-all) + */ + INTERNAL_ERROR = 99, +} WalletFfiError; + +/** + * Opaque pointer to the Wallet instance. + * + * This type is never instantiated directly - it's used as an opaque handle + * to hide the internal wallet structure from C code. + */ +typedef struct WalletHandle { + uint8_t _private[0]; +} WalletHandle; + +/** + * 32-byte array type for AccountId, keys, hashes, etc. + */ +typedef struct FfiBytes32 { + uint8_t data[32]; +} FfiBytes32; + +/** + * Single entry in the account list. + */ +typedef struct FfiAccountListEntry { + struct FfiBytes32 account_id; + bool is_public; +} FfiAccountListEntry; + +/** + * List of accounts returned by wallet_ffi_list_accounts. + */ +typedef struct FfiAccountList { + struct FfiAccountListEntry *entries; + uintptr_t count; +} FfiAccountList; + +/** + * Program ID - 8 u32 values (32 bytes total). + */ +typedef struct FfiProgramId { + uint32_t data[8]; +} FfiProgramId; + +/** + * Account data structure - C-compatible version of nssa Account. + * + * Note: `balance` and `nonce` are u128 values represented as little-endian + * byte arrays since C doesn't have native u128 support. + */ +typedef struct FfiAccount { + struct FfiProgramId program_owner; + /** + * Balance as little-endian [u8; 16] + */ + uint8_t balance[16]; + /** + * Pointer to account data bytes + */ + const uint8_t *data; + /** + * Length of account data + */ + uintptr_t data_len; + /** + * Nonce as little-endian [u8; 16] + */ + uint8_t nonce[16]; +} FfiAccount; + +/** + * Public key info for a public account. + */ +typedef struct FfiPublicAccountKey { + struct FfiBytes32 public_key; +} FfiPublicAccountKey; + +/** + * Public keys for a private account (safe to expose). + */ +typedef struct FfiPrivateAccountKeys { + /** + * Nullifier public key (32 bytes) + */ + struct FfiBytes32 nullifier_public_key; + /** + * Incoming viewing public key (compressed secp256k1 point) + */ + const uint8_t *incoming_viewing_public_key; + /** + * Length of incoming viewing public key (typically 33 bytes) + */ + uintptr_t incoming_viewing_public_key_len; +} FfiPrivateAccountKeys; + +/** + * Result of a transfer operation. + */ +typedef struct FfiTransferResult { + /** + * Transaction hash (null-terminated string, or null on failure) + */ + char *tx_hash; + /** + * Whether the transfer succeeded + */ + bool success; +} FfiTransferResult; + +/** + * Initialize the global Tokio runtime. + * + * This must be called before any async operations (like network calls). + * Safe to call multiple times - subsequent calls are no-ops. + * + * # Returns + * - `Success` if the runtime was initialized or already exists + * - `RuntimeError` if runtime creation failed + */ +enum WalletFfiError wallet_ffi_init_runtime(void); + +/** + * Create a new public account. + * + * Public accounts use standard transaction signing and are suitable for + * non-private operations. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_account_id`: Output pointer for the new account ID (32 bytes) + * + * # Returns + * - `Success` on successful creation + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_account_id` must be a valid pointer to a `FfiBytes32` struct + */ +enum WalletFfiError wallet_ffi_create_account_public(struct WalletHandle *handle, + struct FfiBytes32 *out_account_id); + +/** + * Create a new private account. + * + * Private accounts use privacy-preserving transactions with nullifiers + * and commitments. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_account_id`: Output pointer for the new account ID (32 bytes) + * + * # Returns + * - `Success` on successful creation + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_account_id` must be a valid pointer to a `FfiBytes32` struct + */ +enum WalletFfiError wallet_ffi_create_account_private(struct WalletHandle *handle, + struct FfiBytes32 *out_account_id); + +/** + * List all accounts in the wallet. + * + * Returns both public and private accounts managed by this wallet. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_list`: Output pointer for the account list + * + * # Returns + * - `Success` on successful listing + * - Error code on failure + * + * # Memory + * The returned list must be freed with `wallet_ffi_free_account_list()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_list` must be a valid pointer to a `FfiAccountList` struct + */ +enum WalletFfiError wallet_ffi_list_accounts(struct WalletHandle *handle, + struct FfiAccountList *out_list); + +/** + * Free an account list returned by `wallet_ffi_list_accounts`. + * + * # Safety + * The list must be either null or a valid list returned by `wallet_ffi_list_accounts`. + */ +void wallet_ffi_free_account_list(struct FfiAccountList *list); + +/** + * Get account balance. + * + * For public accounts, this fetches the balance from the network. + * For private accounts, this returns the locally cached balance. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id`: The account ID (32 bytes) + * - `is_public`: Whether this is a public account + * - `out_balance`: Output for balance as little-endian [u8; 16] + * + * # Returns + * - `Success` on successful query + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + * - `out_balance` must be a valid pointer to a `[u8; 16]` array + */ +enum WalletFfiError wallet_ffi_get_balance(struct WalletHandle *handle, + const struct FfiBytes32 *account_id, + bool is_public, + uint8_t (*out_balance)[16]); + +/** + * Get full public account data from the network. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id`: The account ID (32 bytes) + * - `out_account`: Output pointer for account data + * + * # Returns + * - `Success` on successful query + * - Error code on failure + * + * # Memory + * The account data must be freed with `wallet_ffi_free_account_data()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + * - `out_account` must be a valid pointer to a `FfiAccount` struct + */ +enum WalletFfiError wallet_ffi_get_account_public(struct WalletHandle *handle, + const struct FfiBytes32 *account_id, + struct FfiAccount *out_account); + +/** + * Free account data returned by `wallet_ffi_get_account_public`. + * + * # Safety + * The account must be either null or a valid account returned by + * `wallet_ffi_get_account_public`. + */ +void wallet_ffi_free_account_data(struct FfiAccount *account); + +/** + * Get the public key for a public account. + * + * This returns the public key derived from the account's signing key. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id`: The account ID (32 bytes) + * - `out_public_key`: Output pointer for the public key + * + * # Returns + * - `Success` on successful retrieval + * - `KeyNotFound` if the account's key is not in this wallet + * - Error code on other failures + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + * - `out_public_key` must be a valid pointer to a `FfiPublicAccountKey` struct + */ +enum WalletFfiError wallet_ffi_get_public_account_key(struct WalletHandle *handle, + const struct FfiBytes32 *account_id, + struct FfiPublicAccountKey *out_public_key); + +/** + * Get keys for a private account. + * + * Returns the nullifier public key (NPK) and incoming viewing public key (IPK) + * for the specified private account. These keys are safe to share publicly. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id`: The account ID (32 bytes) + * - `out_keys`: Output pointer for the key data + * + * # Returns + * - `Success` on successful retrieval + * - `AccountNotFound` if the private account is not in this wallet + * - Error code on other failures + * + * # Memory + * The keys structure must be freed with `wallet_ffi_free_private_account_keys()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + * - `out_keys` must be a valid pointer to a `FfiPrivateAccountKeys` struct + */ +enum WalletFfiError wallet_ffi_get_private_account_keys(struct WalletHandle *handle, + const struct FfiBytes32 *account_id, + struct FfiPrivateAccountKeys *out_keys); + +/** + * Free private account keys returned by `wallet_ffi_get_private_account_keys`. + * + * # Safety + * The keys must be either null or valid keys returned by + * `wallet_ffi_get_private_account_keys`. + */ +void wallet_ffi_free_private_account_keys(struct FfiPrivateAccountKeys *keys); + +/** + * Convert an account ID to a Base58 string. + * + * # Parameters + * - `account_id`: The account ID (32 bytes) + * + * # Returns + * - Pointer to null-terminated Base58 string on success + * - Null pointer on error + * + * # Memory + * The returned string must be freed with `wallet_ffi_free_string()`. + * + * # Safety + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + */ +char *wallet_ffi_account_id_to_base58(const struct FfiBytes32 *account_id); + +/** + * Parse a Base58 string into an account ID. + * + * # Parameters + * - `base58_str`: Null-terminated Base58 string + * - `out_account_id`: Output pointer for the account ID (32 bytes) + * + * # Returns + * - `Success` on successful parsing + * - `InvalidAccountId` if the string is not valid Base58 + * - Error code on other failures + * + * # Safety + * - `base58_str` must be a valid pointer to a null-terminated C string + * - `out_account_id` must be a valid pointer to a `FfiBytes32` struct + */ +enum WalletFfiError wallet_ffi_account_id_from_base58(const char *base58_str, + struct FfiBytes32 *out_account_id); + +/** + * Synchronize private accounts to a specific block. + * + * This scans the blockchain from the last synced block to the specified block, + * updating private account balances based on any relevant transactions. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `block_id`: Target block number to sync to + * + * # Returns + * - `Success` if synchronization completed + * - `SyncError` if synchronization failed + * - Error code on other failures + * + * # Note + * This operation can take a while for large block ranges. The wallet + * internally uses a progress bar which may output to stdout. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + */ +enum WalletFfiError wallet_ffi_sync_to_block(struct WalletHandle *handle, uint64_t block_id); + +/** + * Get the last synced block number. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_block_id`: Output pointer for the block number + * + * # Returns + * - `Success` on success + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_block_id` must be a valid pointer to a `u64` + */ +enum WalletFfiError wallet_ffi_get_last_synced_block(struct WalletHandle *handle, + uint64_t *out_block_id); + +/** + * Get the current block height from the sequencer. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `out_block_height`: Output pointer for the current block height + * + * # Returns + * - `Success` on success + * - `NetworkError` if the sequencer is unreachable + * - Error code on other failures + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `out_block_height` must be a valid pointer to a `u64` + */ +enum WalletFfiError wallet_ffi_get_current_block_height(struct WalletHandle *handle, + uint64_t *out_block_height); + +/** + * Send a public token transfer. + * + * Transfers tokens from one public account to another on the network. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `from`: Source account ID (must be owned by this wallet) + * - `to`: Destination account ID + * - `amount`: Amount to transfer as little-endian [u8; 16] + * - `out_result`: Output pointer for transfer result + * + * # Returns + * - `Success` if the transfer was submitted successfully + * - `InsufficientFunds` if the source account doesn't have enough balance + * - `KeyNotFound` if the source account's signing key is not in this wallet + * - Error code on other failures + * + * # Memory + * The result must be freed with `wallet_ffi_free_transfer_result()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `from` must be a valid pointer to a `FfiBytes32` struct + * - `to` must be a valid pointer to a `FfiBytes32` struct + * - `amount` must be a valid pointer to a `[u8; 16]` array + * - `out_result` must be a valid pointer to a `FfiTransferResult` struct + */ +enum WalletFfiError wallet_ffi_transfer_public(struct WalletHandle *handle, + const struct FfiBytes32 *from, + const struct FfiBytes32 *to, + const uint8_t (*amount)[16], + struct FfiTransferResult *out_result); + +/** + * Register a public account on the network. + * + * This initializes a public account on the blockchain. The account must be + * owned by this wallet. + * + * # Parameters + * - `handle`: Valid wallet handle + * - `account_id`: Account ID to register + * - `out_result`: Output pointer for registration result + * + * # Returns + * - `Success` if the registration was submitted successfully + * - Error code on failure + * + * # Memory + * The result must be freed with `wallet_ffi_free_transfer_result()`. + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + * - `account_id` must be a valid pointer to a `FfiBytes32` struct + * - `out_result` must be a valid pointer to a `FfiTransferResult` struct + */ +enum WalletFfiError wallet_ffi_register_public_account(struct WalletHandle *handle, + const struct FfiBytes32 *account_id, + struct FfiTransferResult *out_result); + +/** + * Free a transfer result returned by `wallet_ffi_transfer_public` or + * `wallet_ffi_register_public_account`. + * + * # Safety + * The result must be either null or a valid result from a transfer function. + */ +void wallet_ffi_free_transfer_result(struct FfiTransferResult *result); + +/** + * Create a new wallet with fresh storage. + * + * This initializes a new wallet with a new seed derived from the password. + * Use this for first-time wallet creation. + * + * # Parameters + * - `config_path`: Path to the wallet configuration file (JSON) + * - `storage_path`: Path where wallet data will be stored + * - `password`: Password for encrypting the wallet seed + * + * # Returns + * - Opaque wallet handle on success + * - Null pointer on error (call `wallet_ffi_get_last_error()` for details) + * + * # Safety + * All string parameters must be valid null-terminated UTF-8 strings. + */ +struct WalletHandle *wallet_ffi_create_new(const char *config_path, + const char *storage_path, + const char *password); + +/** + * Open an existing wallet from storage. + * + * This loads a wallet that was previously created with `wallet_ffi_create_new()`. + * + * # Parameters + * - `config_path`: Path to the wallet configuration file (JSON) + * - `storage_path`: Path where wallet data is stored + * + * # Returns + * - Opaque wallet handle on success + * - Null pointer on error (call `wallet_ffi_get_last_error()` for details) + * + * # Safety + * All string parameters must be valid null-terminated UTF-8 strings. + */ +struct WalletHandle *wallet_ffi_open(const char *config_path, const char *storage_path); + +/** + * Destroy a wallet handle and free its resources. + * + * After calling this function, the handle is invalid and must not be used. + * + * # Safety + * - The handle must be either null or a valid handle from `wallet_ffi_create_new()` or + * `wallet_ffi_open()`. + * - The handle must not be used after this call. + */ +void wallet_ffi_destroy(struct WalletHandle *handle); + +/** + * Save wallet state to persistent storage. + * + * This should be called periodically or after important operations to ensure + * wallet data is persisted to disk. + * + * # Parameters + * - `handle`: Valid wallet handle + * + * # Returns + * - `Success` on successful save + * - Error code on failure + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + */ +enum WalletFfiError wallet_ffi_save(struct WalletHandle *handle); + +/** + * Get the sequencer address from the wallet configuration. + * + * # Parameters + * - `handle`: Valid wallet handle + * + * # Returns + * - Pointer to null-terminated string on success (caller must free with + * `wallet_ffi_free_string()`) + * - Null pointer on error + * + * # Safety + * - `handle` must be a valid wallet handle from `wallet_ffi_create_new` or `wallet_ffi_open` + */ +char *wallet_ffi_get_sequencer_addr(struct WalletHandle *handle); + +/** + * Free a string returned by wallet FFI functions. + * + * # Safety + * The pointer must be either null or a valid string returned by an FFI function. + */ +void wallet_ffi_free_string(char *ptr); + +#endif /* WALLET_FFI_H */ diff --git a/wallet/Cargo.toml b/wallet/Cargo.toml index 292cebac..45b0788c 100644 --- a/wallet/Cargo.toml +++ b/wallet/Cargo.toml @@ -26,7 +26,7 @@ itertools.workspace = true sha2.workspace = true futures.workspace = true risc0-zkvm.workspace = true -async-stream = "0.3.6" +async-stream.workspace = true indicatif = { version = "0.18.3", features = ["improved_unicode"] } optfield = "0.4.0" url.workspace = true diff --git a/wallet/src/config.rs b/wallet/src/config.rs index 8da28bce..3a5d6e0c 100644 --- a/wallet/src/config.rs +++ b/wallet/src/config.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::{Context as _, Result}; -use common::sequencer_client::BasicAuth; +use common::config::BasicAuth; use key_protocol::key_management::{ KeyChain, key_tree::{