From fac3043a5a6dedb37970c6efb2c93ac787eac642 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 14:57:13 +0000 Subject: [PATCH 01/10] Render HyperFuel client via native cargo crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the @envio-dev/hyperfuel-client npm package with the hyperfuel-client cargo crate, exposed through a native napi HyperfuelClient class — mirroring how the Hypersync client is rendered. The crate vendors its capnp-generated code, so building no longer needs the capnp compiler. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- Cargo.lock | 679 +++++++++++++++++- packages/cli/Cargo.toml | 3 + packages/cli/src/hyperfuel_source/config.rs | 33 + packages/cli/src/hyperfuel_source/mod.rs | 46 ++ packages/cli/src/hyperfuel_source/query.rs | 188 +++++ packages/cli/src/hyperfuel_source/types.rs | 134 ++++ packages/cli/src/lib.rs | 1 + packages/envio/package.json | 1 - packages/envio/src/Core.res | 3 + .../envio/src/sources/HyperFuelClient.res | 207 +----- pnpm-lock.yaml | 73 -- 11 files changed, 1076 insertions(+), 292 deletions(-) create mode 100644 packages/cli/src/hyperfuel_source/config.rs create mode 100644 packages/cli/src/hyperfuel_source/mod.rs create mode 100644 packages/cli/src/hyperfuel_source/query.rs create mode 100644 packages/cli/src/hyperfuel_source/types.rs diff --git a/Cargo.lock b/Cargo.lock index 87a1409fab..a3e16d171b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,34 +46,96 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy-dyn-abi" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf69d3061e2e908a4370bda5d8d6529d5080232776975489eec0b49ce971027e" +dependencies = [ + "alloy-json-abi 0.8.26", + "alloy-primitives 0.8.26", + "alloy-sol-type-parser 0.8.26", + "alloy-sol-types 0.8.26", + "const-hex", + "itoa", + "serde", + "serde_json", + "winnow", +] + [[package]] name = "alloy-dyn-abi" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc2db5c583aaef0255aa63a4fe827f826090142528bba48d1bf4119b62780cad" dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-type-parser", - "alloy-sol-types", + "alloy-json-abi 1.5.7", + "alloy-primitives 1.5.7", + "alloy-sol-type-parser 1.5.7", + "alloy-sol-types 1.5.7", "itoa", "serde", "serde_json", "winnow", ] +[[package]] +name = "alloy-json-abi" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4584e3641181ff073e9d5bec5b3b8f78f9749d9fb108a1cfbc4399a4a139c72a" +dependencies = [ + "alloy-primitives 0.8.26", + "alloy-sol-type-parser 0.8.26", + "serde", + "serde_json", +] + [[package]] name = "alloy-json-abi" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9dbe713da0c737d9e5e387b0ba790eb98b14dd207fe53eef50e19a5a8ec3dac" dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", + "alloy-primitives 1.5.7", + "alloy-sol-type-parser 1.5.7", "serde", "serde_json", ] +[[package]] +name = "alloy-primitives" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "777d58b30eb9a4db0e5f59bc30e8c2caef877fee7dc8734cf242a51a60f22e05" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more 2.1.1", + "foldhash 0.1.5", + "hashbrown 0.15.5", + "indexmap", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.8.5", + "ruint", + "rustc-hash", + "serde", + "sha3", + "tiny-keccak", +] + [[package]] name = "alloy-primitives" version = "1.5.7" @@ -111,27 +173,59 @@ dependencies = [ "bytes", ] +[[package]] +name = "alloy-sol-macro" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e68b32b6fa0d09bb74b4cefe35ccc8269d711c26629bc7cd98a47eeb12fe353f" +dependencies = [ + "alloy-sol-macro-expander 0.8.26", + "alloy-sol-macro-input 0.8.26", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "alloy-sol-macro" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab81bab693da9bb79f7a95b64b394718259fdd7e41dceeced4cad57cb71c4f6a" dependencies = [ - "alloy-sol-macro-expander", - "alloy-sol-macro-input", + "alloy-sol-macro-expander 1.5.7", + "alloy-sol-macro-input 1.5.7", "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "alloy-sol-macro-expander" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2afe6879ac373e58fd53581636f2cce843998ae0b058ebe1e4f649195e2bd23c" +dependencies = [ + "alloy-sol-macro-input 0.8.26", + "const-hex", + "heck", + "indexmap", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", + "syn-solidity 0.8.26", + "tiny-keccak", +] + [[package]] name = "alloy-sol-macro-expander" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "489f1620bb7e2483fb5819ed01ab6edc1d2f93939dce35a5695085a1afd1d699" dependencies = [ - "alloy-sol-macro-input", + "alloy-sol-macro-input 1.5.7", "const-hex", "heck", "indexmap", @@ -140,7 +234,23 @@ dependencies = [ "quote", "sha3", "syn 2.0.117", - "syn-solidity", + "syn-solidity 1.5.7", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ba01aee235a8c699d07e5be97ba215607564e71be72f433665329bec307d28" +dependencies = [ + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "syn 2.0.117", + "syn-solidity 0.8.26", ] [[package]] @@ -156,7 +266,17 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "syn-solidity", + "syn-solidity 1.5.7", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c13fc168b97411e04465f03e632f31ef94cad1c7c8951bf799237fd7870d535" +dependencies = [ + "serde", + "winnow", ] [[package]] @@ -169,15 +289,28 @@ dependencies = [ "winnow", ] +[[package]] +name = "alloy-sol-types" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e960c4b52508ef2ae1e37cae5058e905e9ae099b107900067a503f8c454036f" +dependencies = [ + "alloy-json-abi 0.8.26", + "alloy-primitives 0.8.26", + "alloy-sol-macro 0.8.26", + "const-hex", + "serde", +] + [[package]] name = "alloy-sol-types" version = "1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64612d29379782a5dde6f4b6570d9c756d734d760c0c94c254d361e678a6591f" dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-macro", + "alloy-json-abi 1.5.7", + "alloy-primitives 1.5.7", + "alloy-sol-macro 1.5.7", "serde", ] @@ -270,6 +403,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -459,6 +601,12 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "array-init-cursor" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed51fe0f224d1d4ea768be38c51f9f831dee9d05c163c11fba0b8c44387b1fc3" + [[package]] name = "arrayvec" version = "0.7.6" @@ -696,6 +844,39 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atoi" version = "2.0.0" @@ -705,6 +886,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atoi_simd" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae037714f313c1353189ead58ef9eec30a8e8dc101b2622d461418fd59e28a9" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -852,6 +1039,17 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "brotli" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "8.0.2" @@ -860,7 +1058,17 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -885,6 +1093,26 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -1151,6 +1379,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1587,9 +1824,9 @@ dependencies = [ name = "envio" version = "0.0.1-dev" dependencies = [ - "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-dyn-abi 1.5.7", + "alloy-json-abi 1.5.7", + "alloy-primitives 1.5.7", "anyhow", "arrayvec", "async-recursion", @@ -1605,6 +1842,7 @@ dependencies = [ "futures-util", "graphql-parser", "handlebars", + "hyperfuel-client", "hypersync-client", "include_dir", "inquire", @@ -1617,6 +1855,7 @@ dependencies = [ "openssl", "paste", "pathdiff", + "polars-arrow", "pretty_assertions", "regex", "reqwest 0.11.27", @@ -1634,6 +1873,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing-subscriber", + "url", ] [[package]] @@ -1652,6 +1892,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1663,6 +1909,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95765f67b4b18863968b4a1bd5bb576f732b29a4a28c7cd84c09fa3e2875f33c" + [[package]] name = "faster-hex" version = "0.9.0" @@ -1760,6 +2018,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] @@ -2067,6 +2326,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", + "rayon", + "serde", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2074,6 +2345,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash 0.1.5", + "serde", ] [[package]] @@ -2257,6 +2529,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2295,6 +2568,76 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperfuel-client" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c591e080a7b56d2f95553b1f232e0bde05e2c5ad0e4fdca0dd698ed16f3915f1" +dependencies = [ + "alloy-dyn-abi 0.8.26", + "alloy-json-abi 0.8.26", + "anyhow", + "arrayvec", + "bincode", + "capnp", + "faster-hex", + "fastrange-rs", + "futures", + "hyperfuel-format", + "hyperfuel-net-types", + "hyperfuel-schema", + "log", + "nohash-hasher", + "num_cpus", + "polars-arrow", + "polars-parquet", + "rand 0.8.5", + "rayon", + "reqwest 0.12.28", + "ruint", + "serde", + "serde_json", + "tokio", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "hyperfuel-format" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a07f3546b013d2d3a2083ffc580897eb67feaa79d04b66c31098b8d17f9e9c" +dependencies = [ + "arrayvec", + "derive_more 1.0.0", + "faster-hex", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "hyperfuel-net-types" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7283c780ec92a6f58ac2e5cdbd0492904b063d50361e4bc884d0d1b6b8af7882" +dependencies = [ + "arrayvec", + "capnp", + "hyperfuel-format", + "serde", +] + +[[package]] +name = "hyperfuel-schema" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aaa329f946f4508099a6ed6a8570a1d657735676bc084119c2af8b99ea69f09" +dependencies = [ + "anyhow", + "polars-arrow", +] + [[package]] name = "hyperlocal" version = "0.9.1" @@ -2316,9 +2659,9 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb904f3c4d4a03d9462a9e6be0a4b4f1872e466dbd36661e1ceed27485a54e03" dependencies = [ - "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-dyn-abi 1.5.7", + "alloy-json-abi 1.5.7", + "alloy-primitives 1.5.7", "anyhow", "arrayvec", "arrow", @@ -2355,7 +2698,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de5a308029c5ea9c5dcd4bc1c76bbbec76ff25044fab79cba29dbc46b177f34e" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.5.7", "arrayvec", "derive_more 1.0.0", "faster-hex", @@ -2880,6 +3223,25 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "lz4_flex" version = "0.12.2" @@ -2906,6 +3268,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49388d20533534cd19360ad3d6a7dadc885944aa802ba3995040c5ec11288c6" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -2951,6 +3322,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4851161a11d3ad0bf9402d90ffc3967bf231768bfd7aeb61755ad06dbf1a142" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79a74ddee9e0c27d2578323c13905793e91622148f138ba29738f9dddb835e90" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "target-features", +] + [[package]] name = "napi" version = "3.8.5" @@ -3125,6 +3518,15 @@ dependencies = [ "libc", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -3277,7 +3679,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli", + "brotli 8.0.2", "bytes", "chrono", "flate2", @@ -3298,6 +3700,16 @@ dependencies = [ "zstd", ] +[[package]] +name = "parquet-format-safe" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1131c54b167dd4e4799ce762e1ab01549ebb94d5bdd13e6ec1b467491c378e1f" +dependencies = [ + "async-trait", + "futures", +] + [[package]] name = "paste" version = "1.0.15" @@ -3387,6 +3799,133 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "planus" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1691dd09e82f428ce8d6310bd6d5da2557c82ff17694d2a32cad7242aea89f" +dependencies = [ + "array-init-cursor", +] + +[[package]] +name = "polars-arrow" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d19c6db79cb6a3c55af3b5a3976276edaab64cbf7f69b392617c2af30d7742" +dependencies = [ + "ahash", + "atoi_simd", + "bytemuck", + "chrono", + "dyn-clone", + "either", + "ethnum", + "fast-float", + "getrandom 0.2.17", + "hashbrown 0.14.5", + "itoa", + "lz4", + "multiversion", + "num-traits", + "parking_lot", + "polars-arrow-format", + "polars-error", + "polars-utils", + "ryu", + "simdutf8", + "streaming-iterator", + "strength_reduce", + "version_check", + "zstd", +] + +[[package]] +name = "polars-arrow-format" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b0ef2474af9396b19025b189d96e992311e6a47f90c53cd998b36c4c64b84c" +dependencies = [ + "planus", + "serde", +] + +[[package]] +name = "polars-compute" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30194a5ff325f61d6fcb62dc215c9210f308fc4fc85a493ef777dbcd938cba24" +dependencies = [ + "bytemuck", + "either", + "num-traits", + "polars-arrow", + "polars-error", + "polars-utils", + "strength_reduce", + "version_check", +] + +[[package]] +name = "polars-error" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07101d1803ca2046cdb3a8adb1523ddcc879229860f0ac56a853034269dec1e1" +dependencies = [ + "polars-arrow-format", + "simdutf8", + "thiserror 1.0.69", +] + +[[package]] +name = "polars-parquet" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2993265079ffa07dd16277189444424f8d787b00b01c6f6e001f58bab543ce" +dependencies = [ + "ahash", + "async-stream", + "base64 0.22.1", + "brotli 6.0.0", + "bytemuck", + "ethnum", + "flate2", + "futures", + "lz4", + "num-traits", + "parquet-format-safe", + "polars-arrow", + "polars-compute", + "polars-error", + "polars-utils", + "simdutf8", + "snap", + "streaming-decompression", + "zstd", +] + +[[package]] +name = "polars-utils" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19dd73207bd15efb0ae5c9c3ece3227927ed6a16ad63578acec342378e6bdcb4" +dependencies = [ + "ahash", + "bytemuck", + "bytes", + "hashbrown 0.14.5", + "indexmap", + "memmap2", + "num-traits", + "once_cell", + "polars-error", + "raw-cpuid", + "rayon", + "smartstring", + "stacker", + "version_check", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3510,6 +4049,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3614,6 +4163,7 @@ dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", ] [[package]] @@ -3699,6 +4249,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -3875,6 +4434,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots", ] [[package]] @@ -4418,6 +4978,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "snap" version = "1.1.1" @@ -4460,12 +5031,46 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "streaming-decompression" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6cc3b19bfb128a8ad11026086e31d3ce9ad23f8ea37354b31383a187c44cf3" +dependencies = [ + "fallible-streaming-iterator", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -4552,6 +5157,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-solidity" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4e6eed052a117409a1a744c8bda9c3ea6934597cf7419f791cb7d590871c4c" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "syn-solidity" version = "1.5.7" @@ -4628,6 +5245,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tempdir" version = "0.3.7" @@ -4800,6 +5423,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -5225,6 +5849,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 915d147c16..0b104e2c49 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -45,6 +45,9 @@ colored = "2.0.4" thiserror = "1.0.50" fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" +hyperfuel-client = "3.2.0" +polars-arrow = "0.42" +url = "2" faster-hex = "0.9" ruint = "1" env_logger = "0.11" diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/hyperfuel_source/config.rs new file mode 100644 index 0000000000..6be3c4f1d6 --- /dev/null +++ b/packages/cli/src/hyperfuel_source/config.rs @@ -0,0 +1,33 @@ +use anyhow::{Context, Result}; +use napi_derive::napi; +use std::num::NonZeroU64; +use url::Url; + +/// Configuration for the HyperFuel client. +#[napi(object)] +#[derive(Default, Clone)] +pub struct ClientConfig { + pub url: String, + pub bearer_token: Option, + pub http_req_timeout_millis: Option, +} + +impl TryFrom for hyperfuel_client::ClientConfig { + type Error = anyhow::Error; + + fn try_from(config: ClientConfig) -> Result { + Ok(Self { + url: Some(Url::parse(&config.url).context("parse hyperfuel url")?), + bearer_token: config.bearer_token, + http_req_timeout_millis: config + .http_req_timeout_millis + .and_then(|v| u64::try_from(v).ok()) + .and_then(NonZeroU64::new), + // Retries are handled by the indexer, not the binary client. + max_num_retries: Some(0), + retry_backoff_ms: None, + retry_base_ms: None, + retry_ceiling_ms: None, + }) + } +} diff --git a/packages/cli/src/hyperfuel_source/mod.rs b/packages/cli/src/hyperfuel_source/mod.rs new file mode 100644 index 0000000000..3cb59fc4e9 --- /dev/null +++ b/packages/cli/src/hyperfuel_source/mod.rs @@ -0,0 +1,46 @@ +use anyhow::Context; +use napi_derive::napi; + +mod config; +mod query; +mod types; + +use config::ClientConfig; +use query::Query; +use types::{convert_response, QueryResponse}; + +#[napi] +pub struct HyperfuelClient { + inner: hyperfuel_client::Client, +} + +#[napi] +impl HyperfuelClient { + #[napi(factory)] + pub fn new(cfg: ClientConfig) -> napi::Result { + let client_config: hyperfuel_client::ClientConfig = + cfg.try_into().context("build config").map_err(map_err)?; + let inner = hyperfuel_client::Client::new(client_config) + .context("build client") + .map_err(map_err)?; + Ok(HyperfuelClient { inner }) + } + + #[napi] + pub async fn get_selected_data(&self, query: Query) -> napi::Result { + let query: hyperfuel_client::net_types::Query = + query.try_into().context("parse query").map_err(map_err)?; + let res = self.inner.get_arrow(&query).await.map_err(|e| { + // The client embeds a `{:?}` debug dump in its error message; keep + // only the first line so it stays readable on retries. + let message = format!("{e}"); + let summary = message.lines().next().unwrap_or(message.as_str()); + napi::Error::from_reason(format!("Failed to get data from HyperFuel: {summary}")) + })?; + Ok(convert_response(res)) + } +} + +fn map_err(e: anyhow::Error) -> napi::Error { + napi::Error::from_reason(format!("{:?}", e)) +} diff --git a/packages/cli/src/hyperfuel_source/query.rs b/packages/cli/src/hyperfuel_source/query.rs new file mode 100644 index 0000000000..fcfcd7136e --- /dev/null +++ b/packages/cli/src/hyperfuel_source/query.rs @@ -0,0 +1,188 @@ +use anyhow::{anyhow, Context, Result}; +use hyperfuel_client::format::{Hash, Hex}; +use hyperfuel_client::net_types; +use napi::bindgen_prelude::BigInt; +use napi_derive::napi; +use std::collections::BTreeSet; + +/// Query for retrieving Fuel data. +#[napi(object)] +#[derive(Default)] +pub struct Query { + pub from_block: i64, + #[napi(js_name = "toBlock")] + pub to_block_exclusive: Option, + pub receipts: Option>, + pub inputs: Option>, + pub outputs: Option>, + pub include_all_blocks: Option, + pub field_selection: FieldSelection, + pub max_num_blocks: Option, + pub max_num_transactions: Option, +} + +#[napi(object)] +#[derive(Default)] +pub struct ReceiptSelection { + pub root_contract_id: Option>, + pub to_address: Option>, + pub asset_id: Option>, + pub receipt_type: Option>, + pub sender: Option>, + pub recipient: Option>, + pub contract_id: Option>, + pub ra: Option>, + pub rb: Option>, + pub rc: Option>, + pub rd: Option>, + pub tx_status: Option>, +} + +#[napi(object)] +#[derive(Default)] +pub struct InputSelection { + pub owner: Option>, + pub asset_id: Option>, + pub contract: Option>, + pub sender: Option>, + pub recipient: Option>, + pub input_type: Option>, + pub tx_status: Option>, +} + +#[napi(object)] +#[derive(Default)] +pub struct OutputSelection { + pub to: Option>, + pub asset_id: Option>, + pub contract: Option>, + pub output_type: Option>, + pub tx_status: Option>, +} + +#[napi(object)] +#[derive(Default)] +pub struct FieldSelection { + pub block: Option>, + pub transaction: Option>, + pub receipt: Option>, +} + +fn parse_hashes(v: Option>) -> Result> { + v.unwrap_or_default() + .into_iter() + .map(|s| Hash::decode_hex(&s).map_err(|e| anyhow!("failed to parse hash {s}: {e:?}"))) + .collect() +} + +fn bigints_to_u64(v: Option>) -> Vec { + v.unwrap_or_default() + .into_iter() + .map(|b| b.get_u64().1) + .collect() +} + +impl TryFrom for net_types::ReceiptSelection { + type Error = anyhow::Error; + + fn try_from(s: ReceiptSelection) -> Result { + Ok(net_types::ReceiptSelection { + root_contract_id: parse_hashes(s.root_contract_id)?, + to: Vec::new(), + to_address: parse_hashes(s.to_address)?, + asset_id: parse_hashes(s.asset_id)?, + receipt_type: s.receipt_type.unwrap_or_default(), + sender: parse_hashes(s.sender)?, + recipient: parse_hashes(s.recipient)?, + contract_id: parse_hashes(s.contract_id)?, + ra: bigints_to_u64(s.ra), + rb: bigints_to_u64(s.rb), + rc: bigints_to_u64(s.rc), + rd: bigints_to_u64(s.rd), + tx_status: s.tx_status.unwrap_or_default(), + tx_type: Vec::new(), + }) + } +} + +impl TryFrom for net_types::InputSelection { + type Error = anyhow::Error; + + fn try_from(s: InputSelection) -> Result { + Ok(net_types::InputSelection { + owner: parse_hashes(s.owner)?, + asset_id: parse_hashes(s.asset_id)?, + contract: parse_hashes(s.contract)?, + sender: parse_hashes(s.sender)?, + recipient: parse_hashes(s.recipient)?, + input_type: s.input_type.unwrap_or_default(), + tx_status: s.tx_status.unwrap_or_default(), + tx_type: Vec::new(), + }) + } +} + +impl TryFrom for net_types::OutputSelection { + type Error = anyhow::Error; + + fn try_from(s: OutputSelection) -> Result { + Ok(net_types::OutputSelection { + to: parse_hashes(s.to)?, + asset_id: parse_hashes(s.asset_id)?, + contract: parse_hashes(s.contract)?, + output_type: s.output_type.unwrap_or_default(), + tx_status: s.tx_status.unwrap_or_default(), + tx_type: Vec::new(), + }) + } +} + +impl From for net_types::FieldSelection { + fn from(f: FieldSelection) -> Self { + net_types::FieldSelection { + block: f.block.unwrap_or_default().into_iter().collect(), + transaction: f.transaction.unwrap_or_default().into_iter().collect(), + receipt: f.receipt.unwrap_or_default().into_iter().collect(), + input: BTreeSet::new(), + output: BTreeSet::new(), + } + } +} + +fn try_collect(v: Option>) -> Result> +where + T: TryInto, +{ + v.unwrap_or_default() + .into_iter() + .map(TryInto::try_into) + .collect() +} + +impl TryFrom for net_types::Query { + type Error = anyhow::Error; + + fn try_from(q: Query) -> Result { + let from_block = u64::try_from(q.from_block).context("from_block must be >= 0")?; + let to_block = q + .to_block_exclusive + .map(|b| u64::try_from(b).context("toBlock must be >= 0")) + .transpose()?; + + Ok(net_types::Query { + from_block, + to_block, + receipts: try_collect(q.receipts)?, + inputs: try_collect(q.inputs)?, + outputs: try_collect(q.outputs)?, + include_all_blocks: q.include_all_blocks.unwrap_or(false), + field_selection: q.field_selection.into(), + max_num_blocks: q.max_num_blocks.map(|n| n as usize), + max_num_transactions: q.max_num_transactions.map(|n| n as usize), + max_num_receipts: None, + max_num_inputs: None, + max_num_outputs: None, + join_mode: net_types::JoinMode::Default, + }) + } +} diff --git a/packages/cli/src/hyperfuel_source/types.rs b/packages/cli/src/hyperfuel_source/types.rs new file mode 100644 index 0000000000..b9729125be --- /dev/null +++ b/packages/cli/src/hyperfuel_source/types.rs @@ -0,0 +1,134 @@ +use hyperfuel_client::{ArrowBatch, ArrowResponse}; +use napi::bindgen_prelude::BigInt; +use napi_derive::napi; +use polars_arrow::array::{BinaryArray, Int64Array, StaticArray, UInt64Array, UInt8Array}; + +#[napi(object)] +pub struct QueryResponse { + pub archive_height: Option, + pub next_block: i64, + pub total_execution_time: i64, + pub data: QueryResponseData, +} + +#[napi(object)] +pub struct QueryResponseData { + pub receipts: Vec, + pub blocks: Option>, +} + +#[napi(object)] +pub struct Receipt { + pub receipt_index: i64, + pub root_contract_id: Option, + pub tx_id: String, + pub block_height: i64, + pub receipt_type: i64, + pub data: Option, + pub rb: Option, + pub val: Option, + pub sub_id: Option, + pub amount: Option, + pub asset_id: Option, + pub to: Option, + pub to_address: Option, +} + +#[napi(object)] +pub struct Block { + pub id: String, + pub height: i64, + pub time: i64, +} + +fn encode_hex(bytes: &[u8]) -> String { + format!("0x{}", faster_hex::hex_string(bytes)) +} + +fn hex_at(arr: &Option<&BinaryArray>, idx: usize) -> Option { + arr.and_then(|a| a.get(idx)).map(encode_hex) +} + +fn u64_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { + arr.and_then(|a| a.get(idx)) +} + +fn u8_at(arr: &Option<&UInt8Array>, idx: usize) -> Option { + arr.and_then(|a| a.get(idx)) +} + +fn i64_at(arr: &Option<&Int64Array>, idx: usize) -> Option { + arr.and_then(|a| a.get(idx)) +} + +fn bigint_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { + u64_at(arr, idx).map(BigInt::from) +} + +fn receipts_from_arrow(batches: &[ArrowBatch]) -> Vec { + let mut out = Vec::new(); + for batch in batches { + let receipt_index = batch.column::("receipt_index").ok(); + let root_contract_id = batch.column::>("root_contract_id").ok(); + let tx_id = batch.column::>("tx_id").ok(); + let block_height = batch.column::("block_height").ok(); + let receipt_type = batch.column::("receipt_type").ok(); + let data = batch.column::>("data").ok(); + let rb = batch.column::("rb").ok(); + let val = batch.column::("val").ok(); + let sub_id = batch.column::>("sub_id").ok(); + let amount = batch.column::("amount").ok(); + let asset_id = batch.column::>("asset_id").ok(); + let to = batch.column::>("to").ok(); + let to_address = batch.column::>("to_address").ok(); + + for idx in 0..batch.chunk.len() { + out.push(Receipt { + receipt_index: u64_at(&receipt_index, idx).unwrap_or_default() as i64, + root_contract_id: hex_at(&root_contract_id, idx), + tx_id: hex_at(&tx_id, idx).unwrap_or_else(|| "0x".to_string()), + block_height: u64_at(&block_height, idx).unwrap_or_default() as i64, + receipt_type: u8_at(&receipt_type, idx).unwrap_or_default() as i64, + data: hex_at(&data, idx), + rb: bigint_at(&rb, idx), + val: bigint_at(&val, idx), + sub_id: hex_at(&sub_id, idx), + amount: bigint_at(&amount, idx), + asset_id: hex_at(&asset_id, idx), + to: hex_at(&to, idx), + to_address: hex_at(&to_address, idx), + }); + } + } + out +} + +fn blocks_from_arrow(batches: &[ArrowBatch]) -> Vec { + let mut out = Vec::new(); + for batch in batches { + let id = batch.column::>("id").ok(); + let height = batch.column::("height").ok(); + let time = batch.column::("time").ok(); + + for idx in 0..batch.chunk.len() { + out.push(Block { + id: hex_at(&id, idx).unwrap_or_else(|| "0x".to_string()), + height: u64_at(&height, idx).unwrap_or_default() as i64, + time: i64_at(&time, idx).unwrap_or_default(), + }); + } + } + out +} + +pub(crate) fn convert_response(res: ArrowResponse) -> QueryResponse { + QueryResponse { + archive_height: res.archive_height.map(|h| h as i64), + next_block: res.next_block as i64, + total_execution_time: res.total_execution_time as i64, + data: QueryResponseData { + receipts: receipts_from_arrow(&res.data.receipts), + blocks: Some(blocks_from_arrow(&res.data.blocks)), + }, + } +} diff --git a/packages/cli/src/lib.rs b/packages/cli/src/lib.rs index 00afaf4a71..b8d763c1f4 100644 --- a/packages/cli/src/lib.rs +++ b/packages/cli/src/lib.rs @@ -9,6 +9,7 @@ mod evm; pub mod executor; mod fuel; mod hbs_templating; +mod hyperfuel_source; mod hypersync_source; #[cfg_attr(test, allow(dead_code))] mod napi; diff --git a/packages/envio/package.json b/packages/envio/package.json index 648f89fa4d..bdfa438ccd 100644 --- a/packages/envio/package.json +++ b/packages/envio/package.json @@ -47,7 +47,6 @@ "dependencies": { "@clickhouse/client": "1.17.0", "@elastic/ecs-pino-format": "1.4.0", - "@envio-dev/hyperfuel-client": "1.2.2", "@fuel-ts/crypto": "0.96.1", "@fuel-ts/errors": "0.96.1", "@fuel-ts/hasher": "0.96.1", diff --git a/packages/envio/src/Core.res b/packages/envio/src/Core.res index ee8a754ccf..be3889630e 100644 --- a/packages/envio/src/Core.res +++ b/packages/envio/src/Core.res @@ -5,6 +5,7 @@ // NAPI encodes Rust `Option` as `null | T` (never `undefined`), so the // tighter `Null.t` captures the exact boundary shape. type hypersyncClientCtor +type hyperfuelClientCtor type decoderCtor type addon = { @@ -12,6 +13,8 @@ type addon = { runCli: (~args: array, ~envioPackageDir: Null.t) => promise>, @as("HypersyncClient") hypersyncClient: hypersyncClientCtor, + @as("HyperfuelClient") + hyperfuelClient: hyperfuelClientCtor, @as("Decoder") decoder: decoderCtor, } diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 3f77816d91..81d5419d49 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -1,11 +1,9 @@ -type unchecksummedEthAddress = string - type t type cfg = { url: string, bearerToken?: string, - http_req_timeout_millis?: int, + httpReqTimeoutMillis?: int, } module QueryTypes = { type blockFieldOptions = @@ -171,216 +169,32 @@ module QueryTypes = { } module FuelTypes = { - /** An object containing information about a transaction. */ - type transaction = { - /** block the transaction is in. */ - blockHeight: int, - /** A unique transaction id. */ - id: string, - /** An array of asset ids used for the transaction inputs. */ - inputAssetIds?: array, - /** An array of contracts used for the transaction inputs. */ - inputContracts?: array, - /** - * A contract used for the transaction input. - * A unique 32 byte identifier for the UTXO for a contract used for the transaction input. - */ - inputContractUtxoId?: string, - /** The root of amount of coins owned by contract before transaction execution for a contract used for the transaction input. */ - inputContractBalanceRoot?: string, - /** The state root of contract before transaction execution for a contract used for the transaction input. */ - inputContractStateRoot?: string, - /** A pointer to the TX whose output is being spent for a contract used for the transaction input. */ - inputContractTxPointerBlockHeight?: int, - /** A pointer to the TX whose output is being spent for a contract used for the transaction input. */ - inputContractTxPointerTxIndex?: int, - /** The contract id for a contract used for the transaction input. */ - inputContract?: string, - /** The gas price for the transaction. */ - gasPrice?: bigint, - /** The gas limit for the transaction. */ - gasLimit?: bigint, - /** The minimum block height that the transaction can be included at. */ - maturity?: int, - /** The amount minted in the transaction. */ - mintAmount?: bigint, - /** The asset ID for coins minted in the transaction. */ - mintAssetId?: string, - /** The location of the transaction in the block. */ - txPointerBlockHeight?: int, - txPointerTxIndex?: int, - /** Script, creating a new contract, or minting new coins */ - txType: int, - /** The index of the input from a transaction that changed the state of a contract. */ - outputContractInputIndex?: int, - /** The root of amount of coins owned by contract after transaction execution from a transaction that changed the state of a contract. */ - outputContractBalanceRoot?: string, - /** The state root of contract after transaction execution from a transaction that changed the state of a contract. */ - outputContractStateRoot?: string, - /** An array of witnesses. */ - witnesses?: string, - /** The root of the receipts. */ - receiptsRoot?: string, - /** The status type of the transaction. */ - status: int, - /** for SubmittedStatus, SuccessStatus, and FailureStatus, the time a transaction was submitted, successful, or failed */ - time: int, - /** - * for SuccessStatus, the state of the program execution - * for SqueezedOutStatus & FailureStatus, the reason the transaction was squeezed out or failed - */ - reason?: string, - /** The script to execute. */ - script?: string, - /** The script input parameters. */ - scriptData?: string, - /** The witness index of contract bytecode. */ - bytecodeWitnessIndex?: int, - /** The length of the transaction bytecode. */ - bytecodeLength?: int, - /** The salt value for the transaction. */ - salt?: string, - } - /** An object representing all possible types of receipts. */ type receipt = { - /** Index of the receipt in the block */ receiptIndex: int, - /** Contract that produced the receipt */ rootContractId?: Address.t, - /** transaction that this receipt originated from */ txId: string, - /** The status type of the transaction this receipt originated from */ - txStatus: int, - /** block that the receipt originated in */ blockHeight: int, - /** The value of the program counter register $pc, which is the memory address of the current instruction. */ - pc?: int, - /** The value of register $is, which is the pointer to the start of the currently-executing code. */ - is?: int, - /** The recipient contract */ - to?: string, - /** The recipient address */ - toAddress?: string, - /** The amount of coins transferred. */ - amount?: bigint, - /** The asset id of the coins transferred. */ - assetId?: string, - /** The gas used for the transaction. */ - gas?: int, - /** The first parameter for a CALL receipt type, holds the function selector. */ - param1?: bigint, - /** The second parameter for a CALL receipt type, typically used for the user-specified input to the ABI function being selected. */ - param2?: bigint, - /** The value of registers at the end of execution, used for debugging. */ - val?: bigint, - /** The value of the pointer register, used for debugging. */ - ptr?: bigint, - /** A 32-byte String of MEM[$rC, $rD]. The syntax MEM[x, y] means the memory range starting at byte x, of length y bytes. */ - digest?: string, - /** The decimal string representation of an 8-bit unsigned integer for the panic reason. Only returned if the receipt type is PANIC. */ - reason?: int, - /** The value of register $rA. */ - ra?: bigint, - /** The value of register $rB. */ - rb?: bigint, - /** The value of register $rC. */ - rc?: bigint, - /** The value of register $rD. */ - rd?: bigint, - /** The length of the receipt. */ - len?: bigint, - /** The type of receipt. */ receiptType: FuelSDK.receiptType, - /** 0 if script exited successfully, any otherwise. */ - result?: int, - /** The amount of gas consumed by the script. */ - gasUsed?: int, - /** The receipt data. */ data?: string, - /** The address of the message sender. */ - sender?: string, - /** The address of the message recipient. */ - recipient?: string, - /** The nonce value for a message. */ - nonce?: string, - /** Current context if in an internal context. null otherwise */ - contractId?: Address.t, - /** The sub id. */ + rb?: bigint, + val?: bigint, subId?: string, - } - - // Unused - in indexer currently - type input = { - txId: string, - blockHeight: int, - inputType: int, - utxoId?: string, - owner?: string, amount?: bigint, assetId?: string, - txPointerBlockHeight?: int, - txPointerTxIndex?: int, - witnessIndex?: int, - predicateGasUsed?: int, - predicate?: string, - predicateData?: string, - balanceRoot?: string, - stateRoot?: string, - contract?: string, - sender?: string, - recipient?: string, - nonce?: string, - data?: string, - } - - // Unused in indexer currently - type output = { - txId: string, - blockHeight: int, - outputType: int, to?: string, - amount?: bigint, - assetId?: string, - inputIndex?: int, - balanceRoot?: string, - stateRoot?: string, - contract?: string, + toAddress?: string, } - // Unused in indexer currently - /** The block header contains metadata about a certain block. */ type block = { - /** String of the header */ id: string, - /** The block height for the data availability layer up to which (inclusive) input messages are processed. */ - daHeight: int, - consensusParametersVersion: int, - stateTransitionBytecodeVersion: int, - /** The number of transactions in the block. */ - transactionsCount: string, - /** The number of receipt messages in the block. */ - messageReceiptCount: string, - /** The merkle root of the transactions in the block. */ - transactionsRoot: string, - messageOutboxRoot: string, - eventInboxRoot: string, - /** The block height. */ height: int, - /** The merkle root of all previous consensus header Stringes (not including this block). */ - prevRoot: string, - /** The timestamp for the block. */ time: int, - /** The String of the serialized application header for this block. */ - applicationHash: string, } } type queryResponseDataTyped = { - transactions: array, receipts: array, blocks: option>, - inputs: array, - outputs: array, } type queryResponseTyped = { @@ -398,11 +212,14 @@ type queryResponseTyped = { data: queryResponseDataTyped, } -@module("@envio-dev/hyperfuel-client") @scope("HyperfuelClient") -external make: cfg => t = "new" -let make = (cfg: cfg) => { - make({...cfg, bearerToken: "3dc856dd-b0ea-494f-b27e-017b8b6b7e07"}) -} +@send +external classNew: (Core.hyperfuelClientCtor, cfg) => t = "new" + +let make = (cfg: cfg) => + Core.getAddon().hyperfuelClient->classNew({ + ...cfg, + bearerToken: "3dc856dd-b0ea-494f-b27e-017b8b6b7e07", + }) @send external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08cb898859..4aaf1e1f48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,9 +48,6 @@ importers: '@elastic/ecs-pino-format': specifier: 1.4.0 version: 1.4.0 - '@envio-dev/hyperfuel-client': - specifier: 1.2.2 - version: 1.2.2 '@fuel-ts/crypto': specifier: 0.96.1 version: 0.96.1 @@ -417,49 +414,6 @@ packages: resolution: {integrity: sha512-eCSBUTgl8KbPyxky8cecDRLCYu2C1oFV4AZ72bEsI+TxXEvaljaL2kgttfzfu7gW+M89eCz55s49uF2t+YMTWA==} engines: {node: '>=10'} - '@envio-dev/hyperfuel-client-darwin-arm64@1.2.2': - resolution: {integrity: sha512-eQyd9kJCIz/4WCTjkjpQg80DA3pdneHP7qhJIVQ2ZG+Jew9o5XDG+uI0Y16AgGzZ6KGmJSJF6wyUaaAjJfbO1Q==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@envio-dev/hyperfuel-client-darwin-x64@1.2.2': - resolution: {integrity: sha512-l7lRMSoyIiIvKZgQPfgqg7H1xnrQ37A8yUp4S2ys47R8f/wSCSrmMaY1u7n6CxVYCpR9fajwy0/356UgwwhVKw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@envio-dev/hyperfuel-client-linux-arm64-gnu@1.2.2': - resolution: {integrity: sha512-kNiC/1fKuXnoSxp8yEsloDw4Ot/mIcNoYYGLl2CipSIpBtSuiBH5nb6eBcxnRZdKOwf5dKZtZ7MVPL9qJocNJw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@envio-dev/hyperfuel-client-linux-x64-gnu@1.2.2': - resolution: {integrity: sha512-XDkvkBG/frS+xiZkJdY4KqOaoAwyxPdi2MysDQgF8NmZdssi32SWch0r4LTqKWLLlCBg9/R55POeXL5UAjg2wQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@envio-dev/hyperfuel-client-linux-x64-musl@1.2.2': - resolution: {integrity: sha512-DKnKJJSwsYtA7YT0EFGhFB5Eqoo42X0l0vZBv4lDuxngEXiiNjeLemXoKQVDzhcbILD7eyXNa5jWUc+2hpmkEg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@envio-dev/hyperfuel-client-win32-x64-msvc@1.2.2': - resolution: {integrity: sha512-SwIgTAVM9QhCFPyHwL+e1yQ6o3paV6q25klESkXw+r/KW9QPhOOyA6Yr8nfnur3uqMTLJHAKHTLUnkyi/Nh7Aw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@envio-dev/hyperfuel-client@1.2.2': - resolution: {integrity: sha512-raKA6DshYSle0sAOHBV1OkSRFMN+Mkz8sFiMmS3k+m5nP6pP56E17CRRePBL5qmR6ZgSEvGOz/44QUiKNkK9Pg==} - engines: {node: '>= 10'} - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -3121,33 +3075,6 @@ snapshots: dependencies: '@elastic/ecs-helpers': 1.1.0 - '@envio-dev/hyperfuel-client-darwin-arm64@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client-darwin-x64@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client-linux-arm64-gnu@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client-linux-x64-gnu@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client-linux-x64-musl@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client-win32-x64-msvc@1.2.2': - optional: true - - '@envio-dev/hyperfuel-client@1.2.2': - optionalDependencies: - '@envio-dev/hyperfuel-client-darwin-arm64': 1.2.2 - '@envio-dev/hyperfuel-client-darwin-x64': 1.2.2 - '@envio-dev/hyperfuel-client-linux-arm64-gnu': 1.2.2 - '@envio-dev/hyperfuel-client-linux-x64-gnu': 1.2.2 - '@envio-dev/hyperfuel-client-linux-x64-musl': 1.2.2 - '@envio-dev/hyperfuel-client-win32-x64-msvc': 1.2.2 - '@esbuild/aix-ppc64@0.27.2': optional: true From c616b875759b9a42af653afe6d7801767bbea370 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 09:23:07 +0000 Subject: [PATCH 02/10] Use ENVIO_API_TOKEN for HyperFuel and drop dead code Pass the user's API token through to the HyperFuel client as the bearer token (mirroring HyperSync) instead of a hardcoded value, and prune the now-unused query/response surface: block-data querying, transaction and input/output selections, and the unused field-option variants. Build the client config through serde so we don't need a direct url dependency. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- Cargo.lock | 1 - packages/cli/Cargo.toml | 1 - packages/cli/src/hyperfuel_source/config.rs | 23 ++-- packages/cli/src/hyperfuel_source/query.rs | 114 ++---------------- packages/envio/src/ChainFetcher.res | 4 +- packages/envio/src/sources/HyperFuel.res | 105 ++-------------- packages/envio/src/sources/HyperFuel.resi | 22 +--- .../envio/src/sources/HyperFuelClient.res | 112 +---------------- .../envio/src/sources/HyperFuelSource.res | 12 +- 9 files changed, 53 insertions(+), 341 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3e16d171b..69ce71bf4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,7 +1873,6 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing-subscriber", - "url", ] [[package]] diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 0b104e2c49..07fb408412 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -47,7 +47,6 @@ fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" hyperfuel-client = "3.2.0" polars-arrow = "0.42" -url = "2" faster-hex = "0.9" ruint = "1" env_logger = "0.11" diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/hyperfuel_source/config.rs index 6be3c4f1d6..cb9f376ecd 100644 --- a/packages/cli/src/hyperfuel_source/config.rs +++ b/packages/cli/src/hyperfuel_source/config.rs @@ -1,7 +1,5 @@ use anyhow::{Context, Result}; use napi_derive::napi; -use std::num::NonZeroU64; -use url::Url; /// Configuration for the HyperFuel client. #[napi(object)] @@ -16,18 +14,19 @@ impl TryFrom for hyperfuel_client::ClientConfig { type Error = anyhow::Error; fn try_from(config: ClientConfig) -> Result { - Ok(Self { - url: Some(Url::parse(&config.url).context("parse hyperfuel url")?), - bearer_token: config.bearer_token, - http_req_timeout_millis: config + // hyperfuel_client::ClientConfig holds a `url::Url`; go through serde so + // it parses the (already validated) endpoint without us taking a direct + // dependency on the url crate. + let json = serde_json::json!({ + "url": config.url, + "bearer_token": config.bearer_token, + "http_req_timeout_millis": config .http_req_timeout_millis .and_then(|v| u64::try_from(v).ok()) - .and_then(NonZeroU64::new), + .filter(|v| *v > 0), // Retries are handled by the indexer, not the binary client. - max_num_retries: Some(0), - retry_backoff_ms: None, - retry_base_ms: None, - retry_ceiling_ms: None, - }) + "max_num_retries": 0, + }); + serde_json::from_value(json).context("build hyperfuel client config") } } diff --git a/packages/cli/src/hyperfuel_source/query.rs b/packages/cli/src/hyperfuel_source/query.rs index fcfcd7136e..c3255f3ca2 100644 --- a/packages/cli/src/hyperfuel_source/query.rs +++ b/packages/cli/src/hyperfuel_source/query.rs @@ -3,9 +3,8 @@ use hyperfuel_client::format::{Hash, Hex}; use hyperfuel_client::net_types; use napi::bindgen_prelude::BigInt; use napi_derive::napi; -use std::collections::BTreeSet; -/// Query for retrieving Fuel data. +/// Query for retrieving Fuel receipts and their blocks. #[napi(object)] #[derive(Default)] pub struct Query { @@ -13,50 +12,15 @@ pub struct Query { #[napi(js_name = "toBlock")] pub to_block_exclusive: Option, pub receipts: Option>, - pub inputs: Option>, - pub outputs: Option>, - pub include_all_blocks: Option, pub field_selection: FieldSelection, - pub max_num_blocks: Option, - pub max_num_transactions: Option, } #[napi(object)] #[derive(Default)] pub struct ReceiptSelection { pub root_contract_id: Option>, - pub to_address: Option>, - pub asset_id: Option>, pub receipt_type: Option>, - pub sender: Option>, - pub recipient: Option>, - pub contract_id: Option>, - pub ra: Option>, pub rb: Option>, - pub rc: Option>, - pub rd: Option>, - pub tx_status: Option>, -} - -#[napi(object)] -#[derive(Default)] -pub struct InputSelection { - pub owner: Option>, - pub asset_id: Option>, - pub contract: Option>, - pub sender: Option>, - pub recipient: Option>, - pub input_type: Option>, - pub tx_status: Option>, -} - -#[napi(object)] -#[derive(Default)] -pub struct OutputSelection { - pub to: Option>, - pub asset_id: Option>, - pub contract: Option>, - pub output_type: Option>, pub tx_status: Option>, } @@ -64,7 +28,6 @@ pub struct OutputSelection { #[derive(Default)] pub struct FieldSelection { pub block: Option>, - pub transaction: Option>, pub receipt: Option>, } @@ -88,51 +51,10 @@ impl TryFrom for net_types::ReceiptSelection { fn try_from(s: ReceiptSelection) -> Result { Ok(net_types::ReceiptSelection { root_contract_id: parse_hashes(s.root_contract_id)?, - to: Vec::new(), - to_address: parse_hashes(s.to_address)?, - asset_id: parse_hashes(s.asset_id)?, receipt_type: s.receipt_type.unwrap_or_default(), - sender: parse_hashes(s.sender)?, - recipient: parse_hashes(s.recipient)?, - contract_id: parse_hashes(s.contract_id)?, - ra: bigints_to_u64(s.ra), rb: bigints_to_u64(s.rb), - rc: bigints_to_u64(s.rc), - rd: bigints_to_u64(s.rd), tx_status: s.tx_status.unwrap_or_default(), - tx_type: Vec::new(), - }) - } -} - -impl TryFrom for net_types::InputSelection { - type Error = anyhow::Error; - - fn try_from(s: InputSelection) -> Result { - Ok(net_types::InputSelection { - owner: parse_hashes(s.owner)?, - asset_id: parse_hashes(s.asset_id)?, - contract: parse_hashes(s.contract)?, - sender: parse_hashes(s.sender)?, - recipient: parse_hashes(s.recipient)?, - input_type: s.input_type.unwrap_or_default(), - tx_status: s.tx_status.unwrap_or_default(), - tx_type: Vec::new(), - }) - } -} - -impl TryFrom for net_types::OutputSelection { - type Error = anyhow::Error; - - fn try_from(s: OutputSelection) -> Result { - Ok(net_types::OutputSelection { - to: parse_hashes(s.to)?, - asset_id: parse_hashes(s.asset_id)?, - contract: parse_hashes(s.contract)?, - output_type: s.output_type.unwrap_or_default(), - tx_status: s.tx_status.unwrap_or_default(), - tx_type: Vec::new(), + ..Default::default() }) } } @@ -141,24 +63,12 @@ impl From for net_types::FieldSelection { fn from(f: FieldSelection) -> Self { net_types::FieldSelection { block: f.block.unwrap_or_default().into_iter().collect(), - transaction: f.transaction.unwrap_or_default().into_iter().collect(), receipt: f.receipt.unwrap_or_default().into_iter().collect(), - input: BTreeSet::new(), - output: BTreeSet::new(), + ..Default::default() } } } -fn try_collect(v: Option>) -> Result> -where - T: TryInto, -{ - v.unwrap_or_default() - .into_iter() - .map(TryInto::try_into) - .collect() -} - impl TryFrom for net_types::Query { type Error = anyhow::Error; @@ -168,21 +78,19 @@ impl TryFrom for net_types::Query { .to_block_exclusive .map(|b| u64::try_from(b).context("toBlock must be >= 0")) .transpose()?; + let receipts = q + .receipts + .unwrap_or_default() + .into_iter() + .map(TryInto::try_into) + .collect::>>()?; Ok(net_types::Query { from_block, to_block, - receipts: try_collect(q.receipts)?, - inputs: try_collect(q.inputs)?, - outputs: try_collect(q.outputs)?, - include_all_blocks: q.include_all_blocks.unwrap_or(false), + receipts, field_selection: q.field_selection.into(), - max_num_blocks: q.max_num_blocks.map(|n| n as usize), - max_num_transactions: q.max_num_transactions.map(|n| n as usize), - max_num_receipts: None, - max_num_inputs: None, - max_num_outputs: None, - join_mode: net_types::JoinMode::Default, + ..Default::default() }) } } diff --git a/packages/envio/src/ChainFetcher.res b/packages/envio/src/ChainFetcher.res index 58a99179e7..721d152183 100644 --- a/packages/envio/src/ChainFetcher.res +++ b/packages/envio/src/ChainFetcher.res @@ -228,7 +228,9 @@ let make = ( ~rpcs=evmRpcs, ~lowercaseAddresses, ) - | Config.FuelSourceConfig({hypersync}) => [HyperFuelSource.make({chain, endpointUrl: hypersync})] + | Config.FuelSourceConfig({hypersync}) => [ + HyperFuelSource.make({chain, endpointUrl: hypersync, apiToken: Env.envioApiToken}), + ] | Config.SvmSourceConfig({rpc}) => [Svm.makeRPCSource(~chain, ~rpc)] // For tests: use ready-to-use sources directly | Config.CustomSources(sources) => sources diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/HyperFuel.res index b64b5911c0..737238ee89 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/HyperFuel.res @@ -5,12 +5,12 @@ module CachedClients = { let cache: dict = Dict.make() - let getClient = url => { - switch cache->Utils.Dict.dangerouslyGetNonOption(url) { + let getClient = (~serverUrl, ~apiToken) => { + switch cache->Utils.Dict.dangerouslyGetNonOption(serverUrl) { | Some(client) => client | None => - let newClient = HyperFuelClient.make({url: url}) - cache->Dict.set(url, newClient) + let newClient = HyperFuelClient.make({url: serverUrl, bearerToken: apiToken}) + cache->Dict.set(serverUrl, newClient) newClient } } @@ -36,29 +36,8 @@ type item = { block: block, } -type blockNumberAndHash = { - blockNumber: int, - hash: string, -} - type logsQueryPage = hyperSyncPage -type missingParams = { - queryName: string, - missingParams: array, -} -type queryError = UnexpectedMissingParams(missingParams) - -let queryErrorToMsq = (e: queryError): string => { - switch e { - | UnexpectedMissingParams({queryName, missingParams}) => - `${queryName} query failed due to unexpected missing params on response: - ${missingParams->Array.joinUnsafe(", ")}` - } -} - -type queryResponse<'a> = result<'a, queryError> - module GetLogs = { type error = | UnexpectedMissingParams({missingParams: array}) @@ -166,14 +145,20 @@ module GetLogs = { page } - let query = async (~serverUrl, ~fromBlock, ~toBlock, ~recieptsSelection): logsQueryPage => { + let query = async ( + ~serverUrl, + ~apiToken, + ~fromBlock, + ~toBlock, + ~recieptsSelection, + ): logsQueryPage => { let query: HyperFuelClient.QueryTypes.query = makeRequestBody( ~fromBlock, ~toBlockInclusive=toBlock, ~recieptsSelection, ) - let hyperFuelClient = CachedClients.getClient(serverUrl) + let hyperFuelClient = CachedClients.getClient(~serverUrl, ~apiToken) let res = await hyperFuelClient->HyperFuelClient.getSelectedData(query) if res.nextBlock <= fromBlock { @@ -184,72 +169,6 @@ module GetLogs = { } } -module BlockData = { - let convertResponse = (res: HyperFuelClient.queryResponseTyped): option< - ReorgDetection.blockDataWithTimestamp, - > => { - res.data.blocks->Option.flatMap(blocks => { - blocks - ->Array.get(0) - ->Option.map(block => { - switch block { - | {height: blockNumber, time: timestamp, id: blockHash} => - ( - { - blockTimestamp: timestamp, - blockNumber, - blockHash, - }: ReorgDetection.blockDataWithTimestamp - ) - } - }) - }) - } - - let rec queryBlockData = async (~serverUrl, ~blockNumber, ~logger): option< - ReorgDetection.blockDataWithTimestamp, - > => { - let query: HyperFuelClient.QueryTypes.query = { - fromBlock: blockNumber, - toBlockExclusive: blockNumber + 1, - // FIXME: Theoretically it should work without the outputs filter, but it doesn't for some reason - outputs: [%raw(`{}`)], - // FIXME: Had to add inputs {} as well, since it failed on block 1211599 during wildcard Call indexing - inputs: [%raw(`{}`)], - fieldSelection: { - block: [Height, Id, Time], - }, - includeAllBlocks: true, - } - - let hyperFuelClient = CachedClients.getClient(serverUrl) - - let logger = Logging.createChildFrom( - ~logger, - ~params={"logType": "hypersync get blockhash query", "blockNumber": blockNumber}, - ) - - let executeQuery = () => hyperFuelClient->HyperFuelClient.getSelectedData(query) - - let res = await executeQuery->Time.retryAsyncWithExponentialBackOff(~logger) - - // If the block is not found, retry the query. This can occur since replicas of hypersync might not hack caught up yet - if res.nextBlock <= blockNumber { - let logger = Logging.createChild(~params={"url": serverUrl}) - let delayMilliseconds = 100 - logger->Logging.childInfo( - `Block #${blockNumber->Int.toString} not found in HyperFuel. HyperFuel has multiple instances and it's possible that they drift independently slightly from the head. Indexing should continue correctly after retrying the query in ${delayMilliseconds->Int.toString}ms.`, - ) - await Time.resolvePromiseAfterDelay(~delayMilliseconds) - await queryBlockData(~serverUrl, ~blockNumber, ~logger) - } else { - res->convertResponse - } - } -} - -let queryBlockData = BlockData.queryBlockData - let heightRoute = Rest.route(() => { path: "/height", method: Get, diff --git a/packages/envio/src/sources/HyperFuel.resi b/packages/envio/src/sources/HyperFuel.resi index 0e6a6cbfbc..b99cd03e1c 100644 --- a/packages/envio/src/sources/HyperFuel.resi +++ b/packages/envio/src/sources/HyperFuel.resi @@ -18,23 +18,8 @@ type item = { block: block, } -type blockNumberAndHash = { - blockNumber: int, - hash: string, -} - type logsQueryPage = hyperSyncPage -type missingParams = { - queryName: string, - missingParams: array, -} -type queryError = UnexpectedMissingParams(missingParams) - -let queryErrorToMsq: queryError => string - -type queryResponse<'a> = result<'a, queryError> - module GetLogs: { type error = | UnexpectedMissingParams({missingParams: array}) @@ -44,16 +29,11 @@ module GetLogs: { let query: ( ~serverUrl: string, + ~apiToken: string, ~fromBlock: int, ~toBlock: option, ~recieptsSelection: array, ) => promise } -let queryBlockData: ( - ~serverUrl: string, - ~blockNumber: int, - ~logger: Pino.t, -) => promise> - let heightRoute: Rest.route diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 81d5419d49..41c63406e6 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -3,115 +3,41 @@ type t type cfg = { url: string, bearerToken?: string, - httpReqTimeoutMillis?: int, } module QueryTypes = { type blockFieldOptions = | @as("id") Id - | @as("da_height") DaHeight - | @as("transactions_count") TransactionsCount - | @as("message_receipt_count") MessageReceiptCount - | @as("transactions_root") TransactionsRoot - | @as("message_receipt_root") MessageReceiptRoot | @as("height") Height - | @as("prev_root") PrevRoot | @as("time") Time - | @as("application_hash") ApplicationHash type blockFieldSelection = array - type transactionFieldOptions = - | @as("id") Id - | @as("block_height") BlockHeight - | @as("input_asset_ids") InputAssetIds - | @as("input_contracts") InputContracts - | @as("input_contract_utxo_id") InputContractUtxoId - | @as("input_contract_balance_root") InputContractBalanceRoot - | @as("input_contract_state_root") InputContractStateRoot - | @as("input_contract_tx_pointer_block_height") InputContractTxPointerBlockHeight - | @as("input_contract_tx_pointer_tx_index") InputContractTxPointerTxIndex - | @as("input_contract") InputContract - | @as("gas_price") GasPrice - | @as("gas_limit") GasLimit - | @as("maturity") Maturity - | @as("mint_amount") MintAmount - | @as("mint_asset_id") MintAssetId - | @as("tx_pointer_block_height") TxPointerBlockHeight - | @as("tx_pointer_tx_index") TxPointerTxIndex - | @as("tx_type") TxType - | @as("output_contract_input_index") OutputContractInputIndex - | @as("output_contract_balance_root") OutputContractBalanceRoot - | @as("output_contract_state_root") OutputContractStateRoot - | @as("witnesses") Witnesses - | @as("receipts_root") ReceiptsRoot - | @as("status") Status - | @as("time") Time - | @as("reason") Reason - | @as("script") Script - | @as("script_data") ScriptData - | @as("bytecode_witness_index") BytecodeWitnessIndex - | @as("bytecode_length") BytecodeLength - | @as("salt") Salt - - type transactionFieldSelection = array - type receiptFieldOptions = | @as("tx_id") TxId - | @as("tx_status") TxStatus | @as("block_height") BlockHeight - | @as("pc") Pc - | @as("is") Is - | @as("to") To | @as("to_address") ToAddress | @as("amount") Amount | @as("asset_id") AssetId - | @as("gas") Gas - | @as("param1") Param1 - | @as("param2") Param2 | @as("val") Val - | @as("ptr") Ptr - | @as("digest") Digest - | @as("reason") Reason - | @as("ra") Ra | @as("rb") Rb - | @as("rc") Rc - | @as("rd") Rd - | @as("len") Len | @as("receipt_type") ReceiptType | @as("receipt_index") ReceiptIndex - | @as("result") Result - | @as("gas_used") GasUsed | @as("data") Data - | @as("sender") Sender - | @as("recipient") Recipient - | @as("nonce") Nonce - | @as("contract_id") ContractId | @as("root_contract_id") RootContractId | @as("sub_id") SubId + | @as("to") To type receiptFieldSelection = array type fieldSelection = { block?: blockFieldSelection, - transaction?: transactionFieldSelection, receipt?: receiptFieldSelection, } - type inputSelection - type outputSelection - type receiptSelection = { rootContractId?: array, - toAddress?: array, - assetId?: array, receiptType?: array, - sender?: array, - recipient?: array, - contractId?: array, - ra?: array, rb?: array, - rc?: array, - rd?: array, txStatus?: array, } @@ -135,36 +61,10 @@ module QueryTypes = { */ receipts?: array, /** - * List of input selections, the query will return inputs that match any of these selections and - * it will return inputs that are related to the returned objects. - */ - inputs?: array, - /** - * List of output selections, the query will return outputs that match any of these selections and - * it will return outputs that are related to the returned objects. - */ - outputs?: array, - /** - * Whether to include all blocks regardless of if they are related to a returned transaction or log. Normally - * the server will return only the blocks that are related to the transaction or logs in the response. But if this - * is set to true, the server will return data for all blocks in the requested range [from_block, to_block). - */ - includeAllBlocks?: bool, - /** * Field selection. The user can select which fields they are interested in, requesting less fields will improve * query execution time and reduce the payload size so the user should always use a minimal number of fields. */ fieldSelection: fieldSelection, - /** - * Maximum number of blocks that should be returned, the server might return more blocks than this number but - * it won't overshoot by too much. - */ - maxNumBlocks?: int, - /** - * Maximum number of transactions that should be returned, the server might return more transactions than this number but - * it won't overshoot by too much. - */ - maxNumTransactions?: int, } } @@ -198,7 +98,7 @@ type queryResponseDataTyped = { } type queryResponseTyped = { - /** Current height of the source hypersync instance */ + /** Current height of the source HyperFuel instance */ archiveHeight?: int, /** * Next block to query for, the responses are paginated so @@ -206,7 +106,7 @@ type queryResponseTyped = { * didn't get responses up to the to_block they specified in the Query. */ nextBlock: int, - /** Total time it took the hypersync instance to execute the query. */ + /** Total time it took the HyperFuel instance to execute the query. */ totalExecutionTime: int, /** Response data */ data: queryResponseDataTyped, @@ -215,11 +115,7 @@ type queryResponseTyped = { @send external classNew: (Core.hyperfuelClientCtor, cfg) => t = "new" -let make = (cfg: cfg) => - Core.getAddon().hyperfuelClient->classNew({ - ...cfg, - bearerToken: "3dc856dd-b0ea-494f-b27e-017b8b6b7e07", - }) +let make = (cfg: cfg) => Core.getAddon().hyperfuelClient->classNew(cfg) @send external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" diff --git a/packages/envio/src/sources/HyperFuelSource.res b/packages/envio/src/sources/HyperFuelSource.res index 02b94a80c7..462026c750 100644 --- a/packages/envio/src/sources/HyperFuelSource.res +++ b/packages/envio/src/sources/HyperFuelSource.res @@ -207,11 +207,20 @@ let memoGetSelectionConfig = (~chain) => { type options = { chain: ChainMap.Chain.t, endpointUrl: string, + apiToken: option, } -let make = ({chain, endpointUrl}: options): t => { +let make = ({chain, endpointUrl, apiToken}: options): t => { let name = "HyperFuel" + let apiToken = switch apiToken { + | Some(token) => token + | None => + JsError.throwWithMessage(`An API token is required for using HyperFuel as a data-source. +Set the ENVIO_API_TOKEN environment variable in your .env file. +Learn more or get a free API token at: https://envio.dev/app/api-tokens`) + } + let getSelectionConfig = memoGetSelectionConfig(~chain) let getItemsOrThrow = async ( @@ -240,6 +249,7 @@ let make = ({chain, endpointUrl}: options): t => { ) let pageUnsafe = try await HyperFuel.GetLogs.query( ~serverUrl=endpointUrl, + ~apiToken, ~fromBlock, ~toBlock, ~recieptsSelection, From 3e60839e1a6735e471e761a3f9451501bcc87551 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:23:34 +0000 Subject: [PATCH 03/10] Harden HyperFuel client error paths and own the HTTP layer The hyperfuel-client crate's Client supports neither custom user agents nor authorized /height requests, so requests are now made directly with reqwest (hyperindex/{version} user agent plus bearer token on both /height and /query/arrow-ipc), with the crate kept for its wire types and capnp/arrow response parsing. - Missing required response columns now surface as a typed MissingFields error (same JSON payload protocol as hypersync_source) which the ReScript side converts to UnexpectedMissingParams, instead of silently defaulting to zero values. - Fix the dead `HyperSync.GetLogs.Error` catch in HyperFuelSource that swallowed WrongInstance and missing-params errors into the generic retry path. - A rejected API token (401) on the height path now logs an actionable ENVIO_API_TOKEN message and blocks instead of retrying, mirroring HyperSync; covered by tests against a local HTTP server. - Reject rb filter values that don't fit u64 instead of truncating. - Drop the now-unused EnvioApiClient module and heightRoute. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- Cargo.lock | 1 + packages/cli/Cargo.toml | 3 +- packages/cli/src/hyperfuel_source/config.rs | 23 -- packages/cli/src/hyperfuel_source/mod.rs | 137 +++++++-- packages/cli/src/hyperfuel_source/parse.rs | 64 ++++ packages/cli/src/hyperfuel_source/query.rs | 12 +- packages/cli/src/hyperfuel_source/types.rs | 280 ++++++++++++++++-- packages/envio/src/sources/EnvioApiClient.res | 15 - packages/envio/src/sources/HyperFuel.res | 46 ++- packages/envio/src/sources/HyperFuel.resi | 2 +- .../envio/src/sources/HyperFuelClient.res | 12 +- .../envio/src/sources/HyperFuelSource.res | 18 +- .../fuel_test/test/HyperFuelHeight_test.res | 75 +++++ 13 files changed, 583 insertions(+), 105 deletions(-) create mode 100644 packages/cli/src/hyperfuel_source/parse.rs delete mode 100644 packages/envio/src/sources/EnvioApiClient.res create mode 100644 scenarios/fuel_test/test/HyperFuelHeight_test.res diff --git a/Cargo.lock b/Cargo.lock index 69ce71bf4b..f5755e9505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1831,6 +1831,7 @@ dependencies = [ "arrayvec", "async-recursion", "bollard", + "capnp", "clap", "clap-markdown", "colored", diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 07fb408412..1c9fb13491 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -46,7 +46,8 @@ thiserror = "1.0.50" fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" hyperfuel-client = "3.2.0" -polars-arrow = "0.42" +capnp = "0.23" +polars-arrow = { version = "0.42", features = ["io_ipc", "io_ipc_compression"] } faster-hex = "0.9" ruint = "1" env_logger = "0.11" diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/hyperfuel_source/config.rs index cb9f376ecd..73d88383be 100644 --- a/packages/cli/src/hyperfuel_source/config.rs +++ b/packages/cli/src/hyperfuel_source/config.rs @@ -1,4 +1,3 @@ -use anyhow::{Context, Result}; use napi_derive::napi; /// Configuration for the HyperFuel client. @@ -7,26 +6,4 @@ use napi_derive::napi; pub struct ClientConfig { pub url: String, pub bearer_token: Option, - pub http_req_timeout_millis: Option, -} - -impl TryFrom for hyperfuel_client::ClientConfig { - type Error = anyhow::Error; - - fn try_from(config: ClientConfig) -> Result { - // hyperfuel_client::ClientConfig holds a `url::Url`; go through serde so - // it parses the (already validated) endpoint without us taking a direct - // dependency on the url crate. - let json = serde_json::json!({ - "url": config.url, - "bearer_token": config.bearer_token, - "http_req_timeout_millis": config - .http_req_timeout_millis - .and_then(|v| u64::try_from(v).ok()) - .filter(|v| *v > 0), - // Retries are handled by the indexer, not the binary client. - "max_num_retries": 0, - }); - serde_json::from_value(json).context("build hyperfuel client config") - } } diff --git a/packages/cli/src/hyperfuel_source/mod.rs b/packages/cli/src/hyperfuel_source/mod.rs index 3cb59fc4e9..ab0d311c5f 100644 --- a/packages/cli/src/hyperfuel_source/mod.rs +++ b/packages/cli/src/hyperfuel_source/mod.rs @@ -1,46 +1,149 @@ -use anyhow::Context; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; use napi_derive::napi; mod config; +mod parse; mod query; mod types; use config::ClientConfig; use query::Query; -use types::{convert_response, QueryResponse}; +use types::{convert_response, ConvertError, QueryResponse}; #[napi] pub struct HyperfuelClient { - inner: hyperfuel_client::Client, + http: reqwest::Client, + url: String, + bearer_token: Option, } #[napi] impl HyperfuelClient { #[napi(factory)] - pub fn new(cfg: ClientConfig) -> napi::Result { - let client_config: hyperfuel_client::ClientConfig = - cfg.try_into().context("build config").map_err(map_err)?; - let inner = hyperfuel_client::Client::new(client_config) - .context("build client") + pub fn new(cfg: ClientConfig, user_agent: String) -> napi::Result { + // The hyperfuel-client crate's Client supports neither custom user + // agents nor authorized /height requests, so HTTP is done here and + // the crate is used only for its wire types and response parsing. + let http = reqwest::Client::builder() + .user_agent(user_agent) + .timeout(Duration::from_secs(30)) + .tcp_keepalive(Duration::from_secs(7200)) + .build() + .context("build http client") + .map_err(map_err)?; + Ok(HyperfuelClient { + http, + url: cfg.url.trim_end_matches('/').to_string(), + bearer_token: cfg.bearer_token, + }) + } + + #[napi] + pub async fn get_height(&self) -> napi::Result { + let res = self + .request(self.http.get(format!("{}/height", self.url))) + .await + .map_err(|e| { + napi::Error::from_reason(format!("Failed to get HyperFuel height: {e}")) + })?; + + #[derive(serde::Deserialize)] + struct ArchiveHeight { + height: Option, + } + let height: ArchiveHeight = res + .json() + .await + .context("read height response json") .map_err(map_err)?; - Ok(HyperfuelClient { inner }) + height + .height + .context("missing height in response") + .map_err(map_err) } #[napi] pub async fn get_selected_data(&self, query: Query) -> napi::Result { let query: hyperfuel_client::net_types::Query = query.try_into().context("parse query").map_err(map_err)?; - let res = self.inner.get_arrow(&query).await.map_err(|e| { - // The client embeds a `{:?}` debug dump in its error message; keep - // only the first line so it stays readable on retries. - let message = format!("{e}"); - let summary = message.lines().next().unwrap_or(message.as_str()); - napi::Error::from_reason(format!("Failed to get data from HyperFuel: {summary}")) - })?; - Ok(convert_response(res)) + let res = self + .request( + self.http + .post(format!("{}/query/arrow-ipc", self.url)) + .json(&query), + ) + .await + .map_err(|e| { + napi::Error::from_reason(format!("Failed to get data from HyperFuel: {e}")) + })?; + + let bytes = res + .bytes() + .await + .context("read response body") + .map_err(map_err)?; + let parsed = tokio::task::spawn_blocking(move || parse::parse_query_response(&bytes)) + .await + .context("join parse task") + .map_err(map_err)? + .context("parse query response") + .map_err(map_err)?; + + convert_response(parsed).map_err(convert_error_to_napi) + } +} + +impl HyperfuelClient { + async fn request(&self, mut req: reqwest::RequestBuilder) -> Result { + if let Some(bearer_token) = &self.bearer_token { + req = req.bearer_auth(bearer_token); + } + let res = req.send().await.context("execute http request")?; + let status = res.status(); + if !status.is_success() { + let body = res.text().await.unwrap_or_default(); + return Err(anyhow!("server responded with status {status}: {body}")); + } + Ok(res) + } +} + +/// Encodes `ConvertError::MissingFields` as a JSON payload in the napi +/// error's message — the same protocol as hypersync_source, which the +/// ReScript side recovers via JSON.parse and a `kind` dispatch. +fn convert_error_to_napi(err: ConvertError) -> napi::Error { + match err { + ConvertError::MissingFields(fields) => { + let payload = serde_json::json!({ + "kind": "MissingFields", + "fields": fields, + }) + .to_string(); + napi::Error::new(napi::Status::InvalidArg, payload) + } + ConvertError::Other(e) => map_err(e), } } fn map_err(e: anyhow::Error) -> napi::Error { napi::Error::from_reason(format!("{:?}", e)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn convert_error_serializes_as_expected_json() { + let err = + ConvertError::MissingFields(vec!["receipt.txId".to_string(), "block.time".to_string()]); + let napi_err = convert_error_to_napi(err); + let parsed: serde_json::Value = + serde_json::from_str(&napi_err.reason).expect("payload must be JSON"); + assert_eq!(parsed["kind"], "MissingFields"); + assert_eq!(parsed["fields"][0], "receipt.txId"); + assert_eq!(parsed["fields"][1], "block.time"); + } +} diff --git a/packages/cli/src/hyperfuel_source/parse.rs b/packages/cli/src/hyperfuel_source/parse.rs new file mode 100644 index 0000000000..659bca9852 --- /dev/null +++ b/packages/cli/src/hyperfuel_source/parse.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hyperfuel_client::net_types::hyperfuel_net_types_capnp; +use hyperfuel_client::ArrowBatch; +use polars_arrow::io::ipc; + +pub struct ParsedResponse { + pub archive_height: Option, + pub next_block: u64, + pub total_execution_time: u64, + pub receipts: Vec, + pub blocks: Vec, +} + +fn read_chunks(bytes: &[u8]) -> Result> { + let mut reader = std::io::Cursor::new(bytes); + + let metadata = ipc::read::read_file_metadata(&mut reader).context("read metadata")?; + let schema = metadata.schema.clone(); + let reader = ipc::read::FileReader::new(reader, metadata, None, None); + + reader + .map(|chunk| { + chunk.context("read chunk").map(|chunk| ArrowBatch { + chunk: Arc::new(chunk), + schema: schema.clone(), + }) + }) + .collect() +} + +pub fn parse_query_response(bytes: &[u8]) -> Result { + let mut opts = capnp::message::ReaderOptions::new(); + // Bounded limits for untrusted network input; the traversal cap is raised + // to 512 MiB (64M words) to fit large paginated arrow payloads. + opts.nesting_limit(64) + .traversal_limit_in_words(Some(64 * 1024 * 1024)); + let message_reader = + capnp::serialize_packed::read_message(bytes, opts).context("create message reader")?; + + let query_response = message_reader + .get_root::() + .context("get root")?; + + let archive_height = match query_response.get_archive_height() { + -1 => None, + h => Some(h), + }; + + let data = query_response.get_data().context("read data")?; + let receipts = + read_chunks(data.get_receipts().context("get receipts")?).context("parse receipt data")?; + let blocks = + read_chunks(data.get_blocks().context("get blocks")?).context("parse block data")?; + + Ok(ParsedResponse { + archive_height, + next_block: query_response.get_next_block(), + total_execution_time: query_response.get_total_execution_time(), + receipts, + blocks, + }) +} diff --git a/packages/cli/src/hyperfuel_source/query.rs b/packages/cli/src/hyperfuel_source/query.rs index c3255f3ca2..997be45c26 100644 --- a/packages/cli/src/hyperfuel_source/query.rs +++ b/packages/cli/src/hyperfuel_source/query.rs @@ -38,10 +38,16 @@ fn parse_hashes(v: Option>) -> Result> { .collect() } -fn bigints_to_u64(v: Option>) -> Vec { +fn bigints_to_u64(v: Option>) -> Result> { v.unwrap_or_default() .into_iter() - .map(|b| b.get_u64().1) + .map(|b| { + let (sign_bit, value, lossless) = b.get_u64(); + if sign_bit || !lossless { + anyhow::bail!("rb filter value must be an unsigned 64-bit integer"); + } + Ok(value) + }) .collect() } @@ -52,7 +58,7 @@ impl TryFrom for net_types::ReceiptSelection { Ok(net_types::ReceiptSelection { root_contract_id: parse_hashes(s.root_contract_id)?, receipt_type: s.receipt_type.unwrap_or_default(), - rb: bigints_to_u64(s.rb), + rb: bigints_to_u64(s.rb).context("parse rb filter")?, tx_status: s.tx_status.unwrap_or_default(), ..Default::default() }) diff --git a/packages/cli/src/hyperfuel_source/types.rs b/packages/cli/src/hyperfuel_source/types.rs index b9729125be..1e94aedea2 100644 --- a/packages/cli/src/hyperfuel_source/types.rs +++ b/packages/cli/src/hyperfuel_source/types.rs @@ -1,8 +1,11 @@ -use hyperfuel_client::{ArrowBatch, ArrowResponse}; +use anyhow::{Context, Result}; +use hyperfuel_client::ArrowBatch; use napi::bindgen_prelude::BigInt; use napi_derive::napi; use polars_arrow::array::{BinaryArray, Int64Array, StaticArray, UInt64Array, UInt8Array}; +use crate::hyperfuel_source::parse::ParsedResponse; + #[napi(object)] pub struct QueryResponse { pub archive_height: Option, @@ -14,7 +17,7 @@ pub struct QueryResponse { #[napi(object)] pub struct QueryResponseData { pub receipts: Vec, - pub blocks: Option>, + pub blocks: Vec, } #[napi(object)] @@ -41,6 +44,21 @@ pub struct Block { pub time: i64, } +/// `MissingFields` is the shape the JS side recognizes (via the JSON payload +/// protocol shared with hypersync_source) and converts to +/// `UnexpectedMissingParams`; `Other` falls through to the generic napi error. +#[derive(Debug)] +pub(crate) enum ConvertError { + MissingFields(Vec), + Other(anyhow::Error), +} + +impl From for ConvertError { + fn from(e: anyhow::Error) -> Self { + Self::Other(e) + } +} + fn encode_hex(bytes: &[u8]) -> String { format!("0x{}", faster_hex::hex_string(bytes)) } @@ -53,19 +71,17 @@ fn u64_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { arr.and_then(|a| a.get(idx)) } -fn u8_at(arr: &Option<&UInt8Array>, idx: usize) -> Option { - arr.and_then(|a| a.get(idx)) -} - -fn i64_at(arr: &Option<&Int64Array>, idx: usize) -> Option { - arr.and_then(|a| a.get(idx)) -} - fn bigint_at(arr: &Option<&UInt64Array>, idx: usize) -> Option { u64_at(arr, idx).map(BigInt::from) } -fn receipts_from_arrow(batches: &[ArrowBatch]) -> Vec { +fn i64_field(arr: &Option<&UInt64Array>, idx: usize, name: &str) -> Result> { + u64_at(arr, idx) + .map(|v| v.try_into().with_context(|| format!("{name} overflow"))) + .transpose() +} + +fn receipts_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { let mut out = Vec::new(); for batch in batches { let receipt_index = batch.column::("receipt_index").ok(); @@ -83,12 +99,35 @@ fn receipts_from_arrow(batches: &[ArrowBatch]) -> Vec { let to_address = batch.column::>("to_address").ok(); for idx in 0..batch.chunk.len() { + let mut missing: Vec = Vec::new(); + let receipt_index_val = i64_field(&receipt_index, idx, "receipt.receiptIndex")? + .or_else(|| { + missing.push("receipt.receiptIndex".into()); + None + }); + let tx_id_val = hex_at(&tx_id, idx).or_else(|| { + missing.push("receipt.txId".into()); + None + }); + let block_height_val = + i64_field(&block_height, idx, "receipt.blockHeight")?.or_else(|| { + missing.push("receipt.blockHeight".into()); + None + }); + let receipt_type_val = receipt_type.and_then(|a| a.get(idx)).or_else(|| { + missing.push("receipt.receiptType".into()); + None + }); + if !missing.is_empty() { + return Err(ConvertError::MissingFields(missing)); + } + out.push(Receipt { - receipt_index: u64_at(&receipt_index, idx).unwrap_or_default() as i64, + receipt_index: receipt_index_val.unwrap(), root_contract_id: hex_at(&root_contract_id, idx), - tx_id: hex_at(&tx_id, idx).unwrap_or_else(|| "0x".to_string()), - block_height: u64_at(&block_height, idx).unwrap_or_default() as i64, - receipt_type: u8_at(&receipt_type, idx).unwrap_or_default() as i64, + tx_id: tx_id_val.unwrap(), + block_height: block_height_val.unwrap(), + receipt_type: receipt_type_val.unwrap() as i64, data: hex_at(&data, idx), rb: bigint_at(&rb, idx), val: bigint_at(&val, idx), @@ -100,10 +139,10 @@ fn receipts_from_arrow(batches: &[ArrowBatch]) -> Vec { }); } } - out + Ok(out) } -fn blocks_from_arrow(batches: &[ArrowBatch]) -> Vec { +fn blocks_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> { let mut out = Vec::new(); for batch in batches { let id = batch.column::>("id").ok(); @@ -111,24 +150,209 @@ fn blocks_from_arrow(batches: &[ArrowBatch]) -> Vec { let time = batch.column::("time").ok(); for idx in 0..batch.chunk.len() { + let mut missing: Vec = Vec::new(); + let id_val = hex_at(&id, idx).or_else(|| { + missing.push("block.id".into()); + None + }); + let height_val = i64_field(&height, idx, "block.height")?.or_else(|| { + missing.push("block.height".into()); + None + }); + let time_val = time.and_then(|a| a.get(idx)).or_else(|| { + missing.push("block.time".into()); + None + }); + if !missing.is_empty() { + return Err(ConvertError::MissingFields(missing)); + } + out.push(Block { - id: hex_at(&id, idx).unwrap_or_else(|| "0x".to_string()), - height: u64_at(&height, idx).unwrap_or_default() as i64, - time: i64_at(&time, idx).unwrap_or_default(), + id: id_val.unwrap(), + height: height_val.unwrap(), + time: time_val.unwrap(), }); } } - out + Ok(out) } -pub(crate) fn convert_response(res: ArrowResponse) -> QueryResponse { - QueryResponse { - archive_height: res.archive_height.map(|h| h as i64), - next_block: res.next_block as i64, - total_execution_time: res.total_execution_time as i64, +pub(crate) fn convert_response(res: ParsedResponse) -> Result { + Ok(QueryResponse { + archive_height: res.archive_height, + next_block: res + .next_block + .try_into() + .context("convert next_block") + .map_err(ConvertError::Other)?, + total_execution_time: res + .total_execution_time + .try_into() + .context("convert total_execution_time") + .map_err(ConvertError::Other)?, data: QueryResponseData { - receipts: receipts_from_arrow(&res.data.receipts), - blocks: Some(blocks_from_arrow(&res.data.blocks)), + receipts: receipts_from_arrow(&res.receipts)?, + blocks: blocks_from_arrow(&res.blocks)?, }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use polars_arrow::array::Array; + use polars_arrow::datatypes::{ArrowDataType, ArrowSchema, Field}; + use polars_arrow::record_batch::RecordBatchT; + use std::sync::Arc; + + fn make_batch(fields: Vec<(Field, Box)>) -> ArrowBatch { + let (schema_fields, arrays): (Vec<_>, Vec<_>) = fields.into_iter().unzip(); + ArrowBatch { + chunk: Arc::new(RecordBatchT::new(arrays)), + schema: Arc::new(ArrowSchema::from(schema_fields)), + } + } + + fn binary_field(name: &str, values: Vec>) -> (Field, Box) { + ( + Field::new(name, ArrowDataType::Binary, true), + Box::new(BinaryArray::::from_iter(values.into_iter())), + ) + } + + fn u64_field(name: &str, values: Vec>) -> (Field, Box) { + ( + Field::new(name, ArrowDataType::UInt64, true), + Box::new(UInt64Array::from(values)), + ) + } + + fn u8_field(name: &str, values: Vec>) -> (Field, Box) { + ( + Field::new(name, ArrowDataType::UInt8, true), + Box::new(UInt8Array::from(values)), + ) + } + + fn i64_field(name: &str, values: Vec>) -> (Field, Box) { + ( + Field::new(name, ArrowDataType::Int64, true), + Box::new(Int64Array::from(values)), + ) + } + + fn full_receipt_batch() -> ArrowBatch { + make_batch(vec![ + u64_field("receipt_index", vec![Some(1)]), + binary_field("tx_id", vec![Some(&[0xab; 32])]), + u64_field("block_height", vec![Some(42)]), + u8_field("receipt_type", vec![Some(6)]), + binary_field("root_contract_id", vec![Some(&[0xcd; 32])]), + binary_field("data", vec![Some(&[0x01, 0x02])]), + u64_field("rb", vec![Some(7)]), + ]) + } + + #[test] + fn converts_receipts_with_optional_columns_absent() { + let receipts = receipts_from_arrow(&[full_receipt_batch()]).unwrap(); + assert_eq!(receipts.len(), 1); + let r = &receipts[0]; + assert_eq!( + ( + r.receipt_index, + r.tx_id.as_str(), + r.block_height, + r.receipt_type, + r.root_contract_id.as_deref(), + r.data.as_deref(), + r.rb.as_ref().map(|b| b.get_u64().1), + r.val.as_ref().map(|b| b.get_u64().1), + ), + ( + 1, + format!("0x{}", "ab".repeat(32)).as_str(), + 42, + 6, + Some(format!("0x{}", "cd".repeat(32)).as_str()), + Some("0x0102"), + Some(7), + None, + ) + ); + } + + #[test] + fn missing_required_receipt_column_is_typed_error() { + // tx_id column not in the response at all + let batch = make_batch(vec![ + u64_field("receipt_index", vec![Some(1)]), + u64_field("block_height", vec![Some(42)]), + u8_field("receipt_type", vec![Some(6)]), + ]); + match receipts_from_arrow(&[batch]) { + Err(ConvertError::MissingFields(fields)) => { + assert_eq!(fields, vec!["receipt.txId".to_string()]) + } + Err(ConvertError::Other(e)) => panic!("unexpected ConvertError::Other: {e:?}"), + Ok(_) => panic!("expected MissingFields, got Ok"), + } + } + + #[test] + fn null_required_receipt_value_is_typed_error() { + let batch = make_batch(vec![ + u64_field("receipt_index", vec![None]), + binary_field("tx_id", vec![None]), + u64_field("block_height", vec![Some(42)]), + u8_field("receipt_type", vec![Some(6)]), + ]); + match receipts_from_arrow(&[batch]) { + Err(ConvertError::MissingFields(fields)) => assert_eq!( + fields, + vec![ + "receipt.receiptIndex".to_string(), + "receipt.txId".to_string() + ] + ), + Err(ConvertError::Other(e)) => panic!("unexpected ConvertError::Other: {e:?}"), + Ok(_) => panic!("expected MissingFields, got Ok"), + } + } + + #[test] + fn missing_block_time_is_typed_error() { + let batch = make_batch(vec![ + binary_field("id", vec![Some(&[0xee; 32])]), + u64_field("height", vec![Some(42)]), + ]); + match blocks_from_arrow(&[batch]) { + Err(ConvertError::MissingFields(fields)) => { + assert_eq!(fields, vec!["block.time".to_string()]) + } + Err(ConvertError::Other(e)) => panic!("unexpected ConvertError::Other: {e:?}"), + Ok(_) => panic!("expected MissingFields, got Ok"), + } + } + + #[test] + fn converts_blocks() { + let batch = make_batch(vec![ + binary_field("id", vec![Some(&[0xee; 32])]), + u64_field("height", vec![Some(42)]), + i64_field("time", vec![Some(1745179292)]), + ]); + let blocks = blocks_from_arrow(&[batch]).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!( + (blocks[0].id.as_str(), blocks[0].height, blocks[0].time), + (format!("0x{}", "ee".repeat(32)).as_str(), 42, 1745179292i64) + ); + } + + #[test] + fn empty_batches_convert_to_empty() { + assert_eq!(receipts_from_arrow(&[]).unwrap().len(), 0); + assert_eq!(blocks_from_arrow(&[]).unwrap().len(), 0); } } diff --git a/packages/envio/src/sources/EnvioApiClient.res b/packages/envio/src/sources/EnvioApiClient.res deleted file mode 100644 index 79626d8f39..0000000000 --- a/packages/envio/src/sources/EnvioApiClient.res +++ /dev/null @@ -1,15 +0,0 @@ -// Rest client for envio's own REST endpoints (e.g. the HyperSync/HyperFuel -// /height poll). Tags requests with the hyperindex User-Agent so they're -// attributable on the server, mirroring the SSE height stream and the Rust -// data-query client which already set it. -let make = (baseUrl: string): Rest.client => { - let userAgent = `hyperindex/${Utils.EnvioPackage.value.version}` - Rest.client(baseUrl, ~fetcher=(args: Rest.ApiFetcher.args) => { - let headers = switch args.headers { - | Some(headers) => headers - | None => Dict.make() - } - headers->Dict.set("User-Agent", userAgent->(Utils.magic: string => unknown)) - Rest.ApiFetcher.default({...args, headers: Some(headers)}) - }) -} diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/HyperFuel.res index 737238ee89..06e705e66c 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/HyperFuel.res @@ -45,6 +45,31 @@ module GetLogs = { exception Error(error) + // Rust encodes structured failures as a JSON payload in the napi error's + // message: `{"kind":"MissingFields","fields":["receipt.txId", ...]}`. + // JSON.parse + shape check is the recovery protocol — no string-grepping + // on anyhow's Debug format. + let extractMissingParams = (exn: exn): option> => { + let message = switch exn { + | JsExn(jsExn) => jsExn->JsExn.message + | _ => None + } + switch message { + | None => None + | Some(msg) => + switch msg->JSON.parseOrThrow->JSON.Decode.object { + | exception _ => None + | None => None + | Some(obj) => + switch (obj->Dict.get("kind"), obj->Dict.get("fields")) { + | (Some(String("MissingFields")), Some(Array(fields))) => + Some(fields->Array.filterMap(JSON.Decode.string)) + | _ => None + } + } + } + } + let makeRequestBody = ( ~fromBlock, ~toBlockInclusive, @@ -100,9 +125,7 @@ module GetLogs = { let {receipts, blocks} = response_data let blocksDict = Dict.make() - blocks - ->(Utils.magic: option<'a> => 'a) - ->Array.forEach(block => { + blocks->Array.forEach(block => { blocksDict->Dict.set(block.height->(Utils.magic: int => string), block) }) @@ -160,7 +183,14 @@ module GetLogs = { let hyperFuelClient = CachedClients.getClient(~serverUrl, ~apiToken) - let res = await hyperFuelClient->HyperFuelClient.getSelectedData(query) + let res = switch await hyperFuelClient->HyperFuelClient.getSelectedData(query) { + | res => res + | exception exn => + switch exn->extractMissingParams { + | Some(missingParams) => throw(Error(UnexpectedMissingParams({missingParams: missingParams}))) + | None => throw(exn) + } + } if res.nextBlock <= fromBlock { // Might happen when /height response was from another instance of HyperSync throw(Error(WrongInstance)) @@ -169,9 +199,5 @@ module GetLogs = { } } -let heightRoute = Rest.route(() => { - path: "/height", - method: Get, - input: _ => (), - responses: [s => s.field("height", S.int)], -}) +let getHeight = (~serverUrl, ~apiToken) => + CachedClients.getClient(~serverUrl, ~apiToken)->HyperFuelClient.getHeight diff --git a/packages/envio/src/sources/HyperFuel.resi b/packages/envio/src/sources/HyperFuel.resi index b99cd03e1c..803f2f75f8 100644 --- a/packages/envio/src/sources/HyperFuel.resi +++ b/packages/envio/src/sources/HyperFuel.resi @@ -36,4 +36,4 @@ module GetLogs: { ) => promise } -let heightRoute: Rest.route +let getHeight: (~serverUrl: string, ~apiToken: string) => promise diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 41c63406e6..39ab93e209 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -94,7 +94,7 @@ module FuelTypes = { type queryResponseDataTyped = { receipts: array, - blocks: option>, + blocks: array, } type queryResponseTyped = { @@ -113,9 +113,15 @@ type queryResponseTyped = { } @send -external classNew: (Core.hyperfuelClientCtor, cfg) => t = "new" +external classNew: (Core.hyperfuelClientCtor, cfg, ~userAgent: string) => t = "new" -let make = (cfg: cfg) => Core.getAddon().hyperfuelClient->classNew(cfg) +let make = (cfg: cfg) => { + let envioVersion = Utils.EnvioPackage.value.version + Core.getAddon().hyperfuelClient->classNew(cfg, ~userAgent=`hyperindex/${envioVersion}`) +} @send external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" + +@send +external getHeight: t => promise = "getHeight" diff --git a/packages/envio/src/sources/HyperFuelSource.res b/packages/envio/src/sources/HyperFuelSource.res index 462026c750..b250b9502a 100644 --- a/packages/envio/src/sources/HyperFuelSource.res +++ b/packages/envio/src/sources/HyperFuelSource.res @@ -2,6 +2,8 @@ open Source exception EventRoutingFailed +let isUnauthorizedError = (message: string) => message->String.includes("401 Unauthorized") + let mintEventTag = "mint" let burnEventTag = "burn" let transferEventTag = "transfer" @@ -254,7 +256,7 @@ Learn more or get a free API token at: https://envio.dev/app/api-tokens`) ~toBlock, ~recieptsSelection, ) catch { - | HyperSync.GetLogs.Error(error) => + | HyperFuel.GetLogs.Error(error) => throw( Source.GetItemsError( Source.FailedGettingItems({ @@ -459,8 +461,6 @@ Learn more or get a free API token at: https://envio.dev/app/api-tokens`) let getBlockHashes = (~blockNumbers as _, ~logger as _) => JsError.throwWithMessage("HyperFuel does not support getting block hashes") - let jsonApiClient = EnvioApiClient.make(endpointUrl) - { name, sourceFor: Sync, @@ -470,7 +470,17 @@ Learn more or get a free API token at: https://envio.dev/app/api-tokens`) poweredByHyperSync: true, getHeightOrThrow: async () => { let timerRef = Hrtime.makeTimer() - let height = await HyperFuel.heightRoute->Rest.fetch((), ~client=jsonApiClient) + let height = try await HyperFuel.getHeight(~serverUrl=endpointUrl, ~apiToken) catch { + | JsExn(e) => + switch e->JsExn.message { + | Some(message) if message->isUnauthorizedError => + Logging.error(`Your ENVIO_API_TOKEN was rejected by HyperFuel (401 Unauthorized). The indexer will not be able to fetch events. Update the token and try again using 'envio start' or 'envio dev'. For more info: https://docs.envio.dev/docs/HyperSync/api-tokens`) + // Retrying an unauthorized request can never succeed, so block forever + let _ = await Promise.make((_, _) => ()) + 0 + | _ => throw(JsExn(e)) + } + } let seconds = timerRef->Hrtime.timeSince->Hrtime.toSecondsFloat Prometheus.SourceRequestCount.increment( ~sourceName=name, diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res new file mode 100644 index 0000000000..1c32a17557 --- /dev/null +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -0,0 +1,75 @@ +open Vitest + +// For Logging.setLogger call +let _ = Env.logStrategy + +type server +type req = {headers: dict} +type res + +@module("node:http") +external createServer: ((req, res) => unit) => server = "createServer" +@send external listen: (server, int, unit => unit) => unit = "listen" +@send external close: server => unit = "close" +@send external address: server => {"port": int} = "address" +@send external writeHead: (res, int) => unit = "writeHead" +@send external endWith: (res, string) => unit = "end" + +let startServer = async handler => { + let server = createServer(handler) + await Promise.make((resolve, _) => server->listen(0, () => resolve())) + let port = (server->address)["port"] + (server, `http://127.0.0.1:${port->Int.toString}`) +} + +describe("HyperFuelSource - getHeightOrThrow", () => { + let chain = ChainMap.Chain.makeUnsafe(~chainId=0) + + Async.it("Requests height via the client with auth and user agent headers", async t => { + let capturedHeaders = ref(None) + let (server, endpointUrl) = await startServer((req, res) => { + capturedHeaders := Some(req.headers) + res->writeHead(200) + res->endWith(`{"height": 123}`) + }) + + let source = HyperFuelSource.make({ + chain, + endpointUrl, + apiToken: Some("test-token"), + }) + let height = await source.getHeightOrThrow() + server->close + + let headers = capturedHeaders.contents->Option.getOrThrow + t.expect(( + height, + headers->Dict.get("authorization"), + headers->Dict.get("user-agent"), + )).toEqual(( + 123, + Some("Bearer test-token"), + Some(`hyperindex/${Utils.EnvioPackage.value.version}`), + )) + }) + + Async.it("Blocks forever on 401 instead of throwing for a retry", async t => { + let (server, endpointUrl) = await startServer((_req, res) => { + res->writeHead(401) + res->endWith("Unauthorized") + }) + + let source = HyperFuelSource.make({ + chain, + endpointUrl, + apiToken: Some("rejected-token"), + }) + let result = await Promise.race([ + source.getHeightOrThrow()->Promise.thenResolve(_ => "resolved"), + Time.resolvePromiseAfterDelay(~delayMilliseconds=300)->Promise.thenResolve(() => "blocked"), + ]) + server->close + + t.expect(result).toEqual("blocked") + }) +}) From 84d0808c62dc19bcc9e2da2ae53e41037dffbbd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:16:56 +0000 Subject: [PATCH 04/10] Guarantee temp server cleanup in HyperFuel height test Close the server in both the success and failure paths so a failing assertion can't leak a listener and destabilize later test runs. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- .../fuel_test/test/HyperFuelHeight_test.res | 77 +++++++++++-------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res index 1c32a17557..290f654cb9 100644 --- a/scenarios/fuel_test/test/HyperFuelHeight_test.res +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -22,54 +22,67 @@ let startServer = async handler => { (server, `http://127.0.0.1:${port->Int.toString}`) } +// Guarantees the temp server is closed even if the body throws, so a failing +// assertion can't leak a listener and hang/cross-talk with later tests. +let withServer = async (handler, body) => { + let (server, endpointUrl) = await startServer(handler) + try { + let result = await body(endpointUrl) + server->close + result + } catch { + | exn => + server->close + throw(exn) + } +} + describe("HyperFuelSource - getHeightOrThrow", () => { let chain = ChainMap.Chain.makeUnsafe(~chainId=0) Async.it("Requests height via the client with auth and user agent headers", async t => { let capturedHeaders = ref(None) - let (server, endpointUrl) = await startServer((req, res) => { + await withServer((req, res) => { capturedHeaders := Some(req.headers) res->writeHead(200) res->endWith(`{"height": 123}`) - }) + }, async endpointUrl => { + let source = HyperFuelSource.make({ + chain, + endpointUrl, + apiToken: Some("test-token"), + }) + let height = await source.getHeightOrThrow() - let source = HyperFuelSource.make({ - chain, - endpointUrl, - apiToken: Some("test-token"), + let headers = capturedHeaders.contents->Option.getOrThrow + t.expect(( + height, + headers->Dict.get("authorization"), + headers->Dict.get("user-agent"), + )).toEqual(( + 123, + Some("Bearer test-token"), + Some(`hyperindex/${Utils.EnvioPackage.value.version}`), + )) }) - let height = await source.getHeightOrThrow() - server->close - - let headers = capturedHeaders.contents->Option.getOrThrow - t.expect(( - height, - headers->Dict.get("authorization"), - headers->Dict.get("user-agent"), - )).toEqual(( - 123, - Some("Bearer test-token"), - Some(`hyperindex/${Utils.EnvioPackage.value.version}`), - )) }) Async.it("Blocks forever on 401 instead of throwing for a retry", async t => { - let (server, endpointUrl) = await startServer((_req, res) => { + await withServer((_req, res) => { res->writeHead(401) res->endWith("Unauthorized") - }) + }, async endpointUrl => { + let source = HyperFuelSource.make({ + chain, + endpointUrl, + apiToken: Some("rejected-token"), + }) + let result = await Promise.race([ + source.getHeightOrThrow()->Promise.thenResolve(_ => "resolved"), + Time.resolvePromiseAfterDelay(~delayMilliseconds=300)->Promise.thenResolve(() => "blocked"), + ]) - let source = HyperFuelSource.make({ - chain, - endpointUrl, - apiToken: Some("rejected-token"), + t.expect(result).toEqual("blocked") }) - let result = await Promise.race([ - source.getHeightOrThrow()->Promise.thenResolve(_ => "resolved"), - Time.resolvePromiseAfterDelay(~delayMilliseconds=300)->Promise.thenResolve(() => "blocked"), - ]) - server->close - - t.expect(result).toEqual("blocked") }) }) From 682096b68f2ae0f0a37c1c1f81dbc548cbeed420 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:40:04 +0000 Subject: [PATCH 05/10] Use hyperfuel-client's Client instead of a hand-rolled HTTP layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go back to driving queries and height checks through hyperfuel_client::Client (get_arrow + get_height), which handles HTTP, auth, and Cap'n Proto/Arrow decoding internally. This drops the direct capnp dependency and the parse.rs envelope decoder, keeping capnp purely transitive — matching how the Hypersync client is wired. The only thing lost is the custom hyperindex/{version} user-agent, which the crate's Client can't set and the previous JS client never sent. Bearer auth still applies to both /height and /query, so the 401 handling and header test are unaffected. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- Cargo.lock | 1 - packages/cli/Cargo.toml | 1 - packages/cli/src/hyperfuel_source/config.rs | 18 +++ packages/cli/src/hyperfuel_source/mod.rs | 103 ++++-------------- packages/cli/src/hyperfuel_source/parse.rs | 64 ----------- packages/cli/src/hyperfuel_source/types.rs | 17 +-- .../envio/src/sources/HyperFuelClient.res | 7 +- .../fuel_test/test/HyperFuelHeight_test.res | 9 +- 8 files changed, 54 insertions(+), 166 deletions(-) delete mode 100644 packages/cli/src/hyperfuel_source/parse.rs diff --git a/Cargo.lock b/Cargo.lock index f5755e9505..69ce71bf4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1831,7 +1831,6 @@ dependencies = [ "arrayvec", "async-recursion", "bollard", - "capnp", "clap", "clap-markdown", "colored", diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 20db9a882d..263a19d59f 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -46,7 +46,6 @@ thiserror = "1.0.50" fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" hyperfuel-client = "3.2.0" -capnp = "0.23" polars-arrow = { version = "0.42", features = ["io_ipc", "io_ipc_compression"] } faster-hex = "0.9" ruint = "1" diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/hyperfuel_source/config.rs index 73d88383be..476820e519 100644 --- a/packages/cli/src/hyperfuel_source/config.rs +++ b/packages/cli/src/hyperfuel_source/config.rs @@ -1,3 +1,4 @@ +use anyhow::{Context, Result}; use napi_derive::napi; /// Configuration for the HyperFuel client. @@ -7,3 +8,20 @@ pub struct ClientConfig { pub url: String, pub bearer_token: Option, } + +impl TryFrom for hyperfuel_client::ClientConfig { + type Error = anyhow::Error; + + fn try_from(config: ClientConfig) -> Result { + // hyperfuel_client::ClientConfig holds a `url::Url`; go through serde so + // it parses the (already validated) endpoint without us taking a direct + // dependency on the url crate. + let json = serde_json::json!({ + "url": config.url, + "bearer_token": config.bearer_token, + // Retries are handled by the indexer, not the binary client. + "max_num_retries": 0, + }); + serde_json::from_value(json).context("build hyperfuel client config") + } +} diff --git a/packages/cli/src/hyperfuel_source/mod.rs b/packages/cli/src/hyperfuel_source/mod.rs index ab0d311c5f..db18010308 100644 --- a/packages/cli/src/hyperfuel_source/mod.rs +++ b/packages/cli/src/hyperfuel_source/mod.rs @@ -1,10 +1,7 @@ -use std::time::Duration; - -use anyhow::{anyhow, Context, Result}; +use anyhow::Context; use napi_derive::napi; mod config; -mod parse; mod query; mod types; @@ -14,99 +11,43 @@ use types::{convert_response, ConvertError, QueryResponse}; #[napi] pub struct HyperfuelClient { - http: reqwest::Client, - url: String, - bearer_token: Option, + inner: hyperfuel_client::Client, } #[napi] impl HyperfuelClient { #[napi(factory)] - pub fn new(cfg: ClientConfig, user_agent: String) -> napi::Result { - // The hyperfuel-client crate's Client supports neither custom user - // agents nor authorized /height requests, so HTTP is done here and - // the crate is used only for its wire types and response parsing. - let http = reqwest::Client::builder() - .user_agent(user_agent) - .timeout(Duration::from_secs(30)) - .tcp_keepalive(Duration::from_secs(7200)) - .build() - .context("build http client") + pub fn new(cfg: ClientConfig) -> napi::Result { + let client_config: hyperfuel_client::ClientConfig = + cfg.try_into().context("build config").map_err(map_err)?; + let inner = hyperfuel_client::Client::new(client_config) + .context("build client") .map_err(map_err)?; - Ok(HyperfuelClient { - http, - url: cfg.url.trim_end_matches('/').to_string(), - bearer_token: cfg.bearer_token, - }) + Ok(HyperfuelClient { inner }) } #[napi] pub async fn get_height(&self) -> napi::Result { - let res = self - .request(self.http.get(format!("{}/height", self.url))) - .await - .map_err(|e| { - napi::Error::from_reason(format!("Failed to get HyperFuel height: {e}")) - })?; - - #[derive(serde::Deserialize)] - struct ArchiveHeight { - height: Option, - } - let height: ArchiveHeight = res - .json() - .await - .context("read height response json") - .map_err(map_err)?; - height - .height - .context("missing height in response") - .map_err(map_err) + let height = self.inner.get_height().await.map_err(|e| { + // The client embeds a `{:?}` debug dump in its error message; keep + // only the first line so it stays readable on retries. + let message = format!("{e}"); + let summary = message.lines().next().unwrap_or(message.as_str()); + napi::Error::from_reason(format!("Failed to get HyperFuel height: {summary}")) + })?; + height.try_into().context("convert height").map_err(map_err) } #[napi] pub async fn get_selected_data(&self, query: Query) -> napi::Result { let query: hyperfuel_client::net_types::Query = query.try_into().context("parse query").map_err(map_err)?; - let res = self - .request( - self.http - .post(format!("{}/query/arrow-ipc", self.url)) - .json(&query), - ) - .await - .map_err(|e| { - napi::Error::from_reason(format!("Failed to get data from HyperFuel: {e}")) - })?; - - let bytes = res - .bytes() - .await - .context("read response body") - .map_err(map_err)?; - let parsed = tokio::task::spawn_blocking(move || parse::parse_query_response(&bytes)) - .await - .context("join parse task") - .map_err(map_err)? - .context("parse query response") - .map_err(map_err)?; - - convert_response(parsed).map_err(convert_error_to_napi) - } -} - -impl HyperfuelClient { - async fn request(&self, mut req: reqwest::RequestBuilder) -> Result { - if let Some(bearer_token) = &self.bearer_token { - req = req.bearer_auth(bearer_token); - } - let res = req.send().await.context("execute http request")?; - let status = res.status(); - if !status.is_success() { - let body = res.text().await.unwrap_or_default(); - return Err(anyhow!("server responded with status {status}: {body}")); - } - Ok(res) + let res = self.inner.get_arrow(&query).await.map_err(|e| { + let message = format!("{e}"); + let summary = message.lines().next().unwrap_or(message.as_str()); + napi::Error::from_reason(format!("Failed to get data from HyperFuel: {summary}")) + })?; + convert_response(res).map_err(convert_error_to_napi) } } diff --git a/packages/cli/src/hyperfuel_source/parse.rs b/packages/cli/src/hyperfuel_source/parse.rs deleted file mode 100644 index 659bca9852..0000000000 --- a/packages/cli/src/hyperfuel_source/parse.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::sync::Arc; - -use anyhow::{Context, Result}; -use hyperfuel_client::net_types::hyperfuel_net_types_capnp; -use hyperfuel_client::ArrowBatch; -use polars_arrow::io::ipc; - -pub struct ParsedResponse { - pub archive_height: Option, - pub next_block: u64, - pub total_execution_time: u64, - pub receipts: Vec, - pub blocks: Vec, -} - -fn read_chunks(bytes: &[u8]) -> Result> { - let mut reader = std::io::Cursor::new(bytes); - - let metadata = ipc::read::read_file_metadata(&mut reader).context("read metadata")?; - let schema = metadata.schema.clone(); - let reader = ipc::read::FileReader::new(reader, metadata, None, None); - - reader - .map(|chunk| { - chunk.context("read chunk").map(|chunk| ArrowBatch { - chunk: Arc::new(chunk), - schema: schema.clone(), - }) - }) - .collect() -} - -pub fn parse_query_response(bytes: &[u8]) -> Result { - let mut opts = capnp::message::ReaderOptions::new(); - // Bounded limits for untrusted network input; the traversal cap is raised - // to 512 MiB (64M words) to fit large paginated arrow payloads. - opts.nesting_limit(64) - .traversal_limit_in_words(Some(64 * 1024 * 1024)); - let message_reader = - capnp::serialize_packed::read_message(bytes, opts).context("create message reader")?; - - let query_response = message_reader - .get_root::() - .context("get root")?; - - let archive_height = match query_response.get_archive_height() { - -1 => None, - h => Some(h), - }; - - let data = query_response.get_data().context("read data")?; - let receipts = - read_chunks(data.get_receipts().context("get receipts")?).context("parse receipt data")?; - let blocks = - read_chunks(data.get_blocks().context("get blocks")?).context("parse block data")?; - - Ok(ParsedResponse { - archive_height, - next_block: query_response.get_next_block(), - total_execution_time: query_response.get_total_execution_time(), - receipts, - blocks, - }) -} diff --git a/packages/cli/src/hyperfuel_source/types.rs b/packages/cli/src/hyperfuel_source/types.rs index 1e94aedea2..9ad7185791 100644 --- a/packages/cli/src/hyperfuel_source/types.rs +++ b/packages/cli/src/hyperfuel_source/types.rs @@ -1,11 +1,9 @@ use anyhow::{Context, Result}; -use hyperfuel_client::ArrowBatch; +use hyperfuel_client::{ArrowBatch, ArrowResponse}; use napi::bindgen_prelude::BigInt; use napi_derive::napi; use polars_arrow::array::{BinaryArray, Int64Array, StaticArray, UInt64Array, UInt8Array}; -use crate::hyperfuel_source::parse::ParsedResponse; - #[napi(object)] pub struct QueryResponse { pub archive_height: Option, @@ -177,9 +175,14 @@ fn blocks_from_arrow(batches: &[ArrowBatch]) -> Result, ConvertError> Ok(out) } -pub(crate) fn convert_response(res: ParsedResponse) -> Result { +pub(crate) fn convert_response(res: ArrowResponse) -> Result { Ok(QueryResponse { - archive_height: res.archive_height, + archive_height: res + .archive_height + .map(i64::try_from) + .transpose() + .context("convert archive_height") + .map_err(ConvertError::Other)?, next_block: res .next_block .try_into() @@ -191,8 +194,8 @@ pub(crate) fn convert_response(res: ParsedResponse) -> Result t = "new" +external classNew: (Core.hyperfuelClientCtor, cfg) => t = "new" -let make = (cfg: cfg) => { - let envioVersion = Utils.EnvioPackage.value.version - Core.getAddon().hyperfuelClient->classNew(cfg, ~userAgent=`hyperindex/${envioVersion}`) -} +let make = (cfg: cfg) => Core.getAddon().hyperfuelClient->classNew(cfg) @send external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res index 290f654cb9..ffbdb991d7 100644 --- a/scenarios/fuel_test/test/HyperFuelHeight_test.res +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -40,7 +40,7 @@ let withServer = async (handler, body) => { describe("HyperFuelSource - getHeightOrThrow", () => { let chain = ChainMap.Chain.makeUnsafe(~chainId=0) - Async.it("Requests height via the client with auth and user agent headers", async t => { + Async.it("Requests height via the client with the bearer auth header", async t => { let capturedHeaders = ref(None) await withServer((req, res) => { capturedHeaders := Some(req.headers) @@ -55,14 +55,9 @@ describe("HyperFuelSource - getHeightOrThrow", () => { let height = await source.getHeightOrThrow() let headers = capturedHeaders.contents->Option.getOrThrow - t.expect(( - height, - headers->Dict.get("authorization"), - headers->Dict.get("user-agent"), - )).toEqual(( + t.expect((height, headers->Dict.get("authorization"))).toEqual(( 123, Some("Bearer test-token"), - Some(`hyperindex/${Utils.EnvioPackage.value.version}`), )) }) }) From 5dd8cfc258006297f84612d3b6b709a841b81eda Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 11:53:02 +0000 Subject: [PATCH 06/10] Bump hyperfuel-client to 4.0.0 and restore the user-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4.0.0 adds new_with_agent (intended for language bindings / HyperIndex), so the hyperindex/{version} user-agent is set again while still going through the crate's Client — no custom HTTP layer or capnp dependency. The config field was renamed bearer_token -> api_token, and the client now validates that the token is a UUID, so the height tests use a UUID-shaped token. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- Cargo.lock | 5 +++-- packages/cli/Cargo.toml | 2 +- packages/cli/src/hyperfuel_source/config.rs | 2 +- packages/cli/src/hyperfuel_source/mod.rs | 4 ++-- packages/envio/src/sources/HyperFuelClient.res | 7 +++++-- .../fuel_test/test/HyperFuelHeight_test.res | 18 +++++++++++++----- 6 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 69ce71bf4b..d81351dc66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2569,9 +2569,9 @@ dependencies = [ [[package]] name = "hyperfuel-client" -version = "3.2.0" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c591e080a7b56d2f95553b1f232e0bde05e2c5ad0e4fdca0dd698ed16f3915f1" +checksum = "2dc89b6e970dc4a896a05735b76f4ef7afe0464e425568495eb94ab514dff2a5" dependencies = [ "alloy-dyn-abi 0.8.26", "alloy-json-abi 0.8.26", @@ -2599,6 +2599,7 @@ dependencies = [ "tokio", "tokio-util", "url", + "uuid", "xxhash-rust", ] diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 263a19d59f..21be80732b 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -45,7 +45,7 @@ colored = "2.0.4" thiserror = "1.0.50" fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" -hyperfuel-client = "3.2.0" +hyperfuel-client = "4.0.0" polars-arrow = { version = "0.42", features = ["io_ipc", "io_ipc_compression"] } faster-hex = "0.9" ruint = "1" diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/hyperfuel_source/config.rs index 476820e519..7d7113a14e 100644 --- a/packages/cli/src/hyperfuel_source/config.rs +++ b/packages/cli/src/hyperfuel_source/config.rs @@ -18,7 +18,7 @@ impl TryFrom for hyperfuel_client::ClientConfig { // dependency on the url crate. let json = serde_json::json!({ "url": config.url, - "bearer_token": config.bearer_token, + "api_token": config.bearer_token.unwrap_or_default(), // Retries are handled by the indexer, not the binary client. "max_num_retries": 0, }); diff --git a/packages/cli/src/hyperfuel_source/mod.rs b/packages/cli/src/hyperfuel_source/mod.rs index db18010308..44cd7c9744 100644 --- a/packages/cli/src/hyperfuel_source/mod.rs +++ b/packages/cli/src/hyperfuel_source/mod.rs @@ -17,10 +17,10 @@ pub struct HyperfuelClient { #[napi] impl HyperfuelClient { #[napi(factory)] - pub fn new(cfg: ClientConfig) -> napi::Result { + pub fn new(cfg: ClientConfig, user_agent: String) -> napi::Result { let client_config: hyperfuel_client::ClientConfig = cfg.try_into().context("build config").map_err(map_err)?; - let inner = hyperfuel_client::Client::new(client_config) + let inner = hyperfuel_client::Client::new_with_agent(client_config, user_agent) .context("build client") .map_err(map_err)?; Ok(HyperfuelClient { inner }) diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 0b6d3231f3..39ab93e209 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -113,9 +113,12 @@ type queryResponseTyped = { } @send -external classNew: (Core.hyperfuelClientCtor, cfg) => t = "new" +external classNew: (Core.hyperfuelClientCtor, cfg, ~userAgent: string) => t = "new" -let make = (cfg: cfg) => Core.getAddon().hyperfuelClient->classNew(cfg) +let make = (cfg: cfg) => { + let envioVersion = Utils.EnvioPackage.value.version + Core.getAddon().hyperfuelClient->classNew(cfg, ~userAgent=`hyperindex/${envioVersion}`) +} @send external getSelectedData: (t, QueryTypes.query) => promise = "getSelectedData" diff --git a/scenarios/fuel_test/test/HyperFuelHeight_test.res b/scenarios/fuel_test/test/HyperFuelHeight_test.res index ffbdb991d7..90a876614d 100644 --- a/scenarios/fuel_test/test/HyperFuelHeight_test.res +++ b/scenarios/fuel_test/test/HyperFuelHeight_test.res @@ -40,7 +40,10 @@ let withServer = async (handler, body) => { describe("HyperFuelSource - getHeightOrThrow", () => { let chain = ChainMap.Chain.makeUnsafe(~chainId=0) - Async.it("Requests height via the client with the bearer auth header", async t => { + // The native client validates that the token is a UUID before sending requests. + let apiToken = "11111111-1111-1111-1111-111111111111" + + Async.it("Requests height via the client with auth and user agent headers", async t => { let capturedHeaders = ref(None) await withServer((req, res) => { capturedHeaders := Some(req.headers) @@ -50,14 +53,19 @@ describe("HyperFuelSource - getHeightOrThrow", () => { let source = HyperFuelSource.make({ chain, endpointUrl, - apiToken: Some("test-token"), + apiToken: Some(apiToken), }) let height = await source.getHeightOrThrow() let headers = capturedHeaders.contents->Option.getOrThrow - t.expect((height, headers->Dict.get("authorization"))).toEqual(( + t.expect(( + height, + headers->Dict.get("authorization"), + headers->Dict.get("user-agent"), + )).toEqual(( 123, - Some("Bearer test-token"), + Some(`Bearer ${apiToken}`), + Some(`hyperindex/${Utils.EnvioPackage.value.version}`), )) }) }) @@ -70,7 +78,7 @@ describe("HyperFuelSource - getHeightOrThrow", () => { let source = HyperFuelSource.make({ chain, endpointUrl, - apiToken: Some("rejected-token"), + apiToken: Some(apiToken), }) let result = await Promise.race([ source.getHeightOrThrow()->Promise.thenResolve(_ => "resolved"), From 84fa92faafe9bee50b0c8ec7728b3744fecc4a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 12:26:02 +0000 Subject: [PATCH 07/10] Drop unused polars-arrow io_ipc features The IPC features were only needed by the removed parse.rs envelope decoder. The client now returns already-decoded ArrowBatches, so hyperfuel_source only reads polars-arrow array types. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- packages/cli/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index 21be80732b..1881b868b6 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -46,7 +46,7 @@ thiserror = "1.0.50" fuel-abi-types = "0.7.0" hypersync-client = "1.2.0" hyperfuel-client = "4.0.0" -polars-arrow = { version = "0.42", features = ["io_ipc", "io_ipc_compression"] } +polars-arrow = "0.42" faster-hex = "0.9" ruint = "1" env_logger = "0.11" From 8848921316c2b461dd2410a47438ea75baf663d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:33:27 +0000 Subject: [PATCH 08/10] Rename hyperfuel_source module to fuel_hypersync_source Match the evm_hypersync_source / svm_hypersync_source naming convention. The napi HyperfuelClient class name is unchanged, so no JS/ReScript references need updating. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- .../src/{hyperfuel_source => fuel_hypersync_source}/config.rs | 0 .../cli/src/{hyperfuel_source => fuel_hypersync_source}/mod.rs | 0 .../src/{hyperfuel_source => fuel_hypersync_source}/query.rs | 0 .../src/{hyperfuel_source => fuel_hypersync_source}/types.rs | 0 packages/cli/src/lib.rs | 2 +- 5 files changed, 1 insertion(+), 1 deletion(-) rename packages/cli/src/{hyperfuel_source => fuel_hypersync_source}/config.rs (100%) rename packages/cli/src/{hyperfuel_source => fuel_hypersync_source}/mod.rs (100%) rename packages/cli/src/{hyperfuel_source => fuel_hypersync_source}/query.rs (100%) rename packages/cli/src/{hyperfuel_source => fuel_hypersync_source}/types.rs (100%) diff --git a/packages/cli/src/hyperfuel_source/config.rs b/packages/cli/src/fuel_hypersync_source/config.rs similarity index 100% rename from packages/cli/src/hyperfuel_source/config.rs rename to packages/cli/src/fuel_hypersync_source/config.rs diff --git a/packages/cli/src/hyperfuel_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs similarity index 100% rename from packages/cli/src/hyperfuel_source/mod.rs rename to packages/cli/src/fuel_hypersync_source/mod.rs diff --git a/packages/cli/src/hyperfuel_source/query.rs b/packages/cli/src/fuel_hypersync_source/query.rs similarity index 100% rename from packages/cli/src/hyperfuel_source/query.rs rename to packages/cli/src/fuel_hypersync_source/query.rs diff --git a/packages/cli/src/hyperfuel_source/types.rs b/packages/cli/src/fuel_hypersync_source/types.rs similarity index 100% rename from packages/cli/src/hyperfuel_source/types.rs rename to packages/cli/src/fuel_hypersync_source/types.rs diff --git a/packages/cli/src/lib.rs b/packages/cli/src/lib.rs index 301024a4af..feca85dfd4 100644 --- a/packages/cli/src/lib.rs +++ b/packages/cli/src/lib.rs @@ -10,8 +10,8 @@ mod evm_hypersync_source; mod evm_rpc_source; pub mod executor; mod fuel; +mod fuel_hypersync_source; mod hbs_templating; -mod hyperfuel_source; #[cfg_attr(test, allow(dead_code))] mod napi; mod project_paths; From 452596774388aa392f4f40b7521b77f33575ed86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 08:58:46 +0000 Subject: [PATCH 09/10] Rename HyperFuel config field to apiToken and dedup error mapping Use api_token (apiToken in JS) to match hyperfuel_client::ClientConfig's field name, and extract the shared "first line of the error" mapping into a request_err helper instead of duplicating the closure. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- .../cli/src/fuel_hypersync_source/config.rs | 4 +-- packages/cli/src/fuel_hypersync_source/mod.rs | 30 +++++++++++-------- packages/envio/src/sources/HyperFuel.res | 2 +- .../envio/src/sources/HyperFuelClient.res | 2 +- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/fuel_hypersync_source/config.rs b/packages/cli/src/fuel_hypersync_source/config.rs index 7d7113a14e..af5a1ae403 100644 --- a/packages/cli/src/fuel_hypersync_source/config.rs +++ b/packages/cli/src/fuel_hypersync_source/config.rs @@ -6,7 +6,7 @@ use napi_derive::napi; #[derive(Default, Clone)] pub struct ClientConfig { pub url: String, - pub bearer_token: Option, + pub api_token: Option, } impl TryFrom for hyperfuel_client::ClientConfig { @@ -18,7 +18,7 @@ impl TryFrom for hyperfuel_client::ClientConfig { // dependency on the url crate. let json = serde_json::json!({ "url": config.url, - "api_token": config.bearer_token.unwrap_or_default(), + "api_token": config.api_token.unwrap_or_default(), // Retries are handled by the indexer, not the binary client. "max_num_retries": 0, }); diff --git a/packages/cli/src/fuel_hypersync_source/mod.rs b/packages/cli/src/fuel_hypersync_source/mod.rs index 44cd7c9744..62a2c42381 100644 --- a/packages/cli/src/fuel_hypersync_source/mod.rs +++ b/packages/cli/src/fuel_hypersync_source/mod.rs @@ -28,13 +28,11 @@ impl HyperfuelClient { #[napi] pub async fn get_height(&self) -> napi::Result { - let height = self.inner.get_height().await.map_err(|e| { - // The client embeds a `{:?}` debug dump in its error message; keep - // only the first line so it stays readable on retries. - let message = format!("{e}"); - let summary = message.lines().next().unwrap_or(message.as_str()); - napi::Error::from_reason(format!("Failed to get HyperFuel height: {summary}")) - })?; + let height = self + .inner + .get_height() + .await + .map_err(|e| request_err("Failed to get HyperFuel height", e))?; height.try_into().context("convert height").map_err(map_err) } @@ -42,15 +40,23 @@ impl HyperfuelClient { pub async fn get_selected_data(&self, query: Query) -> napi::Result { let query: hyperfuel_client::net_types::Query = query.try_into().context("parse query").map_err(map_err)?; - let res = self.inner.get_arrow(&query).await.map_err(|e| { - let message = format!("{e}"); - let summary = message.lines().next().unwrap_or(message.as_str()); - napi::Error::from_reason(format!("Failed to get data from HyperFuel: {summary}")) - })?; + let res = self + .inner + .get_arrow(&query) + .await + .map_err(|e| request_err("Failed to get data from HyperFuel", e))?; convert_response(res).map_err(convert_error_to_napi) } } +/// The client embeds a `{:?}` debug dump in its error message; keep only the +/// first line so it stays readable when the indexer surfaces it on retries. +fn request_err(prefix: &str, e: anyhow::Error) -> napi::Error { + let message = format!("{e}"); + let summary = message.lines().next().unwrap_or(message.as_str()); + napi::Error::from_reason(format!("{prefix}: {summary}")) +} + /// Encodes `ConvertError::MissingFields` as a JSON payload in the napi /// error's message — the same protocol as hypersync_source, which the /// ReScript side recovers via JSON.parse and a `kind` dispatch. diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/HyperFuel.res index 06e705e66c..6723e8e079 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/HyperFuel.res @@ -9,7 +9,7 @@ module CachedClients = { switch cache->Utils.Dict.dangerouslyGetNonOption(serverUrl) { | Some(client) => client | None => - let newClient = HyperFuelClient.make({url: serverUrl, bearerToken: apiToken}) + let newClient = HyperFuelClient.make({url: serverUrl, apiToken}) cache->Dict.set(serverUrl, newClient) newClient } diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index 39ab93e209..a9a8cad0d7 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -2,7 +2,7 @@ type t type cfg = { url: string, - bearerToken?: string, + apiToken?: string, } module QueryTypes = { type blockFieldOptions = From 4a45a6bc1d2a436021bc9fc0c86b0edf3d42c61a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:22:41 +0000 Subject: [PATCH 10/10] Require api_token and create the HyperFuel client once like HyperSync Make the napi config's api_token required (the source already throws when it's missing), and drop the per-URL CachedClients dict. The client is now built once in HyperFuelSource.make with a mkLogAndRaise guard and threaded into GetLogs.query / getHeight as ~client, matching how HyperSyncSource manages its client. https://claude.ai/code/session_01JtXswv5kkvQWpiZGvY17AK --- .../cli/src/fuel_hypersync_source/config.rs | 4 +-- packages/envio/src/sources/HyperFuel.res | 30 ++----------------- packages/envio/src/sources/HyperFuel.resi | 5 +--- .../envio/src/sources/HyperFuelClient.res | 2 +- .../envio/src/sources/HyperFuelSource.res | 15 ++++++---- 5 files changed, 17 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/fuel_hypersync_source/config.rs b/packages/cli/src/fuel_hypersync_source/config.rs index af5a1ae403..e7f6c2ec75 100644 --- a/packages/cli/src/fuel_hypersync_source/config.rs +++ b/packages/cli/src/fuel_hypersync_source/config.rs @@ -6,7 +6,7 @@ use napi_derive::napi; #[derive(Default, Clone)] pub struct ClientConfig { pub url: String, - pub api_token: Option, + pub api_token: String, } impl TryFrom for hyperfuel_client::ClientConfig { @@ -18,7 +18,7 @@ impl TryFrom for hyperfuel_client::ClientConfig { // dependency on the url crate. let json = serde_json::json!({ "url": config.url, - "api_token": config.api_token.unwrap_or_default(), + "api_token": config.api_token, // Retries are handled by the indexer, not the binary client. "max_num_retries": 0, }); diff --git a/packages/envio/src/sources/HyperFuel.res b/packages/envio/src/sources/HyperFuel.res index 6723e8e079..a1a356deb5 100644 --- a/packages/envio/src/sources/HyperFuel.res +++ b/packages/envio/src/sources/HyperFuel.res @@ -1,21 +1,3 @@ -//Manage clients in cache so we don't need to reinstantiate each time -//Ideally client should be passed in as a param to the functions but -//we are still sharing the same signature with eth archive query builder - -module CachedClients = { - let cache: dict = Dict.make() - - let getClient = (~serverUrl, ~apiToken) => { - switch cache->Utils.Dict.dangerouslyGetNonOption(serverUrl) { - | Some(client) => client - | None => - let newClient = HyperFuelClient.make({url: serverUrl, apiToken}) - cache->Dict.set(serverUrl, newClient) - newClient - } - } -} - type hyperSyncPage<'item> = { items: array<'item>, nextBlock: int, @@ -169,8 +151,7 @@ module GetLogs = { } let query = async ( - ~serverUrl, - ~apiToken, + ~client: HyperFuelClient.t, ~fromBlock, ~toBlock, ~recieptsSelection, @@ -181,9 +162,7 @@ module GetLogs = { ~recieptsSelection, ) - let hyperFuelClient = CachedClients.getClient(~serverUrl, ~apiToken) - - let res = switch await hyperFuelClient->HyperFuelClient.getSelectedData(query) { + let res = switch await client->HyperFuelClient.getSelectedData(query) { | res => res | exception exn => switch exn->extractMissingParams { @@ -192,12 +171,9 @@ module GetLogs = { } } if res.nextBlock <= fromBlock { - // Might happen when /height response was from another instance of HyperSync + // Might happen when /height response was from another instance of HyperFuel throw(Error(WrongInstance)) } res->convertResponse } } - -let getHeight = (~serverUrl, ~apiToken) => - CachedClients.getClient(~serverUrl, ~apiToken)->HyperFuelClient.getHeight diff --git a/packages/envio/src/sources/HyperFuel.resi b/packages/envio/src/sources/HyperFuel.resi index 803f2f75f8..81f7d42320 100644 --- a/packages/envio/src/sources/HyperFuel.resi +++ b/packages/envio/src/sources/HyperFuel.resi @@ -28,12 +28,9 @@ module GetLogs: { exception Error(error) let query: ( - ~serverUrl: string, - ~apiToken: string, + ~client: HyperFuelClient.t, ~fromBlock: int, ~toBlock: option, ~recieptsSelection: array, ) => promise } - -let getHeight: (~serverUrl: string, ~apiToken: string) => promise diff --git a/packages/envio/src/sources/HyperFuelClient.res b/packages/envio/src/sources/HyperFuelClient.res index a9a8cad0d7..2fb7f8b961 100644 --- a/packages/envio/src/sources/HyperFuelClient.res +++ b/packages/envio/src/sources/HyperFuelClient.res @@ -2,7 +2,7 @@ type t type cfg = { url: string, - apiToken?: string, + apiToken: string, } module QueryTypes = { type blockFieldOptions = diff --git a/packages/envio/src/sources/HyperFuelSource.res b/packages/envio/src/sources/HyperFuelSource.res index 5e36e4f26f..a0e9d68a59 100644 --- a/packages/envio/src/sources/HyperFuelSource.res +++ b/packages/envio/src/sources/HyperFuelSource.res @@ -218,9 +218,15 @@ let make = ({chain, endpointUrl, apiToken}: options): t => { let apiToken = switch apiToken { | Some(token) => token | None => - JsError.throwWithMessage(`An API token is required for using HyperFuel as a data-source. + JsError.throwWithMessage(`An Envio API token is required for using HyperFuel as a data-source. Set the ENVIO_API_TOKEN environment variable in your .env file. -Learn more or get a free API token at: https://envio.dev/app/api-tokens`) +Learn more or get a free Envio API token at: https://envio.dev/app/api-tokens`) + } + + let client = switch HyperFuelClient.make({url: endpointUrl, apiToken}) { + | client => client + | exception exn => + exn->ErrorHandling.mkLogAndRaise(~msg="Failed to instantiate the HyperFuel client") } let getSelectionConfig = memoGetSelectionConfig(~chain) @@ -250,8 +256,7 @@ Learn more or get a free API token at: https://envio.dev/app/api-tokens`) ~method="getLogs", ) let pageUnsafe = try await HyperFuel.GetLogs.query( - ~serverUrl=endpointUrl, - ~apiToken, + ~client, ~fromBlock, ~toBlock, ~recieptsSelection, @@ -471,7 +476,7 @@ Learn more or get a free API token at: https://envio.dev/app/api-tokens`) poweredByHyperSync: true, getHeightOrThrow: async () => { let timerRef = Hrtime.makeTimer() - let height = try await HyperFuel.getHeight(~serverUrl=endpointUrl, ~apiToken) catch { + let height = try await client->HyperFuelClient.getHeight catch { | JsExn(e) => switch e->JsExn.message { | Some(message) if message->isUnauthorizedError =>