From 30efa69c5449b8203eb4c47baee29b5f15e5a72c Mon Sep 17 00:00:00 2001 From: grumbach Date: Fri, 10 Jul 2026 07:57:19 +0900 Subject: [PATCH] Add ADR-0005 client-side earned reward eligibility gate Collect the signed audit reports that quote responders now ship, and gate quote collection on them so a client prefers to pay nodes that have earned their place (a clean audited week at the quoted size). This is earned future eligibility, not slashing: it only affects who a client chooses to pay next, never settled payments. - eligibility predicate: majority-of-opinions vouch bar (floor 3), with a two-tier fallback (dues-at-any-size, then today's ungated rules) so the network never stalls under fast growth or churn; - verify each report's signature, request nonce, and size caps before trusting its rows; the gate leaves payee selection and payment verification untouched; - observe-only by default so shadow eligibility can be calibrated before any hard gate is enabled. Pin ant-protocol and ant-node to the immutable ADR-0005 PR revs so the build is reproducible, and add the client-side ADR document. --- Cargo.lock | 826 +++++++++--------- Cargo.toml | 17 + ant-core/Cargo.toml | 17 +- ant-core/examples/bench-quoting.rs | 2 + ant-core/src/data/client/eligibility.rs | 801 +++++++++++++++++ ant-core/src/data/client/merkle.rs | 85 +- ant-core/src/data/client/mod.rs | 1 + ant-core/src/data/client/quote.rs | 310 ++++++- ant-core/tests/e2e_adr0005.rs | 529 +++++++++++ ant-core/tests/support/mod.rs | 165 +++- ...3-client-earned-reward-eligibility-gate.md | 92 ++ 11 files changed, 2377 insertions(+), 468 deletions(-) create mode 100644 ant-core/src/data/client/eligibility.rs create mode 100644 ant-core/tests/e2e_adr0005.rs create mode 100644 docs/adr/ADR-0003-client-earned-reward-eligibility-gate.md diff --git a/Cargo.lock b/Cargo.lock index c7cb5364..9fc5bad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,9 +69,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -109,13 +109,13 @@ dependencies = [ [[package]] name = "alloy-chains" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84e0378e959aa6a885897522080a990e80eb317f1e9a222a604492ea50e13096" +checksum = "3b5cc30538e90795a57647bef8d8864aad6e8d86190617009b4ef8d8b647b49a" dependencies = [ "alloy-primitives", "num_enum", - "strum", + "phf 0.14.0", ] [[package]] @@ -413,7 +413,7 @@ dependencies = [ "cfg-if", "const-hex", "derive_more", - "foldhash 0.2.0", + "foldhash", "hashbrown 0.17.1", "indexmap 2.14.0", "itoa", @@ -473,9 +473,9 @@ dependencies = [ [[package]] name = "alloy-rlp" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc90b1e703d3c03f4ff7f48e82dd0bc1c8211ab7d079cd836a06fcfeb06651cb" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" dependencies = [ "alloy-rlp-derive", "arrayvec", @@ -484,13 +484,13 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36834a5c0a2fa56e171bf256c34d70fca07d0c0031583edea1c4946b7889c9e" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -625,7 +625,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -643,7 +643,7 @@ dependencies = [ "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -661,7 +661,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -751,7 +751,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -893,8 +893,7 @@ dependencies = [ [[package]] name = "ant-node" version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f7f5de9683a7ce865dbc34eb51356fc748a0ee5b274e4fb343aec4de5dc3c81" +source = "git+https://github.com/grumbach/ant-node?rev=111320385aa5a4eb8d90a19c13e63a7aaf172af4#111320385aa5a4eb8d90a19c13e63a7aaf172af4" dependencies = [ "ant-protocol", "blake3", @@ -942,9 +941,8 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b665880152b4e124094acc1faaff5f20a275594e6364b8a729b50f3bc326674" +version = "3.0.0" +source = "git+https://github.com/grumbach/ant-protocol?rev=2015113270bbde8204a1740674bed2ada52d2ca6#2015113270bbde8204a1740674bed2ada52d2ca6" dependencies = [ "blake3", "bytes", @@ -961,9 +959,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -1032,6 +1030,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -1059,7 +1074,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.118", ] [[package]] @@ -1097,7 +1122,20 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -1133,6 +1171,30 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "ark-std" version = "0.3.0" @@ -1163,6 +1225,16 @@ dependencies = [ "rand 0.8.6", ] +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1171,9 +1243,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -1199,7 +1271,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -1211,7 +1283,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1233,7 +1305,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1244,7 +1316,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1282,7 +1354,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1293,9 +1365,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -1303,14 +1375,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1431,36 +1504,57 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a30a22d1f112dde8e16be7b45c63645dc165cef254f835b3e1e9553e485cfa64" +dependencies = [ + "hex-conservative 0.3.2", +] + [[package]] name = "bitcoin-io" -version = "0.1.100" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11301df0b06f22dea7bb1916403fdd88a371031e495c49b8f96931b28189e175" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.100" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9901a56e133a1fc86eeb1113e2591f45f4682451ca893bff494d2f88918e3f" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", ] [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1493,9 +1587,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1523,9 +1617,9 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ "borsh-derive", "bytes", @@ -1534,15 +1628,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1601,9 +1695,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -1644,9 +1738,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -1685,9 +1779,9 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1763,7 +1857,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1991,18 +2085,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2010,27 +2104,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -2103,7 +2197,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2127,7 +2221,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2138,7 +2232,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2197,7 +2291,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2220,7 +2313,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2242,7 +2335,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -2273,7 +2366,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", ] @@ -2346,7 +2439,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2355,7 +2448,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -2419,7 +2512,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2481,22 +2574,22 @@ dependencies = [ [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2508,7 +2601,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2530,8 +2623,7 @@ dependencies = [ [[package]] name = "evmlib" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8da0d9ad5b5cab92cc92ddac9afb884f86b226340caf17f6e5c41d51175dcaa1" +source = "git+https://github.com/WithAutonomi/evmlib?branch=adr-0003-signed-quote-fields#a7ac7e2620cb6a50151dd0c619fd75e03b02f05c" dependencies = [ "alloy", "ant-merkle", @@ -2691,12 +2783,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -2805,7 +2891,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2883,16 +2969,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -2930,9 +3016,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", @@ -2968,15 +3054,6 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.16.1" @@ -2985,7 +3062,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2994,7 +3071,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash 0.2.0", + "foldhash", "serde", "serde_core", ] @@ -3078,6 +3155,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hex-conservative" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830e599c2904b08f0834ee6337d8fe8f0ed4a63b5d9e7a7f49c0ffa06d08d360" +dependencies = [ + "arrayvec", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3119,9 +3205,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3164,9 +3250,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -3261,7 +3347,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.58.0", + "windows-core 0.62.2", ] [[package]] @@ -3355,12 +3441,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -3402,7 +3482,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.10.1", + "rand 0.10.2", "tokio", "url", "xmltree", @@ -3425,7 +3505,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3567,7 +3647,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3595,28 +3675,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3655,9 +3734,9 @@ dependencies = [ [[package]] name = "keccak-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1766b89733097006f3a1388a02849865d6bc98c89273cb622e29fdd209922183" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" dependencies = [ "digest 0.10.7", "sha3-asm", @@ -3694,12 +3773,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" version = "0.2.186" @@ -3723,9 +3796,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -3764,9 +3837,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru" @@ -3812,7 +3885,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3832,9 +3905,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -3947,9 +4020,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -4008,7 +4081,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4117,9 +4190,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags", "cfg-if", @@ -4137,7 +4210,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4148,18 +4221,18 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4225,7 +4298,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4298,9 +4371,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" dependencies = [ "memchr", "ucd-trie", @@ -4313,7 +4386,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared 0.14.0", ] [[package]] @@ -4322,7 +4404,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -4333,10 +4415,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4348,6 +4430,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -4365,7 +4456,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4462,16 +4553,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -4520,7 +4601,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4559,15 +4640,15 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", - "quinn-udp 0.5.14", + "quinn-udp 0.5.15", "rustc-hash", "rustls", "socket2 0.6.4", @@ -4579,15 +4660,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -4601,16 +4683,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2 0.6.4", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4628,9 +4710,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -4678,12 +4760,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", + "chacha20 0.10.1", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4732,6 +4814,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4743,9 +4834,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" dependencies = [ "rustversion", ] @@ -4832,14 +4923,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -4849,9 +4940,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -4860,9 +4951,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -4999,14 +5090,15 @@ dependencies = [ [[package]] name = "ruint" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0298da754d1395046b0afdc2f20ee76d29a8ae310cd30ffa84ed42acba9cb12a" +checksum = "45caf26f647c19115bf9c453c70ffe4a4a3a6390dceebd942610584f99b8ddce" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -5033,15 +5125,15 @@ checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" [[package]] name = "rustc-demangle" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-hex" @@ -5091,9 +5183,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "log", @@ -5127,9 +5219,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -5208,9 +5300,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" @@ -5625,7 +5717,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5711,7 +5803,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5759,7 +5851,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5806,9 +5898,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3f15d4e239ebe08413eed880e0f9b5af4b40ee0472543320efa91d488e96a7" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" dependencies = [ "cc", "cfg-if", @@ -5885,9 +5977,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -5949,27 +6041,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "subtle" version = "2.6.1" @@ -5995,9 +6066,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -6013,7 +6084,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6042,7 +6113,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6114,7 +6185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6146,7 +6217,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6157,7 +6228,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6180,12 +6251,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -6197,15 +6267,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -6270,7 +6340,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6480,7 +6550,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6674,16 +6744,16 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6743,27 +6813,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -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]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -6774,9 +6835,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6784,9 +6845,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6794,48 +6855,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -6849,18 +6888,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver 1.0.28", -] - [[package]] name = "wasmtimer" version = "0.4.3" @@ -6877,9 +6904,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -6897,9 +6924,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -6990,6 +7017,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-implement" version = "0.57.0" @@ -6998,7 +7038,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7009,7 +7049,18 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -7020,7 +7071,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7031,7 +7082,18 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] @@ -7338,100 +7400,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver 1.0.28", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -7554,28 +7528,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7595,28 +7569,28 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7649,7 +7623,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f3978b22..11f48004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,20 @@ [workspace] members = ["ant-core", "ant-cli"] resolver = "2" +# ─── TEMPORARY: STRIP BEFORE MERGE — ADR-0004/0005 coordinated cutover ─── +# The ADR-0004 commitment-bound-quote types and the ADR-0005 audit-report wire +# types are not yet published to crates.io, so this workspace pins them to +# immutable PR revs (never a mutable branch) so the build is reproducible; the +# committed Cargo.lock records the exact resolution. +# +# AT RELEASE (once the ADR-0004/0005 versions are published, in order +# evmlib → ant-protocol): +# 1. delete this entire [patch.crates-io] block, AND +# 2. bump the `ant-protocol` pin in ant-core/Cargo.toml to the published +# version (evmlib is re-exported through ant-protocol, no direct pin): +# ant-protocol = "3.0.0" # <- published ADR-0005 ant-protocol (major: wire change) +[patch.crates-io] +evmlib = { git = "https://github.com/WithAutonomi/evmlib", branch = "adr-0003-signed-quote-fields" } +# ADR-0005 audit-report wire types (github.com/WithAutonomi/ant-protocol/pull/19), +# pinned to the immutable PR rev. +ant-protocol = { git = "https://github.com/grumbach/ant-protocol", rev = "2015113270bbde8204a1740674bed2ada52d2ca6" } diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index a6d9d006..1a55cc11 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -37,7 +37,7 @@ tower-http = { version = "0.6.8", features = ["cors"] } # under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for # those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc # deps here or the version can skew between ant-client and ant-node. -ant-protocol = "2.3.0" +ant-protocol = "3.0.0" xor_name = "5" self_encryption = "0.36" futures = "0.3" @@ -62,10 +62,10 @@ sysinfo = { version = "0.32", default-features = false, features = ["system"] } # `ant-protocol` pin above — a version skew pulls a second copy of # `saorsa-core` into the graph and makes `ant_node`'s and `ant_protocol`'s # `MultiAddr` mutually incompatible in `node/devnet.rs`. While the runtime -# `ant-protocol` pin above points at a released version, this ant-node must -# track the matching released version carrying the same saorsa-core / -# ant-protocol lineage. -ant-node = { version = "0.14.3", optional = true } +# `ant-protocol` pin above points at an immutable git rev, so this ant-node must +# point at the matching ADR-0005 ant-node PR rev carrying the same saorsa-core / +# ant-protocol lineage rather than a released version. +ant-node = { git = "https://github.com/grumbach/ant-node", rev = "111320385aa5a4eb8d90a19c13e63a7aaf172af4", optional = true } tracing-subscriber = { version = "0.3", features = ["env-filter"] } [target.'cfg(unix)'.dependencies] @@ -96,7 +96,12 @@ test-utils = [] # always compile even without the `devnet` feature. Pinned to the same # version as the runtime dep so there is a single ant-node / # saorsa-core version across the whole graph. -ant-node = { version = "0.14.3", features = ["test-utils"] } +# `ant-protocol` pin above points at an immutable git rev, so this ant-node must +# point at the matching ADR-0005 ant-node PR rev carrying the same saorsa-core / +# ant-protocol lineage rather than a released version. The dev-dep enables +# `test-utils` so the e2e/integration suite can reach the node's test-only +# payment-verifier helpers. +ant-node = { git = "https://github.com/grumbach/ant-node", rev = "111320385aa5a4eb8d90a19c13e63a7aaf172af4", features = ["test-utils"] } serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/examples/bench-quoting.rs b/ant-core/examples/bench-quoting.rs index abf14865..1b59d8e2 100644 --- a/ant-core/examples/bench-quoting.rs +++ b/ant-core/examples/bench-quoting.rs @@ -356,6 +356,7 @@ async fn bench_normal_once(client: &Client, rep: usize) -> Rep { address, data_size: 1024, data_type: 0, + report_nonce: [0u8; 32], }), }; let bytes = match msg.encode() { @@ -509,6 +510,7 @@ async fn bench_merkle_once(client: &Client, rep: usize, concurrency: usize) -> R data_type: 0, data_size: 1024, merkle_payment_timestamp, + report_nonce: [0u8; 32], }), }; let bytes = match msg.encode() { diff --git a/ant-core/src/data/client/eligibility.rs b/ant-core/src/data/client/eligibility.rs new file mode 100644 index 00000000..f513c898 --- /dev/null +++ b/ant-core/src/data/client/eligibility.rs @@ -0,0 +1,801 @@ +//! ADR-0005 earned reward eligibility — the client-side predicate. +//! +//! Quote responses carry each responder's signed **audit report**: its raw +//! per-peer tally of audit outcomes (passes per day, the largest commitment +//! size that passed, and two row-local markers). This module aggregates a +//! quorum of those reports into the payee-selection decision: a quoter is +//! **eligible** when enough of its neighbours testify it has been audited +//! clean for about a week at the size it is monetizing. +//! +//! Everything here is policy over reported facts — no scores, no weights, no +//! decay. The parameters are client-side and env-tunable, so retuning the +//! gate never needs a fleet release. Reports are consumed at collection time +//! and dropped; nothing here is forwarded to storers. +//! +//! Default mode is **observe-only**: the gate logs what it would do without +//! changing selection. `ADR5_ENFORCE=1` turns enforcement on (used by the +//! local testnet; production flips only once telemetry shows honest nodes +//! reliably qualify). + +use ant_protocol::payment::{AuditReport, AuditReportDay, AuditReportRow}; +use ant_protocol::transport::PeerId; +use std::collections::HashMap; +use tracing::{debug, info, warn}; + +/// Client eligibility policy over reported audit facts (ADR-0005). +/// +/// Day units are the node's tally-day buckets: a fixed 86400 s (one day) in +/// production, so the client's day-based thresholds line up with the ages the +/// node stamps into its reports. +#[derive(Debug, Clone, Copy)] +pub struct EligibilityPolicy { + /// A qualifying observer must report at least this many distinct days + /// that each carry a covering pass (D — the dues, ~a week). + pub min_distinct_days: u16, + /// The most recent qualifying day must be at most this old (the window's + /// head must be covered: a node that stops answering audits drops out + /// within ~a day). + pub max_recency_days: u16, + /// Size coverage slack: a day qualifies only if its largest passed + /// commitment size × this multiplier ≥ the quoted count (grind small, + /// cash in big fails day-coverage; honest growth clears in an audit + /// cycle). + pub size_slack: u32, + /// Days older than this are ignored entirely — the client's own window + /// bound, independent of what a reporter chooses to ship. + pub window_days: u16, + /// Floor of the eligibility bar (ADR-0005 v4): a subject always needs at + /// least this many qualifying vouches, however few reporters know it. + pub quorum_floor: usize, + /// Enforce (change selection) vs observe-only (log would-be decisions). + pub enforce: bool, +} + +impl Default for EligibilityPolicy { + fn default() -> Self { + // NOTE: `window_days` can only NARROW the node's `TALLY_WINDOW_DAYS`, it + // cannot widen it; and there is NO client knob for conviction + // stickiness (node-side `CONVICTION_STICKY_DAYS`). "Retunable without a + // node release" is true for D / recency / slack / floor / enforce ONLY. + Self { + min_distinct_days: env_u64_clamped("ADR5_D_DAYS", 7, 0, u64::from(u16::MAX)) as u16, + max_recency_days: env_u64_clamped("ADR5_RECENCY_DAYS", 1, 0, u64::from(u16::MAX)) + as u16, + size_slack: env_u64_clamped("ADR5_SIZE_SLACK", 2, 1, u64::from(u32::MAX)) as u32, + window_days: env_u64_clamped("ADR5_WINDOW_DAYS", 14, 0, u64::from(u16::MAX)) as u16, + quorum_floor: env_u64_clamped("ADR5_QUORUM", 3, 1, u64::from(u32::MAX)) as usize, + enforce: std::env::var("ADR5_ENFORCE").is_ok_and(|v| v == "1" || v == "true"), + } + } +} + +/// Read `name` as a u64, clamped to `[lo, hi]`, defaulting to `default`. +/// WARNs (not silently) when a set value is non-numeric (falls back to +/// default) or out of range (clamped) — otherwise an operator who typed +/// `ADR5_SIZE_SLACK=0` or a typo would get the opposite of what they meant +/// with no signal. +fn env_u64_clamped(name: &str, default: u64, lo: u64, hi: u64) -> u64 { + let Some(raw) = std::env::var(name).ok() else { + return default.clamp(lo, hi); + }; + let Some(parsed) = raw.parse::().ok() else { + warn!("ADR-0005: {name}={raw:?} is not a number; using default {default}"); + return default.clamp(lo, hi); + }; + let clamped = parsed.clamp(lo, hi); + if clamped != parsed { + warn!("ADR-0005: {name}={parsed} out of range [{lo}, {hi}]; clamped to {clamped}"); + } + clamped +} + +/// Does one report day cover a quote of `quoted_key_count` under `policy`'s +/// size slack? (`max_passed_key_count * slack >= quoted`.) A baseline quote +/// (`quoted == 0`) is trivially covered by any pass. +#[must_use] +fn day_covers_size( + day: &AuditReportDay, + quoted_key_count: u32, + policy: &EligibilityPolicy, +) -> bool { + u64::from(day.max_passed_key_count) * u64::from(policy.size_slack) + >= u64::from(quoted_key_count) +} + +/// Shared history check for one observer's row: unfenced, unconvicted, and +/// enough DISTINCT recent days that satisfy `day_qualifies`. +/// +/// This is the single place the ADR-0005 "≥ D distinct covering days, freshest +/// within recency, inside the window" rule lives. The per-day `day_qualifies` +/// predicate is what makes it size-aware or size-relaxed: +/// - size-eligible ([`row_qualifies`]): `day_covers_size` — the day must +/// also cover the quoted size, exactly as v4. +/// - dues-eligible ([`row_has_dues`], v5 tier 2): `|_| true` — any pass day. +/// +/// Crucially the size check is applied PER DAY, INSIDE the distinct-day and +/// recency computation — it is NOT a separate whole-row predicate. Factoring +/// it out (`has_dues && covers_size_somewhere`) would be WRONG: a row with 7 +/// small passes at ages 0-6 plus one covering pass at age 13 has dues and a +/// covering day, yet must NOT be size-eligible (only one covering day, and it +/// is stale). Keeping size a day predicate preserves v4 semantics exactly. +/// +/// Duplicate `age_days` collapse to one distinct day, so a malformed report +/// cannot multiply its own testimony. Pass counts are not summed — a per-day +/// `passes != 0` guard is the only pass requirement (there is no total-passes +/// threshold). +fn row_meets_history( + row: &AuditReportRow, + policy: &EligibilityPolicy, + day_qualifies: impl Fn(&AuditReportDay) -> bool, +) -> bool { + if row.fenced || row.convicted { + return false; + } + let mut qualifying_ages: std::collections::HashSet = std::collections::HashSet::new(); + for day in &row.days { + if day.passes == 0 || day.age_days > policy.window_days { + continue; + } + if day_qualifies(day) { + qualifying_ages.insert(day.age_days); + } + } + let freshest_age = qualifying_ages.iter().min().copied(); + qualifying_ages.len() >= usize::from(policy.min_distinct_days) + && freshest_age.is_some_and(|age| age <= policy.max_recency_days) +} + +/// Does `row` qualify the subject for a quote **at `quoted_key_count`** (the +/// SIZE-eligible tier)? Unfenced, unconvicted, and ≥ D distinct recent days +/// that each cover the quoted size. Identical semantics to v4. +#[must_use] +pub fn row_qualifies( + row: &AuditReportRow, + quoted_key_count: u32, + policy: &EligibilityPolicy, +) -> bool { + row_meets_history(row, policy, |day| { + day_covers_size(day, quoted_key_count, policy) + }) +} + +/// Does `row` show the subject has done its **dues** (the size-RELAXED tier 2, +/// ADR-0005 v5)? Same history rule as [`row_qualifies`] but with NO size +/// coverage: ≥ D distinct recent pass days at ANY size. A fresh identity (no +/// pass days) and a caught cheater (fenced/convicted) still fail — the +/// fallback drops only the size requirement, never the dues or the catch. +/// +/// Every size-eligible row is dues-eligible: its covering days are a subset of +/// its any-size pass days, so it has ≥ D such days and its freshest any-size +/// day is no older than its freshest covering day. +#[must_use] +pub fn row_has_dues(row: &AuditReportRow, policy: &EligibilityPolicy) -> bool { + row_meets_history(row, policy, |_day| true) +} + +/// Which tier a subject cleared (ADR-0005 v5 two-tier fallback). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// Majority of judges vouch AT THE QUOTED SIZE — full eligibility. + Size, + /// Majority of judges vouch for the DUES (a clean audited week at any + /// size) but too few cover the quoted size — the v5 size-relaxed fallback. + Dues, +} + +/// One subject's eligibility, once it has cleared at least the dues bar. Every +/// entry is dues-eligible; `tier == Size` additionally means it cleared the +/// size bar. `size_vouches`/`dues_vouches` are the qualifying counts (dues is +/// a superset, so `dues_vouches >= size_vouches`). +#[derive(Debug, Clone, Copy)] +pub struct SubjectEligibility { + /// Highest tier the subject cleared. + pub tier: Tier, + /// Judges vouching at the quoted size. + pub size_vouches: usize, + /// Judges vouching for the dues (any size). + pub dues_vouches: usize, +} + +/// Aggregate collected reports into the eligible subset of `subjects`, tagged +/// by the highest tier each cleared (ADR-0005 v5). +/// +/// `reports` is keyed by the REPORTER (the quote responder that shipped it, +/// already signature-verified and nonce-bound). Judges of a subject are the +/// reporters — never the subject itself — that carry a row for it (an +/// "opinion"); reporters with no row ABSTAIN and are not counted. The bar is +/// the same majority-of-opinions bar for both tiers; only the qualifying +/// numerator differs (size-covering vs dues-only). The returned map contains +/// every subject that cleared at least the dues bar; `tier` distinguishes +/// which. +/// +/// The map is keyed by `(PeerId, quoted_size)`, NOT peer alone: eligibility is +/// size-specific, and the same peer may appear at two quoted sizes in one +/// candidate set. Keying on the pair keeps each `(peer, size)` evaluated and +/// filtered against its own quote, so a size-eligible entry for a small quote +/// can never leak Size-tier admission onto a larger quote for that same peer. +#[must_use] +pub fn eligible_subjects( + subjects: &[(PeerId, u32)], + reports: &HashMap, + policy: &EligibilityPolicy, +) -> HashMap<(PeerId, u32), SubjectEligibility> { + let mut out: HashMap<(PeerId, u32), SubjectEligibility> = HashMap::new(); + for (subject, quoted_count) in subjects { + let subject_bytes = *subject.as_bytes(); + // Majority-of-opinions bar (v4). An "opinion" is a reporter (never the + // subject itself) whose report carries a row for the subject at all; + // reporters with no row ABSTAIN. The SAME opinion denominator and bar + // apply to both tiers — a row that vouches for dues but not size is + // still a non-vouching SIZE opinion, so it counts in the size + // denominator. Only the qualifying numerator differs. Excluding a peer + // therefore requires non-vouching rows from over half its opinionated + // reporters — genuine catches (sticky convictions hold the catcher's + // row in the denominator for a dues period) or a collusion the size of + // the neighbourhood-capture boundary. Abstention cannot suppress. + let mut opinions = 0usize; + let mut size_vouches = 0usize; + let mut dues_vouches = 0usize; + for (reporter, report) in reports { + if reporter == subject { + continue; + } + let Some(row) = report + .rows + .iter() + .find(|row| row.subject_peer_id == subject_bytes) + else { + continue; + }; + opinions += 1; + // dues is a superset of size, so check size first and count both. + if row_qualifies(row, *quoted_count, policy) { + size_vouches += 1; + dues_vouches += 1; + } else if row_has_dues(row, policy) { + dues_vouches += 1; + } + } + let bar = policy.quorum_floor.max(opinions / 2 + 1); + let tier = if size_vouches >= bar { + Some(Tier::Size) + } else if dues_vouches >= bar { + Some(Tier::Dues) + } else { + None + }; + // Structured per-subject decision line — the local-testnet runner + // greps these to build the eligibility timeline. + info!( + target: "adr5::eligibility", + subject = %subject, + quoted_count, + vouches = size_vouches, + dues_vouches, + opinions, + bar, + eligible = tier == Some(Tier::Size), + dues_eligible = tier.is_some(), + "eligibility decision" + ); + if let Some(tier) = tier { + out.insert( + (*subject, *quoted_count), + SubjectEligibility { + tier, + size_vouches, + dues_vouches, + }, + ); + } + } + out +} + +/// Which tier `gate_quoter_set` selected from (ADR-0005 v5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GateTier { + /// Selected the size-eligible subset (full eligibility, `need` met). + Size, + /// Selected the dues-eligible subset (size relaxed, v5 fallback). + Dues, + /// Neither tier filled `need`: today's ungated rules apply. Degraded + /// security — earned eligibility is bypassed. + Ungated, +} + +impl GateTier { + fn label(self) -> &'static str { + match self { + GateTier::Size => "size-eligible", + GateTier::Dues => "dues-eligible (size relaxed)", + GateTier::Ungated => "ungated", + } + } +} + +/// Apply the ADR-0005 gate to a quoter set at collection time (v5 two-tier +/// fallback). +/// +/// Returns the peers to select from. When enforcing, three tiers are tried in +/// order until one fills `need`: +/// 1. **size-eligible** — subjects a majority of judges vouch for AT THE +/// QUOTED SIZE. Whoever wins downstream selection is fully eligible. +/// 2. **dues-eligible (size relaxed)** — if too few are size-eligible, +/// subjects a majority vouch for on DUES (a clean audited week at any +/// size). This is the v5 fallback for fast network-wide growth, where +/// nobody has a full week at the newly grown size. It STILL excludes +/// fresh identities (no dues) and caught cheaters (fenced/convicted), so +/// it drops only the size requirement; ADR-0004 independently forces a +/// current-size proof at payment, so a node can't be paid for a size it +/// doesn't hold. Every size-eligible node is in this tier too. +/// 3. **ungated** — neither tier fills `need`: today's rules apply. This is +/// a degraded-SECURITY mode (earned eligibility bypassed), logged so +/// operators can see how often the network runs ungated. +/// +/// Observe-only (`!policy.enforce`) never changes selection; it logs which +/// tier it WOULD have used so the rollout calibration can distinguish all +/// three outcomes. +/// +/// NOTE (pre-existing v4 limitation, accepted): `size_count >= need` does not +/// guarantee downstream single-node selection succeeds — the witnessed path +/// searches for a close-group whose median issuer has enough witness support +/// and can still return `None`. This tiering filters the candidate set; it +/// does not drive that selector through the tiers. Merkle has no equivalent +/// issue once the selected tier holds ≥ `need` candidates. +#[must_use] +pub fn gate_quoter_set( + items: Vec, + subject_of: impl Fn(&T) -> (PeerId, u32), + reports: &HashMap, + policy: &EligibilityPolicy, + need: usize, + context: &str, +) -> Vec { + let subjects: Vec<(PeerId, u32)> = items.iter().map(&subject_of).collect(); + let eligible = eligible_subjects(&subjects, reports, policy); + let total = items.len(); + // dues set is a superset of the size set; count both. + let size_count = eligible.values().filter(|e| e.tier == Tier::Size).count(); + let dues_count = eligible.len(); + + // The tier that WOULD be selected (also what observe-only reports). + let tier = if size_count >= need { + GateTier::Size + } else if dues_count >= need { + GateTier::Dues + } else { + GateTier::Ungated + }; + + if !policy.enforce { + info!( + "ADR-0005 observe-only [{context}]: would use {} — size-eligible {size_count}/{total}, \ + dues-eligible {dues_count}/{total} (need {need}, floor {}, reports {}); \ + selection unchanged", + tier.label(), + policy.quorum_floor, + reports.len(), + ); + return items; + } + + match tier { + GateTier::Size => { + info!( + "ADR-0005 gate [{context}]: size-eligible — selecting among {size_count} \ + (dropped {} not size-eligible)", + total - size_count, + ); + items + .into_iter() + .filter(|item| { + eligible + .get(&subject_of(item)) + .is_some_and(|e| e.tier == Tier::Size) + }) + .collect() + } + GateTier::Dues => { + // Too few size-eligible; fall back to the dues-eligible subset, + // which INCLUDES the size-eligible ones. No further ranking: + // single-node re-sorts by price/distance and merkle by closeness, + // and preferring the dues tier over ungated IS the "most dues + // done" preference the ADR calls for. + info!( + "ADR-0005 gate [{context}]: DUES-ELIGIBLE (size relaxed) — only \ + {size_count} size-eligible < need {need}; selecting among {dues_count} \ + with a clean audited week at any size (dropped {} without dues)", + total - dues_count, + ); + items + .into_iter() + .filter(|item| eligible.contains_key(&subject_of(item))) + .collect() + } + GateTier::Ungated => { + info!( + "ADR-0005 gate [{context}]: UNGATED (degraded security) — only \ + {size_count} size-eligible / {dues_count} dues-eligible < need {need}; \ + paying under today's rules, earned eligibility bypassed" + ); + debug!( + "ADR-0005 ungated [{context}] eligible set: {:?}", + eligible + .keys() + .map(|(peer, size)| format!("{peer}@{size}")) + .collect::>() + ); + items + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use ant_protocol::payment::AuditReportDay; + + fn policy() -> EligibilityPolicy { + EligibilityPolicy { + min_distinct_days: 7, + max_recency_days: 1, + size_slack: 2, + window_days: 14, + quorum_floor: 3, + enforce: true, + } + } + + fn clean_row(subject: [u8; 32], days: u16, passes_per_day: u32, size: u32) -> AuditReportRow { + AuditReportRow { + subject_peer_id: subject, + days: (0..days) + .map(|age| AuditReportDay { + age_days: age, + passes: passes_per_day, + max_passed_key_count: size, + }) + .collect(), + fenced: false, + convicted: false, + } + } + + #[test] + fn a_week_of_covering_passes_qualifies() { + let row = clean_row([1; 32], 7, 3, 1000); + assert!(row_qualifies(&row, 1000, &policy())); + assert!( + row_qualifies(&row, 2000, &policy()), + "2x slack covers a doubled quote" + ); + assert!( + !row_qualifies(&row, 2001, &policy()), + "beyond slack the size is not covered" + ); + } + + #[test] + fn too_few_days_or_stale_fails() { + assert!(!row_qualifies( + &clean_row([1; 32], 6, 5, 1000), + 1000, + &policy() + )); + let mut stale = clean_row([1; 32], 7, 5, 1000); + for day in &mut stale.days { + day.age_days += 2; // freshest is now 2 days old + } + assert!(!row_qualifies(&stale, 1000, &policy())); + } + + #[test] + fn fence_and_conviction_disqualify() { + let mut row = clean_row([1; 32], 7, 5, 1000); + row.fenced = true; + assert!(!row_qualifies(&row, 1000, &policy())); + let mut row = clean_row([1; 32], 7, 5, 1000); + row.convicted = true; + assert!(!row_qualifies(&row, 1000, &policy())); + } + + #[test] + fn grind_small_cash_big_fails_day_coverage() { + // A week of passes at size 10, then one fresh pass at size 10_000: + // quoting 10_000 must NOT qualify (only one covering day). + let mut row = clean_row([1; 32], 7, 5, 10); + if let Some(first) = row.days.first_mut() { + first.max_passed_key_count = 10_000; + } + assert!(!row_qualifies(&row, 10_000, &policy())); + // The small history still qualifies small quotes. + assert!(row_qualifies(&row, 10, &policy())); + } + + #[test] + fn duplicate_ages_collapse_to_one_day() { + // 7 entries all claiming age 0: one distinct day, not seven. + let mut row = clean_row([1; 32], 1, 5, 1000); + let day = row.days[0]; + row.days = vec![day; 7]; + assert!(!row_qualifies(&row, 1000, &policy())); + } + + #[test] + fn days_beyond_the_client_window_are_ignored() { + // 7 covering days but 3 of them older than the window: only 4 count. + let mut row = clean_row([1; 32], 7, 5, 1000); + for day in row.days.iter_mut().skip(4) { + day.age_days += 100; + } + assert!(!row_qualifies(&row, 1000, &policy())); + } + + fn report_from(reporter_byte: u8, rows: Vec) -> (PeerId, AuditReport) { + let reporter = PeerId::from_bytes([reporter_byte; 32]); + ( + reporter, + AuditReport { + reporter_peer_id: [reporter_byte; 32], + nonce: [0; 32], + rows, + signature: Vec::new(), + }, + ) + } + + #[test] + fn relative_bar_aggregation_and_self_vouch_exclusion() { + let subject = PeerId::from_bytes([9; 32]); + let subjects = vec![(subject, 1000u32)]; + let good_row = clean_row([9; 32], 7, 5, 1000); + + // Two qualifying observers: below the floor of 3. + let mut reports: HashMap = [ + report_from(1, vec![good_row.clone()]), + report_from(2, vec![good_row.clone()]), + ] + .into_iter() + .collect(); + assert!(eligible_subjects(&subjects, &reports, &policy()).is_empty()); + + // A self-report must not count toward the bar. + let (self_reporter, self_report) = report_from(9, vec![good_row.clone()]); + reports.insert(self_reporter, self_report); + assert!(eligible_subjects(&subjects, &reports, &policy()).is_empty()); + + // A third independent observer clears the floor (3 reporters, bar = + // max(3, 3-2) = 3, vouches 3) at the SIZE tier. + let (r3, rep3) = report_from(3, vec![good_row.clone()]); + reports.insert(r3, rep3); + let eligible = eligible_subjects(&subjects, &reports, &policy()); + let e = eligible.get(&(subject, 1000u32)).expect("size-eligible"); + assert_eq!(e.tier, Tier::Size); + assert_eq!(e.size_vouches, 3); + + // v4 MAJORITY-OF-OPINIONS bar: reporters with NO row for the subject + // abstain — they must not raise the bar (no shortfall to calibrate). + for r in 4..=10u8 { + let (rr, rep_empty) = report_from(r, Vec::new()); + reports.insert(rr, rep_empty); + } + let eligible = eligible_subjects(&subjects, &reports, &policy()); + let e = eligible + .get(&(subject, 1000u32)) + .expect("abstainers don't suppress"); + assert_eq!( + e.tier, + Tier::Size, + "abstaining reporters must not suppress (3 opinions, 3 vouches)" + ); + assert_eq!(e.size_vouches, 3); + + // But non-vouching ROWS count as opinions: catchers with sticky + // convicted rows push the subject below the majority. + let mut convicted_row = clean_row([9; 32], 7, 5, 1000); + convicted_row.convicted = true; + convicted_row.days.clear(); + for r in 4..=8u8 { + let (rr, repv) = report_from(r, vec![convicted_row.clone()]); + reports.insert(rr, repv); + } + // 8 opinions (3 clean + 5 convicted), bar = 8/2+1 = 5 > 3 vouches. + assert!( + eligible_subjects(&subjects, &reports, &policy()).is_empty(), + "a majority of non-vouching rows must exclude the subject" + ); + } + + #[test] + fn gate_filters_only_when_enforcing_with_enough_eligible() { + let subject_a = PeerId::from_bytes([9; 32]); + let subject_b = PeerId::from_bytes([8; 32]); + let items = vec![(subject_a, 1000u32), (subject_b, 1000u32)]; + let good_row_a = clean_row([9; 32], 7, 5, 1000); + let reports: HashMap = [ + report_from(1, vec![good_row_a.clone()]), + report_from(2, vec![good_row_a.clone()]), + report_from(3, vec![good_row_a]), + ] + .into_iter() + .collect(); + + // Enforcing, need 1, subject_a eligible → only subject_a survives. + let kept = gate_quoter_set(items.clone(), |item| *item, &reports, &policy(), 1, "test"); + assert_eq!(kept, vec![(subject_a, 1000u32)]); + + // Need 2 but only 1 eligible → degraded, unchanged. + let kept = gate_quoter_set(items.clone(), |item| *item, &reports, &policy(), 2, "test"); + assert_eq!(kept.len(), 2); + + // Observe-only → unchanged even with an eligible subset. + let mut observe = policy(); + observe.enforce = false; + let kept = gate_quoter_set(items, |item| *item, &reports, &observe, 1, "test"); + assert_eq!(kept.len(), 2); + } + + // ------------------------------------------------------------------ + // ADR-0005 v5: dues-eligibility (size-relaxed tier 2) + // ------------------------------------------------------------------ + + #[test] + fn dues_ignores_size_but_keeps_the_week() { + // A clean week at size 1 (a small node) has done its dues, but is NOT + // size-eligible for a 100k-chunk quote. + let row = clean_row([1; 32], 7, 5, 1); + assert!( + row_has_dues(&row, &policy()), + "a clean week is dues, any size" + ); + assert!( + !row_qualifies(&row, 100_000, &policy()), + "a small node is not size-eligible for a huge quote" + ); + assert!( + row_qualifies(&row, 1, &policy()), + "it IS size-eligible at its own small size" + ); + } + + #[test] + fn dues_still_excludes_fresh_and_caught() { + // Fresh identity: no pass days at all -> neither dues nor size. + let fresh = clean_row([1; 32], 0, 0, 1000); + assert!(!row_has_dues(&fresh, &policy())); + assert!(!row_qualifies(&fresh, 1, &policy())); + + // Convicted: fails both, even with a full week of days underneath. + let mut convicted = clean_row([1; 32], 7, 5, 1000); + convicted.convicted = true; + assert!(!row_has_dues(&convicted, &policy())); + assert!(!row_qualifies(&convicted, 1000, &policy())); + + // Fenced: fails both. + let mut fenced = clean_row([1; 32], 7, 5, 1000); + fenced.fenced = true; + assert!(!row_has_dues(&fenced, &policy())); + assert!(!row_qualifies(&fenced, 1000, &policy())); + } + + #[test] + fn dues_is_a_superset_of_size_including_recency() { + // codex-flagged counterexample class: 7 small passes at ages 0-6 plus + // ONE covering pass at age 13. This has dues (7 distinct recent pass + // days) but is NOT size-eligible for a big quote (only 1 covering day, + // and it is stale) — the split must NOT collapse to + // has_dues && covers_somewhere. + let mut row = clean_row([1; 32], 7, 5, 1); // ages 0..6, size 1 + row.days.push(AuditReportDay { + age_days: 13, + passes: 5, + max_passed_key_count: 100_000, + }); + assert!(row_has_dues(&row, &policy()), "7 recent pass days = dues"); + assert!( + !row_qualifies(&row, 100_000, &policy()), + "one stale covering day is NOT size-eligibility" + ); + } + + #[test] + fn gate_falls_back_to_dues_when_size_short_but_never_admits_bad() { + // 3 judges, all with a clean week at SMALL size 1; a big quote (size + // 100k) makes nobody size-eligible, but all are dues-eligible. + let subject = PeerId::from_bytes([9; 32]); + let small_week = clean_row([9; 32], 7, 5, 1); + let mut reports: HashMap = (1..=3u8) + .map(|r| report_from(r, vec![small_week.clone()])) + .collect(); + + let big = vec![(subject, 100_000u32)]; + let elig = eligible_subjects(&big, &reports, &policy()); + assert_eq!( + elig.get(&(subject, 100_000u32)).map(|e| e.tier), + Some(Tier::Dues), + "size short, dues met -> Dues tier" + ); + // Enforcing gate at big size keeps the dues-eligible node. + let kept = gate_quoter_set(big.clone(), |i| *i, &reports, &policy(), 1, "test"); + assert_eq!(kept, big, "dues fallback keeps the node"); + + // A convicted judge-target is NOT dues-eligible even under fallback: + // every judge carries a convicted row for it. + let convicted_subject = PeerId::from_bytes([7; 32]); + let mut conv = clean_row([7; 32], 7, 5, 1); + conv.convicted = true; + for r in 1..=3u8 { + if let Some(rep) = reports.get_mut(&PeerId::from_bytes([r; 32])) { + rep.rows.push(conv.clone()); + } + } + let elig = eligible_subjects(&[(convicted_subject, 100_000u32)], &reports, &policy()); + assert!( + !elig.contains_key(&(convicted_subject, 100_000u32)), + "a convicted node fails BOTH tiers" + ); + } + + #[test] + fn size_eligible_subjects_are_also_dues_tier_members() { + // A size-eligible node reports tier Size but is counted in the dues + // set too (dues_vouches >= size_vouches). gate_quoter_set's tier-2 + // filter must include it. + let subject = PeerId::from_bytes([9; 32]); + let full = clean_row([9; 32], 7, 5, 1000); + let reports: HashMap = (1..=3u8) + .map(|r| report_from(r, vec![full.clone()])) + .collect(); + let elig = eligible_subjects(&[(subject, 1000u32)], &reports, &policy()); + let e = elig.get(&(subject, 1000u32)).expect("eligible"); + assert_eq!(e.tier, Tier::Size); + assert_eq!(e.size_vouches, 3); + assert_eq!(e.dues_vouches, 3, "size vouches are also dues vouches"); + } + + #[test] + fn same_peer_at_two_sizes_never_leaks_size_tier_across_quotes() { + // The eligibility map is keyed by (peer, size). If it were keyed by peer + // alone, one peer appearing at a small (size-eligible) and a large + // (dues-only) quote would collapse to one entry, and the small quote's + // Size tier could admit the large quote through the size-only filter. + // With a clean week at size 1 only: + // (S, 1) -> Size-eligible + // (S, 100_000) -> Dues-eligible only (1 * slack < 100_000) + let subject = PeerId::from_bytes([9; 32]); + let small_week = clean_row([9; 32], 7, 5, 1); + let reports: HashMap = (1..=3u8) + .map(|r| report_from(r, vec![small_week.clone()])) + .collect(); + + // The large quote is evaluated on its own key: Dues, not Size. + let elig = eligible_subjects( + &[(subject, 1u32), (subject, 100_000u32)], + &reports, + &policy(), + ); + assert_eq!( + elig.get(&(subject, 1u32)).map(|e| e.tier), + Some(Tier::Size), + "the small quote is size-eligible" + ); + assert_eq!( + elig.get(&(subject, 100_000u32)).map(|e| e.tier), + Some(Tier::Dues), + "the large quote is dues-only, regardless of the small quote's tier" + ); + + // The Size-tier gate over BOTH quotes must keep only the small one — the + // large quote has zero size vouches and must not ride the peer's small + // quote into the size tier. + let both = vec![(subject, 1u32), (subject, 100_000u32)]; + let kept = gate_quoter_set(both, |i| *i, &reports, &policy(), 1, "test"); + assert_eq!( + kept, + vec![(subject, 1u32)], + "size gate admits only the size-eligible (peer,size) pair" + ); + } +} diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index af0e44ba..6383a4db 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -18,8 +18,11 @@ use ant_protocol::payment::commitment::{ MAX_COMMITMENT_SIDECAR_BYTES, }; use ant_protocol::payment::{ - calculate_price, serialize_merkle_proof, verify_merkle_candidate_signature, + calculate_price, serialize_merkle_proof, verify_audit_report, + verify_merkle_candidate_signature, AuditReport, MAX_AUDIT_REPORT_BYTES, }; + +use crate::data::client::eligibility::{self, EligibilityPolicy}; use ant_protocol::transport::PeerId; use ant_protocol::{ compute_address, send_and_await_chunk_response, ChunkMessage, ChunkMessageBody, @@ -706,7 +709,18 @@ impl Client { let timeout = Duration::from_secs(self.config().quote_timeout_secs); // Query extra peers to handle validation failures (bad sigs, wrong type, etc.) - let query_count = CANDIDATES_PER_POOL * 2; + // ADR-0005: when the eligibility gate is ENFORCING, widen the query so + // the gate has eligible substitutes beyond the closest 32 — exactly the + // single-node rationale ("a single ineligible fast responder would + // force degraded mode"), and it applies at least as strongly here: + // merkle needs 16 eligible candidates vs single-node's 7, so it is the + // MORE likely path to spurious degraded/ungated selection. Observe-only + // keeps the fixed over-query (and byte cost) unchanged. + let query_count = if EligibilityPolicy::default().enforce { + CANDIDATES_PER_POOL * 4 + } else { + CANDIDATES_PER_POOL * 2 + }; let mut remote_peers = self .network() .find_closest_peers(address, query_count) @@ -736,11 +750,15 @@ impl Client { for (peer_id, peer_addrs) in &remote_peers { let request_id = self.next_request_id(); + // ADR-0005: fresh nonce per candidate request; the responder's + // audit report must echo (and sign) it. + let report_nonce: [u8; 32] = rand::random(); let request = MerkleCandidateQuoteRequest { address: *address, data_type, data_size, merkle_payment_timestamp, + report_nonce, }; let message = ChunkMessage { request_id, @@ -772,12 +790,15 @@ impl Client { MerkleCandidateQuoteResponse::Success { candidate_node, commitment, + audit_report, }, ) => { match rmp_serde::from_slice::( &candidate_node, ) { - Ok(node) => Some(Ok((node, commitment))), + Ok(node) => { + Some(Ok((node, commitment, audit_report, report_nonce))) + } Err(e) => Some(Err(Error::Serialization(format!( "Failed to deserialize candidate node from {peer_id_clone}: {e}" )))), @@ -829,7 +850,15 @@ impl Client { impl std::future::Future< Output = ( PeerId, - std::result::Result<(MerklePaymentCandidateNode, Option>), Error>, + std::result::Result< + ( + MerklePaymentCandidateNode, + Option>, + Option>, + [u8; 32], + ), + Error, + >, ), >, >, @@ -837,11 +866,14 @@ impl Client { merkle_payment_timestamp: u64, ) -> Result<[MerklePaymentCandidateNode; CANDIDATES_PER_POOL]> { let mut valid: Vec<(PeerId, MerklePaymentCandidateNode)> = Vec::new(); + // ADR-0005: verified audit reports by reporter, for the pool-selection + // eligibility gate below. Dropped after selection, never forwarded. + let mut reports: HashMap = HashMap::new(); let mut failures: Vec = Vec::new(); while let Some((peer_id, result)) = futures.next().await { match result { - Ok((candidate, commitment)) => { + Ok((candidate, commitment, audit_report, report_nonce)) => { if !verify_merkle_candidate_signature(&candidate) { warn!("Invalid ML-DSA-65 signature from merkle candidate {peer_id}"); failures.push(format!("{peer_id}: invalid signature")); @@ -880,6 +912,33 @@ impl Client { failures.push(format!("{peer_id}: bad commitment binding ({detail})")); continue; } + // ADR-0005: resolve the candidate's signed audit report. + // Auxiliary testimony about OTHER peers — absent or + // unverifiable never invalidates this candidate; it just + // vouches for nobody this round. + if let Some(bytes) = &audit_report { + if bytes.len() <= MAX_AUDIT_REPORT_BYTES { + match rmp_serde::from_slice::(bytes) { + Ok(report) + if verify_audit_report( + &report, + &candidate.pub_key, + candidate_peer.as_bytes(), + &report_nonce, + ) => + { + reports.insert(candidate_peer, report); + } + _ => warn!( + "Ignoring invalid audit report from merkle candidate {peer_id}" + ), + } + } else { + warn!( + "Ignoring oversized audit report from merkle candidate {peer_id}" + ); + } + } valid.push((candidate_peer, candidate)); } Err(e) => { @@ -900,6 +959,22 @@ impl Client { let target_peer = PeerId::from_bytes(*target_address); valid.sort_by_key(|(peer_id, _)| peer_id.xor_distance(&target_peer)); + // ADR-0005 gate at pool composition: when enforcing AND a full pool of + // eligible candidates exists, the pool is filled eligible-only (order + // stays closest-first, keeping the storer's 13-of-16 close-set check + // satisfiable) — so whichever candidate the contract picks is + // eligible. Otherwise today's closest-16 applies (observe-only or + // explicitly degraded), logged inside the gate. + let valid = eligibility::gate_quoter_set( + valid, + |(peer_id, candidate)| (*peer_id, candidate.committed_key_count), + &reports, + &EligibilityPolicy::default(), + CANDIDATES_PER_POOL, + "merkle-pool", + ); + drop(reports); + let candidates: Vec = valid .into_iter() .take(CANDIDATES_PER_POOL) diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index 5214df9b..3cb487b1 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod cached_merkle; pub(crate) mod cached_single; pub mod chunk; pub mod data; +pub mod eligibility; pub mod file; pub mod merkle; pub mod payment; diff --git a/ant-core/src/data/client/quote.rs b/ant-core/src/data/client/quote.rs index 3143d71d..23791dc2 100644 --- a/ant-core/src/data/client/quote.rs +++ b/ant-core/src/data/client/quote.rs @@ -3,6 +3,7 @@ //! Handles requesting storage quotes from network nodes and //! managing payment for data storage. +use crate::data::client::eligibility::{self, EligibilityPolicy}; use crate::data::client::peer_xor_distance; use crate::data::client::Client; use crate::data::client::PUT_TARGET_WIDTH; @@ -13,6 +14,7 @@ use ant_protocol::payment::commitment::{ commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT, MAX_COMMITMENT_SIDECAR_BYTES, }; +use ant_protocol::payment::{verify_audit_report, AuditReport, MAX_AUDIT_REPORT_BYTES}; use ant_protocol::payment::{verify_quote_content, verify_quote_signature}; use ant_protocol::transport::{ DHTNode, MultiAddr, P2PNode, PeerId, ResponderView, WitnessedCloseGroup, @@ -72,6 +74,12 @@ type QuotedPeer = ( Option>, ); +/// A classified quote response: the verified quote, its price, and the +/// ADR-0004 commitment sidecar. The ADR-0005 audit report travels separately +/// (a sink filled for every VERIFIED responder, including already-stored +/// voters — their testimony counts even though they issue no payable quote). +type ClassifiedQuote = (PaymentQuote, Amount, Option>); + /// Check that a quote's `pub_key` is well-formed and BLAKE3-hashes to the /// claimed `peer_id`. /// @@ -229,13 +237,17 @@ fn quote_commitment_binding_is_valid( /// On success the returned commitment is the opaque signed-commitment blob the /// node shipped with the quote (`None` for a baseline quote), to be forwarded /// as a sidecar in the PUT bundle. +#[allow(clippy::too_many_arguments)] fn classify_quote_response( peer_id: &PeerId, expected_content: &[u8; 32], quote_bytes: &[u8], already_stored: bool, commitment: Option>, -) -> std::result::Result<(PaymentQuote, Amount, Option>), Error> { + audit_report: Option>, + report_nonce: &[u8; 32], + report_sink: &std::sync::Mutex>, +) -> std::result::Result { let payment_quote = rmp_serde::from_slice::(quote_bytes).map_err(|e| { Error::Serialization(format!("Failed to deserialize quote from {peer_id}: {e}")) })?; @@ -292,10 +304,58 @@ fn classify_quote_response( }); } + // ADR-0005: resolve the responder's signed audit report BEFORE the + // already-stored short-circuit — an already-stored voter issues no + // payable quote, but its (fully verified) testimony about other peers + // still counts toward the eligibility quorum. The report is auxiliary: + // absent or unverifiable never invalidates this peer's own quote; it + // just means this responder vouches for nobody this round. The signature + // is checked against the quote's own (already peer-bound) public key, + // and the echoed nonce proves the report was generated for this request. + let report = audit_report.and_then(|bytes| { + // Structured size line — the local-testnet runner greps these for the + // overhead budget check (build/verify timing is measured by the runner). + debug!( + target: "adr5::report_size", + peer = %peer_id, + bytes = bytes.len(), + "audit report received" + ); + if bytes.len() > MAX_AUDIT_REPORT_BYTES { + warn!( + "Ignoring oversized audit report from {peer_id} ({} bytes)", + bytes.len() + ); + return None; + } + let parsed: AuditReport = match rmp_serde::from_slice(&bytes) { + Ok(report) => report, + Err(e) => { + warn!("Ignoring unparseable audit report from {peer_id}: {e}"); + return None; + } + }; + if verify_audit_report( + &parsed, + &payment_quote.pub_key, + peer_id.as_bytes(), + report_nonce, + ) { + Some(parsed) + } else { + warn!("Ignoring audit report from {peer_id} — signature/nonce/shape check failed"); + None + } + }); + if let Ok(mut sink) = report_sink.lock() { + *sink = report; + } + if already_stored { debug!("Peer {peer_id} already has chunk"); return Err(Error::AlreadyStored); } + let price = payment_quote.price; debug!("Received quote from {peer_id}: price = {price}"); Ok((payment_quote, price, commitment)) @@ -331,10 +391,15 @@ async fn request_store_quote_from_peer( data_type: u32, per_peer_timeout: Duration, ) -> StoreQuoteRequestResult { + // ADR-0005: a fresh random nonce per request; the responder's audit + // report must echo (and sign) it, so reports cannot be precomputed or + // replayed across requests. + let report_nonce: [u8; 32] = rand::random(); let request = ChunkQuoteRequest { address, data_size, data_type, + report_nonce, }; let message = ChunkMessage { request_id, @@ -350,10 +415,15 @@ async fn request_store_quote_from_peer( Err(Error::Protocol(format!( "Failed to encode quote request for {peer_id}: {e}" ))), + None, ); } }; + // Sink for the responder's verified audit report: filled by the + // classifier for every VERIFIED responder, including already-stored + // voters whose classification is an Err(AlreadyStored). + let report_sink = std::sync::Mutex::new(None); let result = send_and_await_chunk_response( &node, &peer_id, @@ -366,12 +436,16 @@ async fn request_store_quote_from_peer( quote, already_stored, commitment, + audit_report, }) => Some(classify_quote_response( &peer_id, &address, "e, already_stored, commitment, + audit_report, + &report_nonce, + &report_sink, )), ChunkMessageBody::QuoteResponse(ChunkQuoteResponse::Error(e)) => Some(Err( Error::Protocol(format!("Quote error from {peer_id}: {e}")), @@ -383,20 +457,29 @@ async fn request_store_quote_from_peer( ) .await; - (peer_id, peer_addrs, result) + let report = report_sink.into_inner().unwrap_or_default(); + (peer_id, peer_addrs, result, report) } #[allow(clippy::too_many_arguments)] fn record_store_quote_result( peer_id: PeerId, addrs: Vec, - quote_result: Result<(PaymentQuote, Amount, Option>)>, + quote_result: Result, + report: Option, address: &[u8; 32], quotes: &mut Vec, + reports: &mut HashMap, already_stored_peers: &mut Vec<(PeerId, [u8; 32])>, failures: &mut Vec, bad_quote_count: &mut usize, ) { + // ADR-0005: keep every VERIFIED responder's report for the + // collection-time eligibility decision (already-stored voters testify + // too); dropped after selection, never forwarded. + if let Some(report) = report { + reports.insert(peer_id, report); + } match quote_result { Ok((quote, price, commitment)) => { quotes.push((peer_id, addrs, quote, price, commitment)); @@ -417,11 +500,12 @@ fn record_store_quote_result( } fn witnessed_quote_launch_budget( + target: usize, successful_quotes: usize, in_flight: usize, remaining_peers: usize, ) -> usize { - CLOSE_GROUP_SIZE + target .saturating_sub(successful_quotes.saturating_add(in_flight)) .min(remaining_peers) } @@ -502,7 +586,8 @@ pub(crate) type StoreQuote = ( type StoreQuoteRequestResult = ( PeerId, Vec, - Result<(PaymentQuote, Amount, Option>)>, + Result, + Option, ); type VotersByPeer = HashMap>; type WitnessedVoteData = (HashMap, VotersByPeer, Vec<(PeerId, usize)>); @@ -1125,6 +1210,9 @@ impl Client { // the closest witnessed peers first and only falls back to further // witnessed peers when a closer peer fails to produce a usable quote. let mut quotes = Vec::with_capacity(peer_query_count); + // ADR-0005: verified audit reports by reporter, for the collection-time + // eligibility gate below. Dropped after selection. + let mut reports: HashMap = HashMap::new(); let mut already_stored_peers: Vec<(PeerId, [u8; 32])> = Vec::new(); let mut failures: Vec = Vec::new(); @@ -1139,6 +1227,19 @@ impl Client { QuoteSelectionPolicy::WitnessedMedianVoters { .. } ); + // ADR-0005: when the eligibility gate is ENFORCING, collect quotes + // from the whole witnessed candidate set instead of stopping at the + // first CLOSE_GROUP_SIZE successes — the gate needs eligible + // substitutes beyond the closest 7, or a single ineligible fast + // responder would force degraded mode. Observe-only keeps today's + // early-stop behaviour (and byte cost) unchanged. + let eligibility_policy = EligibilityPolicy::default(); + let collection_target = if eligibility_policy.enforce { + remote_peers.len().max(CLOSE_GROUP_SIZE) + } else { + CLOSE_GROUP_SIZE + }; + if staged_witnessed_collection { let mut quote_futures = FuturesUnordered::new(); let mut next_peer_index = 0usize; @@ -1146,6 +1247,7 @@ impl Client { tokio::time::timeout(overall_timeout, async { loop { let launch_count = witnessed_quote_launch_budget( + collection_target, quotes.len(), quote_futures.len(), remote_peers.len().saturating_sub(next_peer_index), @@ -1165,11 +1267,12 @@ impl Client { )); } - if quotes.len() >= CLOSE_GROUP_SIZE || quote_futures.is_empty() { + if quotes.len() >= collection_target || quote_futures.is_empty() { break; } - let Some((peer_id, addrs, quote_result)) = quote_futures.next().await + let Some((peer_id, addrs, quote_result, report)) = + quote_futures.next().await else { break; }; @@ -1177,8 +1280,10 @@ impl Client { peer_id, addrs, quote_result, + report, address, &mut quotes, + &mut reports, &mut already_stored_peers, &mut failures, &mut bad_quote_count, @@ -1219,13 +1324,17 @@ impl Client { let collect_result: std::result::Result, _> = tokio::time::timeout(overall_timeout, async { - while let Some((peer_id, addrs, quote_result)) = quote_futures.next().await { + while let Some((peer_id, addrs, quote_result, report)) = + quote_futures.next().await + { record_store_quote_result( peer_id, addrs, quote_result, + report, address, &mut quotes, + &mut reports, &mut already_stored_peers, &mut failures, &mut bad_quote_count, @@ -1297,6 +1406,20 @@ impl Client { let quote_count = quotes.len(); let total_responses = quote_count + failure_count + already_stored_count; + // ADR-0005 gate at collection: when enforcing AND a full close group of + // eligible quoters exists, selection runs on the eligible subset — so + // whoever wins the median is eligible. Otherwise (observe-only, or too + // few eligible = explicitly degraded) today's rules apply, logged. + let quotes = eligibility::gate_quoter_set( + quotes, + |(peer_id, _, quote, _, _): &StoreQuote| (*peer_id, quote.committed_key_count), + &reports, + &eligibility_policy, + CLOSE_GROUP_SIZE, + "single-node", + ); + drop(reports); + if quotes.len() >= CLOSE_GROUP_SIZE { let selected_quotes = match quote_selection_policy { QuoteSelectionPolicy::ClosestByDistance => select_closest_quotes(quotes, address), @@ -1358,6 +1481,45 @@ mod tests { use std::time::SystemTime; use xor_name::XorName; + /// P3 guard (codex impl-gate): a VERIFIED report arriving with an + /// `Err(AlreadyStored)` classification must still reach the eligibility + /// report map — already-stored voters testify too. + #[test] + fn already_stored_responder_report_reaches_the_map() { + use ant_protocol::payment::AuditReport; + + let peer = PeerId::from_bytes([3u8; 32]); + let report = AuditReport { + reporter_peer_id: [3u8; 32], + nonce: [1u8; 32], + rows: Vec::new(), + signature: Vec::new(), + }; + let mut quotes = Vec::new(); + let mut reports = HashMap::new(); + let mut already = Vec::new(); + let mut failures = Vec::new(); + let mut bad = 0usize; + record_store_quote_result( + peer, + Vec::new(), + Err(Error::AlreadyStored), + Some(report), + &[9u8; 32], + &mut quotes, + &mut reports, + &mut already, + &mut failures, + &mut bad, + ); + assert!(quotes.is_empty()); + assert_eq!(already.len(), 1, "already-stored vote recorded"); + assert!( + reports.contains_key(&peer), + "already-stored responder's testimony must reach the report map" + ); + } + /// A real ML-DSA-65 keypair plus its derived peer ID. struct Keypair { peer_id: PeerId, @@ -1623,30 +1785,51 @@ mod tests { #[test] fn witnessed_quote_launch_budget_keeps_exact_quote_window() { assert_eq!( - witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE * 2), + witnessed_quote_launch_budget(CLOSE_GROUP_SIZE, 0, 0, CLOSE_GROUP_SIZE * 2), CLOSE_GROUP_SIZE, "initial SNP quote fetch should launch the closest seven peers" ); assert_eq!( - witnessed_quote_launch_budget(1, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE), + witnessed_quote_launch_budget( + CLOSE_GROUP_SIZE, + 1, + CLOSE_GROUP_SIZE - 1, + CLOSE_GROUP_SIZE + ), 0, "a successful quote should not launch an extra fallback" ); assert_eq!( - witnessed_quote_launch_budget(0, CLOSE_GROUP_SIZE - 1, CLOSE_GROUP_SIZE), + witnessed_quote_launch_budget( + CLOSE_GROUP_SIZE, + 0, + CLOSE_GROUP_SIZE - 1, + CLOSE_GROUP_SIZE + ), 1, "a failed in-flight quote should launch the next closest fallback" ); assert_eq!( - witnessed_quote_launch_budget(CLOSE_GROUP_SIZE - 1, 0, 3), + witnessed_quote_launch_budget(CLOSE_GROUP_SIZE, CLOSE_GROUP_SIZE - 1, 0, 3), 1, "only one more peer is needed for the seventh quote" ); assert_eq!( - witnessed_quote_launch_budget(0, 0, CLOSE_GROUP_SIZE - 1), + witnessed_quote_launch_budget(CLOSE_GROUP_SIZE, 0, 0, CLOSE_GROUP_SIZE - 1), CLOSE_GROUP_SIZE - 1, "launch budget is capped by remaining candidates" ); + // ADR-0005 enforcement widens the target to the whole candidate set. + assert_eq!( + witnessed_quote_launch_budget( + CLOSE_GROUP_SIZE * 2, + CLOSE_GROUP_SIZE, + 0, + CLOSE_GROUP_SIZE + ), + CLOSE_GROUP_SIZE, + "an enforcing client keeps collecting past the first seven" + ); } #[test] @@ -2264,7 +2447,16 @@ mod tests { let content = [7u8; 32]; let (peer_id, quote) = signed_baseline_quote(content); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &content, &bytes, false, None); + let result = classify_quote_response( + &peer_id, + &content, + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); match result { Ok((q, price, commitment)) => { assert_eq!(q.pub_key, quote.pub_key); @@ -2283,7 +2475,16 @@ mod tests { let (peer_id, mut quote) = signed_baseline_quote(content); quote.signature = vec![0u8; quote.signature.len()]; // corrupt the signature let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &content, &bytes, false, None); + let result = classify_quote_response( + &peer_id, + &content, + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::BadQuoteBinding { .. })), "a quote with an invalid signature must be rejected; got {result:?}" @@ -2295,7 +2496,16 @@ mod tests { // A validly-signed quote for a DIFFERENT address is dropped before pay. let (peer_id, quote) = signed_baseline_quote([7u8; 32]); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &[9u8; 32], &bytes, false, None); + let result = classify_quote_response( + &peer_id, + &[9u8; 32], + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::BadQuoteBinding { .. })), "a quote for the wrong content must be rejected; got {result:?}" @@ -2306,7 +2516,16 @@ mod tests { fn classifier_rejects_crossed_keypair_with_typed_error() { let (peer_id, _, quote, _, _) = bad_quote_real(); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, false, None); + let result = classify_quote_response( + &peer_id, + &[0u8; 32], + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); match result { Err(Error::BadQuoteBinding { peer_id: pid, @@ -2337,7 +2556,16 @@ mod tests { let (peer_id, _, quote, _, _) = bad_quote_real(); let bytes = serialize_quote("e); // The peer claims already_stored=true, but its quote has a crossed key. - let result = classify_quote_response(&peer_id, &[0u8; 32], &bytes, true, None); + let result = classify_quote_response( + &peer_id, + &[0u8; 32], + &bytes, + true, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::BadQuoteBinding { .. })), "crossed-key peer must be classified BadQuoteBinding even when \ @@ -2352,7 +2580,16 @@ mod tests { let content = [7u8; 32]; let (peer_id, quote) = signed_baseline_quote(content); let bytes = serialize_quote("e); - let result = classify_quote_response(&peer_id, &content, &bytes, true, None); + let result = classify_quote_response( + &peer_id, + &content, + &bytes, + true, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::AlreadyStored)), "honest peer's already_stored vote must be honoured; got {result:?}" @@ -2363,7 +2600,16 @@ mod tests { fn classifier_returns_serialization_error_on_bad_bytes() { let (peer_id, _, _, _, _) = good_quote_real(); let garbage = b"this is not a valid msgpack PaymentQuote".to_vec(); - let result = classify_quote_response(&peer_id, &[0u8; 32], &garbage, false, None); + let result = classify_quote_response( + &peer_id, + &[0u8; 32], + &garbage, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::Serialization(_))), "garbage bytes must produce a Serialization error; got {result:?}" @@ -2385,8 +2631,17 @@ mod tests { for (peer_id, quote) in &responders { let bytes = serialize_quote(quote); let storer_verdict = storer_binding_would_accept(peer_id, quote); - let classifier_verdict = - classify_quote_response(peer_id, &content, &bytes, false, None).is_ok(); + let classifier_verdict = classify_quote_response( + peer_id, + &content, + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ) + .is_ok(); assert_eq!( classifier_verdict, storer_verdict, "classifier and storer-binding-spec must agree on every responder \ @@ -2642,7 +2897,16 @@ mod tests { .as_bytes() .to_vec(); let bytes = serialize_quote("e); - let result = classify_quote_response(&kp.peer_id, &content, &bytes, false, None); + let result = classify_quote_response( + &kp.peer_id, + &content, + &bytes, + false, + None, + None, + &[0u8; 32], + &std::sync::Mutex::new(None), + ); assert!( matches!(result, Err(Error::BadQuoteCommitment { .. })), "off-curve quote must be dropped as BadQuoteCommitment; got {result:?}" diff --git a/ant-core/tests/e2e_adr0005.rs b/ant-core/tests/e2e_adr0005.rs new file mode 100644 index 00000000..345de123 --- /dev/null +++ b/ant-core/tests/e2e_adr0005.rs @@ -0,0 +1,529 @@ +//! ADR-0005 E2E: earned reward eligibility over real QUIC + Anvil. +//! +//! MiniTestnet nodes run no replication engine, so no real audits fire; each +//! node instead carries a real `AuditTally` (wired as its quote generator's +//! report source) that the tests SEED with controlled facts. That makes the +//! full wire path — tally → signed report on the quote response → client +//! verification → eligibility gate at collection — deterministic: what a +//! quorum of observers testifies is exactly what the test wrote. +//! +//! Timing note: histories are seeded with real past timestamps (1 day = +//! 86 400 s, the production bucket), so no time compression is involved here. +//! The emergent audit-driven timing ("honest nodes qualify in ≈ a week") is +//! Layer 3's job — the process-level local testnet with compressed days. + +mod support; + +use ant_core::data::client::merkle::PaymentMode; +use ant_core::data::{compute_address, Client}; +use bytes::Bytes; +use serial_test::serial; +use std::sync::Arc; +use support::{test_client_config, MiniTestnet, DEFAULT_NODE_COUNT}; + +const COMMITTED_KEYS: u32 = 700; +const WEEK_DAYS: u64 = 7; + +/// Sets the ADR-0005 enforcement env for one test and clears it on drop +/// (tests are `#[serial]`, so no cross-test interleaving). +struct EnforceGuard; +impl EnforceGuard { + fn set() -> Self { + std::env::set_var("ADR5_ENFORCE", "1"); + Self + } +} +impl Drop for EnforceGuard { + fn drop(&mut self) { + std::env::remove_var("ADR5_ENFORCE"); + } +} + +/// Seed a full clean week for every (observer, subject) pair except subjects +/// listed in `skip` — those get NO testimony anywhere (fresh/unproven nodes). +fn seed_all_except(testnet: &MiniTestnet, skip: &[usize]) { + for subject in 0..testnet.nodes.len() { + if skip.contains(&subject) { + continue; + } + testnet.seed_history_for_all_observers(subject, COMMITTED_KEYS, WEEK_DAYS); + } +} + +/// XOR distance between a peer id and an address, comparable byte-wise. +fn xor_distance(peer: &[u8; 32], addr: &[u8; 32]) -> [u8; 32] { + let mut d = [0u8; 32]; + for i in 0..32 { + d[i] = peer[i] ^ addr[i]; + } + d +} + +/// Mine chunk content whose address puts `target` inside the closest +/// `within` peers of the testnet (excluding `client_index`, which never +/// quotes to itself). With ~14 nodes this converges in a handful of tries. +fn mine_content_with_target_close( + testnet: &MiniTestnet, + target_index: usize, + client_index: usize, + within: usize, +) -> Bytes { + let peer_ids: Vec<(usize, [u8; 32])> = (0..testnet.nodes.len()) + .filter(|i| *i != client_index) + .map(|i| (i, testnet.peer_id_bytes(i))) + .collect(); + for salt in 0u64..100_000 { + let content = Bytes::from(format!("adr-0005 mined payload {salt}")); + let address = compute_address(&content); + let mut by_distance = peer_ids.clone(); + by_distance.sort_by_key(|(_, peer)| xor_distance(peer, &address)); + if by_distance + .iter() + .take(within) + .any(|(i, _)| *i == target_index) + { + return content; + } + } + panic!("could not mine an address near the target peer"); +} + +/// Scenario 1 + 5 (gate-on liveness both ways): with EVERY quoter eligible, +/// enforcement changes nothing observable — a full put/get round-trip works +/// and a close group of quotes is collected. With NOBODY eligible (empty +/// tallies), enforcement degrades to today's rules and the round-trip STILL +/// works: the gate never stalls the network. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_enforced_gate_keeps_liveness_all_eligible_and_none_eligible() { + let testnet = + MiniTestnet::start_with_commitments_and_tallies(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; + let node = testnet.node(3).expect("node 3 exists"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let _guard = EnforceGuard::set(); + + // Phase A: nobody has testimony yet (all tallies empty) -> degraded mode, + // uploads must still succeed. + let content = Bytes::from("adr-0005 degraded-mode payload"); + let address = compute_address(&content); + let stored = client + .chunk_put(content.clone()) + .await + .expect("degraded-mode put must succeed (the gate never stalls)"); + assert_eq!(stored, address); + + // Phase B: seed a full week for everyone -> gate-on path, everyone + // eligible, round-trip still green. + seed_all_except(&testnet, &[]); + let content = Bytes::from("adr-0005 all-eligible payload"); + let address = compute_address(&content); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("quote collection under enforcement"); + assert!( + quotes.len() >= ant_protocol::CLOSE_GROUP_SIZE, + "full close group under enforcement, got {}", + quotes.len() + ); + let stored = client + .chunk_put(content.clone()) + .await + .expect("all-eligible enforced put must succeed"); + assert_eq!(stored, address); + let retrieved = client.chunk_get(&address).await.expect("get back"); + assert!(retrieved.is_some(), "stored chunk must be retrievable"); + + testnet.teardown().await; +} + +/// Scenario 3b (forced single-node substitution): one node has NO testimony +/// anywhere and sits inside the closest-7 of a mined address. Enforcement +/// must widen collection, drop it from the payable set, and still assemble a +/// full close group of eligible quotes. Observe-only (default) must keep it. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_unproven_quoter_is_substituted_under_enforcement() { + let testnet = + MiniTestnet::start_with_commitments_and_tallies(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; + let client_index = 0usize; + let fresh_index = 5usize; + seed_all_except(&testnet, &[fresh_index]); + + let node = testnet.node(client_index).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let content = mine_content_with_target_close( + &testnet, + fresh_index, + client_index, + ant_protocol::CLOSE_GROUP_SIZE, + ); + let address = compute_address(&content); + let fresh_peer = testnet.peer_id_bytes(fresh_index); + + // Observe-only first: the unproven quoter is closest-7, so it appears. + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("observe-only quote collection"); + assert!( + quotes + .iter() + .any(|(peer, _, _, _, _)| *peer.as_bytes() == fresh_peer), + "observe-only keeps the unproven closest-7 quoter" + ); + + // Enforcement: substituted away, full close group still assembled. + let _guard = EnforceGuard::set(); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("enforced quote collection must not degrade with substitutes available"); + assert_eq!( + quotes.len(), + ant_protocol::CLOSE_GROUP_SIZE, + "a full close group of eligible quoters" + ); + assert!( + quotes + .iter() + .all(|(peer, _, _, _, _)| *peer.as_bytes() != fresh_peer), + "the unproven quoter must be substituted out of the payable set" + ); + + // And the gated payment path completes end to end. + let stored = client + .chunk_put(content.clone()) + .await + .expect("enforced put with substitution must succeed"); + assert_eq!(stored, address); + + testnet.teardown().await; +} + +/// Scenario 2 (conviction resets the dues): a convicted node is excluded even +/// though it had a full week of history; once it re-earns a fresh week of +/// passes (which also clears the outstanding-conviction marker), it is +/// payable again. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_convicted_node_excluded_until_it_reearns() { + let testnet = + MiniTestnet::start_with_commitments_and_tallies(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; + let client_index = 0usize; + let subject_index = 6usize; + seed_all_except(&testnet, &[]); + + let node = testnet.node(client_index).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let content = mine_content_with_target_close( + &testnet, + subject_index, + client_index, + ant_protocol::CLOSE_GROUP_SIZE, + ); + let address = compute_address(&content); + let subject_peer = testnet.peer_id_bytes(subject_index); + let _guard = EnforceGuard::set(); + + // Convicted at every observer: the week of history is gone. + testnet.convict_at_all_observers(subject_index, 0); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("enforced collection with a convicted closest-7 quoter"); + assert!( + quotes + .iter() + .all(|(peer, _, _, _, _)| *peer.as_bytes() != subject_peer), + "a convicted node must not be payable" + ); + + // v4 sticky convictions: a fresh week of passes alone does NOT restore + // eligibility while the marker is outstanding. + testnet.seed_history_for_all_observers(subject_index, COMMITTED_KEYS, WEEK_DAYS); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("collection inside the sticky period"); + assert!( + quotes + .iter() + .all(|(peer, _, _, _, _)| *peer.as_bytes() != subject_peer), + "the sticky conviction must hold through fresh passes for a dues period" + ); + + // Model "one dues period has since elapsed": re-record the conviction 8 + // days in the past (aging it beyond CONVICTION_STICKY_DAYS zeroes the + // rows again), then re-earn a fresh week on top -> payable again. + testnet.convict_at_all_observers(subject_index, 8); + testnet.seed_history_for_all_observers(subject_index, COMMITTED_KEYS, WEEK_DAYS); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("collection after the sticky period + re-earned dues"); + assert!( + quotes + .iter() + .any(|(peer, _, _, _, _)| *peer.as_bytes() == subject_peer), + "a re-earned node must be payable again (conviction is one dues period, not a ban)" + ); + + testnet.teardown().await; +} + +/// Silent-hopper fencing: a fenced node (unanswered challenge on a monetized +/// pin, no pass since) is excluded exactly like a convicted one, but keeps +/// its history — a single fresh pass restores it. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_fenced_node_excluded_until_next_pass() { + let testnet = + MiniTestnet::start_with_commitments_and_tallies(DEFAULT_NODE_COUNT, COMMITTED_KEYS).await; + let client_index = 0usize; + let subject_index = 4usize; + seed_all_except(&testnet, &[]); + + let node = testnet.node(client_index).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let content = mine_content_with_target_close( + &testnet, + subject_index, + client_index, + ant_protocol::CLOSE_GROUP_SIZE, + ); + let address = compute_address(&content); + let subject_peer = testnet.peer_id_bytes(subject_index); + let _guard = EnforceGuard::set(); + + testnet.fence_at_all_observers(subject_index); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("enforced collection with a fenced closest-7 quoter"); + assert!( + quotes + .iter() + .all(|(peer, _, _, _, _)| *peer.as_bytes() != subject_peer), + "a fenced node must not be payable while the challenge is outstanding" + ); + + // One fresh pass per observer clears the fence; history was kept, so the + // node is immediately payable again (no re-grind — that is conviction's + // job). + testnet.seed_history_for_all_observers(subject_index, COMMITTED_KEYS, 1); + let quotes = client + .get_store_quotes(&address, content.len() as u64, 0) + .await + .expect("collection after the fence clears"); + assert!( + quotes + .iter() + .any(|(peer, _, _, _, _)| *peer.as_bytes() == subject_peer), + "a fence clears on the next pass, restoring the kept history" + ); + + testnet.teardown().await; +} + +/// A tracing writer that appends into a shared buffer, so a test can assert +/// on the client's structured `adr5::*` gate decisions. +#[derive(Clone)] +struct CaptureWriter(std::sync::Arc>>); +impl std::io::Write for CaptureWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().expect("capture lock").extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for CaptureWriter { + type Writer = Self; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } +} + +/// Scenario 3c (merkle mixed-eligibility pool): with one unproven node in the +/// candidate field and 16+ eligible candidates available, an enforced merkle +/// upload completes end-to-end — the pool is composed eligible-first and the +/// storers' closeness/payment verification still passes. Non-vacuous by +/// construction: the client's own gate decisions must show the unproven node +/// was EVALUATED in a merkle candidate field and NEVER judged eligible, and +/// at least one merkle-pool gate dropped an ineligible candidate. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_merkle_mixed_pool_uploads_under_enforcement() { + // Merkle pools need 16 candidates; 35 nodes matches the merkle e2e suite. + let testnet = MiniTestnet::start_with_commitments_and_tallies(35, COMMITTED_KEYS).await; + let fresh_index = 7usize; + seed_all_except(&testnet, &[fresh_index]); + let fresh_peer_hex = hex::encode(testnet.peer_id_bytes(fresh_index)); + + let node = testnet.node(0).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let _guard = EnforceGuard::set(); + + // Capture the gate's structured decisions for the duration of the upload. + let capture = CaptureWriter(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .finish(); + let _sub_guard = tracing::subscriber::set_default(subscriber); + + // Upload enough data for several pools so the unproven node lands in at + // least one 32-wide candidate query with overwhelming probability. + let data = Bytes::from(vec![0x5Au8; 2 * 1024 * 1024]); + let result = client + .data_upload_with_mode(data.clone(), PaymentMode::Merkle) + .await + .expect("enforced merkle upload with a mixed-eligibility field must succeed"); + let retrieved = client + .data_download(&result.data_map) + .await + .expect("download back"); + assert_eq!(retrieved, data); + + let captured = String::from_utf8_lossy(&capture.0.lock().expect("capture lock")).to_string(); + let fresh_decisions: Vec<&str> = captured + .lines() + .filter(|l| l.contains("eligibility decision") && l.contains(fresh_peer_hex.as_str())) + .collect(); + assert!( + !fresh_decisions.is_empty(), + "the unproven node must have been evaluated in at least one candidate field" + ); + assert!( + fresh_decisions.iter().all(|l| l.contains("eligible=false")), + "the unproven node must never be judged eligible" + ); + assert!( + captured + .lines() + .any(|l| l.contains("ADR-0005 gate [merkle-pool]") + && !l.contains("dropped 0 ineligible")), + "at least one merkle pool must have dropped an ineligible candidate" + ); + + testnet.teardown().await; +} + +/// v5 fast-growth (tier-2 dues fallback): the whole network grew fast, so +/// nobody has a week of history AT THE CURRENT (grown) size — size-eligibility +/// is empty network-wide. The two-tier gate must then fall back to +/// DUES-eligibility (a clean audited week at any size) and keep paying, WHILE +/// still excluding a fresh no-history node and a convicted node. This is the +/// scenario that motivated the whole two-tier change. +#[tokio::test(flavor = "multi_thread")] +#[serial] +async fn adr5_fast_growth_falls_back_to_dues_but_excludes_fresh_and_convicted() { + // Nodes commit to (and quote at) the LARGE grown size... + const GROWN_SIZE: u32 = COMMITTED_KEYS; // 700, the current commitment + const SMALL_HISTORY: u32 = 1; // ...but only ever passed audits when small + let testnet = + MiniTestnet::start_with_commitments_and_tallies(DEFAULT_NODE_COUNT, GROWN_SIZE).await; + + let client_index = 0usize; + let fresh_index = 5usize; + let convicted_index = 6usize; + + // Everyone except the fresh node earns a clean week — but at the SMALL + // size, so with slack 2x nobody covers the grown quote (1*2 < 700). All + // are dues-eligible; none are size-eligible. + for subject in 0..testnet.nodes.len() { + if subject == fresh_index { + continue; + } + testnet.seed_history_for_all_observers(subject, SMALL_HISTORY, WEEK_DAYS); + } + // The convicted node has its clean week wiped at every observer. + testnet.convict_at_all_observers(convicted_index, 0); + + let fresh_peer_hex = hex::encode(testnet.peer_id_bytes(fresh_index)); + let convicted_peer_hex = hex::encode(testnet.peer_id_bytes(convicted_index)); + + let node = testnet.node(client_index).expect("client node"); + let client = Client::from_node(Arc::clone(&node), test_client_config()) + .with_wallet(testnet.wallet().clone()); + let _guard = EnforceGuard::set(); + + let capture = CaptureWriter(std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .finish(); + let _sub_guard = tracing::subscriber::set_default(subscriber); + + // Uploads still succeed via the dues fallback despite zero size-eligibility. + let content = Bytes::from("adr-0005 fast-growth payload"); + let address = compute_address(&content); + let stored = client + .chunk_put(content.clone()) + .await + .expect("dues fallback must keep uploads working under fast growth"); + assert_eq!(stored, address); + + let captured = String::from_utf8_lossy(&capture.0.lock().expect("capture lock")).to_string(); + + // The gate actually used the dues tier (not size, not ungated). + assert!( + captured + .lines() + .any(|l| l.contains("DUES-ELIGIBLE (size relaxed)")), + "the gate must fall back to the dues tier, not stay size or go ungated" + ); + + // Every decision for a well-behaved subject: NOT size-eligible (grown size + // uncovered) but dues-eligible. + let sized_lines: Vec<&str> = captured + .lines() + .filter(|l| l.contains("eligibility decision")) + .collect(); + assert!( + sized_lines + .iter() + .any(|l| l.contains("eligible=false") && l.contains("dues_eligible=true")), + "well-behaved nodes are dues-eligible but not size-eligible" + ); + + // The fresh node and the convicted node are NEITHER size- nor + // dues-eligible. (A single upload's close group may not include a given + // node; retry a few uploads so each is evaluated at least once, then + // assert every decision for it fails both tiers — non-vacuously.) + for extra in 0..8 { + let c = Bytes::from(format!("adr-0005 fast-growth probe {extra}")); + let _ = client.chunk_put(c).await; + } + let captured = String::from_utf8_lossy(&capture.0.lock().expect("capture lock")).to_string(); + for (label, hex) in [ + ("fresh", &fresh_peer_hex), + ("convicted", &convicted_peer_hex), + ] { + let decisions: Vec<&str> = captured + .lines() + .filter(|l| l.contains("eligibility decision") && l.contains(hex.as_str())) + .collect(); + assert!( + !decisions.is_empty(), + "the {label} node must have been evaluated at least once" + ); + assert!( + decisions + .iter() + .all(|l| l.contains("eligible=false") && l.contains("dues_eligible=false")), + "the {label} node must fail BOTH tiers even under the dues fallback" + ); + } + + testnet.teardown().await; +} diff --git a/ant-core/tests/support/mod.rs b/ant-core/tests/support/mod.rs index 9486fecc..b7fcab0e 100644 --- a/ant-core/tests/support/mod.rs +++ b/ant-core/tests/support/mod.rs @@ -24,6 +24,7 @@ use ant_node::payment::{ EvmVerifierConfig, PaymentVerifier, PaymentVerifierConfig, QuoteGenerator, QuotingMetricsTracker, }; +use ant_node::replication::audit_tally::{AuditTally, TallyReportSource}; use ant_node::replication::commitment_state::{BuiltCommitment, ResponderCommitmentState}; use ant_node::storage::{AntProtocol, LmdbStorage, LmdbStorageConfig}; // Wire / transport / EVM types: route through ant-protocol so the test @@ -97,6 +98,12 @@ pub fn test_client_config() -> ClientConfig { pub struct TestNode { pub p2p_node: Option>, pub protocol: Option>, + /// ADR-0005: this node's audit tally (present when the testnet was + /// started with tallies). Tests seed it directly — MiniTestnet nodes run + /// no replication engine, so no real audits feed it — which makes the + /// tally -> signed report -> client verify -> eligibility gate path fully + /// deterministic. + pub audit_tally: Option>>, _handler_task: Option>, } @@ -115,7 +122,7 @@ impl MiniTestnet { /// Use `DEFAULT_NODE_COUNT` for standard tests, 35+ for merkle tests (need 16 peers per pool). /// Nodes emit baseline `(0, None)` quotes (no commitment source wired). pub async fn start(node_count: usize) -> Self { - Self::start_inner(node_count, None).await + Self::start_inner(node_count, None, false).await } /// ADR-0004: start a testnet where every node carries a live storage @@ -124,10 +131,21 @@ impl MiniTestnet { /// in the quote response). Exercises the full node→client→storer binding /// handshake against real QUIC + Anvil. pub async fn start_with_commitments(node_count: usize, key_count: u32) -> Self { - Self::start_inner(node_count, Some(key_count)).await + Self::start_inner(node_count, Some(key_count), false).await } - async fn start_inner(node_count: usize, commitment_key_count: Option) -> Self { + /// ADR-0005: like [`Self::start_with_commitments`] but every node also + /// carries an (empty) audit tally wired as its quote generator's report + /// source. Tests seed the tallies and the nodes testify accordingly. + pub async fn start_with_commitments_and_tallies(node_count: usize, key_count: u32) -> Self { + Self::start_inner(node_count, Some(key_count), true).await + } + + async fn start_inner( + node_count: usize, + commitment_key_count: Option, + with_tallies: bool, + ) -> Self { // Start Anvil EVM testnet FIRST let testnet = Testnet::new().await.expect("start Anvil testnet"); let evm_network = testnet.to_network(); @@ -152,13 +170,14 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = Self::spawn_node( + let (node, protocol, tally, handler) = Self::spawn_node( addr, &bootstrap_addrs, temp_dir.path(), &evm_network, i, commitment_key_count, + with_tallies, ) .await; @@ -166,6 +185,7 @@ impl MiniTestnet { nodes.push(TestNode { p2p_node: Some(Arc::clone(&node)), protocol: Some(protocol), + audit_tally: tally, _handler_task: Some(handler), }); temp_dirs.push(temp_dir); @@ -178,19 +198,21 @@ impl MiniTestnet { let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port); let temp_dir = tempfile::TempDir::new().expect("create temp dir"); - let (node, protocol, handler) = Self::spawn_node( + let (node, protocol, tally, handler) = Self::spawn_node( addr, &bootstrap_addrs, temp_dir.path(), &evm_network, i, commitment_key_count, + with_tallies, ) .await; nodes.push(TestNode { p2p_node: Some(Arc::clone(&node)), protocol: Some(protocol), + audit_tally: tally, _handler_task: Some(handler), }); temp_dirs.push(temp_dir); @@ -284,7 +306,7 @@ impl MiniTestnet { &self.evm_network } - #[allow(clippy::too_many_lines)] + #[allow(clippy::too_many_lines, clippy::type_complexity)] async fn spawn_node( listen_addr: SocketAddr, bootstrap_peers: &[SocketAddr], @@ -292,7 +314,13 @@ impl MiniTestnet { evm_network: &EvmNetwork, node_index: usize, commitment_key_count: Option, - ) -> (Arc, Arc, tokio::task::JoinHandle<()>) { + with_tally: bool, + ) -> ( + Arc, + Arc, + Option>>, + tokio::task::JoinHandle<()>, + ) { // Generate ML-DSA-65 identity for this node let identity = Arc::new(NodeIdentity::generate().expect("generate node identity")); @@ -383,6 +411,20 @@ impl MiniTestnet { protocol.attach_commitment_source(source); } + // ADR-0005: wire an (empty) audit tally as the report source so quote + // responses carry this node's signed testimony once a test seeds it. + let audit_tally = if with_tally { + let tally = Arc::new(std::sync::RwLock::new(AuditTally::default())); + let reporter_peer_id = *blake3::hash(identity.public_key().as_bytes()).as_bytes(); + protocol.attach_report_source(Arc::new(TallyReportSource::new( + Arc::clone(&tally), + reporter_peer_id, + ))); + Some(tally) + } else { + None + }; + // Start message handler loop let handler_node = Arc::clone(&node); let handler_protocol = Arc::clone(&protocol); @@ -440,7 +482,114 @@ impl MiniTestnet { } }); - (node, protocol, handler) + (node, protocol, audit_tally, handler) + } + + /// ADR-0005: seed `observer`'s tally with a clean audited history for + /// `subject`: one pass per day for `days` distinct days ending now, each + /// at `key_count` committed keys. `days = 7` is a full week of dues. + pub fn seed_history(&self, observer: usize, subject: &[u8; 32], key_count: u32, days: u64) { + let tally = self.nodes[observer] + .audit_tally + .as_ref() + .expect("testnet started with tallies"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs(); + let mut guard = tally.write().expect("tally lock"); + let subject = ant_protocol::transport::PeerId::from_bytes(*subject); + // Three passes per day: 7 days -> 21 passes, clearing the default + // ADR5_P_PASSES = 20. + for d in 0..days { + for p in 0..3u64 { + guard.record_pass(subject, key_count, now - d * 86_400 - p); + } + } + } + + /// ADR-0005: every tally-bearing node except `subject_index` gets a clean + /// `days`-day history for node `subject_index`. + pub fn seed_history_for_all_observers(&self, subject_index: usize, key_count: u32, days: u64) { + let subject = *self.nodes[subject_index] + .p2p_node + .as_ref() + .expect("subject node running") + .peer_id() + .as_bytes(); + for (i, node) in self.nodes.iter().enumerate() { + if i != subject_index && node.audit_tally.is_some() { + self.seed_history(i, &subject, key_count, days); + } + } + } + + /// ADR-0005: record a deterministic conviction of `subject_index` at every + /// other tally-bearing node (what a real caught-deleting node would face), + /// `age_days` in the past (0 = now; v4 sticky convictions last + /// `CONVICTION_STICKY_DAYS`, so tests use an old conviction to model "the + /// sticky period has since elapsed"). + pub fn convict_at_all_observers(&self, subject_index: usize, age_days: u64) { + let subject = *self.nodes[subject_index] + .p2p_node + .as_ref() + .expect("subject node running") + .peer_id() + .as_bytes(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs() + - age_days * 86_400; + let subject = ant_protocol::transport::PeerId::from_bytes(subject); + for (i, node) in self.nodes.iter().enumerate() { + if i == subject_index { + continue; + } + if let Some(tally) = &node.audit_tally { + tally + .write() + .expect("tally lock") + .record_conviction(subject, now); + } + } + } + + /// ADR-0005: fence `subject_index` at every other tally-bearing node (what + /// a silent hopper faces after ignoring monetized-pin challenges). + pub fn fence_at_all_observers(&self, subject_index: usize) { + let subject = *self.nodes[subject_index] + .p2p_node + .as_ref() + .expect("subject node running") + .peer_id() + .as_bytes(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_secs(); + let subject = ant_protocol::transport::PeerId::from_bytes(subject); + for (i, node) in self.nodes.iter().enumerate() { + if i == subject_index { + continue; + } + if let Some(tally) = &node.audit_tally { + tally + .write() + .expect("tally lock") + .record_unanswered_monetized_challenge(subject, now); + } + } + } + + /// Peer id bytes of node `index`. + pub fn peer_id_bytes(&self, index: usize) -> [u8; 32] { + *self.nodes[index] + .p2p_node + .as_ref() + .expect("node running") + .peer_id() + .as_bytes() } /// Shut down a node by index, simulating a failure. diff --git a/docs/adr/ADR-0003-client-earned-reward-eligibility-gate.md b/docs/adr/ADR-0003-client-earned-reward-eligibility-gate.md new file mode 100644 index 00000000..3af92f17 --- /dev/null +++ b/docs/adr/ADR-0003-client-earned-reward-eligibility-gate.md @@ -0,0 +1,92 @@ +# ADR-0003: Client-side earned-reward eligibility gate + +- **Status:** Proposed +- **Date:** 2026-07-10 +- **Decision owners:** Anselme (@grumbach) +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** ant-node ADR-0005 (earned reward eligibility — the canonical design and node-side tally/report plumbing); ADR-0002 (client-side fallback); ADR-0001 (adopt ADRs) + +## Context + +The full design and rationale for earned reward eligibility live in the +**ant-node ADR-0005** document. This ADR records only the **client's** half: the +eligibility predicate and where the gate sits in quote collection. The node +produces signed audit reports; the client is the only party that decides +eligibility and acts on it. Nothing here changes payee selection, payment +construction, or on-chain verification. + +This is **earned future eligibility, not slashing**: the client may decline to +*pay* a node that has not yet earned its place, but no settled payment is ever +removed and no penalty is applied on-chain. + +## Decision + +- **Collect signed reports, verify before trusting.** For each quote request the + client collects the responders' signed audit reports. A report is only trusted + after its ML-DSA signature verifies against the responder's peer-bound quote + key, its echoed nonce matches this request's fresh random nonce (replay + protection), and it is within the byte/row/day caps (an over-cap report is + dropped whole before parse). + +- **The eligibility predicate.** A subject quoter is eligible when **more than + half of its opinionated reporters vouch** for it (floor 3). An opinion is a + reporter that carries a row for the subject; reporters with no row abstain. A + row vouches when it is unfenced, unconvicted, and shows ≥ D distinct days + (default 7) each carrying a pass at a proven size covering the quoted size + within a slack factor (default 2×), inside a trailing window (default 14 days), + most recent covering day within the recency bound (default 1 day). The subject + never vouches for itself. `fenced` and `convicted` rows count as non-vouching + opinions (denominator, not numerator); a convicted row stays sticky for the dues + period, matching the node semantics — the client and node MUST agree on this. + +- **Two-tier collection gate, then fallback.** The client fills its quote set / + merkle candidate pool eligible-first at the quoted size. If too few clear the + size bar (e.g. fast network-wide growth), it prefers nodes with a clean audited + week at **any** size (the dues tier), then falls back to today's ungated rules. + The fallback is a **degraded-security mode** — it is logged and surfaced as a + metric so operators can see when the network is running ungated. The dues tier + still excludes fresh identities and caught cheaters; only the size requirement is + relaxed, and ADR-0004 still forces a current-size proof at payment. + +- **Observe-only first.** Enforcement is off by default. The client runs + observe-only (logging would-be exclusions) so shadow eligibility can be + calibrated against churn / NAT / newcomer cases before any hard gate is enabled. + +- **Policy is client-side.** D, slack, recency, floor, and the enforce switch are + client policy (env-tunable), so they can be retuned without a node release. The + trailing window and conviction stickiness are node-side constants. + +## Consequences + +- Payee selection and payment verification are untouched: the gate only narrows + the candidate list before the existing median/closest selection runs. +- The client does per-quote signature/nonce/cap verification on collected reports; + bounded work, all client-side. +- Honest newcomers are never marked malicious: they are simply not yet + size-eligible and are still selectable via the dues tier or the ungated + fallback. Suppressing a qualified node requires capturing more than half its + per-address reporter set — the same neighbourhood-capture boundary the network + already accepts. + +## Validation + +The client half of ADR-0005 is correct only if the following hold; these are +exercised by the `e2e_adr0005` suite and validated against the node's canonical +ADR-0005 criteria: + +- **Selection unchanged when nobody is gated.** With enforcement off (observe-only) + the client selects exactly as today; the gate only logs would-be exclusions. +- **Eligible-first without stalling.** With enough eligible quoters the client pays + an eligible one; when too few are size-eligible it falls back to the dues tier + and then to today's ungated rules, and uploads still succeed. +- **The fallback still excludes the two things the gate is for.** Under the dues + tier a fresh no-history node and a convicted/fenced node are never selected. +- **Reports are trusted only after verification.** A report with a bad signature, + wrong request nonce, or over-cap size contributes nothing to the decision. +- **Payee selection and payment verification are unaffected**, confirmed by the + existing payment/merkle e2e suites passing unchanged. + +See ant-node ADR-0005 for the full trade-off analysis, validation criteria, and +the node-side tally/report design.