From ba9d2be2058f574fd7a4b789d0eadded3eca6735 Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 17 Apr 2026 17:19:09 +0900 Subject: [PATCH 1/4] Depend on ant-protocol 2.0.0; bump to 0.11.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the wire contract (chunk messages, data types, chunk_protocol helper, payment proof + single-node payment + signature verification) into the new ant-protocol crate so ant-client and ant-node can ship on independent release cycles. Kept as thin re-export shims at the same paths for backwards compatibility: - ant_node::ant_protocol::* -> ant_protocol::chunk::* - ant_node::client::* -> ant_protocol::data_types / chunk_protocol - ant_node::payment::proof -> ant_protocol::payment::proof - ant_node::payment::single_node -> ant_protocol::payment::single_node - ant_node::payment::{verify_quote_content, verify_quote_signature, verify_merkle_candidate_signature} -> ant_protocol::payment::verify Moved to ant-protocol (shared on-disk format, no node runtime needed to read): - DevnetManifest, DevnetEvmInfo -> ant_protocol::devnet_manifest Node-only code stays: - QuoteGenerator, wire_ml_dsa_signer (node signs quotes, client verifies only) - PaymentVerifier, CacheStats, pricing, EvmVerifierConfig (on-chain verification state machine) - LmdbStorage, AntProtocol handler (node-side storage) - Devnet / NetworkSpawner (devnet lifecycle — client uses the re-exported Devnet type via ant-node dev-dep) Internal imports either kept as crate::ant_protocol::* (resolved through the shim) or rewritten to import directly from ant_protocol where that reads cleaner (verifier.rs, storage/handler.rs). Verification: - cargo fmt --all -- --check: clean - cargo clippy --all-targets --all-features -- -D warnings: clean - cargo test --lib: 445/445 passing - cargo doc --all-features --no-deps: builds --- CHANGELOG.md | 58 +++ Cargo.lock | 165 ++++---- Cargo.toml | 12 + src/ant_protocol/chunk.rs | 616 ------------------------------ src/ant_protocol/mod.rs | 81 +--- src/client/chunk_protocol.rs | 82 ---- src/client/data_types.rs | 196 ---------- src/client/mod.rs | 37 +- src/devnet.rs | 35 +- src/payment/mod.rs | 8 +- src/payment/proof.rs | 393 +------------------ src/payment/quote.rs | 138 +------ src/payment/single_node.rs | 707 +---------------------------------- src/payment/verifier.rs | 11 +- src/storage/handler.rs | 22 +- 15 files changed, 238 insertions(+), 2323 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 src/ant_protocol/chunk.rs delete mode 100644 src/client/chunk_protocol.rs delete mode 100644 src/client/data_types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..114ce6dd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,58 @@ +# Changelog + +All notable changes to the `ant-node` crate will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.11.0] — Unreleased + +### Changed + +The wire-protocol surface previously owned by `ant-node` has moved to +the new [`ant-protocol`] crate. All previously-exported paths continue +to resolve via re-exports, so existing downstream imports keep working +unchanged. + +[`ant-protocol`]: https://crates.io/crates/ant-protocol + +- `ant_node::ant_protocol` — now re-exports from `ant_protocol::chunk`. + Both `ant_node::ant_protocol::ChunkMessage` and + `ant_node::ant_protocol::chunk::ChunkMessage` resolve. +- `ant_node::client` — `compute_address`, `peer_id_to_xor_name`, + `xor_distance`, `DataChunk`, `ChunkStats`, `XorName`, and + `send_and_await_chunk_response` now re-export from + `ant_protocol::{data_types, chunk_protocol}`. + `hex_node_id_to_encoded_peer_id` stays as node-owned code. +- `ant_node::payment::proof`, `ant_node::payment::single_node` — now + re-export from `ant_protocol::payment::{proof, single_node}`. +- `ant_node::payment::{verify_quote_content, verify_quote_signature, + verify_merkle_candidate_signature}` — now re-export from + `ant_protocol::payment::verify`. +- `ant_node::devnet::{DevnetManifest, DevnetEvmInfo}` — now re-export + from `ant_protocol::devnet_manifest`. JSON format unchanged. + +### Security + +- `ant_protocol::SingleNodePayment::verify` (used via + `ant_node::payment::PaymentVerifier`) now rejects proofs whose median + quote has zero price or zero paid amount. Previously a malicious + client could have submitted a zero-priced median, and the on-chain + `completedPayments >= 0` check would have trivially succeeded. +- `ant_node::payment::PaymentVerifier` now rejects unknown + `ProofType` tag bytes (including future variants added on an + `ant-protocol` minor bump) instead of silently accepting them. + +### Added + +- Re-export of the `chunk` submodule from `ant-protocol` so + `ant_node::ant_protocol::chunk::` paths keep resolving for + downstream callers that used the longer path. + +### Deprecation notice + +`ant_node::ant_protocol`, `ant_node::client` (except for the node-only +`hex_node_id_to_encoded_peer_id`), `ant_node::payment::{proof, +single_node}`, and `ant_node::payment::verify_*` will be removed in a +future 0.x release once the wider ecosystem has migrated to +`ant_protocol::*` directly. No timeline yet. diff --git a/Cargo.lock b/Cargo.lock index 5988f22e..cb5df6e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,7 +152,7 @@ dependencies = [ "either", "k256", "once_cell", - "rand 0.8.6", + "rand 0.8.5", "secp256k1", "serde", "serde_json", @@ -266,14 +266,13 @@ dependencies = [ [[package]] name = "alloy-eip7928" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "407510740da514b694fecb44d8b3cebdc60d448f70cc5d24743e8ba273448a6e" +checksum = "f8222b1d88f9a6d03be84b0f5e76bb60cd83991b43ad8ab6477f0e4a7809b98d" dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", - "once_cell", "serde", ] @@ -408,7 +407,7 @@ dependencies = [ "alloy-signer-local", "k256", "libc", - "rand 0.8.6", + "rand 0.8.5", "serde_json", "tempfile", "thiserror 2.0.18", @@ -623,7 +622,7 @@ dependencies = [ "alloy-signer", "async-trait", "k256", - "rand 0.8.6", + "rand 0.8.5", "thiserror 2.0.18", ] @@ -841,6 +840,7 @@ version = "0.11.0" dependencies = [ "aes-gcm-siv", "alloy", + "ant-protocol", "blake3", "bytes", "chrono", @@ -861,7 +861,7 @@ dependencies = [ "parking_lot", "postcard", "proptest", - "rand 0.8.6", + "rand 0.8.5", "reqwest", "rmp-serde", "saorsa-core", @@ -887,6 +887,24 @@ dependencies = [ "zip", ] +[[package]] +name = "ant-protocol" +version = "2.0.0" +source = "git+https://github.com/WithAutonomi/ant-protocol?branch=chore/repin-saorsa-core-rc-2026.4.2#b4360b1802bc6090047a6a67cc2b593402bf52fb" +dependencies = [ + "blake3", + "bytes", + "evmlib", + "hex", + "postcard", + "rmp-serde", + "saorsa-core", + "saorsa-pqc 0.5.1", + "serde", + "tokio", + "tracing", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -1068,7 +1086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -1078,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -1088,7 +1106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -1221,9 +1239,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" dependencies = [ "aws-lc-sys", "zeroize", @@ -1231,9 +1249,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -1583,9 +1601,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -1605,9 +1623,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -1702,12 +1720,11 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "const_format" -version = "0.2.36" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" dependencies = [ "const_format_proc_macros", - "konst", ] [[package]] @@ -2330,7 +2347,7 @@ dependencies = [ "ant-merkle", "exponential-backoff", "hex", - "rand 0.8.6", + "rand 0.8.5", "rmp-serde", "serde", "serde_with", @@ -2464,7 +2481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.6", + "rand 0.8.5", "rustc-hex", "static_assertions", ] @@ -3372,21 +3389,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "konst" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" -dependencies = [ - "konst_macro_rules", -] - -[[package]] -name = "konst_macro_rules" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" - [[package]] name = "lazy_static" version = "1.5.0" @@ -3916,7 +3918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand 0.8.6", + "rand 0.8.5", ] [[package]] @@ -4248,9 +4250,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4558,9 +4560,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.18.0" +version = "1.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -4575,7 +4577,7 @@ dependencies = [ "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.6", + "rand 0.8.5", "rand 0.9.4", "rlp", "ruint-macro", @@ -4650,9 +4652,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -4734,9 +4736,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" dependencies = [ "aws-lc-rs", "ring", @@ -4804,7 +4806,7 @@ dependencies = [ "once_cell", "parking_lot", "postcard", - "rand 0.8.6", + "rand 0.8.5", "saorsa-pqc 0.5.1", "saorsa-transport", "serde", @@ -4843,7 +4845,7 @@ dependencies = [ "log", "pbkdf2", "postcard", - "rand 0.8.6", + "rand 0.8.5", "rand_chacha 0.3.1", "rand_core 0.6.4", "rayon", @@ -4884,7 +4886,7 @@ dependencies = [ "log", "pbkdf2", "postcard", - "rand 0.8.6", + "rand 0.8.5", "rand_chacha 0.3.1", "rand_core 0.6.4", "rayon", @@ -4930,7 +4932,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "quinn-udp 0.6.1", - "rand 0.8.6", + "rand 0.8.5", "rcgen", "regex", "reqwest", @@ -5037,7 +5039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.6", + "rand 0.8.5", "secp256k1-sys", "serde", ] @@ -5097,7 +5099,7 @@ dependencies = [ "bytes", "chacha20poly1305", "hex", - "rand 0.8.6", + "rand 0.8.5", "rand_chacha 0.3.1", "rayon", "serde", @@ -5296,9 +5298,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.9" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ "digest 0.10.7", "keccak", @@ -5460,12 +5462,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - [[package]] name = "syn" version = "1.0.109" @@ -5707,9 +5703,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" dependencies = [ "bytes", "libc", @@ -5833,7 +5829,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.1", ] [[package]] @@ -5842,7 +5838,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.1", ] [[package]] @@ -5910,12 +5906,11 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "symlink", "thiserror 2.0.18", "time", "tracing-subscriber", @@ -6003,9 +5998,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -6104,9 +6099,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6162,11 +6157,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ - "wit-bindgen 0.57.1", + "wit-bindgen", ] [[package]] @@ -6175,7 +6170,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -6303,9 +6298,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" dependencies = [ "rustls-pki-types", ] @@ -6773,9 +6768,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -6789,12 +6784,6 @@ dependencies = [ "wit-bindgen-rust-macro", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -6951,7 +6940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd9dddecfdbc7c17ae93da6d28a5a9c4f5564abe7b735d2530c7a159b6b55e8" dependencies = [ "hex", - "rand 0.8.6", + "rand 0.8.5", "rand_core 0.6.4", "serde", "serde_test", diff --git a/Cargo.toml b/Cargo.toml index f8eaff92..3765d060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,18 @@ name = "ant-devnet" path = "src/bin/ant-devnet/main.rs" [dependencies] +# Wire protocol — the single version-pin shared with ant-client. +# Bumping ant-protocol's `evmlib`/`saorsa-core`/`saorsa-pqc` pins ripples +# through here automatically; we keep a direct saorsa-core dep for +# node-only DHT internals (DHTNode, TrustEvent, DhtNetworkEvent), which +# Cargo unifies with ant-protocol's version constraint. +# +# TODO: swap to `ant-protocol = "2.0.0"` once 2.0.0 is on crates.io. +# Until then, the git pin tracks the matching saorsa-core lineage +# (the rc-2026.4.2 branch) so Cargo can unify the wire types here +# with ant-protocol's re-exports. +ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "chore/repin-saorsa-core-rc-2026.4.2" } + # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.24.0" saorsa-pqc = "0.5" diff --git a/src/ant_protocol/chunk.rs b/src/ant_protocol/chunk.rs deleted file mode 100644 index d8c0840a..00000000 --- a/src/ant_protocol/chunk.rs +++ /dev/null @@ -1,616 +0,0 @@ -//! Chunk message types for the ANT protocol. -//! -//! Chunks are immutable, content-addressed data blocks where the address -//! is the BLAKE3 hash of the content. Maximum size is 4MB. -//! -//! This module defines the wire protocol messages for chunk operations -//! using postcard serialization for compact, fast encoding. - -use serde::{Deserialize, Serialize}; - -/// Protocol identifier for chunk operations. -pub const CHUNK_PROTOCOL_ID: &str = "autonomi.ant.chunk.v1"; - -/// Current protocol version. -pub const PROTOCOL_VERSION: u16 = 1; - -/// Maximum chunk size in bytes (4MB). -pub const MAX_CHUNK_SIZE: usize = 4 * 1024 * 1024; - -/// Maximum wire message size in bytes (5MB). -/// -/// Limits the input buffer accepted by [`ChunkMessage::decode`] to prevent -/// unbounded allocation from malicious or corrupted payloads. Set slightly -/// above [`MAX_CHUNK_SIZE`] to accommodate message envelope overhead. -pub const MAX_WIRE_MESSAGE_SIZE: usize = 5 * 1024 * 1024; - -/// Data type identifier for chunks. -pub const DATA_TYPE_CHUNK: u32 = 0; - -/// Content-addressed identifier (32 bytes). -pub type XorName = [u8; 32]; - -/// Byte length of an [`XorName`]. -pub const XORNAME_LEN: usize = std::mem::size_of::(); - -/// Enum of all chunk protocol message types. -/// -/// Uses a single-byte discriminant for efficient wire encoding. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkMessageBody { - /// Request to store a chunk. - PutRequest(ChunkPutRequest), - /// Response to a PUT request. - PutResponse(ChunkPutResponse), - /// Request to retrieve a chunk. - GetRequest(ChunkGetRequest), - /// Response to a GET request. - GetResponse(ChunkGetResponse), - /// Request a storage quote. - QuoteRequest(ChunkQuoteRequest), - /// Response with a storage quote. - QuoteResponse(ChunkQuoteResponse), - /// Request a merkle candidate quote for batch payments. - MerkleCandidateQuoteRequest(MerkleCandidateQuoteRequest), - /// Response with a merkle candidate quote. - MerkleCandidateQuoteResponse(MerkleCandidateQuoteResponse), -} - -/// Wire-format wrapper that pairs a sender-assigned `request_id` with -/// a [`ChunkMessageBody`]. -/// -/// The sender picks a unique `request_id`; the handler echoes it back -/// in the response so callers can correlate replies by ID rather than -/// by source peer. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkMessage { - /// Sender-assigned identifier, echoed back in the response. - pub request_id: u64, - /// The protocol message body. - pub body: ChunkMessageBody, -} - -impl ChunkMessage { - /// Encode the message to bytes using postcard. - /// - /// # Errors - /// - /// Returns an error if serialization fails. - pub fn encode(&self) -> Result, ProtocolError> { - postcard::to_stdvec(self).map_err(|e| ProtocolError::SerializationFailed(e.to_string())) - } - - /// Decode a message from bytes using postcard. - /// - /// Rejects payloads larger than [`MAX_WIRE_MESSAGE_SIZE`] before - /// attempting deserialization. - /// - /// # Errors - /// - /// Returns [`ProtocolError::MessageTooLarge`] if the input exceeds the - /// size limit, or [`ProtocolError::DeserializationFailed`] if postcard - /// cannot parse the data. - pub fn decode(data: &[u8]) -> Result { - if data.len() > MAX_WIRE_MESSAGE_SIZE { - return Err(ProtocolError::MessageTooLarge { - size: data.len(), - max_size: MAX_WIRE_MESSAGE_SIZE, - }); - } - postcard::from_bytes(data).map_err(|e| ProtocolError::DeserializationFailed(e.to_string())) - } -} - -// ============================================================================= -// PUT Request/Response -// ============================================================================= - -/// Request to store a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkPutRequest { - /// The content-addressed identifier (BLAKE3 of content). - pub address: XorName, - /// The chunk data. - pub content: Vec, - /// Optional payment proof (serialized `ProofOfPayment`). - /// Required for new chunks unless already verified. - pub payment_proof: Option>, -} - -impl ChunkPutRequest { - /// Create a new PUT request. - #[must_use] - pub fn new(address: XorName, content: Vec) -> Self { - Self { - address, - content, - payment_proof: None, - } - } - - /// Create a new PUT request with payment proof. - #[must_use] - pub fn with_payment(address: XorName, content: Vec, payment_proof: Vec) -> Self { - Self { - address, - content, - payment_proof: Some(payment_proof), - } - } -} - -/// Response to a PUT request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkPutResponse { - /// Chunk stored successfully. - Success { - /// The address where the chunk was stored. - address: XorName, - }, - /// Chunk already exists (idempotent success). - AlreadyExists { - /// The existing chunk address. - address: XorName, - }, - /// Payment is required to store this chunk. - PaymentRequired { - /// Error message. - message: String, - }, - /// An error occurred. - Error(ProtocolError), -} - -// ============================================================================= -// GET Request/Response -// ============================================================================= - -/// Request to retrieve a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkGetRequest { - /// The content-addressed identifier to retrieve. - pub address: XorName, -} - -impl ChunkGetRequest { - /// Create a new GET request. - #[must_use] - pub fn new(address: XorName) -> Self { - Self { address } - } -} - -/// Response to a GET request. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkGetResponse { - /// Chunk found and returned. - Success { - /// The chunk address. - address: XorName, - /// The chunk data. - content: Vec, - }, - /// Chunk not found. - NotFound { - /// The requested address. - address: XorName, - }, - /// An error occurred. - Error(ProtocolError), -} - -// ============================================================================= -// Quote Request/Response -// ============================================================================= - -/// Request a storage quote for a chunk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChunkQuoteRequest { - /// The content address of the data to store. - pub address: XorName, - /// Size of the data in bytes. - pub data_size: u64, - /// Data type identifier (0 for chunks). - pub data_type: u32, -} - -impl ChunkQuoteRequest { - /// Create a new quote request. - #[must_use] - pub fn new(address: XorName, data_size: u64) -> Self { - Self { - address, - data_size, - data_type: DATA_TYPE_CHUNK, - } - } -} - -/// Response with a storage quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ChunkQuoteResponse { - /// Quote generated successfully. - /// - /// When `already_stored` is `true` the node already holds this chunk and no - /// payment is required — the client should skip the pay-then-PUT cycle for - /// this address. The quote is still included for informational purposes. - Success { - /// Serialized `PaymentQuote`. - quote: Vec, - /// `true` when the chunk already exists on this node (skip payment). - already_stored: bool, - }, - /// Quote generation failed. - Error(ProtocolError), -} - -// ============================================================================= -// Merkle Candidate Quote Request/Response -// ============================================================================= - -/// Request a merkle candidate quote for batch payments. -/// -/// Part of the merkle batch payment system where clients collect -/// signed candidate quotes from 16 closest peers per pool. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct MerkleCandidateQuoteRequest { - /// The candidate pool address (hash of midpoint || root || timestamp). - pub address: XorName, - /// Data type identifier (0 for chunks). - pub data_type: u32, - /// Size of the data in bytes. - pub data_size: u64, - /// Client-provided merkle payment timestamp (unix seconds). - pub merkle_payment_timestamp: u64, -} - -/// Response with a merkle candidate quote. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum MerkleCandidateQuoteResponse { - /// Candidate quote generated successfully. - /// Contains the serialized `MerklePaymentCandidateNode`. - Success { - /// Serialized `MerklePaymentCandidateNode`. - candidate_node: Vec, - }, - /// Quote generation failed. - Error(ProtocolError), -} - -// ============================================================================= -// Payment Proof Type Tags -// ============================================================================= - -/// Version byte prefix for payment proof serialization. -/// Allows the verifier to detect proof type before deserialization. -pub const PROOF_TAG_SINGLE_NODE: u8 = 0x01; -/// Version byte prefix for merkle payment proofs. -pub const PROOF_TAG_MERKLE: u8 = 0x02; - -// ============================================================================= -// Protocol Errors -// ============================================================================= - -/// Errors that can occur during protocol operations. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ProtocolError { - /// Message serialization failed. - SerializationFailed(String), - /// Message deserialization failed. - DeserializationFailed(String), - /// Wire message exceeds the maximum allowed size. - MessageTooLarge { - /// Actual size of the message in bytes. - size: usize, - /// Maximum allowed size. - max_size: usize, - }, - /// Chunk exceeds maximum size. - ChunkTooLarge { - /// Size of the chunk in bytes. - size: usize, - /// Maximum allowed size. - max_size: usize, - }, - /// Content address mismatch (hash(content) != address). - AddressMismatch { - /// Expected address. - expected: XorName, - /// Actual address computed from content. - actual: XorName, - }, - /// Storage operation failed. - StorageFailed(String), - /// Payment verification failed. - PaymentFailed(String), - /// Quote generation failed. - QuoteFailed(String), - /// Internal error. - Internal(String), -} - -impl std::fmt::Display for ProtocolError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::SerializationFailed(msg) => write!(f, "serialization failed: {msg}"), - Self::DeserializationFailed(msg) => write!(f, "deserialization failed: {msg}"), - Self::MessageTooLarge { size, max_size } => { - write!(f, "message size {size} exceeds maximum {max_size}") - } - Self::ChunkTooLarge { size, max_size } => { - write!(f, "chunk size {size} exceeds maximum {max_size}") - } - Self::AddressMismatch { expected, actual } => { - write!( - f, - "address mismatch: expected {}, got {}", - hex::encode(expected), - hex::encode(actual) - ) - } - Self::StorageFailed(msg) => write!(f, "storage failed: {msg}"), - Self::PaymentFailed(msg) => write!(f, "payment failed: {msg}"), - Self::QuoteFailed(msg) => write!(f, "quote failed: {msg}"), - Self::Internal(msg) => write!(f, "internal error: {msg}"), - } - } -} - -impl std::error::Error for ProtocolError {} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn test_put_request_encode_decode() { - let address = [0xAB; 32]; - let content = vec![1, 2, 3, 4, 5]; - let request = ChunkPutRequest::new(address, content.clone()); - let msg = ChunkMessage { - request_id: 42, - body: ChunkMessageBody::PutRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 42); - if let ChunkMessageBody::PutRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.content, content); - assert!(req.payment_proof.is_none()); - } else { - panic!("expected PutRequest"); - } - } - - #[test] - fn test_put_request_with_payment() { - let address = [0xAB; 32]; - let content = vec![1, 2, 3, 4, 5]; - let payment = vec![10, 20, 30]; - let request = ChunkPutRequest::with_payment(address, content.clone(), payment.clone()); - - assert_eq!(request.address, address); - assert_eq!(request.content, content); - assert_eq!(request.payment_proof, Some(payment)); - } - - #[test] - fn test_get_request_encode_decode() { - let address = [0xCD; 32]; - let request = ChunkGetRequest::new(address); - let msg = ChunkMessage { - request_id: 7, - body: ChunkMessageBody::GetRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 7); - if let ChunkMessageBody::GetRequest(req) = decoded.body { - assert_eq!(req.address, address); - } else { - panic!("expected GetRequest"); - } - } - - #[test] - fn test_put_response_success() { - let address = [0xEF; 32]; - let response = ChunkPutResponse::Success { address }; - let msg = ChunkMessage { - request_id: 99, - body: ChunkMessageBody::PutResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 99); - if let ChunkMessageBody::PutResponse(ChunkPutResponse::Success { address: addr }) = - decoded.body - { - assert_eq!(addr, address); - } else { - panic!("expected PutResponse::Success"); - } - } - - #[test] - fn test_get_response_not_found() { - let address = [0x12; 32]; - let response = ChunkGetResponse::NotFound { address }; - let msg = ChunkMessage { - request_id: 0, - body: ChunkMessageBody::GetResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 0); - if let ChunkMessageBody::GetResponse(ChunkGetResponse::NotFound { address: addr }) = - decoded.body - { - assert_eq!(addr, address); - } else { - panic!("expected GetResponse::NotFound"); - } - } - - #[test] - fn test_quote_request_encode_decode() { - let address = [0x34; 32]; - let request = ChunkQuoteRequest::new(address, 1024); - let msg = ChunkMessage { - request_id: 1, - body: ChunkMessageBody::QuoteRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 1); - if let ChunkMessageBody::QuoteRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.data_size, 1024); - assert_eq!(req.data_type, DATA_TYPE_CHUNK); - } else { - panic!("expected QuoteRequest"); - } - } - - #[test] - fn test_protocol_error_display() { - let err = ProtocolError::ChunkTooLarge { - size: 5_000_000, - max_size: MAX_CHUNK_SIZE, - }; - assert!(err.to_string().contains("5000000")); - assert!(err.to_string().contains(&MAX_CHUNK_SIZE.to_string())); - - let err = ProtocolError::AddressMismatch { - expected: [0xAA; 32], - actual: [0xBB; 32], - }; - let display = err.to_string(); - assert!(display.contains("address mismatch")); - } - - #[test] - fn test_decode_rejects_oversized_payload() { - let oversized = vec![0u8; MAX_WIRE_MESSAGE_SIZE + 1]; - let result = ChunkMessage::decode(&oversized); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!( - matches!(err, ProtocolError::MessageTooLarge { .. }), - "expected MessageTooLarge, got {err:?}" - ); - } - - #[test] - fn test_invalid_decode() { - let invalid_data = vec![0xFF, 0xFF, 0xFF]; - let result = ChunkMessage::decode(&invalid_data); - assert!(result.is_err()); - } - - #[test] - fn test_constants() { - assert_eq!(CHUNK_PROTOCOL_ID, "autonomi.ant.chunk.v1"); - assert_eq!(PROTOCOL_VERSION, 1); - assert_eq!(MAX_CHUNK_SIZE, 4 * 1024 * 1024); - assert_eq!(DATA_TYPE_CHUNK, 0); - } - - #[test] - fn test_proof_tag_constants() { - // Tags must be distinct non-zero bytes - assert_ne!(PROOF_TAG_SINGLE_NODE, PROOF_TAG_MERKLE); - assert_ne!(PROOF_TAG_SINGLE_NODE, 0x00); - assert_ne!(PROOF_TAG_MERKLE, 0x00); - assert_eq!(PROOF_TAG_SINGLE_NODE, 0x01); - assert_eq!(PROOF_TAG_MERKLE, 0x02); - } - - #[test] - fn test_merkle_candidate_quote_request_encode_decode() { - let address = [0x56; 32]; - let request = MerkleCandidateQuoteRequest { - address, - data_type: DATA_TYPE_CHUNK, - data_size: 2048, - merkle_payment_timestamp: 1_700_000_000, - }; - let msg = ChunkMessage { - request_id: 500, - body: ChunkMessageBody::MerkleCandidateQuoteRequest(request), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 500); - if let ChunkMessageBody::MerkleCandidateQuoteRequest(req) = decoded.body { - assert_eq!(req.address, address); - assert_eq!(req.data_type, DATA_TYPE_CHUNK); - assert_eq!(req.data_size, 2048); - assert_eq!(req.merkle_payment_timestamp, 1_700_000_000); - } else { - panic!("expected MerkleCandidateQuoteRequest"); - } - } - - #[test] - fn test_merkle_candidate_quote_response_success_encode_decode() { - let candidate_node_bytes = vec![0xAA, 0xBB, 0xCC, 0xDD]; - let response = MerkleCandidateQuoteResponse::Success { - candidate_node: candidate_node_bytes.clone(), - }; - let msg = ChunkMessage { - request_id: 501, - body: ChunkMessageBody::MerkleCandidateQuoteResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 501); - if let ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, - ) = decoded.body - { - assert_eq!(candidate_node, candidate_node_bytes); - } else { - panic!("expected MerkleCandidateQuoteResponse::Success"); - } - } - - #[test] - fn test_merkle_candidate_quote_response_error_encode_decode() { - let error = ProtocolError::QuoteFailed("no libp2p keypair".to_string()); - let response = MerkleCandidateQuoteResponse::Error(error.clone()); - let msg = ChunkMessage { - request_id: 502, - body: ChunkMessageBody::MerkleCandidateQuoteResponse(response), - }; - - let encoded = msg.encode().expect("encode should succeed"); - let decoded = ChunkMessage::decode(&encoded).expect("decode should succeed"); - - assert_eq!(decoded.request_id, 502); - if let ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Error(err), - ) = decoded.body - { - assert_eq!(err, error); - } else { - panic!("expected MerkleCandidateQuoteResponse::Error"); - } - } -} diff --git a/src/ant_protocol/mod.rs b/src/ant_protocol/mod.rs index 563722d2..ed01cf54 100644 --- a/src/ant_protocol/mod.rs +++ b/src/ant_protocol/mod.rs @@ -1,66 +1,25 @@ -//! ANT protocol implementation for the Autonomi network. -//! -//! This module implements the wire protocol for storing and retrieving -//! data on the Autonomi network. -//! -//! # Data Types -//! -//! The ANT protocol supports a single data type: -//! -//! - **Chunk**: Immutable, content-addressed data (hash == address) -//! -//! # Protocol Overview -//! -//! The protocol uses postcard serialization for compact, fast encoding. -//! Each data type has its own message types for PUT/GET operations. -//! -//! ## Chunk Messages -//! -//! - `ChunkPutRequest` / `ChunkPutResponse` - Store chunks -//! - `ChunkGetRequest` / `ChunkGetResponse` - Retrieve chunks -//! - `ChunkQuoteRequest` / `ChunkQuoteResponse` - Request storage quotes -//! -//! ## Payment Flow -//! -//! 1. Client requests a quote via `ChunkQuoteRequest` -//! 2. Node returns signed `PaymentQuote` in `ChunkQuoteResponse` -//! 3. Client pays on Arbitrum via `PaymentVault.payForQuotes()` -//! 4. Client sends `ChunkPutRequest` with `payment_proof` -//! 5. Node verifies payment and stores chunk -//! -//! # Example -//! -//! ```rust,ignore -//! use ant_node::ant_protocol::{ChunkMessage, ChunkPutRequest, ChunkGetRequest}; -//! -//! // Create a PUT request -//! let address = compute_address(&data); -//! let request = ChunkPutRequest::with_payment(address, data, payment_proof); -//! let message = ChunkMessage::PutRequest(request); -//! let bytes = message.encode()?; -//! -//! // Decode a response -//! let response = ChunkMessage::decode(&response_bytes)?; -//! ``` - -pub mod chunk; - -/// Number of nodes in a Kademlia close group. -/// -/// Clients fetch quotes from the `CLOSE_GROUP_SIZE` closest nodes to a target -/// address and select the median-priced quote for payment. -pub const CLOSE_GROUP_SIZE: usize = 7; +//! Wire protocol re-exports from the [`ant_protocol`] crate. +//! +//! This module existed as first-party ant-node code until version 0.11. +//! The wire contract now lives in the [`ant_protocol`] crate so +//! `ant-client` and `ant-node` can evolve their release cycles +//! independently. Everything this module previously exported is +//! re-exported below verbatim, including the `chunk` submodule path so +//! downstream callers of `ant_node::ant_protocol::chunk::*` keep working. +//! +//! Internal ant-node code can keep using `crate::ant_protocol::…`; the +//! imports resolve to the same types they always did. New code should +//! prefer `ant_protocol::…` directly. -/// Minimum number of close group members that must agree for a decision to be valid. -/// -/// This is a simple majority: `(CLOSE_GROUP_SIZE / 2) + 1`. -pub const CLOSE_GROUP_MAJORITY: usize = (CLOSE_GROUP_SIZE / 2) + 1; +// Re-export the submodule so `ant_node::ant_protocol::chunk::*` keeps +// resolving. Using the fully-qualified path `::ant_protocol::chunk` +// disambiguates from `crate::ant_protocol` (this module). +pub use ::ant_protocol::chunk; -// Re-export chunk types for convenience -pub use chunk::{ +pub use ::ant_protocol::chunk::{ ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, MerkleCandidateQuoteRequest, - MerkleCandidateQuoteResponse, ProtocolError, XorName, CHUNK_PROTOCOL_ID, DATA_TYPE_CHUNK, - MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE, - PROTOCOL_VERSION, XORNAME_LEN, + MerkleCandidateQuoteResponse, ProtocolError, XorName, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY, + CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE, + PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN, }; diff --git a/src/client/chunk_protocol.rs b/src/client/chunk_protocol.rs deleted file mode 100644 index ea9e50c4..00000000 --- a/src/client/chunk_protocol.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Shared helper for the chunk protocol request/response pattern. -//! -//! Extracts the duplicated "subscribe → send → poll event loop" into a single -//! generic function used by both [`super::QuantumClient`] and E2E test helpers. - -use crate::ant_protocol::{ChunkMessage, ChunkMessageBody, CHUNK_PROTOCOL_ID}; -use crate::logging::{debug, warn}; -use saorsa_core::identity::PeerId; -use saorsa_core::{MultiAddr, P2PEvent, P2PNode}; -use std::time::Duration; -use tokio::sync::broadcast::error::RecvError; -use tokio::time::Instant; - -/// Send a chunk-protocol message to `target_peer` and await a matching response. -/// -/// The event loop filters by topic (`CHUNK_PROTOCOL_ID`), source peer, decode -/// errors (warn + skip), and `request_id` mismatch (skip). -/// -/// * `response_handler` — inspects the decoded [`ChunkMessageBody`] and returns: -/// - `Some(Ok(T))` to resolve successfully, -/// - `Some(Err(E))` to resolve with an error, -/// - `None` to keep waiting (wrong variant / not our response). -/// * `send_error` — produces the caller's error type when `send_message` fails. -/// * `timeout_error` — produces the caller's error type on deadline expiry. -/// -/// # Errors -/// -/// Returns `Err(E)` if sending fails (via `send_error`), the `response_handler` -/// returns a protocol-level error, or the deadline expires (via `timeout_error`). -#[allow(clippy::too_many_arguments)] -pub async fn send_and_await_chunk_response( - node: &P2PNode, - target_peer: &PeerId, - message_bytes: Vec, - request_id: u64, - timeout: Duration, - peer_addrs: &[MultiAddr], - response_handler: impl Fn(ChunkMessageBody) -> Option>, - send_error: impl FnOnce(String) -> E, - timeout_error: impl FnOnce() -> E, -) -> Result { - // Subscribe before sending so we don't miss the response - let mut events = node.subscribe_events(); - - node.send_message(target_peer, CHUNK_PROTOCOL_ID, message_bytes, peer_addrs) - .await - .map_err(|e| send_error(e.to_string()))?; - - let deadline = Instant::now() + timeout; - - while Instant::now() < deadline { - let remaining = deadline.saturating_duration_since(Instant::now()); - match tokio::time::timeout(remaining, events.recv()).await { - Ok(Ok(P2PEvent::Message { - topic, - source: Some(source), - data, - })) if topic == CHUNK_PROTOCOL_ID && source == *target_peer => { - let response = match ChunkMessage::decode(&data) { - Ok(r) => r, - Err(e) => { - warn!("Failed to decode chunk message, skipping: {e}"); - continue; - } - }; - if response.request_id != request_id { - continue; - } - if let Some(result) = response_handler(response.body) { - return result; - } - } - Ok(Ok(_)) => {} - Ok(Err(RecvError::Lagged(skipped))) => { - debug!("Chunk protocol events lagged by {skipped} messages, continuing"); - } - Ok(Err(RecvError::Closed)) | Err(_) => break, - } - } - - Err(timeout_error()) -} diff --git a/src/client/data_types.rs b/src/client/data_types.rs deleted file mode 100644 index 191b3251..00000000 --- a/src/client/data_types.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Data type definitions for chunk storage. -//! -//! This module provides the core data types for content-addressed chunk storage -//! on the Autonomi network. Chunks are immutable, content-addressed blobs where -//! the address is the BLAKE3 hash of the content. - -use bytes::Bytes; -/// Compute the content address (BLAKE3 hash) for the given data. -#[must_use] -pub fn compute_address(content: &[u8]) -> XorName { - *blake3::hash(content).as_bytes() -} - -/// Compute the XOR distance between two 32-byte addresses. -/// -/// Lexicographic comparison of the result gives correct Kademlia distance ordering. -#[must_use] -pub fn xor_distance(a: &XorName, b: &XorName) -> XorName { - std::array::from_fn(|i| a[i] ^ b[i]) -} - -/// Convert a hex-encoded peer ID string to an `XorName`. -/// -/// Returns `None` if the string is not valid hex or is not exactly 32 bytes (64 hex chars). -#[must_use] -pub fn peer_id_to_xor_name(peer_id: &str) -> Option { - let bytes = hex::decode(peer_id).ok()?; - if bytes.len() != 32 { - return None; - } - let mut name = [0u8; 32]; - name.copy_from_slice(&bytes); - Some(name) -} - -/// A content-addressed identifier (32 bytes). -/// -/// The address is computed as BLAKE3(content) for chunks, -/// ensuring content-addressed storage. -pub type XorName = [u8; 32]; - -/// A chunk of data with its content-addressed identifier. -/// -/// Chunks are the fundamental storage unit in Autonomi. They are: -/// - **Immutable**: Content cannot be changed after storage -/// - **Content-addressed**: Address = BLAKE3(content) -/// - **Paid**: Storage requires EVM payment on Arbitrum -#[derive(Debug, Clone)] -pub struct DataChunk { - /// The content-addressed identifier (BLAKE3 of content). - pub address: XorName, - /// The raw data content. - pub content: Bytes, -} - -impl DataChunk { - /// Create a new data chunk. - /// - /// Note: This does NOT verify that address == BLAKE3(content). - /// Use `from_content` for automatic address computation. - #[must_use] - pub fn new(address: XorName, content: Bytes) -> Self { - Self { address, content } - } - - /// Create a chunk from content, computing the address automatically. - #[must_use] - pub fn from_content(content: Bytes) -> Self { - let address = compute_address(&content); - Self { address, content } - } - - /// Get the size of the chunk in bytes. - #[must_use] - pub fn size(&self) -> usize { - self.content.len() - } - - /// Verify that the address matches BLAKE3(content). - #[must_use] - pub fn verify(&self) -> bool { - self.address == compute_address(&self.content) - } -} - -/// Statistics about chunk operations. -#[derive(Debug, Default, Clone)] -pub struct ChunkStats { - /// Number of chunks stored. - pub chunks_stored: u64, - /// Number of chunks retrieved. - pub chunks_retrieved: u64, - /// Number of cache hits. - pub cache_hits: u64, - /// Number of misses (not found). - pub misses: u64, - /// Total bytes stored. - pub bytes_stored: u64, - /// Total bytes retrieved. - pub bytes_retrieved: u64, -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - #[test] - fn test_data_chunk_creation() { - let address = [0xAB; 32]; - let content = Bytes::from("test data"); - let chunk = DataChunk::new(address, content.clone()); - - assert_eq!(chunk.address, address); - assert_eq!(chunk.content, content); - assert_eq!(chunk.size(), 9); - } - - #[test] - fn test_chunk_from_content() { - let content = Bytes::from("hello world"); - let chunk = DataChunk::from_content(content.clone()); - - // BLAKE3 of "hello world" - let expected: [u8; 32] = [ - 0xd7, 0x49, 0x81, 0xef, 0xa7, 0x0a, 0x0c, 0x88, 0x0b, 0x8d, 0x8c, 0x19, 0x85, 0xd0, - 0x75, 0xdb, 0xcb, 0xf6, 0x79, 0xb9, 0x9a, 0x5f, 0x99, 0x14, 0xe5, 0xaa, 0xf9, 0x6b, - 0x83, 0x1a, 0x9e, 0x24, - ]; - - assert_eq!(chunk.address, expected); - assert_eq!(chunk.content, content); - assert!(chunk.verify()); - } - - #[test] - fn test_xor_distance_identity() { - let a = [0xAB; 32]; - assert_eq!(xor_distance(&a, &a), [0u8; 32]); - } - - #[test] - fn test_xor_distance_symmetry() { - let a = [0x01; 32]; - let b = [0xFF; 32]; - assert_eq!(xor_distance(&a, &b), xor_distance(&b, &a)); - } - - #[test] - fn test_xor_distance_known_values() { - let a = [0x00; 32]; - let b = [0xFF; 32]; - assert_eq!(xor_distance(&a, &b), [0xFF; 32]); - - let mut c = [0x00; 32]; - c[0] = 0x80; - let mut expected = [0x00; 32]; - expected[0] = 0x80; - assert_eq!(xor_distance(&a, &c), expected); - } - - #[test] - fn test_peer_id_to_xor_name_valid() { - let hex_str = "ab".repeat(32); - let result = peer_id_to_xor_name(&hex_str); - assert_eq!(result, Some([0xAB; 32])); - } - - #[test] - fn test_peer_id_to_xor_name_invalid_hex() { - assert_eq!(peer_id_to_xor_name("not_hex_at_all!"), None); - } - - #[test] - fn test_peer_id_to_xor_name_wrong_length() { - // 16 bytes instead of 32 - let short = "ab".repeat(16); - assert_eq!(peer_id_to_xor_name(&short), None); - - // 33 bytes - let long = "ab".repeat(33); - assert_eq!(peer_id_to_xor_name(&long), None); - } - - #[test] - fn test_chunk_verify() { - // Valid chunk - let content = Bytes::from("test"); - let valid = DataChunk::from_content(content); - assert!(valid.verify()); - - // Invalid chunk (wrong address) - let invalid = DataChunk::new([0; 32], Bytes::from("test")); - assert!(!invalid.verify()); - } -} diff --git a/src/client/mod.rs b/src/client/mod.rs index b67339eb..987d28bb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,49 +1,36 @@ //! Protocol helpers for ant-node client operations. //! -//! This module provides low-level protocol support for client-node communication. -//! For high-level client operations, use the `ant-client` crate instead. +//! This module provides low-level protocol support for client-node +//! communication. For high-level client operations, use the `ant-client` +//! crate instead. //! //! # Architecture //! -//! This module contains: -//! -//! 1. **Protocol message handlers**: Send/await pattern for chunks -//! 2. **Data types**: Common types like `XorName`, `DataChunk`, address computation -//! -//! # Migration Note -//! -//! The `QuantumClient` has been deprecated and consolidated into `ant-client::Client`. -//! Use `ant-client` for all client operations. +//! As of 0.11, the shared protocol types and helpers live in the +//! [`ant_protocol`] crate. This module re-exports them so existing +//! callers of `ant_node::client::*` continue to compile; new code +//! should prefer `ant_protocol::*` directly. //! //! # Example //! //! ```rust,ignore -//! use ant_client::Client; // Use ant-client instead of QuantumClient +//! use ant_client::Client; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // High-level client API //! let client = Client::connect(&bootstrap_peers, Default::default()).await?; -//! -//! // Store data with payment //! let address = client.chunk_put(bytes::Bytes::from("hello world")).await?; -//! -//! // Retrieve data //! let chunk = client.chunk_get(&address).await?; -//! //! Ok(()) //! } //! ``` -mod chunk_protocol; -mod data_types; - -pub use chunk_protocol::send_and_await_chunk_response; -pub use data_types::{ - compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk, XorName, +pub use ant_protocol::chunk_protocol::send_and_await_chunk_response; +pub use ant_protocol::data_types::{ + compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk, }; +pub use ant_protocol::XorName; -// Re-export hex_node_id_to_encoded_peer_id for payment operations use crate::error::{Error, Result}; use evmlib::EncodedPeerId; diff --git a/src/devnet.rs b/src/devnet.rs index 17ded036..e70c0303 100644 --- a/src/devnet.rs +++ b/src/devnet.rs @@ -18,7 +18,6 @@ use saorsa_core::identity::NodeIdentity; use saorsa_core::{ IPDiversityConfig, MultiAddr, NodeConfig as CoreNodeConfig, P2PEvent, P2PNode, PeerId, }; -use serde::{Deserialize, Serialize}; use std::net::{Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; @@ -214,36 +213,10 @@ impl DevnetConfig { } } -/// Devnet manifest for client discovery. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DevnetManifest { - /// Base port for nodes. - pub base_port: u16, - /// Node count. - pub node_count: usize, - /// Bootstrap addresses. - pub bootstrap: Vec, - /// Data directory. - pub data_dir: PathBuf, - /// Creation time in RFC3339. - pub created_at: String, - /// EVM configuration (present when EVM payment enforcement is enabled). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub evm: Option, -} - -/// EVM configuration info included in the devnet manifest. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DevnetEvmInfo { - /// Anvil RPC URL. - pub rpc_url: String, - /// Funded wallet private key (hex-encoded with 0x prefix). - pub wallet_private_key: String, - /// Payment token contract address. - pub payment_token_address: String, - /// Unified payment vault contract address (handles both single-node and merkle payments). - pub payment_vault_address: String, -} +// The manifest types are shared with ant-client (CLI reads them, devnet +// writes them), so they live in ant-protocol. Re-exported here for +// backwards compatibility. +pub use ant_protocol::devnet_manifest::{DevnetEvmInfo, DevnetManifest}; /// Network state for devnet startup lifecycle. #[derive(Debug, Clone)] diff --git a/src/payment/mod.rs b/src/payment/mod.rs index d6cd0d50..72ee0ff5 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -54,9 +54,11 @@ pub use proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, serialize_single_node_proof, PaymentProof, ProofType, }; -pub use quote::{ - verify_merkle_candidate_signature, verify_quote_content, wire_ml_dsa_signer, QuoteGenerator, - XorName, +pub use quote::{wire_ml_dsa_signer, QuoteGenerator, XorName}; +// Wire-side signature verification lives in ant-protocol. Re-exported here +// so `ant_node::payment::verify_*` keeps working for downstream callers. +pub use ant_protocol::payment::verify::{ + verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature, }; pub use single_node::SingleNodePayment; pub use verifier::{ diff --git a/src/payment/proof.rs b/src/payment/proof.rs index 0db0b5e0..269a4366 100644 --- a/src/payment/proof.rs +++ b/src/payment/proof.rs @@ -1,385 +1,10 @@ -//! Payment proof wrapper that includes transaction hashes. +//! Payment proof re-exports from [`ant_protocol`]. //! -//! `PaymentProof` bundles a `ProofOfPayment` (quotes + peer IDs) with the -//! on-chain transaction hashes returned by the wallet after payment. - -use crate::ant_protocol::{PROOF_TAG_MERKLE, PROOF_TAG_SINGLE_NODE}; -use evmlib::common::TxHash; -use evmlib::merkle_payments::MerklePaymentProof; -use evmlib::ProofOfPayment; -use serde::{Deserialize, Serialize}; - -/// A payment proof that includes both the quote-based proof and on-chain tx hashes. -/// -/// This replaces the bare `ProofOfPayment` in serialized proof bytes, adding -/// the transaction hashes that were previously discarded after `payment.pay()`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaymentProof { - /// The original quote-based proof (peer IDs + quotes with ML-DSA-65 signatures). - pub proof_of_payment: ProofOfPayment, - /// Transaction hashes from the on-chain payment. - /// Typically contains one hash for the median (non-zero) quote. - pub tx_hashes: Vec, -} - -/// The detected type of a payment proof. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProofType { - /// `SingleNode` payment (`CLOSE_GROUP_SIZE` quotes, median-paid). - SingleNode, - /// Merkle batch payment (one tx for many chunks). - Merkle, -} - -/// Detect the proof type from the first byte (version tag). -/// -/// Returns `None` if the tag byte is unrecognized or the slice is empty. -#[must_use] -pub fn detect_proof_type(bytes: &[u8]) -> Option { - match bytes.first() { - Some(&PROOF_TAG_SINGLE_NODE) => Some(ProofType::SingleNode), - Some(&PROOF_TAG_MERKLE) => Some(ProofType::Merkle), - _ => None, - } -} - -/// Serialize a `PaymentProof` (single-node) with the version tag prefix. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_single_node_proof( - proof: &PaymentProof, -) -> std::result::Result, rmp_serde::encode::Error> { - let body = rmp_serde::to_vec(proof)?; - let mut tagged = Vec::with_capacity(1 + body.len()); - tagged.push(PROOF_TAG_SINGLE_NODE); - tagged.extend_from_slice(&body); - Ok(tagged) -} - -/// Serialize a `MerklePaymentProof` with the version tag prefix. -/// -/// # Errors -/// -/// Returns an error if serialization fails. -pub fn serialize_merkle_proof( - proof: &MerklePaymentProof, -) -> std::result::Result, rmp_serde::encode::Error> { - let body = rmp_serde::to_vec(proof)?; - let mut tagged = Vec::with_capacity(1 + body.len()); - tagged.push(PROOF_TAG_MERKLE); - tagged.extend_from_slice(&body); - Ok(tagged) -} - -/// Deserialize proof bytes from the `PaymentProof` format (single-node). -/// -/// Expects the first byte to be `PROOF_TAG_SINGLE_NODE`. -/// Returns `(ProofOfPayment, Vec)`. -/// -/// # Errors -/// -/// Returns an error if the tag is missing or the bytes cannot be deserialized. -pub fn deserialize_proof(bytes: &[u8]) -> Result<(ProofOfPayment, Vec), String> { - if bytes.first() != Some(&PROOF_TAG_SINGLE_NODE) { - return Err("Missing single-node proof tag byte".to_string()); - } - let payload = bytes - .get(1..) - .ok_or_else(|| "Single-node proof tag present but no payload".to_string())?; - let proof = rmp_serde::from_slice::(payload) - .map_err(|e| format!("Failed to deserialize single-node proof: {e}"))?; - Ok((proof.proof_of_payment, proof.tx_hashes)) -} - -/// Deserialize proof bytes as a `MerklePaymentProof`. -/// -/// Expects the first byte to be `PROOF_TAG_MERKLE`. -/// -/// # Errors -/// -/// Returns an error if the bytes cannot be deserialized or the tag is wrong. -pub fn deserialize_merkle_proof(bytes: &[u8]) -> std::result::Result { - if bytes.first() != Some(&PROOF_TAG_MERKLE) { - return Err("Missing merkle proof tag byte".to_string()); - } - let payload = bytes - .get(1..) - .ok_or_else(|| "Merkle proof tag present but no payload".to_string())?; - rmp_serde::from_slice::(payload) - .map_err(|e| format!("Failed to deserialize merkle proof: {e}")) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - use alloy::primitives::FixedBytes; - use evmlib::common::Amount; - use evmlib::merkle_payments::{ - MerklePaymentCandidateNode, MerklePaymentCandidatePool, MerklePaymentProof, MerkleTree, - CANDIDATES_PER_POOL, - }; - use evmlib::EncodedPeerId; - use evmlib::PaymentQuote; - use evmlib::RewardsAddress; - use saorsa_core::MlDsa65; - use saorsa_pqc::pqc::types::MlDsaSecretKey; - use saorsa_pqc::pqc::MlDsaOperations; - use std::time::SystemTime; - use xor_name::XorName; - - fn make_test_quote() -> PaymentQuote { - PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(1u64), - rewards_address: RewardsAddress::new([1u8; 20]), - pub_key: vec![], - signature: vec![], - } - } - - fn make_proof_of_payment() -> ProofOfPayment { - let random_peer = EncodedPeerId::new(rand::random()); - ProofOfPayment { - peer_quotes: vec![(random_peer, make_test_quote())], - } - } - - #[test] - fn test_payment_proof_serialization_roundtrip() { - let tx_hash = FixedBytes::from([0xABu8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx_hash], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (pop, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(pop.peer_quotes.len(), 1); - assert_eq!(hashes.len(), 1); - assert_eq!(hashes.first().unwrap(), &tx_hash); - } - - #[test] - fn test_payment_proof_with_empty_tx_hashes() { - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (pop, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(pop.peer_quotes.len(), 1); - assert!(hashes.is_empty()); - } - - #[test] - fn test_deserialize_proof_rejects_garbage() { - let garbage = vec![0xFF, 0x00, 0x01, 0x02]; - let result = deserialize_proof(&garbage); - assert!(result.is_err()); - } - - #[test] - fn test_deserialize_proof_rejects_untagged() { - // Raw msgpack without tag byte must be rejected - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![], - }; - let raw_bytes = rmp_serde::to_vec(&proof).unwrap(); - let result = deserialize_proof(&raw_bytes); - assert!(result.is_err()); - } - - #[test] - fn test_payment_proof_multiple_tx_hashes() { - let tx1 = FixedBytes::from([0x11u8; 32]); - let tx2 = FixedBytes::from([0x22u8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx1, tx2], - }; - - let bytes = serialize_single_node_proof(&proof).unwrap(); - let (_, hashes) = deserialize_proof(&bytes).unwrap(); - - assert_eq!(hashes.len(), 2); - assert_eq!(hashes.first().unwrap(), &tx1); - assert_eq!(hashes.get(1).unwrap(), &tx2); - } - - // ========================================================================= - // detect_proof_type tests - // ========================================================================= - - #[test] - fn test_detect_proof_type_single_node() { - let bytes = [PROOF_TAG_SINGLE_NODE, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, Some(ProofType::SingleNode)); - } - - #[test] - fn test_detect_proof_type_merkle() { - let bytes = [PROOF_TAG_MERKLE, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, Some(ProofType::Merkle)); - } - - #[test] - fn test_detect_proof_type_unknown_tag() { - let bytes = [0xFF, 0x00, 0x01]; - let result = detect_proof_type(&bytes); - assert_eq!(result, None); - } - - #[test] - fn test_detect_proof_type_empty_bytes() { - let bytes: &[u8] = &[]; - let result = detect_proof_type(bytes); - assert_eq!(result, None); - } - - // ========================================================================= - // Tagged serialize/deserialize round-trip tests - // ========================================================================= - - #[test] - fn test_serialize_single_node_proof_roundtrip_with_tag() { - let tx_hash = FixedBytes::from([0xCCu8; 32]); - let proof = PaymentProof { - proof_of_payment: make_proof_of_payment(), - tx_hashes: vec![tx_hash], - }; - - let tagged_bytes = serialize_single_node_proof(&proof).unwrap(); - - // First byte must be the single-node tag - assert_eq!( - tagged_bytes.first().copied(), - Some(PROOF_TAG_SINGLE_NODE), - "Tagged proof must start with PROOF_TAG_SINGLE_NODE" - ); - - // detect_proof_type should identify it - assert_eq!( - detect_proof_type(&tagged_bytes), - Some(ProofType::SingleNode) - ); - - // deserialize_proof handles the tag transparently - let (pop, hashes) = deserialize_proof(&tagged_bytes).unwrap(); - assert_eq!(pop.peer_quotes.len(), 1); - assert_eq!(hashes.len(), 1); - assert_eq!(hashes.first().unwrap(), &tx_hash); - } - - // ========================================================================= - // Merkle proof serialize/deserialize round-trip tests - // ========================================================================= - - /// Create a minimal valid `MerklePaymentProof` from a small merkle tree. - fn make_test_merkle_proof() -> MerklePaymentProof { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Build a tree with 4 addresses (minimal depth) - let addresses: Vec = (0..4u8) - .map(|i| xor_name::XorName::from_content(&[i])) - .collect(); - let tree = MerkleTree::from_xornames(addresses.clone()).unwrap(); - - // Build candidate nodes with ML-DSA-65 signing (matching production) - let candidate_nodes: [MerklePaymentCandidateNode; CANDIDATES_PER_POOL] = - std::array::from_fn(|i| { - let ml_dsa = MlDsa65::new(); - let (pub_key, secret_key) = ml_dsa.generate_keypair().expect("keygen"); - let price = Amount::from(1024u64); - #[allow(clippy::cast_possible_truncation)] - let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = - MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); - let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); - let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); - - MerklePaymentCandidateNode { - pub_key: pub_key.as_bytes().to_vec(), - price, - reward_address, - merkle_payment_timestamp: timestamp, - signature, - } - }); - - let reward_candidates = tree.reward_candidates(timestamp).unwrap(); - let midpoint_proof = reward_candidates.first().unwrap().clone(); - - let pool = MerklePaymentCandidatePool { - midpoint_proof, - candidate_nodes, - }; - - let first_address = *addresses.first().unwrap(); - let address_proof = tree.generate_address_proof(0, first_address).unwrap(); - - MerklePaymentProof::new(first_address, address_proof, pool) - } - - #[test] - fn test_serialize_merkle_proof_roundtrip() { - let merkle_proof = make_test_merkle_proof(); - - let tagged_bytes = serialize_merkle_proof(&merkle_proof).unwrap(); - - // First byte must be the merkle tag - assert_eq!( - tagged_bytes.first().copied(), - Some(PROOF_TAG_MERKLE), - "Tagged merkle proof must start with PROOF_TAG_MERKLE" - ); - - // detect_proof_type should identify it as merkle - assert_eq!(detect_proof_type(&tagged_bytes), Some(ProofType::Merkle)); - - // deserialize_merkle_proof should recover the original proof - let recovered = deserialize_merkle_proof(&tagged_bytes).unwrap(); - assert_eq!(recovered.address, merkle_proof.address); - assert_eq!( - recovered.winner_pool.candidate_nodes.len(), - CANDIDATES_PER_POOL - ); - } - - #[test] - fn test_deserialize_merkle_proof_rejects_wrong_tag() { - let merkle_proof = make_test_merkle_proof(); - let mut tagged_bytes = serialize_merkle_proof(&merkle_proof).unwrap(); - - // Replace the tag with the single-node tag - if let Some(first) = tagged_bytes.first_mut() { - *first = PROOF_TAG_SINGLE_NODE; - } - - let result = deserialize_merkle_proof(&tagged_bytes); - assert!(result.is_err(), "Should reject wrong tag byte"); - let err_msg = result.unwrap_err(); - assert!( - err_msg.contains("Missing merkle proof tag"), - "Error should mention missing tag: {err_msg}" - ); - } - - #[test] - fn test_deserialize_merkle_proof_rejects_empty() { - let result = deserialize_merkle_proof(&[]); - assert!(result.is_err()); - } -} +//! Extracted to the [`ant_protocol`] crate in 0.11 so `ant-client` and +//! `ant-node` share one version of the serialization format. Internal +//! callers using `crate::payment::proof::…` keep working unchanged. + +pub use ant_protocol::payment::proof::{ + deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, + serialize_single_node_proof, PaymentProof, ProofType, +}; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index d3dd9f64..7052e284 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -15,7 +15,7 @@ use evmlib::merkle_payments::MerklePaymentCandidateNode; use evmlib::PaymentQuote; use evmlib::RewardsAddress; use saorsa_core::MlDsa65; -use saorsa_pqc::pqc::types::{MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature}; +use saorsa_pqc::pqc::types::MlDsaSecretKey; use saorsa_pqc::pqc::MlDsaOperations; use std::time::SystemTime; @@ -245,131 +245,10 @@ impl QuoteGenerator { } } -/// Verify a payment quote's content address and ML-DSA-65 signature. -/// -/// # Arguments -/// -/// * `quote` - The quote to verify -/// * `expected_content` - The expected content `XorName` -/// -/// # Returns -/// -/// `true` if the content matches and the ML-DSA-65 signature is valid. -#[must_use] -pub fn verify_quote_content(quote: &PaymentQuote, expected_content: &XorName) -> bool { - // Check content matches - if quote.content.0 != *expected_content { - if crate::logging::enabled!(crate::logging::Level::DEBUG) { - debug!( - "Quote content mismatch: expected {}, got {}", - hex::encode(expected_content), - hex::encode(quote.content.0) - ); - } - return false; - } - true -} - -/// Verify that a payment quote has a valid ML-DSA-65 signature. -/// -/// This replaces ant-evm's `check_is_signed_by_claimed_peer()` which only -/// handles Ed25519/libp2p signatures. Autonomi uses ML-DSA-65 post-quantum -/// signatures for quote signing. -/// -/// # Arguments -/// -/// * `quote` - The quote to verify -/// -/// # Returns -/// -/// `true` if the ML-DSA-65 signature is valid for the quote's content. -#[must_use] -pub fn verify_quote_signature(quote: &PaymentQuote) -> bool { - // Parse public key from quote - let pub_key = match MlDsaPublicKey::from_bytes("e.pub_key) { - Ok(pk) => pk, - Err(e) => { - debug!("Failed to parse ML-DSA-65 public key from quote: {e}"); - return false; - } - }; - - // Parse signature from quote - let signature = match MlDsaSignature::from_bytes("e.signature) { - Ok(sig) => sig, - Err(e) => { - debug!("Failed to parse ML-DSA-65 signature from quote: {e}"); - return false; - } - }; - - // Get the bytes that were signed - let bytes = quote.bytes_for_sig(); - - // Verify using ML-DSA-65 implementation - let ml_dsa = MlDsa65::new(); - match ml_dsa.verify(&pub_key, &bytes, &signature) { - Ok(valid) => { - if !valid { - debug!("ML-DSA-65 quote signature verification failed"); - } - valid - } - Err(e) => { - debug!("ML-DSA-65 verification error: {e}"); - false - } - } -} - -/// Verify a `MerklePaymentCandidateNode` signature using ML-DSA-65. -/// -/// Autonomi uses ML-DSA-65 post-quantum signatures for merkle candidate signing, -/// rather than the ed25519 signatures used by the upstream `ant-evm` library. -/// The `pub_key` field contains the raw ML-DSA-65 public key bytes, and -/// `signature` contains the ML-DSA-65 signature over `bytes_to_sign()`. -/// -/// This replaces `MerklePaymentCandidateNode::verify_signature()` which -/// expects libp2p ed25519 keys. -#[must_use] -pub fn verify_merkle_candidate_signature(candidate: &MerklePaymentCandidateNode) -> bool { - let pub_key = match MlDsaPublicKey::from_bytes(&candidate.pub_key) { - Ok(pk) => pk, - Err(e) => { - debug!("Failed to parse ML-DSA-65 public key from merkle candidate: {e}"); - return false; - } - }; - - let signature = match MlDsaSignature::from_bytes(&candidate.signature) { - Ok(sig) => sig, - Err(e) => { - debug!("Failed to parse ML-DSA-65 signature from merkle candidate: {e}"); - return false; - } - }; - - let msg = MerklePaymentCandidateNode::bytes_to_sign( - &candidate.price, - &candidate.reward_address, - candidate.merkle_payment_timestamp, - ); - - let ml_dsa = MlDsa65::new(); - match ml_dsa.verify(&pub_key, &msg, &signature) { - Ok(valid) => { - if !valid { - debug!("ML-DSA-65 merkle candidate signature verification failed"); - } - valid - } - Err(e) => { - debug!("ML-DSA-65 merkle candidate verification error: {e}"); - false - } - } -} +// Wire-side signature verification (`verify_quote_content`, +// `verify_quote_signature`, `verify_merkle_candidate_signature`) lives +// in `ant_protocol::payment::verify`. Re-exported from +// `crate::payment` for backwards compatibility. /// Wire ML-DSA-65 signing from a node identity into a `QuoteGenerator`. /// @@ -410,6 +289,13 @@ pub fn wire_ml_dsa_signer( mod tests { use super::*; use crate::payment::metrics::QuotingMetricsTracker; + // Verification helpers live in ant-protocol; import them here so the + // long-standing node-side negative tests (tampered keys, swapped + // pub keys, wrong timestamp, etc.) keep running against the canonical + // wire-side implementation. + use ant_protocol::payment::verify::{ + verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature, + }; use evmlib::common::Amount; use saorsa_pqc::pqc::types::MlDsaSecretKey; diff --git a/src/payment/single_node.rs b/src/payment/single_node.rs index 32a93c80..9643c331 100644 --- a/src/payment/single_node.rs +++ b/src/payment/single_node.rs @@ -1,703 +1,8 @@ -//! `SingleNode` payment mode implementation for ant-node. +//! `SingleNode` payment re-exports from [`ant_protocol`]. //! -//! This module implements the `SingleNode` payment strategy from autonomi: -//! - Client gets `CLOSE_GROUP_SIZE` quotes from network -//! - Sort by price and select median (index `CLOSE_GROUP_SIZE / 2`) -//! - Pay ONLY the median-priced node with 3x the quoted amount -//! - Other nodes get `Amount::ZERO` -//! - All are submitted for payment and verification -//! -//! Total cost is the same as Standard mode (3x), but with one actual payment. -//! This saves gas fees while maintaining the same total payment amount. - -use crate::ant_protocol::CLOSE_GROUP_SIZE; -use crate::error::{Error, Result}; -use crate::logging::info; -use evmlib::common::{Amount, QuoteHash}; -use evmlib::wallet::Wallet; -use evmlib::Network as EvmNetwork; -use evmlib::PaymentQuote; -use evmlib::RewardsAddress; - -/// Index of the median-priced node after sorting, derived from `CLOSE_GROUP_SIZE`. -const MEDIAN_INDEX: usize = CLOSE_GROUP_SIZE / 2; - -/// Single node payment structure for a chunk. -/// -/// Contains exactly `CLOSE_GROUP_SIZE` quotes where only the median-priced one -/// receives payment (3x), and the remaining quotes have `Amount::ZERO`. -/// -/// The fixed-size array ensures compile-time enforcement of the quote count, -/// making the median index always valid. -#[derive(Debug, Clone)] -pub struct SingleNodePayment { - /// All quotes (sorted by price) - fixed size ensures median index is always valid - pub quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE], -} - -/// Information about a single quote payment -#[derive(Debug, Clone)] -pub struct QuotePaymentInfo { - /// The quote hash - pub quote_hash: QuoteHash, - /// The rewards address - pub rewards_address: RewardsAddress, - /// The amount to pay (3x for median, 0 for others) - pub amount: Amount, - /// The original quoted price (before 3x multiplier) - pub price: Amount, -} - -impl SingleNodePayment { - /// Create a `SingleNode` payment from `CLOSE_GROUP_SIZE` quotes and their prices. - /// - /// The quotes are automatically sorted by price (cheapest first). - /// The median (index `CLOSE_GROUP_SIZE / 2`) gets 3x its quote price. - /// The others get `Amount::ZERO`. - /// - /// # Arguments - /// - /// * `quotes_with_prices` - Vec of (`PaymentQuote`, Amount) tuples (will be sorted internally) - /// - /// # Errors - /// - /// Returns error if not exactly `CLOSE_GROUP_SIZE` quotes are provided. - pub fn from_quotes(mut quotes_with_prices: Vec<(PaymentQuote, Amount)>) -> Result { - let len = quotes_with_prices.len(); - if len != CLOSE_GROUP_SIZE { - return Err(Error::Payment(format!( - "SingleNode payment requires exactly {CLOSE_GROUP_SIZE} quotes, got {len}" - ))); - } - - // Sort by price (cheapest first) to ensure correct median selection - quotes_with_prices.sort_by_key(|(_, price)| *price); - - // Get median price and calculate 3x - let median_price = quotes_with_prices - .get(MEDIAN_INDEX) - .ok_or_else(|| { - Error::Payment(format!( - "Missing median quote at index {MEDIAN_INDEX}: expected {CLOSE_GROUP_SIZE} quotes but get() failed" - )) - })? - .1; - let enhanced_price = median_price - .checked_mul(Amount::from(3u64)) - .ok_or_else(|| { - Error::Payment("Price overflow when calculating 3x median".to_string()) - })?; - - // Build quote payment info for all CLOSE_GROUP_SIZE quotes - // Use try_from to convert Vec to fixed-size array - let quotes_vec: Vec = quotes_with_prices - .into_iter() - .enumerate() - .map(|(idx, (quote, price))| QuotePaymentInfo { - quote_hash: quote.hash(), - rewards_address: quote.rewards_address, - amount: if idx == MEDIAN_INDEX { - enhanced_price - } else { - Amount::ZERO - }, - price, - }) - .collect(); - - // Convert Vec to array - we already validated length is CLOSE_GROUP_SIZE - let quotes: [QuotePaymentInfo; CLOSE_GROUP_SIZE] = quotes_vec - .try_into() - .map_err(|_| Error::Payment("Failed to convert quotes to fixed array".to_string()))?; - - Ok(Self { quotes }) - } - - /// Get the total payment amount (should be 3x median price) - #[must_use] - pub fn total_amount(&self) -> Amount { - self.quotes.iter().map(|q| q.amount).sum() - } - - /// Get the median quote that receives payment. - /// - /// Returns `None` only if the internal array is somehow shorter than `MEDIAN_INDEX`, - /// which should never happen since the array is fixed-size `[_; CLOSE_GROUP_SIZE]`. - #[must_use] - pub fn paid_quote(&self) -> Option<&QuotePaymentInfo> { - self.quotes.get(MEDIAN_INDEX) - } - - /// Pay for all quotes on-chain using the wallet. - /// - /// Pays 3x to the median quote and 0 to the others. - /// - /// # Errors - /// - /// Returns an error if the payment transaction fails. - pub async fn pay(&self, wallet: &Wallet) -> Result> { - // Build quote payments: (QuoteHash, RewardsAddress, Amount) - let quote_payments: Vec<_> = self - .quotes - .iter() - .map(|q| (q.quote_hash, q.rewards_address, q.amount)) - .collect(); - - info!( - "Paying for {} quotes: 1 real ({} atto) + {} with 0 atto", - CLOSE_GROUP_SIZE, - self.total_amount(), - CLOSE_GROUP_SIZE - 1 - ); - - let (tx_hashes, _gas_info) = wallet.pay_for_quotes(quote_payments).await.map_err( - |evmlib::wallet::PayForQuotesError(err, _)| { - Error::Payment(format!("Failed to pay for quotes: {err}")) - }, - )?; - - // Collect transaction hashes only for non-zero amount quotes - // Zero-amount quotes don't generate on-chain transactions - let mut result_hashes = Vec::new(); - for quote_info in &self.quotes { - if quote_info.amount > Amount::ZERO { - let tx_hash = tx_hashes.get("e_info.quote_hash).ok_or_else(|| { - Error::Payment(format!( - "Missing transaction hash for non-zero quote {}", - quote_info.quote_hash - )) - })?; - result_hashes.push(*tx_hash); - } - } - - info!( - "Payment successful: {} on-chain transactions", - result_hashes.len() - ); - - Ok(result_hashes) - } - - /// Verify that a median-priced quote was paid at least 3× its price on-chain. - /// - /// When multiple quotes share the median price (a tie), the client and - /// verifier may sort them in different order. This method checks all - /// quotes tied at the median price and accepts the payment if any one - /// of them was paid the correct amount. - /// - /// # Returns - /// - /// The on-chain payment amount for the verified quote. - /// - /// # Errors - /// - /// Returns an error if the on-chain lookup fails or none of the - /// median-priced quotes were paid at least 3× the median price. - pub async fn verify(&self, network: &EvmNetwork) -> Result { - let median = &self.quotes[MEDIAN_INDEX]; - let median_price = median.price; - let expected_amount = median.amount; - - // Collect all quotes tied at the median price - let tied_quotes: Vec<&QuotePaymentInfo> = self - .quotes - .iter() - .filter(|q| q.price == median_price) - .collect(); - - info!( - "Verifying median quote payment: expected at least {expected_amount} atto, {} quote(s) tied at median price", - tied_quotes.len() - ); - - let provider = evmlib::utils::http_provider(network.rpc_url().clone()); - let vault_address = *network.payment_vault_address(); - let contract = - evmlib::contract::payment_vault::interface::IPaymentVault::new(vault_address, provider); - - // Check each tied quote — accept if any one was paid correctly - for candidate in &tied_quotes { - let result = contract - .completedPayments(candidate.quote_hash) - .call() - .await - .map_err(|e| Error::Payment(format!("completedPayments lookup failed: {e}")))?; - - let on_chain_amount = Amount::from(result.amount); - - if on_chain_amount >= expected_amount { - info!("Payment verified: {on_chain_amount} atto paid for median-priced quote"); - return Ok(on_chain_amount); - } - } - - Err(Error::Payment(format!( - "No median-priced quote was paid enough: expected at least {expected_amount}, checked {} tied quote(s)", - tied_quotes.len() - ))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy::node_bindings::{Anvil, AnvilInstance}; - use evmlib::testnet::{deploy_network_token_contract, deploy_payment_vault_contract, Testnet}; - use evmlib::transaction_config::TransactionConfig; - use evmlib::utils::{dummy_address, dummy_hash}; - use evmlib::wallet::Wallet; - use reqwest::Url; - use serial_test::serial; - use std::time::SystemTime; - use xor_name::XorName; - - fn make_test_quote(rewards_addr_seed: u8) -> PaymentQuote { - PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(1u64), - rewards_address: RewardsAddress::new([rewards_addr_seed; 20]), - pub_key: vec![], - signature: vec![], - } - } - - /// Start an Anvil node with increased timeout for CI environments. - /// - /// The default timeout is 10 seconds which can be insufficient in CI. - /// This helper uses a 60-second timeout and random port assignment - /// to handle slower CI environments and parallel test execution. - #[allow(clippy::expect_used, clippy::panic)] - fn start_node_with_timeout() -> (AnvilInstance, Url) { - const ANVIL_TIMEOUT_MS: u64 = 60_000; // 60 seconds for CI - - let host = std::env::var("ANVIL_IP_ADDR").unwrap_or_else(|_| "localhost".to_string()); - - // Use port 0 to let the OS assign a random available port. - // This prevents port conflicts when running tests in parallel. - let anvil = Anvil::new() - .timeout(ANVIL_TIMEOUT_MS) - .try_spawn() - .unwrap_or_else(|_| panic!("Could not spawn Anvil node after {ANVIL_TIMEOUT_MS}ms")); - - let url = Url::parse(&format!("http://{host}:{}", anvil.port())) - .expect("Failed to parse Anvil URL"); - - (anvil, url) - } - - /// Test: Standard `CLOSE_GROUP_SIZE`-quote payment verification (autonomi baseline) - #[tokio::test] - #[serial] - #[allow(clippy::expect_used)] - async fn test_standard_quote_payment() { - // Use autonomi's setup pattern with increased timeout for CI - let (node, rpc_url) = start_node_with_timeout(); - let network_token = deploy_network_token_contract(&rpc_url, &node) - .await - .expect("deploy network token"); - let mut payment_vault = - deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) - .await - .expect("deploy data payments"); - - let transaction_config = TransactionConfig::default(); - - // Create CLOSE_GROUP_SIZE random quote payments (autonomi pattern) - let mut quote_payments = vec![]; - for _ in 0..CLOSE_GROUP_SIZE { - let quote_hash = dummy_hash(); - let reward_address = dummy_address(); - let amount = Amount::from(1u64); - quote_payments.push((quote_hash, reward_address, amount)); - } - - // Approve tokens - network_token - .approve( - *payment_vault.contract.address(), - evmlib::common::U256::MAX, - &transaction_config, - ) - .await - .expect("Failed to approve"); - - println!("✓ Approved tokens"); - - // CRITICAL: Set provider to same as network token - payment_vault.set_provider(network_token.contract.provider().clone()); - - // Pay for quotes - let result = payment_vault - .pay_for_quotes(quote_payments.clone(), &transaction_config) - .await; - - assert!(result.is_ok(), "Payment failed: {:?}", result.err()); - println!("✓ Paid for {} quotes", quote_payments.len()); - - // Verify payments via completedPayments mapping - for (quote_hash, _reward_address, amount) in "e_payments { - let result = payment_vault - .contract - .completedPayments(*quote_hash) - .call() - .await - .expect("completedPayments lookup failed"); - - let on_chain_amount = result.amount; - assert!( - on_chain_amount >= u128::try_from(*amount).expect("amount fits u128"), - "On-chain amount should be >= paid amount" - ); - } - - println!("✓ All {CLOSE_GROUP_SIZE} payments verified successfully"); - println!("\n✅ Standard {CLOSE_GROUP_SIZE}-quote payment works!"); - } - - /// Test: `SingleNode` payment strategy (1 real + N-1 dummy payments) - #[tokio::test] - #[serial] - #[allow(clippy::expect_used)] - async fn test_single_node_payment_strategy() { - let (node, rpc_url) = start_node_with_timeout(); - let network_token = deploy_network_token_contract(&rpc_url, &node) - .await - .expect("deploy network token"); - let mut payment_vault = - deploy_payment_vault_contract(&rpc_url, &node, *network_token.contract.address()) - .await - .expect("deploy data payments"); - - let transaction_config = TransactionConfig::default(); - - // Create CLOSE_GROUP_SIZE payments: 1 real (3x) + rest dummy (0x) - let real_quote_hash = dummy_hash(); - let real_reward_address = dummy_address(); - let real_amount = Amount::from(3u64); // 3x amount - - let mut quote_payments = vec![(real_quote_hash, real_reward_address, real_amount)]; - - // Add dummy payments with 0 amount for remaining close group members - for _ in 0..CLOSE_GROUP_SIZE - 1 { - let dummy_quote_hash = dummy_hash(); - let dummy_reward_address = dummy_address(); - let dummy_amount = Amount::from(0u64); // 0 amount - quote_payments.push((dummy_quote_hash, dummy_reward_address, dummy_amount)); - } - - // Approve tokens - network_token - .approve( - *payment_vault.contract.address(), - evmlib::common::U256::MAX, - &transaction_config, - ) - .await - .expect("Failed to approve"); - - println!("✓ Approved tokens"); - - // Set provider - payment_vault.set_provider(network_token.contract.provider().clone()); - - // Pay (1 real payment of 3 atto + N-1 dummy payments of 0 atto) - let result = payment_vault - .pay_for_quotes(quote_payments.clone(), &transaction_config) - .await; - - assert!(result.is_ok(), "Payment failed: {:?}", result.err()); - println!( - "✓ Paid: 1 real (3 atto) + {} dummy (0 atto)", - CLOSE_GROUP_SIZE - 1 - ); - - // Verify via completedPayments mapping - - // Check that real payment is recorded on-chain - let real_result = payment_vault - .contract - .completedPayments(real_quote_hash) - .call() - .await - .expect("completedPayments lookup failed"); - - assert!( - real_result.amount > 0, - "Real payment should have non-zero amount on-chain" - ); - println!("✓ Real payment verified (3 atto)"); - - // Check dummy payments (should have 0 amount) - for (i, (hash, _, _)) in quote_payments.iter().skip(1).enumerate() { - let result = payment_vault - .contract - .completedPayments(*hash) - .call() - .await - .expect("completedPayments lookup failed"); - - println!(" Dummy payment {}: amount={}", i + 1, result.amount); - } - - println!("\n✅ SingleNode payment strategy works!"); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_from_quotes_median_selection() { - let prices: Vec = vec![50, 30, 10, 40, 20, 60, 70]; - let mut quotes_with_prices = Vec::new(); - - for price in &prices { - let quote = PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(*price), - rewards_address: RewardsAddress::new([1u8; 20]), - pub_key: vec![], - signature: vec![], - }; - quotes_with_prices.push((quote, Amount::from(*price))); - } - - let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap(); - - // After sorting by price: 10, 20, 30, 40, 50, 60, 70 - // Median (index 3) = 40, paid amount = 3 * 40 = 120 - let median_quote = payment.quotes.get(MEDIAN_INDEX).unwrap(); - assert_eq!(median_quote.amount, Amount::from(120u64)); - - // Other 6 quotes should have Amount::ZERO - for (i, q) in payment.quotes.iter().enumerate() { - if i != MEDIAN_INDEX { - assert_eq!(q.amount, Amount::ZERO); - } - } - - // Total should be 3 * median price = 120 - assert_eq!(payment.total_amount(), Amount::from(120u64)); - } - - #[test] - fn test_from_quotes_wrong_count() { - let quotes: Vec<_> = (0..3) - .map(|_| (make_test_quote(1), Amount::from(10u64))) - .collect(); - let result = SingleNodePayment::from_quotes(quotes); - assert!(result.is_err()); - } - - #[test] - #[allow(clippy::expect_used)] - fn test_from_quotes_zero_quotes() { - let result = SingleNodePayment::from_quotes(vec![]); - assert!(result.is_err()); - let err_msg = format!("{}", result.expect_err("should fail")); - assert!(err_msg.contains("exactly 7")); - } - - #[test] - fn test_from_quotes_one_quote() { - let result = - SingleNodePayment::from_quotes(vec![(make_test_quote(1), Amount::from(10u64))]); - assert!(result.is_err()); - } - - #[test] - #[allow(clippy::expect_used)] - fn test_from_quotes_wrong_count_six() { - let quotes: Vec<_> = (0..6) - .map(|_| (make_test_quote(1), Amount::from(10u64))) - .collect(); - let result = SingleNodePayment::from_quotes(quotes); - assert!(result.is_err()); - let err_msg = format!("{}", result.expect_err("should fail")); - assert!(err_msg.contains("exactly 7")); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_paid_quote_returns_median() { - let quotes: Vec<_> = (1u8..) - .take(CLOSE_GROUP_SIZE) - .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - let paid = payment.paid_quote().unwrap(); - - // The paid quote should have a non-zero amount - assert!(paid.amount > Amount::ZERO); - - // Total amount should equal the paid quote's amount - assert_eq!(payment.total_amount(), paid.amount); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_all_quotes_have_distinct_addresses() { - let quotes: Vec<_> = (1u8..) - .take(CLOSE_GROUP_SIZE) - .map(|i| (make_test_quote(i), Amount::from(u64::from(i) * 10))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - - // Verify all quotes are present (sorting doesn't lose data) - let mut addresses: Vec<_> = payment.quotes.iter().map(|q| q.rewards_address).collect(); - addresses.sort(); - addresses.dedup(); - assert_eq!(addresses.len(), CLOSE_GROUP_SIZE); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_tied_median_prices_all_share_median_price() { - // Prices: 10, 20, 30, 30, 30, 40, 50 — three quotes tied at median price 30 - let prices = [10u64, 20, 30, 30, 30, 40, 50]; - let mut quotes_with_prices = Vec::new(); - - for (i, price) in prices.iter().enumerate() { - let quote = PaymentQuote { - content: XorName::random(&mut rand::thread_rng()), - timestamp: SystemTime::now(), - price: Amount::from(*price), - #[allow(clippy::cast_possible_truncation)] // i is always < 7 - rewards_address: RewardsAddress::new([i as u8 + 1; 20]), - pub_key: vec![], - signature: vec![], - }; - quotes_with_prices.push((quote, Amount::from(*price))); - } - - let payment = SingleNodePayment::from_quotes(quotes_with_prices).unwrap(); - - // All three tied quotes should have price == 30 - let tied_count = payment - .quotes - .iter() - .filter(|q| q.price == Amount::from(30u64)) - .count(); - assert_eq!(tied_count, 3, "Should have 3 quotes tied at median price"); - - // Only the median index gets the 3x amount - assert_eq!(payment.quotes[MEDIAN_INDEX].amount, Amount::from(90u64)); - assert_eq!(payment.total_amount(), Amount::from(90u64)); - } - - #[test] - #[allow(clippy::unwrap_used)] - fn test_total_amount_equals_3x_median() { - let prices = [100u64, 200, 300, 400, 500, 600, 700]; - let quotes: Vec<_> = prices - .iter() - .map(|price| (make_test_quote(1), Amount::from(*price))) - .collect(); - - let payment = SingleNodePayment::from_quotes(quotes).unwrap(); - // Sorted: 100, 200, 300, 400, 500, 600, 700 — median = 400, total = 3 * 400 = 1200 - assert_eq!(payment.total_amount(), Amount::from(1200u64)); - } - - /// Test: Complete `SingleNode` flow with real contract prices - #[tokio::test] - #[serial] - async fn test_single_node_with_real_prices() -> Result<()> { - // Setup testnet - let testnet = Testnet::new() - .await - .map_err(|e| Error::Payment(format!("Failed to start testnet: {e}")))?; - let network = testnet.to_network(); - let wallet_key = testnet - .default_wallet_private_key() - .map_err(|e| Error::Payment(format!("Failed to get wallet key: {e}")))?; - let wallet = Wallet::new_from_private_key(network.clone(), &wallet_key) - .map_err(|e| Error::Payment(format!("Failed to create wallet: {e}")))?; - - println!("✓ Started Anvil testnet"); - - // Approve tokens - wallet - .approve_to_spend_tokens(*network.payment_vault_address(), evmlib::common::U256::MAX) - .await - .map_err(|e| Error::Payment(format!("Failed to approve tokens: {e}")))?; - - println!("✓ Approved tokens"); - - // Create CLOSE_GROUP_SIZE quotes with prices calculated from record counts - let chunk_xor = XorName::random(&mut rand::thread_rng()); - - let mut quotes_with_prices = Vec::new(); - for i in 0..CLOSE_GROUP_SIZE { - let records_stored = 10 + i; - let price = crate::payment::pricing::calculate_price(records_stored); - - let quote = PaymentQuote { - content: chunk_xor, - timestamp: SystemTime::now(), - price, - rewards_address: wallet.address(), - pub_key: vec![], - signature: vec![], - }; - - quotes_with_prices.push((quote, price)); - } - - println!("✓ Got {CLOSE_GROUP_SIZE} quotes with calculated prices"); - - // Create SingleNode payment (will sort internally and select median) - let payment = SingleNodePayment::from_quotes(quotes_with_prices)?; - - let median_price = payment - .paid_quote() - .ok_or_else(|| Error::Payment("Missing paid quote at median index".to_string()))? - .amount - .checked_div(Amount::from(3u64)) - .ok_or_else(|| Error::Payment("Failed to calculate median price".to_string()))?; - println!("✓ Sorted and selected median price: {median_price} atto"); - - assert_eq!(payment.quotes.len(), CLOSE_GROUP_SIZE); - let median_amount = payment - .quotes - .get(MEDIAN_INDEX) - .ok_or_else(|| { - Error::Payment(format!( - "Index out of bounds: tried to access median index {} but quotes array has {} elements", - MEDIAN_INDEX, - payment.quotes.len() - )) - })? - .amount; - assert_eq!( - payment.total_amount(), - median_amount, - "Only median should have non-zero amount" - ); - - println!( - "✓ Created SingleNode payment: {} atto total (3x median)", - payment.total_amount() - ); - - // Pay on-chain - let tx_hashes = payment.pay(&wallet).await?; - println!("✓ Payment successful: {} transactions", tx_hashes.len()); - - // Verify median quote payment — all nodes run this same check - let verified_amount = payment.verify(&network).await?; - let expected_median_amount = payment.quotes[MEDIAN_INDEX].amount; - - assert_eq!( - verified_amount, expected_median_amount, - "Verified amount should match median payment" - ); - - println!("✓ Payment verified: {verified_amount} atto"); - println!("\n✅ Complete SingleNode flow with real prices works!"); +//! Extracted to the [`ant_protocol`] crate in 0.11 so `pay` and +//! `verify` stay co-located in a single crate that both the client and +//! node test against end to end. Internal callers using +//! `crate::payment::single_node::…` keep working unchanged. - Ok(()) - } -} +pub use ant_protocol::payment::single_node::{QuotePaymentInfo, SingleNodePayment}; diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 26f45212..e1dacaf4 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -10,8 +10,8 @@ use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::proof::{ deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, }; -use crate::payment::quote::{verify_quote_content, verify_quote_signature}; use crate::payment::single_node::SingleNodePayment; +use ant_protocol::payment::verify::{verify_quote_content, verify_quote_signature}; use evmlib::common::Amount; use evmlib::contract::payment_vault; use evmlib::merkle_batch_payment::{OnChainPaymentInfo, PoolHash}; @@ -371,6 +371,15 @@ impl PaymentVerifier { "Unknown payment proof type tag: 0x{tag:02x}" ))); } + // ant-protocol marks `ProofType` as `#[non_exhaustive]`. + // A future proof variant that this node does not yet + // understand must be rejected, not silently accepted. + Some(_) => { + let tag = proof.first().copied().unwrap_or(0); + return Err(Error::Payment(format!( + "Unsupported payment proof type tag: 0x{tag:02x} (this node's protocol version does not handle it — upgrade ant-node)" + ))); + } } // Cache the verified xorname diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 5d6604fe..ab40c396 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -138,14 +138,18 @@ impl AntProtocol { self.handle_merkle_candidate_quote(req), ) } - // Response messages are handled by client subscribers - // (e.g. send_and_await_chunk_response), not by the protocol - // handler. Returning None prevents the caller from sending a - // reply, which would create an infinite ping-pong loop. - ChunkMessageBody::PutResponse(_) - | ChunkMessageBody::GetResponse(_) - | ChunkMessageBody::QuoteResponse(_) - | ChunkMessageBody::MerkleCandidateQuoteResponse(_) => return Ok(None), + // Anything else — response messages are handled by client + // subscribers (e.g. send_and_await_chunk_response), not by the + // protocol handler. Returning None prevents the caller from + // sending a reply, which would create an infinite ping-pong + // loop. + // + // `ChunkMessageBody` is `#[non_exhaustive]` in ant-protocol, so + // a future wire variant added on a protocol minor bump also + // lands here and is dropped. The CHUNK_PROTOCOL_ID multistream- + // select handshake version-gates peers, so this arm should + // only be reached by a misconfigured peer. + _ => return Ok(None), }; let response = ChunkMessage { @@ -824,7 +828,7 @@ mod tests { #[tokio::test] async fn test_merkle_candidate_quote_request() { - use crate::payment::quote::verify_merkle_candidate_signature; + use ant_protocol::payment::verify::verify_merkle_candidate_signature; use evmlib::merkle_payments::MerklePaymentCandidateNode; // create_test_protocol already wires ML-DSA-65 signing From 25a96c617da08625b18e94a16a863d6b07953a2c Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 24 Apr 2026 19:29:23 +0900 Subject: [PATCH 2/4] chore: drop CHANGELOG.md from extraction commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CHANGELOG entry was re-added during the rebase onto rc-2026.4.2 (the auto-merge brought it back from the original extract commit). Chris previously flagged on this branch that the version bump and CHANGELOG shouldn't land here as part of the extraction — the version is now correctly 0.11.0-rc.2 from the rebase base, but the CHANGELOG is extraction-specific content that belongs in whichever release actually cuts 0.11.0, not on this branch. --- CHANGELOG.md | 58 ---------------------------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 114ce6dd..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,58 +0,0 @@ -# Changelog - -All notable changes to the `ant-node` crate will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.11.0] — Unreleased - -### Changed - -The wire-protocol surface previously owned by `ant-node` has moved to -the new [`ant-protocol`] crate. All previously-exported paths continue -to resolve via re-exports, so existing downstream imports keep working -unchanged. - -[`ant-protocol`]: https://crates.io/crates/ant-protocol - -- `ant_node::ant_protocol` — now re-exports from `ant_protocol::chunk`. - Both `ant_node::ant_protocol::ChunkMessage` and - `ant_node::ant_protocol::chunk::ChunkMessage` resolve. -- `ant_node::client` — `compute_address`, `peer_id_to_xor_name`, - `xor_distance`, `DataChunk`, `ChunkStats`, `XorName`, and - `send_and_await_chunk_response` now re-export from - `ant_protocol::{data_types, chunk_protocol}`. - `hex_node_id_to_encoded_peer_id` stays as node-owned code. -- `ant_node::payment::proof`, `ant_node::payment::single_node` — now - re-export from `ant_protocol::payment::{proof, single_node}`. -- `ant_node::payment::{verify_quote_content, verify_quote_signature, - verify_merkle_candidate_signature}` — now re-export from - `ant_protocol::payment::verify`. -- `ant_node::devnet::{DevnetManifest, DevnetEvmInfo}` — now re-export - from `ant_protocol::devnet_manifest`. JSON format unchanged. - -### Security - -- `ant_protocol::SingleNodePayment::verify` (used via - `ant_node::payment::PaymentVerifier`) now rejects proofs whose median - quote has zero price or zero paid amount. Previously a malicious - client could have submitted a zero-priced median, and the on-chain - `completedPayments >= 0` check would have trivially succeeded. -- `ant_node::payment::PaymentVerifier` now rejects unknown - `ProofType` tag bytes (including future variants added on an - `ant-protocol` minor bump) instead of silently accepting them. - -### Added - -- Re-export of the `chunk` submodule from `ant-protocol` so - `ant_node::ant_protocol::chunk::` paths keep resolving for - downstream callers that used the longer path. - -### Deprecation notice - -`ant_node::ant_protocol`, `ant_node::client` (except for the node-only -`hex_node_id_to_encoded_peer_id`), `ant_node::payment::{proof, -single_node}`, and `ant_node::payment::verify_*` will be removed in a -future 0.x release once the wider ecosystem has migrated to -`ant_protocol::*` directly. No timeline yet. From 5e067feb870d9ac899989c02a16e0791c1654578 Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Sun, 26 Apr 2026 20:30:38 +0200 Subject: [PATCH 3/4] chore: bump saorsa-core to 0.24.0 and ant-protocol to 2.0.1 --- Cargo.lock | 5 +++-- Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb5df6e3..f7726c84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -889,8 +889,9 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.0.0" -source = "git+https://github.com/WithAutonomi/ant-protocol?branch=chore/repin-saorsa-core-rc-2026.4.2#b4360b1802bc6090047a6a67cc2b593402bf52fb" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe630f23f4adae05beba7e2c0861ab9c1a6d2cc88c98898e3742bed3a5a4ce35" dependencies = [ "blake3", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 3765d060..ccc45005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ path = "src/bin/ant-devnet/main.rs" # Until then, the git pin tracks the matching saorsa-core lineage # (the rc-2026.4.2 branch) so Cargo can unify the wire types here # with ant-protocol's re-exports. -ant-protocol = { git = "https://github.com/WithAutonomi/ant-protocol", branch = "chore/repin-saorsa-core-rc-2026.4.2" } +ant-protocol = "2.0.1" # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.24.0" From aaa03d7c1be0b85a06350c9d39bb2e8c0530bfb2 Mon Sep 17 00:00:00 2001 From: grumbach Date: Mon, 27 Apr 2026 11:50:43 +0900 Subject: [PATCH 4/4] chore: bump rustls-webpki to 0.103.13 to clear RUSTSEC-2026-0104 Security Audit was failing on PR #73 with: RUSTSEC-2026-0104: rustls-webpki 0.103.12 (via saorsa-transport 0.33.0 -> saorsa-core 0.24.0) Solution: Upgrade to >=0.103.13 Main already has 0.103.13 in its lockfile; this branch's lockfile was stale. `cargo update -p rustls-webpki` is the minimal fix. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f7726c84..a0941dd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4737,9 +4737,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring",