diff --git a/Cargo.lock b/Cargo.lock index 5a2084bb..e05c39bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,13 +94,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]] @@ -398,7 +398,7 @@ dependencies = [ "cfg-if", "const-hex", "derive_more", - "foldhash 0.2.0", + "foldhash", "hashbrown 0.17.1", "indexmap 2.14.0", "itoa", @@ -458,9 +458,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", @@ -469,13 +469,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]] @@ -610,7 +610,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -628,7 +628,7 @@ dependencies = [ "proc-macro2", "quote", "sha3 0.11.0", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -646,7 +646,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.117", + "syn 2.0.118", "syn-solidity", ] @@ -736,7 +736,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -861,9 +861,9 @@ dependencies = [ [[package]] name = "ant-protocol" -version = "2.2.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a35986649c5963ec808efb44621d8b94716cc2ef5a041ea9a1875487578097c" +checksum = "5b665880152b4e124094acc1faaff5f20a275594e6364b8a729b50f3bc326674" dependencies = [ "blake3", "bytes", @@ -880,9 +880,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" @@ -951,6 +951,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" @@ -978,7 +995,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]] @@ -1016,7 +1043,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]] @@ -1052,6 +1092,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" @@ -1082,6 +1146,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" @@ -1090,9 +1164,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" @@ -1118,7 +1192,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -1130,7 +1204,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1152,7 +1226,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1163,7 +1237,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1201,7 +1275,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1212,9 +1286,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", @@ -1222,14 +1296,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]] @@ -1298,36 +1373,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", @@ -1360,9 +1456,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", ] @@ -1390,9 +1486,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", @@ -1401,15 +1497,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]] @@ -1447,9 +1543,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" dependencies = [ "serde", ] @@ -1490,9 +1586,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", @@ -1531,9 +1627,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", @@ -1609,7 +1705,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1815,18 +1911,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", @@ -1834,27 +1930,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" @@ -1927,7 +2023,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1951,7 +2047,7 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1962,7 +2058,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2021,7 +2117,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2044,7 +2139,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2066,7 +2161,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.117", + "syn 2.0.118", "unicode-xid", ] @@ -2097,7 +2192,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", ] @@ -2170,7 +2265,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2179,7 +2274,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "415b6ec780d34dcf624666747194393603d0373b7141eef01d12ee58881507d9" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -2243,7 +2338,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2290,22 +2385,22 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[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]] @@ -2317,7 +2412,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2338,9 +2433,9 @@ dependencies = [ [[package]] name = "evmlib" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd96cf24017cd31cd422c80de3078a821b7884a5b1feb00087e98209326c676" +checksum = "8da0d9ad5b5cab92cc92ddac9afb884f86b226340caf17f6e5c41d51175dcaa1" dependencies = [ "alloy", "ant-merkle", @@ -2500,12 +2595,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" @@ -2599,7 +2688,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -2677,16 +2766,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]] @@ -2724,9 +2813,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", @@ -2762,15 +2851,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" @@ -2779,7 +2859,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -2788,7 +2868,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", ] @@ -2872,6 +2952,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" @@ -2913,9 +3002,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", @@ -2952,9 +3041,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[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", ] @@ -3124,12 +3213,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" @@ -3171,7 +3254,7 @@ dependencies = [ "hyper", "hyper-util", "log", - "rand 0.10.1", + "rand 0.10.2", "tokio", "url", "xmltree", @@ -3194,7 +3277,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3323,7 +3406,7 @@ dependencies = [ "quote", "rustc_version 0.4.1", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3351,28 +3434,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", ] @@ -3411,9 +3493,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", @@ -3450,12 +3532,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" @@ -3479,9 +3555,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", ] @@ -3520,9 +3596,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" @@ -3568,7 +3644,7 @@ checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3582,9 +3658,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memoffset" @@ -3665,9 +3741,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", @@ -3726,7 +3802,7 @@ checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3890,7 +3966,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -3963,9 +4039,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", @@ -3978,7 +4054,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]] @@ -3987,7 +4072,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", ] @@ -3998,10 +4083,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]] @@ -4013,6 +4098,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" @@ -4030,7 +4124,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4121,16 +4215,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" @@ -4179,7 +4263,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -4218,15 +4302,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", @@ -4238,15 +4322,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", @@ -4260,16 +4345,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.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4287,9 +4372,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", ] @@ -4337,12 +4422,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", ] @@ -4391,6 +4476,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" @@ -4402,9 +4496,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", ] @@ -4491,14 +4585,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4519,9 +4613,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" @@ -4615,14 +4709,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", @@ -4655,9 +4750,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[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" @@ -4707,9 +4802,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", @@ -4743,9 +4838,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", @@ -4824,9 +4919,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" @@ -5219,7 +5314,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5282,7 +5377,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5330,7 +5425,7 @@ checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5377,9 +5472,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", @@ -5456,9 +5551,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", ] @@ -5520,27 +5615,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" @@ -5566,9 +5640,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", @@ -5584,7 +5658,7 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5613,7 +5687,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5661,7 +5735,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", @@ -5693,7 +5767,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5704,7 +5778,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -5727,12 +5801,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", @@ -5744,15 +5817,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", @@ -5817,7 +5890,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6016,7 +6089,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6191,11 +6264,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[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", @@ -6249,27 +6322,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" 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", @@ -6280,9 +6344,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", @@ -6290,9 +6354,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", @@ -6300,60 +6364,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 = "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" @@ -6370,9 +6400,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", @@ -6390,9 +6420,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", ] @@ -6482,7 +6512,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6493,7 +6523,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6504,7 +6534,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6515,7 +6545,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -6588,15 +6618,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6645,30 +6666,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6687,12 +6691,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6711,12 +6709,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6735,24 +6727,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6771,12 +6751,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6795,12 +6769,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6819,12 +6787,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6843,12 +6805,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -6867,100 +6823,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" @@ -7083,28 +6951,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.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -7124,28 +6992,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]] @@ -7178,7 +7046,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 ba489e41..e34b4ad4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,14 +39,14 @@ mimalloc = "0.1" # Until then, the git pin tracks the matching saorsa-core lineage # (the rc-2026.4.2 branch) so Cargo can unify the wire types here # with ant-protocol's re-exports. -ant-protocol = "2.2.2" +ant-protocol = "2.3.0" # Core (provides EVERYTHING: networking, DHT, security, trust, storage) saorsa-core = "0.26.2" saorsa-pqc = "0.5" # Payment verification - autonomi network lookup + EVM payment -evmlib = "0.8.1" +evmlib = "0.9.0" xor_name = "5" # Caching - LRU cache for verified XorNames diff --git a/docs/adr/ADR-0004-commitment-bound-quote-pricing.md b/docs/adr/ADR-0004-commitment-bound-quote-pricing.md new file mode 100644 index 00000000..97411d16 --- /dev/null +++ b/docs/adr/ADR-0004-commitment-bound-quote-pricing.md @@ -0,0 +1,336 @@ +# ADR-0004: Commitment-bound quote pricing + +- **Status:** Proposed +- **Date:** 2026-06-12 +- **Decision owners:** Anselme (@grumbach) +- **Reviewers:** +- **Supersedes:** none +- **Superseded by:** none +- **Related:** ADR-0002 (gossip-triggered contiguous-subtree storage audit) + +## Context + +Nodes are paid to store chunks, and the price a node may charge grows with how +much data it holds: the quoted price is a fixed public formula of the node's record +count, so the count *is* the price. Today that count is self-reported and +free: a quote is just a signed price, the client pays the median of the close +group's seven quotes, and nothing ties the price to anything checkable. A node +can claim any count it likes — the only existing check is a node refusing +*its own* stale underpriced quote; a neighbour's quote is explicitly +unjudgeable. + +Meanwhile ADR-0002 already makes every node publish a signed **storage +commitment** — a Merkle root over the chunks it claims to hold plus the exact +**leaf count** — and makes neighbours audit it against real bytes. So the +network already maintains an audited, signed measure of how much data each +node holds. +Pricing just doesn't use it. + +This ADR binds the two. The delivered guarantee is a **price ceiling**: to be +paid above the empty-node baseline, a node must surrender a signed commitment +that passes synchronous binding checks before payment and faces audit after +it. A node may always charge less; what dies is extraction — charging more +than the storage you can prove. + +Terms used below: *commitment* = the signed `(root, key_count)` of ADR-0002. +*Pin* = the commitment's hash, identifying exactly one signed artifact. +*Forced price* = the price computed by the public formula from the pinned +commitment's `key_count`. *Baseline* = the formula at count zero. + +## Decision Drivers + +- Make it impossible to profit from overstating held data, alone or colluding, + short of capturing a whole neighbourhood. +- Every check must be deterministic where possible: exact arithmetic or a + contradiction between two artifacts signed by the same key — never a + tolerance band, never a remote clock. +- A lie must be stopped **before payment** wherever possible; penalty lanes + are backstops, not the ceiling itself. +- Never wrongly penalise an honest node — rotation races, gossip lag, crash + restarts and missing state must be graced, exactly as in ADR-0002. +- Reuse what ADR-0002 ships (the commitment, its gossip, its retention rules, + its audits, its grace lanes, its evidence path) without inventing new + cryptography. + +## Considered Options + +1. **Client-side plausibility checks only** (compare the seven quoted prices, + reject outliers). Rejected: honest heterogeneity (churn, new nodes) looks + like lying, and a neighbourhood that inflates together passes together. A + heuristic can deprioritise; it cannot convict. + +2. **Neighbour-attested quotes** (a quote is valid only when co-signed by + peers vouching for the count). Rejected: new signature plumbing, extra + round-trips on the hot quoting path, and the vouchers are the same peers + who profit from a rising neighbourhood median — collusion built in. + +3. **On-chain enforcement** (post commitments to the contract; the vault + checks price against count). Rejected: per-rotation gas for every node, and + the chain still cannot know whether a count is *true* — that knowledge only + exists off-chain, in the audits. + +4. **Force the price from the pinned commitment (chosen).** The quote carries + the claimed count and the pin of the commitment it priced against, and the + commitment itself travels with the quote, so the binding is verified before + any payment; ADR-0002's audits then check the artifact against the disk. + +## Decision + +We will make a quote's price a **deterministic function of the node's storage +commitment**, verifiable in full by whoever is about to pay, and auditable +afterwards by everyone else. + +- **Forced price, provable on receipt.** A quote gains two fields, both + covered by its signature and therefore by the quote hash the vault settles + against: the claimed `key_count` and the pin of the commitment it prices + against. The quote response carries that full signed commitment alongside — + no extra round trip — so any receiver can verify the whole binding at once: + the commitment's signature and peer binding, claimed count equals the + commitment's `key_count`, and price equals the formula applied to that + count, checked by exact recomputation (never by inverting the price, which + rounds). A node with no commitment quotes the baseline with no pin; any + count above zero requires the pin and its commitment. A quote may pin only + the node's **live current commitment**, snapshotted atomically at issuance; + retired commitments stay answerable under retention but can never be newly + quoted, so quote traffic cannot keep a stale fat commitment alive forever. + This applies to **both quote types** — the single-node quote and the + merkle-batch candidate — which live in the shared payment library and need + a versioned, breaking change to their signed payloads. Pricing thereby + moves from "all records on disk" to the committed, *responsible* set, so + data a node is about to prune no longer raises its price. + +- **The client pays nothing it cannot resolve.** Before paying, the client + runs the full binding check on every quote. The commitment arrived with the + quote, so an unresolvable, withheld, or mismatched pin is never paid — this + synchronous gate, not any later penalty lane, is the ceiling's load-bearing + wall. A failing quote is treated exactly like an unresponsive quoter + (today's retry/recovery path). + +- **Storers re-check the arithmetic; nobody trusts the client to have.** A + malicious client may pay a malformed bundle on purpose, so every storer + re-runs the price-equals-formula-of-count check on every quote in the + bundle (all seven single-node quotes; all sixteen merkle candidates) before + reconstructing the median. This needs only the bundle itself, so every + honest storer reaches the same verdict: an off-curve quote makes the bundle + objectively malformed, rejected by all with no split-brain risk and no + trust action — the rejection is the consequence. + +- **Quoting is advertising: you stay answerable for what you monetize.** + Issuing a quote refreshes the pinned (current) commitment's answerability + retention exactly as gossiping it does — judged by the node's own clock, + current commitment only. A new small request lets any neighbour fetch a + commitment by its pin. Failing to answer for a quoted pin is **graced, + never confirmed**: an unanswerable pin is indistinguishable from an honest + crash-restart (retention is in-memory by design), so it lands in the + existing timeout-strike lane, not the deterministic one. The funnel still + closes because payment already forced the artifact into the open: a cheater + must serve its commitment to be paid at all, and once seen it is audited. + +- **Peers cross-check the original and route monetized commitments into + audit.** The client forwards each quote's commitment sidecar with the + single-node client-put bundle; storers ingest it exactly like a gossiped + commitment (signature and binding checks) and then drop it from the receipt + they persist — so the cross-check is synchronous and the audit never depends + on a post-payment fetch from the accused. Merkle client-put bundles carry NO + sidecars: sixteen per-candidate commitments (~13 KB each serialized) would + exceed the payment-proof size budget — and did, rejecting every merkle PUT in + production the moment nodes carried live commitments. The forced-disclosure + invariant still holds on the merkle path because the client's + resolve-before-pay gate makes every candidate serve its commitment BEFORE it + can enter a paid pool; the storer-side cross-check resolves merkle pins from + gossip or the rate-limited fetch instead, and an unanswerable pin remains + timeout-class while the deterministic first-audit queue closes the funnel. + On fresh client-put bundles only (a + replication receipt's pin has legitimately aged out and is skipped), each + storer compares every neighbour quote's claimed count to the pinned + original — from the sidecar, from gossip if seen within the answerability + TTL, or fetched as a fallback. A mismatch is two artifacts signed by the + same key that contradict each other: reported on first occurrence as new + evidence carrying both artifacts, portable and verifiable by anyone. A + *rational* cheater is self-consistent and never trips this; for them the + binding's job is to force the priced count into one auditable artifact, + and the audit convicts: a commitment first seen through the quote channel + enters a per-peer **deterministic first-audit queue** — deduped by pin, + most recently monetized first, drained within the existing per-peer + cooldown and concurrency caps; the lottery applies only to re-audits — so + the latest commitment earning money for a peer always faces an audit soon, + and minting fresh pins faster than the cooldown forfeits the older ones' + coverage, never the newest's. Inflated counts need fake leaves; fake leaves + fail the byte spot-check in proportion to the inflated fraction; one hit is + a deterministic first-occurrence failure. Pin fetches are rate-limited, + capped per bundle and per peer, negatively cached, and run off the payment + hot path. + +- **Freshness without remote clocks.** The client bounds quote age itself (it + requested the quote moments ago and pays promptly — its own clock, its own + risk). Node-side, no check ever gates on the quote's timestamp; staleness is + bounded by pin answerability instead. The existing percentage-based + staleness gate on a node's own quote is retired: the pin identifies the + exact artifact the price came from, so the comparison is equality against a + frozen value. + +- **Rollout.** The quote format change is a **hard cutover**, not a + mixed-fleet observe-only window. The two fields are part of the signed + payload and therefore of the quote hash, so an old quote's signature fails + on a new node (and vice versa) regardless of any flag — there is no version + in which old and new nodes interoperate on the quote wire. The fleet **and** + the clients upgrade together in one coordinated release of the shared + payment library; no flag accepts an old-format signature or hash. What + *is* a rollout dial is the **arithmetic/binding enforcement**: the + `QUOTE_ARITHMETIC_RECHECK_ENABLED` gate ships observe-only first (recompute + the forced-price/binding rule on every quote and log every would-be + rejection, but reject nothing), then flips to reject once the fleet is on + the new format. That gate is reject-only with no silence lane, so it is + independent of timeout eviction. The **unanswerable-quoted-pin** path is the + only part that couples to ADR-0002's timeout-eviction gate: until that gate + is enabled a never-answering node's exposure is bounded but not zero. The + own-quote price-staleness gate is retired for commitment-bound quotes (it + compared against the on-disk count, which the committed responsible count + legitimately differs from). + +## Consequences + +### Positive + +- The ceiling holds before money moves: an off-curve quote dies at every + checker, a withheld or unresolvable pin is never paid, a count that + contradicts its pinned commitment is first-occurrence signed evidence, and + a commitment that contradicts the disk fails its deterministic first audit. + Each lie lands in an existing lane; no new cryptography. +- Overstating is self-defeating even before detection: an inflated forced + price sits above the neighbourhood median, where it earns nothing on new + uploads while the audit clock runs. +- Understating extracts nothing for the understater — it is a discount, and + its commitment still has to be real to be quoted at all. +- The fuzzy staleness tolerance is replaced by exact equality against a + pinned artifact — strictly fewer ways to be wrong, and no remote-clock + false rejects. + +### Negative / Trade-offs + +- **The ceiling is "data held", not "data deserved".** A node that genuinely + stores self-generated junk keyed into its range prices that storage + honestly-by-the-letter: every check passes because the bytes are real. We + accept this: the attack costs real disk for as long as the price is wanted, + and audits keep it real. Junk can also be *spread* through the documented + replication self-dealing hole at the cost of a settled on-chain payment + plus gas per chunk — victims then hold (and rightfully price) real data, so + the price signal stays truthful about disk even when demand was fake. + Closing junk fully — proving sampled leaves were *paid for* by third + parties — is deliberate future work, not this ADR. +- **A ceiling is not a revenue floor.** The median's economic meaning assumes + the quote set is the true close group, but verification today checks seven + unique quoters, not *which* seven; a malicious client can assemble cheap + quorums, and coordinated undercutting (4 of 7) can suppress the median paid + to honest peers. This ADR neither fixes nor worsens that pre-existing gap; + quote-set closeness enforcement and payment policy are the follow-up that + owns the floor. +- **Price freshness equals rotation cadence.** A quote prices the last + commitment, up to one rotation old. Acceptable: a node's record count moves + slowly relative to an hour. The lever, if ever needed, is rotating early on a + large count change, not loosening the binding. +- **The quote format change is a hard cutover** — the signed payload changes, + so the whole fleet and the clients move together in one coordinated release; + there is no mixed-fleet window. Enforcement then has two *independent* dials: + the arithmetic/binding gate (observe-only → reject, no silence lane, so + independent of timeout eviction), and the unanswerable-quoted-pin silence + lane (gated behind ADR-0002's timeout-eviction enable). + +### Neutral / Operational + +- A quote grows by roughly forty bytes; the quote *response* additionally + carries the pinned signed commitment (a few kilobytes next to an + already-kilobytes quote), with no extra round trip. Single-node client-put + bundles forward the sidecars; merkle bundles omit them (the client already + resolved every candidate's commitment before paying, and sixteen sidecars + per chunk proof exceeded the payment-proof size budget in production); + persisted and replicated receipts keep only the pin and count, so stored + proofs do not grow. +- One new request type (fetch a commitment by pin), rate-limited and + negatively cached like other replication requests. +- One new deterministic evidence variant carrying the two conflicting signed + artifacts (quote and pinned commitment). An off-curve quote is reject-only: + no evidence, no trust action. No repudiation variant: unanswerable pins are + timeout-class by design. +- Quoted-pin answerability reuses the existing retention machinery and TTL; + the only additions are the issuance-time refresh and the current-only rule. +- Median ties (e.g. several baseline quotes on a young network) are broken by + peer id — canonical, not grindable per quote — so the paid slot is not + client-steerable among equals. A baseline median on a mostly-empty + neighbourhood is correct pricing, not a failure. + +## Validation + +How we will know this decision remains correct: + +- **Tests required before this ADR is Accepted.** A quote whose pin cannot be + resolved, whose commitment is withheld, or whose count mismatches its + commitment is never paid by the client; an off-curve quote in a paid bundle + is rejected identically by every storer (exact recomputation, not + inversion); a count contradicting its pinned commitment produces the + evidence variant on first occurrence, client-put context only; an honest + node is never flagged across rotation races, gossip lag, and crash-restart + (an unanswerable quoted pin is graced, never confirmed — a regression test, + since this is the false-eviction hole); quote issuance refreshes + answerability for the current commitment only, and a retired pin cannot be + newly quoted; a sidecar in a client-put bundle is ingested and cross-checked + with no fetch, and persisted receipts carry no sidecar; a commitment first + seen via the quote channel is audited deterministically within the + cooldown/concurrency budget with the most recently monetized pin + prioritised, and a flood of fresh pins does not amplify into unbounded + fetches or audits; a cached + commitment older than the answerability TTL is treated as unknown; a node + with no commitment quotes baseline with no pin and verifies; both quote + types carry and verify the new fields; end-to-end, an inflating node is + caught and earns nothing meanwhile. +- **Economic check in simulation.** With forced pricing, the expected profit + of any *overstating* strategy — small or large, solo or colluding short of + capturing a whole neighbourhood, including strategic count targeting of the + median slot — is at or below honest earnings once the synchronous client + gate, the deterministic first audit, and eviction are priced in; including + during the window where timeout eviction is still gated. +- **Operational signals and re-open triggers.** Mismatch evidence and + would-be rejections on an honest test network stay at zero; fetch traffic + and deterministic-first-audit load stay within budget. Revisit if + junk-minting or replication-seeded junk is observed at scale (escalate the + paid-leaf proof to its own ADR); revisit when quote-set closeness + enforcement lands (it may strengthen the median claims here); revisit the + rotation cadence if record counts ever move fast enough that hour-stale + prices misprice storage. + +## Notes for AI-assisted work + +AI tools may help draft this ADR, but **must not mark it Accepted without human +review**. Accepted ADRs are immutable: create a new superseding ADR rather than +editing an Accepted ADR. + +--- + +## Amendment 1 (2026-07-02): audit grace is removed — a responsive audit miss is always a confirmed failure + +The original decision graces an unanswerable pin as "indistinguishable from an +honest crash-restart (retention is in-memory by design)". That grace — for every +`UnknownCommitment`, not just paid pins — is the quote-gaming loophole: a node can +be paid/credited for a commitment it cannot prove and escape by answering +`UnknownCommitment`/`Transient`. We remove grace entirely, made safe by +guaranteeing an honest node is always answerable for any pin it can be challenged +on: **(1) answerability survives restart** — the responder persists and reloads +its commitment retention (the signed commitments, their key sets, and gossip +deadlines), rebuilding each Merkle tree from the still-durable chunk bytes, so a +restart is no longer an excuse (ML-DSA signatures are randomized, so it is the +persisted *signed* commitment — never a re-signed rebuild — that preserves the +exact pin); and **(2) the auditor only challenges in-window pins** — the +gossip-lottery and downgrade paths pin the responder's own freshly-gossiped root +by construction, and the monetized first-audit path screens a client-forwarded +quote to the answerability window (assuming bounded clock skew). + +With both, an honest responsive node can always answer a pin an auditor could +form, so a **responsive** rejection is graded with no grace: `UnknownCommitment` +(or missing / wrong bytes) is a **confirmed** failure; `Transient` (a local read +error, retried first by the responder) routes to the non-response/timeout lane — +no trust penalty, but the holder credit for the pin is revoked, so it gains no +standing. Only genuine **silence** stays ADR-0002's timeout lane; deterministically +attributing malicious silence/transient conditions network-wide is the +(out-of-scope) distributed non-response problem. This supersedes the "unanswerable +quoted pin is graced, never confirmed" rule and deletes `RejectKind::is_graced`; +the regression tests are updated accordingly. diff --git a/docs/adr/implementation-slices-adr-0004.md b/docs/adr/implementation-slices-adr-0004.md new file mode 100644 index 00000000..1f18a286 --- /dev/null +++ b/docs/adr/implementation-slices-adr-0004.md @@ -0,0 +1,109 @@ +# ADR-0004 implementation slicing + +This file tracks the slicing strategy used to ship ADR-0004 incrementally inside +ant-node alone, while the multi-repo evmlib breaking change ripens. The ADR +itself (`ADR-0004-commitment-bound-quote-pricing.md`) describes the end state; +this document describes the order in which the end state lands. + +The constraint that drives the slicing: `PaymentQuote`, `ProofOfPayment`, +`bytes_for_signing`, and `quote.hash()` live in evmlib (crates.io `0.8.1`) and +flow into the on-chain `payForQuotes` interface. Adding signed fields to +`PaymentQuote` is therefore a coordinated four-repo release +(`evmlib` → `ant-protocol` → `ant-client` → `ant-node`). Until that lands, +every part of ADR-0004 that does NOT require new signed quote fields can — +and should — ship behind the rollout const the ADR's "Rollout" section +already specifies. + +## Slice 1 — arithmetic re-check (shipped) + +**What:** every storer re-runs `price == calculate_price(n)` for some +non-negative integer `n`, by exact recomputation, on every quote in every +payment bundle (all 7 single-node quotes and all 16 merkle candidates), in +every `VerificationContext`. Reject-only when enforced; no trust evidence. +Rollout-gated by `QUOTE_ARITHMETIC_RECHECK_ENABLED` (defaults to `false` — +observe-only). Telemetry runs only after ML-DSA-65 signature verification has +passed, so unauthenticated peers cannot poison rollout logs. + +**Why first:** needs no evmlib change, no new state, no new wire types, no +new gossip; it is the ADR's "every storer re-runs the +price-equals-formula-of-count check on every quote in the bundle" rule in +its purest form. The price already encodes the count, so canonicality testing +the price alone catches every off-curve lie (a strictly weaker attack than +on-curve count inflation, which Slice 2 addresses). + +**Files touched:** `src/payment/verifier.rs` (new functions +`validate_quote_arithmetic`, `validate_merkle_candidate_arithmetic`, +`log_off_curve_single_node`, `log_off_curve_merkle`, +`price_off_curve_diagnostics`, `candidate_count_to_usize`, +`quote_price_is_on_curve`), `src/replication/config.rs` (new const +`QUOTE_ARITHMETIC_RECHECK_ENABLED`). + +**Scope it does NOT cover:** an on-curve quote for a fake `n`. That requires +the signed `claimed_key_count` and `commitment_pin` fields that only Slice 3 +can add. + +## Slice 2 — commitment-binding sidecar (no evmlib change) + +**What:** carry the issuing node's current signed `StorageCommitment` as a +sidecar inside the existing payment-proof envelope. Wire the storer-side +cross-check (claimed count from the quote vs. pinned commitment's +`key_count`) using the sidecar where present, the gossiped cache where the +sidecar pin matches, or a `GetCommitmentByPin` fetch otherwise. Adds the +`FailureEvidence::QuoteCommitmentMismatch` variant. Adds the +deterministic-first-audit queue keyed on monetized pins. + +**Why second:** this is the ADR's "peers cross-check the original and route +monetized commitments into audit" paragraph. It lands the full audit funnel +end-to-end against real signed commitments without changing evmlib. The +sidecar's `claimed_count` is not yet covered by the on-chain quote hash, so +the binding is enforced at the gossip/audit layer rather than at the chain +layer — exactly the residual the ADR's rollout phase already names. + +**Files touched (planned):** `src/payment/proof.rs` (sidecar serialization +envelope), `src/payment/verifier.rs` (cross-check rule), +`src/replication/protocol.rs` (`GetCommitmentByPin` request/response), +`src/replication/commitment_state.rs` (quote-issuance answerability refresh), +`src/replication/mod.rs` (first-audit queue alongside +`last_commitment_by_peer`), `src/replication/types.rs` +(`FailureEvidence::QuoteCommitmentMismatch`), `src/payment/quote.rs` (read +current pin from commitment state). + +## Slice 3 — signed quote fields (multi-repo, breaking cutover) — LANDED + +**What:** signed `committed_key_count: u32` and `commitment_pin: +Option<[u8; 32]>` added to `PaymentQuote` and `MerklePaymentCandidateNode` +in evmlib, included in `bytes_for_signing` (single-node) and `bytes_to_sign` +(merkle), with the quote types' fields placed at the struct **tail** so an +old-format value still rmp-decodes (as `(0, None)`). `ant-protocol` is +patched in lockstep to verify the 5-field merkle message, so the merkle +binding is genuine same-key-signed evidence too. Both `evmlib` and +`ant-protocol` are brought in via `[patch.crates-io]` against local +checkouts; the eventual upstream path is published `evmlib` → +`ant-protocol` → `ant-client` → `ant-node` releases. + +**This is a HARD CUTOVER, not a mixed-fleet observe-only.** Appending the +fields to the signed payload changes every quote's signature and +`quote.hash()`, so an old quote fails signature verification on a new node +regardless of any flag — there is no version in which old and new nodes +interoperate on the quote wire. The whole fleet and the clients upgrade +together. The earlier "Slice 3 deferred behind observe-only" framing was +wrong on this point (a round-2 review finding): only the **arithmetic +enforcement** (`QUOTE_ARITHMETIC_RECHECK_ENABLED`, reject vs log) is a +rollout dial; the signed-fields format is a one-shot breaking change. With +the fields signed, Slice 1's arithmetic gate strengthens from curve +canonicality to the exact `price == calculate_price(committed_key_count)` +binding rule (`binding_violation`), and pricing moves off the on-disk count +entirely (no-commitment → baseline). + +## Rollout coupling + +The ADR's "Rollout" section says full enforcement requires the fleet +upgraded **and** the ADR-0002 timeout-eviction gate enabled. +`QUOTE_ARITHMETIC_RECHECK_ENABLED` (reject vs observe-only-log) is +independent of timeout eviction: the arithmetic/binding gate is reject-only +on a confirmed off-curve or mis-shaped quote, with no silence lane. The +own-quote price-staleness gate is retired for commitment-bound quotes (it +compared against the on-disk count, which the committed responsible count +legitimately differs from). The Slice-2 cross-check's silence lane (an +unanswerable quoted pin) is what couples to timeout eviction, exactly as the +ADR specifies. diff --git a/src/node.rs b/src/node.rs index 5f5fd402..fcd1cb27 100644 --- a/src/node.rs +++ b/src/node.rs @@ -143,31 +143,56 @@ impl NodeBuilder { } // Initialize replication engine (if storage is enabled) - let replication_engine = - if let (Some(ref protocol), Some(fresh_rx)) = (&ant_protocol, fresh_write_rx) { - let storage_arc = protocol.storage(); - let payment_verifier_arc = protocol.payment_verifier_arc(); - match ReplicationEngine::new( - repl_config, - Arc::clone(&p2p_arc), - storage_arc, - payment_verifier_arc, - Arc::clone(&identity), - &self.config.root_dir, - fresh_rx, - shutdown.clone(), - ) - .await - { - Ok(engine) => Some(engine), - Err(e) => { - warn!("Failed to initialize replication engine: {e}"); - None + let replication_engine = if let (Some(ref protocol), Some(fresh_rx)) = + (&ant_protocol, fresh_write_rx) + { + let storage_arc = protocol.storage(); + let payment_verifier_arc = protocol.payment_verifier_arc(); + match ReplicationEngine::new( + repl_config, + Arc::clone(&p2p_arc), + storage_arc, + payment_verifier_arc, + Arc::clone(&identity), + &self.config.root_dir, + fresh_rx, + shutdown.clone(), + ) + .await + { + Ok(engine) => { + // ADR-0004: wire the engine's commitment state as the + // quote generator's commitment source so quotes force + // their price from the live storage commitment. Done + // here because the engine owns the commitment state and + // is built after the protocol. + if let Some(ref protocol) = ant_protocol { + let concrete = Arc::clone(engine.commitment_state()); + let source: Arc = concrete; + protocol.attach_commitment_source(source); + // ADR-0004: share the engine's gossip commitment + // cache with the verifier so the cross-check can + // resolve quote pins against neighbours' commitments. + protocol + .payment_verifier_arc() + .attach_commitment_cache(Arc::clone(engine.last_commitment_by_peer())); + // ADR-0004: give the verifier the monetized-pin sender so + // commitments that back a payment get a deterministic + // first audit from the engine's drainer. + protocol + .payment_verifier_arc() + .attach_monetized_pin_sender(engine.monetized_pin_sender()); } + Some(engine) } - } else { - None - }; + Err(e) => { + warn!("Failed to initialize replication engine: {e}"); + None + } + } + } else { + None + }; let node = RunningNode { config: self.config, diff --git a/src/payment/pricing.rs b/src/payment/pricing.rs index f8a829e7..40563221 100644 --- a/src/payment/pricing.rs +++ b/src/payment/pricing.rs @@ -1,253 +1,13 @@ -//! Quadratic pricing with a baseline floor for ant-node. +//! Quadratic pricing with a baseline floor. //! -//! Formula: `price_per_chunk_ANT(n) = BASELINE + K × (n / D)²` +//! ADR-0004: the pricing formula is now the **single source of truth** in +//! `ant-protocol` (`ant_protocol::payment::pricing`), so the node (when pricing +//! a quote) and the client (when verifying the forced price before paying) +//! compute byte-for-byte identical prices and can never drift. This module +//! re-exports it so every existing `crate::payment::pricing::…` caller keeps +//! working unchanged. //! -//! The non-zero `BASELINE` makes empty nodes charge a meaningful spam-barrier -//! price, and `K` is anchored so per-GB USD pricing matches real-world targets -//! at the current ~$0.10/ANT token price. An earlier formula produced ~$25/GB -//! at the lower stable boundary and ~$0/GB when nodes were empty — both -//! unreasonable. -//! -//! ## Parameters -//! -//! | Constant | Value | Role | -//! |-----------|---------------|-------------------------------------------------| -//! | BASELINE | 0.00390625 ANT| Price at empty (bootstrap-phase spam barrier) | -//! | K | 0.03515625 ANT| Quadratic coefficient | -//! | D | 6000 | Lower stable boundary (records stored) | -//! -//! ## Design Rationale -//! -//! - **Empty / lightly loaded nodes** charge the `BASELINE` floor, preventing -//! free storage and acting as a bootstrap-phase spam barrier. -//! - **Moderately loaded nodes** add a small quadratic contribution on top. -//! - **Heavily loaded nodes** charge quadratically more, pushing clients -//! toward less-loaded nodes elsewhere in the network. - -use evmlib::common::Amount; - -/// Lower stable boundary of the quadratic curve, in records stored. -const PRICING_DIVISOR: u128 = 6000; - -/// `PRICING_DIVISOR²`, precomputed to avoid repeated multiplication. -const DIVISOR_SQUARED: u128 = PRICING_DIVISOR * PRICING_DIVISOR; - -/// Baseline price at empty / bootstrap-phase spam barrier. -/// -/// `0.00390625 ANT × 10¹⁸ wei/ANT = 3_906_250_000_000_000 wei`. -const PRICE_BASELINE_WEI: u128 = 3_906_250_000_000_000; - -/// Quadratic coefficient `K`. -/// -/// `0.03515625 ANT × 10¹⁸ wei/ANT = 35_156_250_000_000_000 wei`. -const PRICE_COEFFICIENT_WEI: u128 = 35_156_250_000_000_000; - -/// Price increment per squared record after simplifying `PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED`. -const PRICE_PER_RECORD_SQUARED_WEI: u128 = PRICE_COEFFICIENT_WEI / DIVISOR_SQUARED; - -/// Derive the quoted record count from a quote price. -/// -/// This is the inverse of [`calculate_price`] and is used to validate quote -/// freshness without relying on wall-clock timestamps. It intentionally floors -/// to the nearest integer record count, matching the existing storage-delta -/// tolerance behaviour. -/// -/// Saturates to `u64::MAX` for any price that would otherwise overflow `u64`. -/// This matters because the verifier calls this on untrusted deserialized -/// `quote.price` values BEFORE signature verification: a panic here is a -/// pre-auth crash vector. Saturating leaves the delta check to reject the -/// quote as out-of-range without aborting the process. -#[must_use] -pub fn derive_records_stored_from_price(price: Amount) -> u64 { - let baseline = Amount::from(PRICE_BASELINE_WEI); - if price <= baseline { - return 0; - } - - let excess = price - baseline; - let n_squared = excess / Amount::from(PRICE_PER_RECORD_SQUARED_WEI); - let root = n_squared.root(2); - // ruint's `Uint::to::()` panics on overflow. We MUST NOT panic here: - // freshness runs on untrusted deserialized `quote.price` before signature - // verification, so a hostile oversized price would otherwise be a pre-auth - // crash vector. Saturate to `u64::MAX` instead; the delta check rejects - // out-of-range quotes. - if root > Amount::from(u64::MAX) { - u64::MAX - } else { - root.to::() - } -} - -/// Calculate storage price in wei from the number of close records stored. -/// -/// Formula: `price_wei = BASELINE + n² × K / D²` -/// -/// where `BASELINE = 0.00390625 ANT`, `K = 0.03515625 ANT`, and `D = 6000`. -/// U256 arithmetic prevents overflow for large record counts. -#[must_use] -pub fn calculate_price(close_records_stored: usize) -> Amount { - let n = Amount::from(close_records_stored); - let n_squared = n.saturating_mul(n); - let quadratic_wei = n_squared.saturating_mul(Amount::from(PRICE_COEFFICIENT_WEI)) - / Amount::from(DIVISOR_SQUARED); - Amount::from(PRICE_BASELINE_WEI).saturating_add(quadratic_wei) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used)] -mod tests { - use super::*; - - /// 1 token = 10¹⁸ wei (used for test sanity-checks). - const WEI_PER_TOKEN: u128 = 1_000_000_000_000_000_000; - - /// Helper: expected price matching the formula `BASELINE + n² × K / D²`. - fn expected_price(n: u64) -> Amount { - let n_amt = Amount::from(n); - let quad = - n_amt * n_amt * Amount::from(PRICE_COEFFICIENT_WEI) / Amount::from(DIVISOR_SQUARED); - Amount::from(PRICE_BASELINE_WEI) + quad - } - - #[test] - fn test_zero_records_gets_baseline() { - // At n = 0 the quadratic term vanishes, leaving the baseline floor. - let price = calculate_price(0); - assert_eq!(price, Amount::from(PRICE_BASELINE_WEI)); - } - - #[test] - fn test_baseline_is_nonzero_spam_barrier() { - // The baseline ensures even empty nodes charge a meaningful price, - // making the legacy MIN_PRICE_WEI = 1 sentinel redundant. - assert!(calculate_price(0) > Amount::ZERO); - assert!(calculate_price(1) > calculate_price(0)); - } - - #[test] - fn test_one_record_above_baseline() { - let price = calculate_price(1); - assert_eq!(price, expected_price(1)); - assert!(price > Amount::from(PRICE_BASELINE_WEI)); - } - - #[test] - fn test_at_divisor_is_baseline_plus_k() { - // At n = D the quadratic contribution equals K × 1² = K. - // price = BASELINE + K = 0.00390625 + 0.03515625 = 0.0390625 ANT - let price = calculate_price(6000); - let expected = Amount::from(PRICE_BASELINE_WEI + PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_double_divisor_is_baseline_plus_four_k() { - // At n = 2D the quadratic contribution is 4K. - let price = calculate_price(12000); - let expected = Amount::from(PRICE_BASELINE_WEI + 4 * PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_triple_divisor_is_baseline_plus_nine_k() { - // At n = 3D the quadratic contribution is 9K. - let price = calculate_price(18000); - let expected = Amount::from(PRICE_BASELINE_WEI + 9 * PRICE_COEFFICIENT_WEI); - assert_eq!(price, expected); - } - - #[test] - fn test_smooth_pricing_no_staircase() { - // 11999 should give a strictly higher price than 6000 (no integer-division plateau). - let price_6k = calculate_price(6000); - let price_11k = calculate_price(11999); - assert!( - price_11k > price_6k, - "11999 records ({price_11k}) should cost more than 6000 ({price_6k})" - ); - } - - #[test] - fn test_price_increases_with_records() { - let price_low = calculate_price(6000); - let price_mid = calculate_price(12000); - let price_high = calculate_price(18000); - assert!(price_mid > price_low); - assert!(price_high > price_mid); - } - - #[test] - fn test_price_increases_monotonically() { - let mut prev_price = Amount::ZERO; - for records in (0..60000).step_by(100) { - let price = calculate_price(records); - assert!( - price >= prev_price, - "Price at {records} records ({price}) should be >= previous ({prev_price})" - ); - prev_price = price; - } - } - - #[test] - fn test_large_value_no_overflow() { - let price = calculate_price(usize::MAX); - assert!(price > Amount::ZERO); - } - - #[test] - fn test_price_deterministic() { - let price1 = calculate_price(12000); - let price2 = calculate_price(12000); - assert_eq!(price1, price2); - } - - #[test] - fn test_quadratic_growth_excluding_baseline() { - // Subtracting the baseline, quadratic contribution should scale with n². - // At 2× records the quadratic portion is 4×; at 4× records it is 16×. - let base = Amount::from(PRICE_BASELINE_WEI); - let quad_6k = calculate_price(6000) - base; - let quad_12k = calculate_price(12000) - base; - let quad_24k = calculate_price(24000) - base; - assert_eq!(quad_12k, quad_6k * Amount::from(4u64)); - assert_eq!(quad_24k, quad_6k * Amount::from(16u64)); - } - - #[test] - fn test_small_record_counts_near_baseline() { - // At small n, price is dominated by the baseline — quadratic term is tiny. - let price = calculate_price(100); - assert_eq!(price, expected_price(100)); - assert!(price < Amount::from(WEI_PER_TOKEN)); // well below 1 ANT - assert!(price > Amount::from(PRICE_BASELINE_WEI)); // strictly above baseline - } - - #[test] - fn test_derive_records_stored_from_price_round_trips() { - for records in [0usize, 1, 5, 100, 6_000, 12_000, 60_000] { - let price = calculate_price(records); - assert_eq!(derive_records_stored_from_price(price), records as u64); - } - } - - #[test] - fn test_derive_records_stored_from_baseline_or_lower_is_zero() { - assert_eq!(derive_records_stored_from_price(Amount::ZERO), 0); - assert_eq!( - derive_records_stored_from_price(Amount::from(PRICE_BASELINE_WEI)), - 0 - ); - } +//! See `ant-protocol/src/payment/pricing.rs` for the formula, constants, and +//! the full unit-test suite. - #[test] - fn test_derive_records_stored_from_max_price_saturates_no_panic() { - // Hostile/malformed quotes may carry an oversized U256 price. - // The verifier calls this BEFORE signature verification, so we MUST - // NOT panic on overflow — saturate to u64::MAX and let the delta - // check reject the quote. - let v = derive_records_stored_from_price(Amount::MAX); - assert_eq!(v, u64::MAX); - } -} +pub use ant_protocol::payment::pricing::{calculate_price, derive_records_stored_from_price}; diff --git a/src/payment/proof.rs b/src/payment/proof.rs index 269a4366..4611138d 100644 --- a/src/payment/proof.rs +++ b/src/payment/proof.rs @@ -5,6 +5,6 @@ //! callers using `crate::payment::proof::…` keep working unchanged. pub use ant_protocol::payment::proof::{ - deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof, - serialize_single_node_proof, PaymentProof, ProofType, + deserialize_merkle_proof, deserialize_proof, deserialize_single_node_proof, detect_proof_type, + serialize_merkle_proof, serialize_single_node_proof, PaymentProof, ProofType, }; diff --git a/src/payment/quote.rs b/src/payment/quote.rs index e78070da..74f18333 100644 --- a/src/payment/quote.rs +++ b/src/payment/quote.rs @@ -11,7 +11,6 @@ use crate::error::{Error, Result}; use crate::logging::debug; use crate::payment::metrics::QuotingMetricsTracker; use crate::payment::pricing::calculate_price; -use crate::storage::lmdb::LmdbStorage; use evmlib::merkle_payments::MerklePaymentCandidateNode; use evmlib::PaymentQuote; use evmlib::RewardsAddress; @@ -28,6 +27,45 @@ pub type XorName = [u8; 32]; /// Signing function type that takes bytes and returns a signature. pub type SignFn = Box Vec + Send + Sync>; +/// The commitment binding a quote prices against (ADR-0004). +/// +/// `key_count` is the leaf count of the pinned commitment and the sole input to +/// the price formula; `pin` is that commitment's hash. A quote carries both, +/// signed, so any receiver can recompute the price and resolve the pin to the +/// signed commitment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct QuoteBinding { + /// Number of keys in the pinned commitment (the price driver). + pub key_count: u32, + /// Hash of the pinned commitment. + pub pin: [u8; 32], +} + +/// Source of the live storage commitment a quote prices against (ADR-0004). +/// +/// Implemented by the responder-side commitment state. Decouples +/// [`QuoteGenerator`] from replication internals: the generator only needs the +/// current commitment's `(key_count, pin)` and the guarantee that asking for it +/// refreshes its answerability ("quoting is advertising"). Returns `None` when +/// there is no live current commitment (never rotated, or retired), in which +/// case the node quotes the baseline with no pin. +pub trait CommitmentSource: Send + Sync { + /// Snapshot the current commitment's binding AND refresh its answerability, + /// atomically. `None` if there is no live current commitment. + fn current_binding_for_quote(&self) -> Option; + + /// ADR-0004: the serialized signed commitment for `pin`, if it is still + /// retained, so the quote response can ship it as a sidecar ("the commitment + /// arrived with the quote"). Returns the same canonical bytes a peer would + /// receive via `GetCommitmentByPin`, so the client's pin match is identical + /// across both resolution paths. `None` if the pin is no longer retained + /// (in which case the response carries no commitment and the client falls + /// back to gossip/fetch). Kept separate from [`Self::current_binding_for_quote`] + /// so the ~5 KB blob is only materialised when a response is being built, + /// never on the `Copy` pricing path. + fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option>; +} + /// Quote generator for creating payment quotes. /// /// Uses the node's signing capabilities to sign quotes, which clients @@ -35,23 +73,21 @@ pub type SignFn = Box Vec + Send + Sync>; pub struct QuoteGenerator { /// The rewards address for receiving payments. rewards_address: RewardsAddress, - /// Fallback in-memory record counter for pricing. + /// In-memory record counter, retained for the `records_stored` / + /// `record_store` / `resync_records` accounting surface the storage handler + /// drives. /// - /// Only consulted when no [`LmdbStorage`] is attached (unit tests, or a - /// mis-configured startup). In production the price is derived from the - /// attached store's `current_chunks()` instead — see [`Self::storage`]. + /// ADR-0004: this is NO LONGER a pricing input. A quote's price is bound to + /// the live storage commitment via [`Self::commitment_source`] (or baseline + /// when none); the on-disk/side record count no longer sets the price. metrics_tracker: QuotingMetricsTracker, - /// Authoritative on-disk record-count source for pricing. - /// - /// When attached, quote prices are computed from - /// [`LmdbStorage::current_chunks()`] — the **same** count the - /// [`PaymentVerifier`](crate::payment::PaymentVerifier) price-floor check - /// compares the paid quote against. Keeping pricing and verification on one - /// source means a quote priced at record count `N` is later checked against - /// a current count that differs only by genuine in-flight growth, instead of - /// by a side-counter-vs-store gap. - /// `None` until [`Self::attach_storage`] is called. - storage: RwLock>>, + /// ADR-0004 commitment source: the live storage commitment the price is + /// bound to. When attached, a quote prices against the committed + /// (responsible) key count and pins that commitment, refreshing its + /// answerability on issuance. `None` until [`Self::attach_commitment_source`] + /// is called — in which case the node falls back to baseline pricing with no + /// pin (observe-only / pre-rotation / unit tests). + commitment_source: RwLock>>, /// Signing function provided by the node. /// Takes bytes and returns a signature. sign_fn: Option, @@ -73,45 +109,75 @@ impl QuoteGenerator { Self { rewards_address, metrics_tracker, - storage: RwLock::new(None), + commitment_source: RwLock::new(None), sign_fn: None, pub_key: Vec::new(), } } - /// Attach the node's [`LmdbStorage`] so quote prices reflect the - /// authoritative on-disk record count. + /// Attach the ADR-0004 commitment source so quotes bind their price to the + /// node's live storage commitment. /// - /// This MUST be wired to the same `LmdbStorage` the - /// [`PaymentVerifier`](crate::payment::PaymentVerifier) price-floor check - /// reads via `current_chunks()`; otherwise pricing and verification diverge - /// and healthy payments can be rejected. Idempotent: calling twice replaces - /// the handle. Uses interior mutability so it can be called on an `Arc`. - pub fn attach_storage(&self, storage: Arc) { - *self.storage.write() = Some(storage); - debug!("QuoteGenerator: LmdbStorage attached for current-records pricing"); + /// Idempotent: calling twice replaces the handle. Uses interior mutability + /// so it can be called on an `Arc` after construction. When attached, + /// [`Self::create_quote`] and [`Self::create_merkle_candidate_quote`] price + /// against the committed responsible key count, pin the current commitment, + /// and refresh its answerability. When absent, both fall back to baseline + /// (no-pin) quotes. + pub fn attach_commitment_source(&self, source: Arc) { + *self.commitment_source.write() = Some(source); + debug!("QuoteGenerator: ADR-0004 commitment source attached"); + } + + /// ADR-0004: the serialized signed commitment for `pin`, so the quote + /// response can ship it as a sidecar. `None` when no commitment source is + /// attached (baseline / pre-rotation / tests) or the pin is no longer + /// retained — in which case the response carries no commitment and the + /// client falls back to gossip/fetch. Lock is dropped before the (heavier) + /// blob materialisation in the impl. + #[must_use] + pub fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option> { + let source = self.commitment_source.read().as_ref().map(Arc::clone); + source.and_then(|src| src.commitment_blob_for_pin(pin)) } - /// Record count used to price quotes. + /// Resolve the ADR-0004 pricing inputs a quote should carry, refreshing the + /// pinned commitment's answerability as a side effect. /// - /// Prefers the attached `LmdbStorage` count (authoritative — counts client - /// PUTs, replication stores, and repair fetches alike, exactly matching the - /// verifier's price-floor source). Falls back to the in-memory - /// `metrics_tracker` when no storage is attached or the read fails, so - /// pricing never panics or stalls. - fn pricing_records_stored(&self) -> usize { - if let Some(storage) = self.storage.read().as_ref() { - match storage.current_chunks() { - Ok(n) => return usize::try_from(n).unwrap_or(usize::MAX), - Err(e) => { - debug!( - "QuoteGenerator: current_chunks() failed ({e}); \ - falling back to metrics_tracker for pricing" - ); - } - } - } - self.metrics_tracker.records_stored() + /// Returns `(committed_key_count, commitment_pin, price_count)`: + /// - with a live commitment, the price is driven by the committed key count + /// and the quote pins that commitment (the ADR-0004 forced price); + /// - with no commitment source or no live current commitment, the node + /// emits a true **baseline** quote: `(0, None)` priced at + /// `calculate_price(0)`. + /// + /// Critically, the no-commitment branch prices at `0`, NOT at the on-disk + /// record count. A `(committed_key_count = 0, commitment_pin = None)` quote + /// is the canonical baseline shape, and ADR-0004's forced-price rule binds + /// that shape to `calculate_price(0)`. Pricing the no-pin quote off the disk + /// count would mint a `(0, None, price > baseline)` quote — a shape a + /// modified node could forge to charge above baseline while carrying no + /// auditable pin. A node that genuinely holds data prices through its + /// commitment (the `Some` branch) once it has rotated one; until then it can + /// only charge baseline, which is correct: it has nothing it can prove. + /// + /// Shared by both quote-generation paths so they stay byte-for-byte + /// consistent in how they bind price to commitment. + fn resolve_quote_pricing(&self) -> (u32, Option<[u8; 32]>, usize) { + // Resolve (and drop) the lock guard before branching: the binding is a + // plain `Copy` value, so the commitment-source lock is never held. + let binding = self + .commitment_source + .read() + .as_ref() + .and_then(|src| src.current_binding_for_quote()); + binding.map_or((0u32, None, 0usize), |binding| { + ( + binding.key_count, + Some(binding.pin), + usize::try_from(binding.key_count).unwrap_or(usize::MAX), + ) + }) } /// Set the signing function for quote generation. @@ -182,17 +248,29 @@ impl QuoteGenerator { let timestamp = SystemTime::now(); - // Calculate price from the authoritative current record count (the same - // count the verifier's price-floor check reads), falling back to the - // in-memory counter only when no storage is attached. - let price = calculate_price(self.pricing_records_stored()); + // ADR-0004 forced price: when a live commitment exists, the price is a + // deterministic function of its committed (responsible) key count, and + // the quote pins that commitment (refreshing its answerability). Absent + // a commitment source — observe-only, pre-first-rotation, or unit tests + // — the node emits the canonical baseline quote `(0, None)` priced at + // `calculate_price(0)`, NOT a price off the on-disk count (see + // `resolve_quote_pricing`: an unpinned, above-baseline price would be a + // forgeable shape). + let (committed_key_count, commitment_pin, price_count) = self.resolve_quote_pricing(); + let price = calculate_price(price_count); // Convert XorName to xor_name::XorName let xor_name = xor_name::XorName(content); // Create bytes for signing (following autonomi's pattern) - let bytes = - PaymentQuote::bytes_for_signing(xor_name, timestamp, &price, &self.rewards_address); + let bytes = PaymentQuote::bytes_for_signing( + xor_name, + timestamp, + &price, + &self.rewards_address, + committed_key_count, + &commitment_pin, + ); // Sign the bytes let signature = sign_fn(&bytes); @@ -206,8 +284,10 @@ impl QuoteGenerator { content: xor_name, timestamp, price, - pub_key: self.pub_key.clone(), rewards_address: self.rewards_address, + committed_key_count, + commitment_pin, + pub_key: self.pub_key.clone(), signature, }; @@ -238,11 +318,12 @@ impl QuoteGenerator { /// Resync the quoting metric to an authoritative count of held records. /// - /// The quote price is driven by `records_stored()`. A monotonic store - /// counter would let a node delete chunks it was paid to hold yet keep - /// quoting as if it still held everything. Callers pass the authoritative - /// count of records the node ACTUALLY HOLDS (from the storage layer) so the - /// price reflects current holdings, including deletions and pruning. + /// ADR-0004: `records_stored()` is NO LONGER a pricing input — the quote + /// price is a function of the node's committed key count (see + /// `resolve_quote_pricing`), not this counter. This resync just keeps the + /// accounting/telemetry metric honest against what the node ACTUALLY HOLDS + /// (from the storage layer), including deletions and pruning, so a monotonic + /// store counter can't drift from reality. pub fn resync_records(&self, count: usize) { self.metrics_tracker.set_records(count); } @@ -272,13 +353,22 @@ impl QuoteGenerator { .as_ref() .ok_or_else(|| Error::Payment("Quote signing not configured".to_string()))?; - let price = calculate_price(self.pricing_records_stored()); + // ADR-0004 forced price for the merkle-batch candidate, mirroring the + // single-node path: bind to the live commitment when present, else + // baseline with no pin. + let (committed_key_count, commitment_pin, price_count) = self.resolve_quote_pricing(); + let price = calculate_price(price_count); - // Compute the same bytes_to_sign used by the upstream library + // ADR-0004: sign the commitment binding into the merkle candidate + // payload too (5-field `bytes_to_sign`), so a count/pin mismatch is + // genuine same-key-signed evidence. ant-protocol verifies this same + // 5-field message. let msg = MerklePaymentCandidateNode::bytes_to_sign( &price, &self.rewards_address, merkle_payment_timestamp, + committed_key_count, + &commitment_pin, ); // Sign with ML-DSA-65 @@ -294,6 +384,8 @@ impl QuoteGenerator { price, reward_address: self.rewards_address, merkle_payment_timestamp, + committed_key_count, + commitment_pin, signature, }; @@ -380,37 +472,38 @@ mod tests { generator } - /// Regression test for the STG-01 quote-pricing mismatch: pricing must read - /// the attached store's `current_chunks()`, NOT the side counter. - /// - /// Before the fix, the price came from `metrics_tracker` (client-PUT count - /// only) while verifier checks read `current_chunks()` (all records, - /// including replicated ones). On a replicating network the store count ran - /// far ahead of the side counter, so every quote looked underpriced. - /// Here we attach a store, write records WITHOUT touching the side counter - /// (mimicking replication stores), and assert the quote prices off the - /// store count — i.e. the two sources now agree. - #[tokio::test] - async fn test_pricing_tracks_attached_storage_not_side_counter() { - use crate::payment::pricing::derive_records_stored_from_price; - use crate::storage::{LmdbStorage, LmdbStorageConfig}; - use tempfile::TempDir; - - let temp_dir = TempDir::new().expect("temp dir"); - let storage = Arc::new( - LmdbStorage::new(LmdbStorageConfig { - root_dir: temp_dir.path().to_path_buf(), - ..LmdbStorageConfig::test_default() + /// Fixed-binding [`CommitmentSource`] for tests: always reports the same + /// `(key_count, pin)` so we can assert the forced-price wiring exactly. + struct FixedCommitmentSource { + key_count: u32, + pin: [u8; 32], + } + impl CommitmentSource for FixedCommitmentSource { + fn current_binding_for_quote(&self) -> Option { + Some(QuoteBinding { + key_count: self.key_count, + pin: self.pin, }) - .await - .expect("create storage"), - ); + } - // Side counter deliberately starts well BELOW the store count to model - // a node whose records arrived mostly via replication (which never - // increments the side counter). - let metrics_tracker = QuotingMetricsTracker::new(3); - let mut generator = QuoteGenerator::new(RewardsAddress::new([1u8; 20]), metrics_tracker); + fn commitment_blob_for_pin(&self, _pin: [u8; 32]) -> Option> { + // The fixed source has no real commitment to serialize; the + // forced-price tests assert on the binding, not the sidecar. + None + } + } + + /// ADR-0004 forced price (single-node): with a commitment source attached, + /// the quote price is exactly `calculate_price(committed_key_count)`, the + /// quote carries that count, and it pins the commitment hash. This replaces + /// the pre-ADR-0004 "price tracks on-disk count" behaviour — pricing is now + /// bound to the signed commitment, not the raw store count. + #[test] + fn test_forced_price_binds_to_commitment() { + let mut generator = QuoteGenerator::new( + RewardsAddress::new([1u8; 20]), + QuotingMetricsTracker::new(3), + ); generator.set_signer(vec![0u8; 64], |bytes| { let mut sig = vec![0u8; 64]; for (i, b) in bytes.iter().take(64).enumerate() { @@ -418,41 +511,55 @@ mod tests { } sig }); - generator.attach_storage(Arc::clone(&storage)); - - // Write 25 distinct records straight to the store, as a replication - // store would — the side counter stays at 3. - for i in 0..25u32 { - let content = format!("replicated-record-{i}"); - let address = LmdbStorage::compute_address(content.as_bytes()); - storage - .put(&address, content.as_bytes()) - .await - .expect("put"); - } - assert_eq!( - generator.records_stored(), - 3, - "side counter must be untouched" - ); - assert_eq!(storage.current_chunks().expect("count"), 25); + + let pin = [7u8; 32]; + generator.attach_commitment_source(Arc::new(FixedCommitmentSource { key_count: 25, pin })); let quote = generator .create_quote([42u8; 32], 1024, 0) .expect("create quote"); - // Price must encode 25 (the store count), not 3 (the side counter). assert_eq!( quote.price, calculate_price(25), - "price must be derived from current_chunks(), not metrics_tracker" + "price must be calculate_price(committed_key_count)" + ); + assert_eq!( + quote.committed_key_count, 25, + "quote carries committed count" ); + assert_eq!(quote.commitment_pin, Some(pin), "quote pins the commitment"); + } + + /// ADR-0004 baseline: with NO commitment source (fresh node, pre first + /// rotation), the quote is the canonical baseline shape — `(0, None)` priced + /// at `calculate_price(0)` — NOT priced off the on-disk count. A node can + /// only charge baseline until it has a commitment it can be audited against. + #[test] + fn test_no_commitment_source_prices_baseline() { + let mut generator = QuoteGenerator::new( + RewardsAddress::new([1u8; 20]), + QuotingMetricsTracker::new(99), + ); + generator.set_signer(vec![0u8; 64], |bytes| { + let mut sig = vec![0u8; 64]; + for (i, b) in bytes.iter().take(64).enumerate() { + sig[i] = *b; + } + sig + }); + + let quote = generator + .create_quote([42u8; 32], 1024, 0) + .expect("create quote"); + assert_eq!( - derive_records_stored_from_price(quote.price), - 25, - "verifier's price-inverse must recover the store count, keeping the \ - local price comparison aligned for a freshly issued quote" + quote.price, + calculate_price(0), + "no commitment source must price at baseline calculate_price(0)" ); + assert_eq!(quote.committed_key_count, 0); + assert_eq!(quote.commitment_pin, None); } #[test] @@ -618,6 +725,8 @@ mod tests { timestamp: SystemTime::now(), price: Amount::from(1u64), rewards_address: RewardsAddress::new([0u8; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: vec![], signature: vec![], }; @@ -714,8 +823,13 @@ mod tests { // Verify the timestamp was set correctly assert_eq!(candidate.merkle_payment_timestamp, timestamp); - // Verify price was calculated from records_stored using the pricing formula - assert_eq!(candidate.price, calculate_price(50)); + // ADR-0004: with no commitment source attached, the merkle candidate is + // a baseline quote — price `calculate_price(0)`, count 0, no pin — + // regardless of the side counter. Pricing is bound to the commitment, + // not the metrics tracker. + assert_eq!(candidate.price, calculate_price(0)); + assert_eq!(candidate.committed_key_count, 0); + assert_eq!(candidate.commitment_pin, None); // Verify the public key is the ML-DSA-65 public key (not ed25519) assert_eq!( @@ -754,7 +868,13 @@ mod tests { .as_secs(); let price = Amount::from(42u64); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &rewards_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign( + &price, + &rewards_address, + timestamp, + 0, + &None, + ); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -763,6 +883,8 @@ mod tests { price, reward_address: rewards_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } } diff --git a/src/payment/verifier.rs b/src/payment/verifier.rs index 21848c32..a48da2a3 100644 --- a/src/payment/verifier.rs +++ b/src/payment/verifier.rs @@ -9,7 +9,7 @@ use crate::logging::{debug, info, warn}; use crate::payment::cache::{CacheStats, VerifiedCache, XorName}; use crate::payment::pricing::{calculate_price, derive_records_stored_from_price}; use crate::payment::proof::{ - deserialize_merkle_proof, deserialize_proof, detect_proof_type, ProofType, + deserialize_merkle_proof, deserialize_single_node_proof, detect_proof_type, ProofType, }; use crate::replication::config::K_BUCKET_SIZE; use crate::storage::lmdb::LmdbStorage; @@ -26,7 +26,6 @@ use parking_lot::{Mutex, RwLock}; use saorsa_core::identity::node_identity::peer_id_from_public_key_bytes; use saorsa_core::identity::PeerId; use saorsa_core::P2PNode; -#[cfg(any(test, feature = "test-utils"))] use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; @@ -38,28 +37,21 @@ use std::time::Instant; /// Proofs smaller than this are rejected as they cannot contain sufficient payment information. pub const MIN_PAYMENT_PROOF_SIZE_BYTES: usize = 32; -/// Maximum allowed size for a payment proof in bytes (256 KB). +/// Maximum allowed size for a payment proof in bytes (512 KB). /// -/// Single-node proofs with 7 ML-DSA-65 quotes reach ~40 KB. -/// Merkle proofs include 16 candidate nodes (each with ~1,952-byte ML-DSA pub key -/// and ~3,309-byte signature) plus merkle branch hashes, totaling ~130 KB. -/// 256 KB provides headroom while still capping memory during verification. -pub const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 262_144; - -/// Maximum percentage by which the median-paid quote may fall below this -/// verifier's current local price before a client PUT is rejected. -/// -/// A 20% floor means a paid quote must be at least `0.8 * P_v`, so an -/// attacker who controls a real K-close issuer still pays at least -/// `0.8 * P_v * 3` for an honest verifier. Honest median-paid bundles have -/// a structural majority guarantee: the four nodes at or below the median -/// accept unless their own price grows more than `1 / 0.8 = 1.25x` between -/// quote and PUT. Above-median nodes may reject when `P_v > 1.25 * median`; -/// those records are backfilled by replication, which deliberately skips -/// this present-tense floor. -const PAID_QUOTE_PRICE_FLOOR_TOLERANCE_PCT: u64 = 20; - -const PERCENT_DENOMINATOR: u64 = 100; +/// Single-node proofs with 7 ML-DSA-65 quotes reach ~40 KB; with ADR-0004 +/// commitment sidecars (one ~5.3 KB commitment per bound quote, ~13 KB each +/// after rmp encoding) they reach ~150 KB. +/// Merkle proofs include 16 candidate nodes (each with ~1,952-byte ML-DSA pub +/// key and ~3,309-byte signature) plus merkle branch hashes, totaling ~130 KB. +/// A merkle proof that ALSO ships all 16 commitment sidecars (clients built +/// before the sidecars were dropped from the per-chunk bundle) reaches +/// ~342 KB — the previous 256 KB cap rejected every such PUT the moment nodes +/// carried live commitments, which is exactly how it was discovered. +/// 512 KB accepts every shape above with headroom while still capping memory +/// during verification. +pub const MAX_PAYMENT_PROOF_SIZE_BYTES: usize = 524_288; + const PAID_QUOTE_PAYMENT_MULTIPLIER: u64 = 3; const PAYMENT_VERIFY_SLOW_LOG_MS: u128 = 500; @@ -75,12 +67,6 @@ struct LegacyMedianCandidate<'a> { expected_amount: Amount, } -fn price_floor(current_price: Amount, tolerance_pct: u64) -> Amount { - current_price.saturating_mul(Amount::from( - PERCENT_DENOMINATOR.saturating_sub(tolerance_pct), - )) / Amount::from(PERCENT_DENOMINATOR) -} - fn median_quote_index(quote_count: usize) -> usize { quote_count / 2 } @@ -176,6 +162,25 @@ pub enum PaymentStatus { PaymentVerified, } +/// Outcome of the ADR-0004 quote-vs-commitment cross-check (see +/// [`PaymentVerifier::cross_check_binding`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossCheck { + /// Pin resolves to the commitment and the counts agree: nothing to report. + Match, + /// Pin resolves to the commitment but the claimed and committed counts + /// disagree: deterministic, first-occurrence contradiction (evidence). + Mismatch { + /// The key count the quote claimed. + quoted_key_count: u32, + /// The key count the pinned commitment actually attests. + committed_key_count: u32, + }, + /// The supplied commitment does not hash to the quote's pin: the pin is + /// unresolved (treat as fetch/skip), never evidence. + PinDoesNotResolve, +} + impl PaymentStatus { /// Returns true if the data can be stored (cached or payment verified). #[must_use] @@ -193,6 +198,29 @@ impl PaymentStatus { /// Default capacity for the merkle pool cache (number of pool hashes to cache). const DEFAULT_POOL_CACHE_CAPACITY: usize = 1_000; +/// ADR-0004: max commitment sidecars processed per bundle. A legitimate bundle +/// carries at most one commitment per quote/candidate — `CANDIDATES_PER_POOL` +/// (16) is the larger of the single-node (`CLOSE_GROUP_SIZE` = 7) and merkle +/// cases, so it covers both. Excess sidecars from a malicious client are +/// ignored before any deserialize/verify work (bounds the hot-path cost). +const MAX_SIDECARS_PER_BUNDLE: usize = evmlib::merkle_batch_payment::CANDIDATES_PER_POOL; + +/// Shared handle to the replication engine's gossip commitment cache +/// (`last_commitment_by_peer`), used by the ADR-0004 cross-check to resolve a +/// quote's pin against a neighbour's recently gossiped commitment. A `tokio` +/// `RwLock` to match the engine's; read with `.await` on the async path. +type CommitmentCache = Arc< + tokio::sync::RwLock< + HashMap, + >, +>; + +/// Per-`(peer, pin)` negative cache for unresolved ADR-0004 pin fetches: a pin a +/// peer answered `NotRetained` (or that timed out) is remembered so repeated +/// bundles don't re-fetch it. Behind an `Arc` so the detached fetch task owns a +/// handle without borrowing the verifier. +type PinFetchNegativeCache = Arc>>; + /// Main payment verifier for ant-node. /// /// Uses: @@ -226,13 +254,6 @@ pub struct PaymentVerifier { /// paths. `None` in unit tests that pre-set [`Self::test_records_override`]; /// production startup MUST call [`attach_storage`]. storage: RwLock>>, - /// Test-only override for the paid-quote local price floor. - /// - /// When `Some(n)`, `validate_paid_quote_price_floor` uses `n` as the - /// current record count instead of querying `storage.current_chunks()`. Set via - /// [`Self::set_records_stored_for_tests`] so unit tests that don't wire a - /// real `LmdbStorage` can still drive the price-floor logic. - test_records_override: RwLock>, /// Test-only override for the paid-quote issuer K-closest check. /// /// Production code derives closest peers from the attached [`P2PNode`]. @@ -244,6 +265,31 @@ pub struct PaymentVerifier { /// exercise the full verifier path without starting an EVM chain. #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock>, + // NOTE: the test-only own-peer-id override was removed with the ADR-retired + // quote-freshness/staleness gate (ADR-0004 binds price to the committed + // count instead), so it no longer has any reader. + /// ADR-0004 gossip commitment cache, shared with the replication engine + /// (`last_commitment_by_peer`). The cross-check resolves a quote's + /// `commitment_pin` against the neighbour's most recently gossiped + /// commitment held here, *only if seen within the answerability TTL*; + /// otherwise the pin is treated as unknown (fetch/skip), never a penalty. + /// A `tokio` `RwLock` to match the engine's; read with `.await` on the + /// async verification path. `None` until [`Self::attach_commitment_cache`] + /// (unit tests, or pre-replication startup). + commitment_cache: RwLock>, + /// ADR-0004 negative cache for unresolved pin fetches: a `(peer, pin)` that + /// resolved to `NotRetained` or timed out is remembered here so repeated + /// bundles citing the same unknown pin don't re-fetch (bounding the + /// amplification an attacker can drive). Keyed by `(PeerId, pin)`. Behind an + /// `Arc` so the detached background fetch task (which runs off the payment + /// hot path) can read and update it without borrowing the verifier. + pin_fetch_negative_cache: PinFetchNegativeCache, + /// ADR-0004: sender to surface monetized pins (commitments that backed a + /// payment) to the replication engine's deterministic first-audit drainer. + /// `None` until [`Self::attach_monetized_pin_sender`] (unit tests, or + /// pre-replication startup), in which case no first audit is scheduled. + monetized_pin_tx: + RwLock>>, /// Configuration. config: PaymentVerifierConfig, } @@ -361,15 +407,45 @@ impl PaymentVerifier { inflight_closeness, p2p_node: RwLock::new(None), storage: RwLock::new(None), - test_records_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] test_paid_quote_k_closest_override: RwLock::new(None), #[cfg(any(test, feature = "test-utils"))] test_completed_payments_override: RwLock::new(HashMap::new()), + commitment_cache: RwLock::new(None), + pin_fetch_negative_cache: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(crate::replication::config::PIN_FETCH_NEGATIVE_CACHE_CAPACITY) + .unwrap_or(NonZeroUsize::MIN), + ))), + monetized_pin_tx: RwLock::new(None), config, } } + /// Attach the ADR-0004 monetized-pin sender (the replication engine's + /// first-audit drainer channel) so the cross-check can route commitments + /// that backed a payment into a deterministic first audit. Idempotent; + /// absent (unit tests / pre-replication) no first audit is scheduled. + pub fn attach_monetized_pin_sender( + &self, + tx: tokio::sync::mpsc::UnboundedSender, + ) { + *self.monetized_pin_tx.write() = Some(tx); + debug!("PaymentVerifier: ADR-0004 monetized-pin sender attached"); + } + + /// Attach the ADR-0004 gossip commitment cache (the replication engine's + /// `last_commitment_by_peer`) so the cross-check can resolve a quote's + /// `commitment_pin` against the neighbour's recently gossiped commitment. + /// + /// Wired by the node once the replication engine exists, alongside the + /// quote generator's commitment source. Idempotent. Absent (unit tests, + /// pre-replication startup), the cross-check resolves no pins from gossip + /// and falls back to fetch/skip — never a penalty. + pub fn attach_commitment_cache(&self, cache: CommitmentCache) { + *self.commitment_cache.write() = Some(cache); + debug!("PaymentVerifier: ADR-0004 commitment cache attached"); + } + /// Attach the node's [`P2PNode`] handle so paid-quote verification can /// check issuer closeness, and merkle-payment verification can check /// candidate `pub_keys` against the DHT's actual closest peers to the pool @@ -402,15 +478,6 @@ impl PaymentVerifier { debug!("PaymentVerifier: LmdbStorage attached for paid-quote price-floor checks"); } - /// Test-only setter for the current record count used by paid-quote - /// price-floor checks. Lets unit tests drive the floor logic without - /// wiring a real `LmdbStorage`. Has no effect in production code because - /// production code is expected to call [`Self::attach_storage`] instead. - #[cfg(any(test, feature = "test-utils"))] - pub fn set_records_stored_for_tests(&self, count: u64) { - *self.test_records_override.write() = Some(count); - } - /// Test-only setter for local closest peers used by the paid-quote /// issuer K-closest check. #[cfg(any(test, feature = "test-utils"))] @@ -441,29 +508,6 @@ impl PaymentVerifier { .insert(quote_hash, amount); } - /// Snapshot the current record count for paid-quote price-floor checks. - /// - /// Prefers the attached `LmdbStorage` (authoritative — covers client PUTs, - /// replication stores, repair fetches, and prune deletes by definition). - /// Falls back to a test override if one was set. Returns `None` only when - /// no source is available (mis-configured production startup). The - /// paid-quote floor rejects client PUTs because the local floor is - /// the economic security gate for this proof policy. - fn current_records_stored(&self) -> Option { - if let Some(storage) = self.storage.read().as_ref() { - match storage.current_chunks() { - Ok(n) => return Some(n), - Err(e) => { - warn!( - "PaymentVerifier: failed to read current_chunks() for price-floor check: {e}" - ); - return None; - } - } - } - *self.test_records_override.read() - } - /// Check if payment is required for the given `XorName`. /// /// This is the main entry point for payment verification: @@ -612,15 +656,24 @@ impl PaymentVerifier { self.verify_merkle_payment(xorname, proof, context).await?; } Some(ProofType::SingleNode) => { - let (payment, tx_hashes) = deserialize_proof(proof).map_err(|e| { + let parsed = deserialize_single_node_proof(proof).map_err(|e| { Error::Payment(format!("Failed to deserialize payment proof: {e}")) })?; - if !tx_hashes.is_empty() { - debug!("Proof includes {} transaction hash(es)", tx_hashes.len()); + if !parsed.tx_hashes.is_empty() { + debug!( + "Proof includes {} transaction hash(es)", + parsed.tx_hashes.len() + ); } - self.verify_evm_payment(xorname, &payment, context).await?; + self.verify_evm_payment( + xorname, + &parsed.proof_of_payment, + &parsed.commitment_sidecars, + context, + ) + .await?; } None => { let tag = proof.first().copied().unwrap_or(0); @@ -703,14 +756,13 @@ impl PaymentVerifier { /// 2. Median-priced candidate quotes are derived from the supplied bundle /// 3. Each candidate is checked for content binding, peer binding, and a /// valid ML-DSA-65 signature - /// 4. Each candidate must also come from a local K-close peer and - /// satisfy the paid-quote price floor + /// 4. Each candidate must also come from a local K-close peer /// 5. A candidate is accepted only if `completedPayments(quoteHash)` is at /// least 3x the median price /// /// Non-median quotes are parsed only to locate the median. Their content, /// peer bindings, and signatures are deliberately ignored: the paid - /// quote's content hash, quote hash, signature, local floor, issuer + /// quote's content hash, quote hash, signature, issuer /// K-closeness check, and on-chain settlement are the authority. A /// one-quote proof is valid when that single quote passes these checks and /// was paid 3x. @@ -718,6 +770,7 @@ impl PaymentVerifier { &self, xorname: &XorName, payment: &ProofOfPayment, + commitment_sidecars: &[Vec], context: VerificationContext, ) -> Result<()> { if crate::logging::enabled!(crate::logging::Level::DEBUG) { @@ -729,6 +782,18 @@ impl PaymentVerifier { } Self::validate_quote_structure(payment)?; + // ADR-0004: re-run the `price == calculate_price(committed_key_count)` + // arithmetic/binding check on EVERY quote in the bundle (all single-node + // quotes), per the ADR's "every storer re-runs the + // price-equals-formula-of-count check on every quote in the bundle" + // rule — bundle-level, before median selection (the candidate loop below + // only sees median-priced quotes). This hard cutover also RETIRES the + // percentage-based own-quote price-staleness gate: a quote's price is + // now exactly bound to its committed count here (both the `(n>0, Some)` + // and baseline `(0, None)` shapes), and the committed responsible count + // legitimately differs from the on-disk count, so the old gate would + // FALSE-REJECT healthy ADR quotes. The binding gate supersedes it. + Self::validate_quote_arithmetic(payment)?; let candidates = Self::legacy_median_candidates(payment)?; let mut failures = Vec::with_capacity(candidates.len()); let mut verified_paid_quote = false; @@ -758,6 +823,20 @@ impl PaymentVerifier { ))); } + // ADR-0004 observe-only telemetry: log off-curve quotes only AFTER the + // paid (median) quote's ML-DSA-65 signature has verified above, so + // unauthenticated senders cannot poison rollout logs. In enforce mode + // `validate_quote_arithmetic` already rejected; this is a no-op there. + Self::log_off_curve_single_node(payment); + + // ADR-0004 cross-check + first-audit enqueue (ClientPut only) runs ONLY + // after on-chain payment verification has SUCCEEDED above, so an unpaid + // (but signed) bundle can never enqueue audits or drive pin fetches — + // closing the free-amplification path. Fresh client-put bundles only. + if context == VerificationContext::ClientPut { + self.cross_check_quotes(payment, commitment_sidecars).await; + } + if crate::logging::enabled!(crate::logging::Level::INFO) { let xorname_hex = hex::encode(xorname); info!("EVM payment verified for {xorname_hex}"); @@ -819,7 +898,6 @@ impl PaymentVerifier { self.validate_paid_quote_issuer_k_closest(xorname, &issuer_peer_id) .await?; - self.validate_paid_quote_price_floor(candidate.quote)?; Self::validate_paid_quote_signature(candidate).await?; @@ -913,32 +991,6 @@ impl PaymentVerifier { Ok(expected_peer_id) } - fn validate_paid_quote_price_floor(&self, quote: &PaymentQuote) -> Result<()> { - let Some(current_records) = self.current_records_stored() else { - return Err(Error::Payment( - "PaymentVerifier: no record-count source attached; cannot verify \ - paid-quote local price floor" - .to_string(), - )); - }; - - let current_price = calculate_price(usize::try_from(current_records).unwrap_or(usize::MAX)); - let min_acceptable_price = price_floor(current_price, PAID_QUOTE_PRICE_FLOOR_TOLERANCE_PCT); - - if quote.price < min_acceptable_price { - let quoted_records = derive_records_stored_from_price(quote.price); - return Err(Error::Payment(format!( - "Paid quote price below local floor: quoted price encodes \ - {quoted_records} records but node currently holds {current_records} \ - (quoted {}, minimum acceptable {min_acceptable_price} at \ - {PAID_QUOTE_PRICE_FLOOR_TOLERANCE_PCT}% under-payment tolerance)", - quote.price - ))); - } - - Ok(()) - } - async fn validate_paid_quote_issuer_k_closest( &self, xorname: &XorName, @@ -1036,6 +1088,660 @@ impl PaymentVerifier { Ok(()) } + /// ADR-0004: enforce that every quoted price lies exactly on the public + /// pricing curve. + /// + /// **Scope** (this slice): canonicality only. The gate proves the price is + /// some `calculate_price(n)` for a non-negative integer `n`; it does NOT + /// yet prove `n` matches a signed commitment, because `PaymentQuote` lives + /// in evmlib (crates.io) and has no `claimed_key_count` / `commitment_pin` + /// fields yet. A future slice will bind `n` to a signed commitment once + /// the evmlib quote payload is extended. Until then, an attacker can still + /// quote `calculate_price(fake_n)` for any fake count and pass this gate; + /// what dies here is the strictly weaker attack of picking a price *off* + /// the curve altogether. + /// + /// **Check**: exact recomputation, never price-inversion. We derive the + /// candidate `n` for which `quote.price` would be the curve value (using + /// the existing inverse `derive_records_stored_from_price`, which floors), + /// then recompute `calculate_price(n)` and require strict equality. + /// On-curve prices round-trip exactly; off-curve prices floor to a smaller + /// `n` whose recomputed value is strictly less than `quote.price` and so + /// are rejected. Floor-then-equality is the canonicality test the ADR + /// specifies; price inversion alone would silently accept any value + /// between two curve points. + /// + /// **Where it runs**: in every [`VerificationContext`] over **every** + /// quote in **both** quote types — all 7 single-node quotes + /// ([`Self::validate_quote_arithmetic`]) and all 16 merkle candidates + /// ([`Self::validate_merkle_candidate_arithmetic`]) — because the rule + /// "every storer re-runs the price-equals-formula-of-count check on every + /// quote in the bundle" (ADR-0004) needs no peer-specific state and depends + /// only on the bundle itself, so every honest storer reaches the same + /// verdict with no split-brain risk. + /// + /// **Reject-only**, per ADR-0004: no trust evidence is emitted, no audit + /// is scheduled. The rejection is the consequence. The gate is + /// rollout-gated by + /// [`crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED`]; when + /// `false`, off-curve quotes are accepted and only telemetered + /// ([`Self::log_off_curve_single_node`] / + /// [`Self::log_off_curve_merkle`]), matching ADR-0004's observe-only + /// rollout. Telemetry is invoked **after** ML-DSA-65 signature + /// verification so unauthenticated senders cannot poison the rollout + /// logs. + fn validate_quote_arithmetic(payment: &ProofOfPayment) -> Result<()> { + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return Ok(()); + } + for (encoded_peer_id, quote) in &payment.peer_quotes { + if let Some(detail) = Self::quote_arithmetic_violation(quote) { + return Err(Error::Payment(format!( + "ADR-0004 off-curve quote rejected for peer {encoded_peer_id:?}: {detail}" + ))); + } + } + Ok(()) + } + + /// The ADR-0004 forced-price rule for a single quote, returning a human + /// diagnostic iff the quote violates it. Shared by single-node quotes and + /// merkle candidates via [`Self::binding_violation`]. + /// + /// The rule is the ADR's exact one — `price == calculate_price( + /// committed_key_count)`, recomputed, never inverted from the price (which + /// rounds) — PLUS a binding-shape check. There is no "legacy degradation" + /// that infers an old quote from its field values: a `(0, None)` quote is + /// rejected unless its price is exactly `calculate_price(0)`, closing the + /// bypass where a modified node strips the pin yet prices above baseline. + /// Old-format quotes (which never carried these fields) are tolerated only + /// at the *wire-decode* layer, where they decode as `(0, None)` and are then + /// held to the same baseline rule; an explicit version negotiation, not + /// field inference, is the sanctioned path if non-baseline legacy quotes + /// must ever be accepted. + fn quote_arithmetic_violation(quote: &evmlib::PaymentQuote) -> Option { + Self::binding_violation( + quote.committed_key_count, + quote.commitment_pin, + "e.price, + ) + } + + /// The shared ADR-0004 binding rule over a `(committed_key_count, + /// commitment_pin, price)` triple, used for both quote types. + /// + /// Enforces, in order: + /// 1. **Shape.** `(0, None)` baseline or `(n>0, Some(pin))` bound; the mixed + /// shapes `(n>0, None)` and `(0, Some(_))` are always rejected — a count + /// without a pin is unauditable, and a pin without a count is incoherent. + /// 2. **Cap.** `committed_key_count <= MAX_COMMITMENT_KEY_COUNT`; a count a + /// commitment could never legitimately attest is rejected before pricing. + /// 3. **Forced price.** `price == calculate_price(committed_key_count)`, by + /// exact recomputation. + fn binding_violation( + committed_key_count: u32, + commitment_pin: Option<[u8; 32]>, + price: &Amount, + ) -> Option { + match (committed_key_count, commitment_pin.is_some()) { + (0, false) | (1.., true) => {} + (1.., false) => { + return Some(format!( + "binding shape invalid: committed_key_count={committed_key_count} > 0 \ + but commitment_pin is None (unauditable count)" + )); + } + (0, true) => { + return Some( + "binding shape invalid: committed_key_count=0 with a commitment_pin \ + (incoherent baseline)" + .to_string(), + ); + } + } + if committed_key_count > crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT { + return Some(format!( + "committed_key_count={committed_key_count} exceeds MAX_COMMITMENT_KEY_COUNT={}", + crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT + )); + } + let expected = calculate_price(Self::candidate_count_to_usize(u64::from( + committed_key_count, + ))); + if &expected == price { + None + } else { + Some(format!( + "price {price} does not equal calculate_price(committed_key_count={committed_key_count}) = {expected}" + )) + } + } + + /// Pure ADR-0004 cross-check: compare a quote's claimed `(key_count, pin)` + /// against a resolved signed commitment. + /// + /// This is the decision core of "peers cross-check the original": given a + /// quote's binding and the actual `StorageCommitment` the pin was resolved + /// to (from the sidecar, the gossip cache, or a fetch), decide whether the + /// quote contradicts the commitment. It is deliberately a pure function over + /// the two artifacts so it is exhaustively unit-testable without any cache, + /// network, or trust wiring; the caller owns resolution and emission. + /// + /// Outcomes: + /// - [`CrossCheck::Match`] — the pin matches the commitment's hash and the + /// counts agree: nothing to report. + /// - [`CrossCheck::Mismatch`] — the pin matches the commitment's hash but + /// the quote's `committed_key_count` differs from the commitment's + /// `key_count`. Two artifacts signed by the same key contradict each + /// other: this is the deterministic, first-occurrence evidence. + /// - [`CrossCheck::PinDoesNotResolve`] — the supplied commitment's hash does + /// not equal the quote's pin (wrong/garbled resolution). NOT evidence: the + /// caller must treat it as an unresolved pin (fetch/skip), never a + /// penalty, exactly like an unanswerable pin. + /// + /// A baseline quote `(0, None)` is never cross-checked (it pins nothing); + /// callers skip it before reaching here. + fn cross_check_binding( + quoted_key_count: u32, + quoted_pin: [u8; 32], + commitment: &crate::replication::commitment::StorageCommitment, + ) -> CrossCheck { + // The pin IS the commitment hash; if the resolved commitment hashes to + // something else, this is not the artifact the quote pinned. + match crate::replication::commitment::commitment_hash(commitment) { + Some(h) if h == quoted_pin => { + if commitment.key_count == quoted_key_count { + CrossCheck::Match + } else { + CrossCheck::Mismatch { + quoted_key_count, + committed_key_count: commitment.key_count, + } + } + } + _ => CrossCheck::PinDoesNotResolve, + } + } + + /// Resolve a cached peer commitment record to its commitment *only if* it + /// was seen within the answerability TTL; a staler entry is treated as + /// unknown (ADR-0004: "a cached commitment older than the answerability TTL + /// is treated as unknown"). Pure over `(record, now, ttl)` so the TTL + /// boundary is unit-testable without the async cache/network path. + fn fresh_cached_commitment( + rec: &crate::replication::commitment_state::PeerCommitmentRecord, + pin: [u8; 32], + now: std::time::Instant, + ttl: std::time::Duration, + ) -> Option { + if now.saturating_duration_since(rec.received_at) >= ttl { + return None; // stale cache entry -> treat as unknown + } + // Only resolve when the cached commitment is actually the one the quote + // pinned. The auditor cache holds a peer's LATEST gossiped commitment, + // which may be a DIFFERENT pin than this quote's; returning it would make + // `cross_check_binding` yield `PinDoesNotResolve` and wrongly suppress + // the fetch fallback for the quoted pin. A pin mismatch here means "not + // cached" -> fall through to fetch. + if rec.commitment_hash() != Some(pin) { + return None; + } + rec.last_commitment().cloned() + } + + /// Resolve a `(peer, pin)` from the gossip commitment cache, if the cache is + /// wired and holds a fresh entry whose hash matches the pin. Shared by the + /// single-node and merkle cross-check paths. + async fn cache_resolve( + cache: Option<&CommitmentCache>, + peer_id: PeerId, + pin: [u8; 32], + now: std::time::Instant, + ttl: std::time::Duration, + ) -> Option { + let cache = cache?; + let guard = cache.read().await; + guard + .get(&peer_id) + .and_then(|rec| Self::fresh_cached_commitment(rec, pin, now, ttl)) + } + + /// Parse and validate ADR-0004 commitment sidecars into a `(peer, pin) -> + /// commitment` map. Each blob is deserialized and held to the SAME gates as + /// a gossip-ingested or fetched commitment (peer id derived from its own + /// `sender_peer_id`, `BLAKE3(pubkey) == sender_peer_id`, valid signature), + /// keyed by `(its own peer, its own hash)`. Resolution then matches a quote + /// only when both the quote's peer AND pin equal the sidecar's, so a sidecar + /// can never satisfy a different peer's or a different pin's quote. An + /// unparseable or invalid sidecar is silently skipped (resolution falls back + /// to gossip/fetch), never a hard error on the payment path. + fn index_valid_sidecars( + sidecars: &[Vec], + ) -> HashMap<(PeerId, [u8; 32]), crate::replication::commitment::StorageCommitment> { + use crate::replication::commitment::MAX_COMMITMENT_SIDECAR_BYTES; + let mut map = HashMap::new(); + // Bound the number of sidecars we even look at: a legitimate bundle has + // at most one commitment per quote/candidate. `MAX_SIDECARS_PER_BUNDLE` + // (= CANDIDATES_PER_POOL, the larger of the two) caps the deserialize/ + // verify work a malicious client can force on the hot path. + for blob in sidecars.iter().take(MAX_SIDECARS_PER_BUNDLE) { + // Cap blob size before parsing: never attempt to deserialize an + // oversized commitment. + if blob.len() > MAX_COMMITMENT_SIDECAR_BYTES { + continue; + } + let Ok(commitment) = + rmp_serde::from_slice::(blob) + else { + continue; // unparseable -> skip + }; + let peer_id = PeerId::from_bytes(commitment.sender_peer_id); + let Some(pin) = crate::replication::commitment::commitment_hash(&commitment) else { + continue; + }; + // Validate against its own (peer, pin): peer binding + pubkey + // derivation + signature + hash==pin. + if Self::fetched_commitment_is_valid(&commitment, &peer_id, pin) { + map.insert((peer_id, pin), commitment); + } + } + map + } + + /// ADR-0004 "peers cross-check the original": for each non-baseline quote in + /// a client-put bundle, resolve its `commitment_pin` and report a count/pin + /// contradiction. + /// + /// Resolution order: the sidecar first (the commitment arrived with the + /// quote, validated synchronously — no state, no network), then the gossip + /// cache (only if the neighbour's commitment was seen within + /// `GOSSIP_ANSWERABILITY_TTL`; a staler entry is treated as unknown, per the + /// ADR's "cached commitment older than the answerability TTL is treated as + /// unknown"), then an off-hot-path `GetCommitmentByPin` fetch for pins still + /// unresolved. A pin that resolves nowhere is simply skipped — an unresolved + /// pin is never a penalty. + /// + /// A genuine [`CrossCheck::Mismatch`] is a deterministic, first-occurrence + /// contradiction between two same-key-signed artifacts: when enforcing, it + /// emits [`FailureEvidence::QuoteCommitmentMismatch`] to the trust engine + /// (same lane as a confirmed deterministic audit failure — NOT the timeout + /// silence lane); when observe-only, it only logs. Always best-effort: a + /// missing cache or absent `P2PNode` degrades to "resolve nothing", never an + /// error on the payment path — the synchronous arithmetic gate and the + /// later audit remain the load-bearing checks. + async fn cross_check_quotes(&self, payment: &ProofOfPayment, commitment_sidecars: &[Vec]) { + let now = std::time::Instant::now(); + let ttl = crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL; + let p2p = self.p2p_node.read().as_ref().map(Arc::clone); + let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); + let cache = self.commitment_cache.read().as_ref().map(Arc::clone); + + // ADR-0004 "the commitment arrived with the quote": parse and FULLY + // validate the sidecars (peer/pubkey/signature/hash gates, keyed by + // `(peer, pin)`), so the cross-check resolves synchronously without a + // gossip-cache hit or a post-payment fetch. An invalid sidecar is simply + // dropped (resolution falls back to gossip/fetch), never a hard error. + let sidecar_map = Self::index_valid_sidecars(commitment_sidecars); + + // Inline pass: resolve from the sidecar first, then the gossip cache + // (cheap, no network). Pins that don't resolve here are collected for + // the off-hot-path fetch. + let mut unresolved: Vec<(PeerId, [u8; 32], u32, Vec)> = Vec::new(); + for (encoded_peer_id, quote) in &payment.peer_quotes { + let Some(pin) = quote.commitment_pin else { + continue; // baseline quote pins nothing + }; + let peer_id = PeerId::from_bytes(*encoded_peer_id.as_bytes()); + + // ADR-0004: this commitment backed a payment — route it for a + // deterministic first audit (the drainer dedups by pin and respects + // the cooldown). Best-effort: a closed channel just means no first + // audit is scheduled, never an error on the payment path. + if let Some(ref tx) = monetized_pin_tx { + let _ = tx.send(crate::replication::MonetizedPinEvent { + peer: peer_id, + pin, + key_count: quote.committed_key_count, + quote_ts: quote.timestamp, + }); + } + // Resolution order: sidecar (synchronous, no state) -> gossip cache + // (fresh within TTL) -> fetch fallback (collected as unresolved). + let resolved = match sidecar_map.get(&(peer_id, pin)) { + Some(c) => Some(c.clone()), + None => Self::cache_resolve(cache.as_ref(), peer_id, pin, now, ttl).await, + }; + match resolved { + Some(commitment) => { + let artifact = rmp_serde::to_vec(quote).unwrap_or_default(); + Self::handle_cross_check( + &peer_id, + pin, + quote.committed_key_count, + artifact, + &commitment, + p2p.as_ref(), + ) + .await; + } + None => unresolved.push(( + peer_id, + pin, + quote.committed_key_count, + rmp_serde::to_vec(quote).unwrap_or_default(), + )), + } + } + + // Off-hot-path fallback: fetch the unresolved pins via + // `GetCommitmentByPin` and cross-check the results in a detached task, + // so `verify_payment` does not block on the network. + if unresolved.is_empty() { + return; + } + let Some(p2p) = p2p else { + return; // no P2P handle: cannot fetch, leave graced + }; + let neg_cache = Arc::clone(&self.pin_fetch_negative_cache); + tokio::spawn(async move { + Self::drain_unresolved_pin_fetches(&p2p, &neg_cache, unresolved).await; + }); + } + + /// Fetch each unresolved pin via `GetCommitmentByPin` and cross-check the + /// result. Bounded at [`MAX_PIN_FETCHES_PER_BUNDLE`] per call, negatively + /// cached per `(peer, pin)`, and graced on any miss/timeout. Shared by the + /// single-node and merkle cross-check paths; meant to run in a detached task + /// off the payment hot path. + async fn drain_unresolved_pin_fetches( + p2p: &Arc, + neg_cache: &PinFetchNegativeCache, + unresolved: Vec<(PeerId, [u8; 32], u32, Vec)>, + ) { + let mut fetched = 0usize; + for (peer_id, pin, quoted_key_count, artifact) in unresolved { + if fetched >= crate::replication::config::MAX_PIN_FETCHES_PER_BUNDLE { + debug!("ADR-0004 pin-fetch cap reached for this bundle; leaving rest graced"); + break; + } + // Skip pins already known-unresolvable for this peer. + if neg_cache.lock().get(&(peer_id, pin)).is_some() { + continue; + } + fetched += 1; + match Self::fetch_commitment_by_pin(p2p, &peer_id, pin).await { + Some(commitment) => { + Self::handle_cross_check( + &peer_id, + pin, + quoted_key_count, + artifact, + &commitment, + Some(p2p), + ) + .await; + } + None => { + // NotRetained / timeout / malformed: graced (never a + // penalty), but remembered so we don't re-fetch. + neg_cache.lock().put((peer_id, pin), ()); + } + } + } + } + + /// Apply the ADR-0004 cross-check verdict for one resolved `(peer, pin, + /// quoted_count)` against `commitment`, emitting a trust failure on a + /// genuine mismatch (when enforcing) or logging it (observe-only). Shared by + /// the inline cache pass and the background fetch path so both reach the + /// same verdict and emission. + async fn handle_cross_check( + peer_id: &PeerId, + pin: [u8; 32], + quoted_key_count: u32, + quote_artifact: Vec, + commitment: &crate::replication::commitment::StorageCommitment, + p2p: Option<&Arc>, + ) { + let CrossCheck::Mismatch { + quoted_key_count, + committed_key_count, + } = Self::cross_check_binding(quoted_key_count, pin, commitment) + else { + return; // Match or PinDoesNotResolve: nothing to report + }; + // The evidence is only meaningful if it carries the signed quote + // artifact (one of the two contradicting same-key signatures). An empty + // artifact — a re-serialization failure upstream — would produce + // non-portable, unverifiable evidence, so grace it (log) instead of + // emitting it: the deterministic first audit still convicts a genuine + // inflater on the disk bytes. + if quote_artifact.is_empty() { + warn!( + "ADR-0004 quote/commitment mismatch for {peer_id}: dropping evidence, \ + quote artifact failed to serialize (graced; the audit still runs)" + ); + return; + } + // Build the portable evidence variant — the two same-key-signed + // artifacts that contradict each other, carried in full so any third + // party can re-verify both signatures and recompute the contradiction. + // This value IS the record; `emit_mismatch_evidence` turns it into the + // trust action (or an observe-only log). + let evidence = crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer: *peer_id, + pinned_commitment: pin, + quoted_key_count, + committed_key_count, + quote_artifact, + commitment: Box::new(commitment.clone()), + }; + Self::emit_mismatch_evidence(&evidence, p2p).await; + } + + /// Route a `QuoteCommitmentMismatch` evidence record: when enforcing, report + /// it to the trust engine as a confirmed deterministic failure (an + /// `ApplicationFailure` — same lane as a confirmed audit failure, NOT the + /// timeout silence lane); when observe-only, only log it. Separated so the + /// evidence→action mapping is unit-testable independent of resolution. + async fn emit_mismatch_evidence( + evidence: &crate::replication::types::FailureEvidence, + p2p: Option<&Arc>, + ) { + let crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer, + quoted_key_count, + committed_key_count, + .. + } = evidence + else { + return; // only this variant is handled here + }; + let enforce = crate::replication::config::QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED; + if enforce { + warn!( + "ADR-0004 quote/commitment mismatch (enforcing) for {peer}: quote claims \ + {quoted_key_count} keys but pinned commitment attests {committed_key_count}" + ); + if let Some(p2p) = p2p { + p2p.report_trust_event( + peer, + saorsa_core::TrustEvent::ApplicationFailure( + crate::replication::config::AUDIT_FAILURE_TRUST_WEIGHT, + ), + ) + .await; + } + } else { + warn!( + "ADR-0004 quote/commitment mismatch observed (not enforcing) for {peer}: quote \ + claims {quoted_key_count} keys but pinned commitment attests {committed_key_count}" + ); + } + } + + /// Fetch a peer's commitment by pin via `GetCommitmentByPin`, returning it + /// only if the peer answered `Found` with a commitment that (a) is validly + /// signed and peer-bound and (b) actually hashes to the requested pin. + /// `None` on `NotRetained`, timeout, malformed, or any verification failure + /// — all graced (the caller never penalises an unresolved pin). + async fn fetch_commitment_by_pin( + p2p: &Arc, + peer_id: &PeerId, + pin: [u8; 32], + ) -> Option { + use crate::replication::config::{PIN_FETCH_TIMEOUT, REPLICATION_PROTOCOL_ID}; + use crate::replication::protocol::{ + GetCommitmentByPin, GetCommitmentByPinResponse, ReplicationMessage, + ReplicationMessageBody, + }; + let msg = ReplicationMessage { + request_id: 0, + body: ReplicationMessageBody::GetCommitmentByPin(GetCommitmentByPin { pin }), + }; + let encoded = msg.encode().ok()?; + let resp = p2p + .send_request(peer_id, REPLICATION_PROTOCOL_ID, encoded, PIN_FETCH_TIMEOUT) + .await + .ok()?; + let decoded = ReplicationMessage::decode(&resp.data).ok()?; + let ReplicationMessageBody::GetCommitmentByPinResponse(GetCommitmentByPinResponse::Found { + commitment, + }) = decoded.body + else { + return None; // NotRetained / unexpected -> graced + }; + Self::fetched_commitment_is_valid(&commitment, peer_id, pin).then_some(commitment) + } + + /// The untrusted-fetched-commitment validation gates, pure over + /// `(commitment, peer_id, pin)` so they are unit-testable. A fetched + /// commitment is accepted only if it passes the SAME gates as a gossip + /// ingest, so a peer cannot answer with another peer's (validly signed) + /// commitment and have it pass as its own: + /// (a) it is bound to THIS peer (`sender_peer_id == peer_id`), + /// (b) the embedded pubkey derives that peer id (`BLAKE3(pk) == id`), + /// (c) its signature is valid (binds the pubkey), + /// (d) it actually hashes to the pin we asked for. + fn fetched_commitment_is_valid( + commitment: &crate::replication::commitment::StorageCommitment, + peer_id: &PeerId, + pin: [u8; 32], + ) -> bool { + commitment.sender_peer_id == *peer_id.as_bytes() + && *blake3::hash(&commitment.sender_public_key).as_bytes() == commitment.sender_peer_id + && crate::replication::commitment::verify_commitment_signature(commitment) + && crate::replication::commitment::commitment_hash(commitment) == Some(pin) + } + + /// Single-node telemetry for off-curve quotes. Always returns; never + /// errors. MUST be called only after ML-DSA-65 signature verification has + /// passed, so unauthenticated peers cannot drive log volume. + fn log_off_curve_single_node(payment: &ProofOfPayment) { + if crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return; // enforce mode already rejected; no separate telemetry. + } + for (encoded_peer_id, quote) in &payment.peer_quotes { + if let Some(detail) = Self::quote_arithmetic_violation(quote) { + warn!( + "ADR-0004 off-curve single-node quote observed (not enforcing): \ + peer {encoded_peer_id:?} {detail}" + ); + } + } + } + + /// ADR-0004 sister gate for the merkle batch path: every candidate's + /// `price` field must lie on the pricing curve, by exact recomputation. + /// See [`Self::validate_quote_arithmetic`] for the rationale; semantics + /// (reject-only, rollout-gated, no trust evidence) are identical. + fn validate_merkle_candidate_arithmetic( + pool: &evmlib::merkle_payments::MerklePaymentCandidatePool, + ) -> Result<()> { + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return Ok(()); + } + for candidate in &pool.candidate_nodes { + if let Some(detail) = Self::binding_violation( + candidate.committed_key_count, + candidate.commitment_pin, + &candidate.price, + ) { + return Err(Error::Payment(format!( + "ADR-0004 merkle candidate rejected (reward {}): {detail}", + candidate.reward_address + ))); + } + } + Ok(()) + } + + /// Merkle batch telemetry for off-curve candidates. Always returns; never + /// errors. MUST be called only after ML-DSA-65 signature verification has + /// passed. + fn log_off_curve_merkle(pool: &evmlib::merkle_payments::MerklePaymentCandidatePool) { + if crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + return; // enforce mode already rejected; no separate telemetry. + } + for candidate in &pool.candidate_nodes { + if let Some(detail) = Self::binding_violation( + candidate.committed_key_count, + candidate.commitment_pin, + &candidate.price, + ) { + warn!( + "ADR-0004 merkle candidate violation observed (not enforcing): \ + reward {} {detail}", + candidate.reward_address + ); + } + } + } + + /// Pure curve-canonicality predicate: does `price` lie exactly on the + /// pricing curve? Equivalent to "there exists some non-negative integer + /// `n` such that `calculate_price(n) == price`". + /// + /// Separated from the rollout-gated outer gates so the canonicality rule + /// itself is unit-testable independent of the gate. Callers MUST use this + /// and not `derive_records_stored_from_price` directly: the latter floors + /// and is not a canonicality test. + /// + /// Saturation: `derive_records_stored_from_price` saturates to `u64::MAX` + /// for prices beyond `calculate_price(u64::MAX)`, and + /// [`Self::candidate_count_to_usize`] saturates to `usize::MAX` on 32-bit + /// targets. Both saturation regimes converge on `calculate_price`'s own + /// saturation ceiling; an honest in-range price (which can never approach + /// these regions — `MAX_COMMITMENT_KEY_COUNT` is `1_000_000`) round-trips + /// exactly. + #[allow(dead_code)] // boolean convenience for tests + follow-up slices + fn quote_price_is_on_curve(price: &Amount) -> bool { + Self::price_off_curve_diagnostics(price).is_none() + } + + /// Returns `Some((candidate_count, recomputed))` iff `price` is off-curve; + /// `None` iff `price` is on-curve. The tuple is the diagnostic detail used + /// by both the rejection error message and the telemetry warning. + fn price_off_curve_diagnostics(price: &Amount) -> Option<(u64, Amount)> { + let candidate_count = derive_records_stored_from_price(*price); + let recomputed = calculate_price(Self::candidate_count_to_usize(candidate_count)); + if recomputed == *price { + None + } else { + Some((candidate_count, recomputed)) + } + } + + /// Narrow the canonicality predicate's `u64` candidate into `usize` for + /// [`calculate_price`]. On every 64-bit target (the only supported + /// production target) this is the identity; on 32-bit targets we saturate + /// to `usize::MAX`, which matches `calculate_price`'s own + /// `Amount::saturating_mul` behaviour so the round-trip still terminates + /// in the same saturation regime rather than panicking. + fn candidate_count_to_usize(candidate_count: u64) -> usize { + usize::try_from(candidate_count).unwrap_or(usize::MAX) + } + /// Minimum number of candidate `pub_keys` (out of 16) whose derived /// `PeerId` must be among the DHT's actual closest peers to the pool /// midpoint address for the pool to be accepted. @@ -1565,6 +2271,15 @@ impl PaymentVerifier { } } + // ADR-0004: every storer re-runs the price-equals-formula-of-count + // check on every merkle candidate, in every context, before median + // reconstruction. Runs AFTER signature verification so observe-only + // telemetry cannot be spoofed by unauthenticated senders. Reject-only + // when enforcement is enabled; no trust evidence emitted in either + // mode. + Self::validate_merkle_candidate_arithmetic(&merkle_proof.winner_pool)?; + Self::log_off_curve_merkle(&merkle_proof.winner_pool); + // Pay-yourself defence: the candidate pub_keys must map to peers the // live DHT actually considers closest to the pool midpoint. Without // this, an attacker can point all 16 reward_address fields at a @@ -1743,8 +2458,99 @@ impl PaymentVerifier { ); } + // ADR-0004: route the merkle-batch candidates through the SAME + // cross-check + first-audit funnel as single-node quotes, AFTER on-chain + // verification has succeeded (so an unpaid pool cannot drive audits or + // fetches). ClientPut only — a replication receipt's pins have aged out. + if context == VerificationContext::ClientPut { + self.cross_check_merkle_candidates( + &merkle_proof.winner_pool, + &merkle_proof.commitment_sidecars, + ) + .await; + } + Ok(()) } + + /// ADR-0004 cross-check for the merkle-batch path: every candidate carries + /// the same signed `(committed_key_count, commitment_pin)` binding as a + /// single-node quote, so each non-baseline candidate is resolved against the + /// gossip cache (or fetched) and routed into the deterministic first audit, + /// exactly like [`Self::cross_check_quotes`]. The candidate's peer id is + /// derived from its `pub_key` (`PeerId = BLAKE3(pub_key)`), matching how the + /// network binds identities. + async fn cross_check_merkle_candidates( + &self, + pool: &evmlib::merkle_payments::MerklePaymentCandidatePool, + commitment_sidecars: &[Vec], + ) { + let now = std::time::Instant::now(); + let ttl = crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL; + let p2p = self.p2p_node.read().as_ref().map(Arc::clone); + let monetized_pin_tx = self.monetized_pin_tx.read().as_ref().cloned(); + let cache = self.commitment_cache.read().as_ref().map(Arc::clone); + // ADR-0004 "the commitment arrived with the quote" for the merkle path: + // validate sidecars exactly as the single-node path does. + let sidecar_map = Self::index_valid_sidecars(commitment_sidecars); + + let mut unresolved: Vec<(PeerId, [u8; 32], u32, Vec)> = Vec::new(); + for candidate in &pool.candidate_nodes { + let Some(pin) = candidate.commitment_pin else { + continue; // baseline candidate pins nothing + }; + let peer_id = PeerId::from_bytes(*blake3::hash(&candidate.pub_key).as_bytes()); + + if let Some(ref tx) = monetized_pin_tx { + let _ = tx.send(crate::replication::MonetizedPinEvent { + peer: peer_id, + pin, + key_count: candidate.committed_key_count, + quote_ts: std::time::UNIX_EPOCH + .checked_add(std::time::Duration::from_secs( + candidate.merkle_payment_timestamp, + )) + .unwrap_or(std::time::UNIX_EPOCH), + }); + } + + let resolved = match sidecar_map.get(&(peer_id, pin)) { + Some(c) => Some(c.clone()), + None => Self::cache_resolve(cache.as_ref(), peer_id, pin, now, ttl).await, + }; + match resolved { + Some(commitment) => { + let artifact = rmp_serde::to_vec(candidate).unwrap_or_default(); + Self::handle_cross_check( + &peer_id, + pin, + candidate.committed_key_count, + artifact, + &commitment, + p2p.as_ref(), + ) + .await; + } + None => unresolved.push(( + peer_id, + pin, + candidate.committed_key_count, + rmp_serde::to_vec(candidate).unwrap_or_default(), + )), + } + } + + if unresolved.is_empty() { + return; + } + let Some(p2p) = p2p else { + return; + }; + let neg_cache = Arc::clone(&self.pin_fetch_negative_cache); + tokio::spawn(async move { + Self::drain_unresolved_pin_fetches(&p2p, &neg_cache, unresolved).await; + }); + } } #[cfg(test)] @@ -1794,6 +2600,8 @@ mod tests { timestamp: SystemTime::now(), price, rewards_address: RewardsAddress::new([rewards_seed; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: pub_key_bytes, signature: Vec::new(), }; @@ -2096,6 +2904,35 @@ mod tests { ); } + /// Regression pin for the DEV-01 (2026-07-06) outage: a merkle proof that + /// ships all 16 ADR-0004 commitment sidecars serializes to ~342 KB, and the + /// old 256 KB cap size-rejected every merkle PUT as soon as nodes carried + /// live commitments. The cap must keep accepting that legacy client shape; + /// a proof of that size must fail on content, never on size. + #[tokio::test] + async fn test_sidecar_bearing_merkle_proof_size_is_accepted() { + const LEGACY_SIDECAR_PROOF_BYTES: usize = 342_171; + + let verifier = create_test_verifier(); + let xorname = [6u8; 32]; + + let legacy_sized_proof = vec![0xFFu8; LEGACY_SIDECAR_PROOF_BYTES]; + let result = verifier + .verify_payment( + &xorname, + Some(&legacy_sized_proof), + VerificationContext::ClientPut, + ) + .await; + assert!(result.is_err()); + let err_msg = format!("{}", result.expect_err("should fail")); + assert!( + !err_msg.contains("too large"), + "A {LEGACY_SIDECAR_PROOF_BYTES}-byte proof must not be size-rejected \ + (sidecar-bearing merkle proofs from pre-fix clients): {err_msg}" + ); + } + #[tokio::test] async fn test_malformed_single_node_proof() { let verifier = create_test_verifier(); @@ -2118,7 +2955,6 @@ mod tests { #[tokio::test] async fn test_legacy_paid_median_full_path_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xA1u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); mark_k_closest_paid_candidates(&verifier, &peer_quotes); @@ -2144,7 +2980,6 @@ mod tests { #[tokio::test] async fn test_legacy_single_quote_proof_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xB1u8; 32]; let (peer_id, quote) = make_signed_quote(xorname, price_at_records(0), 1); let peer_quotes = vec![(peer_id, quote.clone())]; @@ -2165,7 +3000,6 @@ mod tests { #[tokio::test] async fn test_legacy_single_quote_proof_requires_three_x_payment() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xB2u8; 32]; let (peer_id, quote) = make_signed_quote(xorname, price_at_records(0), 1); let peer_quotes = vec![(peer_id, quote.clone())]; @@ -2187,7 +3021,6 @@ mod tests { #[tokio::test] async fn test_legacy_too_many_quotes_rejected() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xB3u8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes.push(make_signed_quote(xorname, price_at_records(7), 8)); @@ -2207,7 +3040,6 @@ mod tests { #[tokio::test] async fn test_legacy_structural_majority_price_at_median_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(1000); let xorname = [0xA2u8; 32]; let peer_quotes = make_signed_legacy_bundle( xorname, @@ -2241,48 +3073,9 @@ mod tests { ); } - #[tokio::test] - async fn test_legacy_above_median_verifier_rejected_by_floor() { - let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(2000); - let xorname = [0xA3u8; 32]; - let peer_quotes = make_signed_legacy_bundle( - xorname, - [ - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(100), - crate::payment::pricing::calculate_price(500), - crate::payment::pricing::calculate_price(1000), - crate::payment::pricing::calculate_price(2000), - crate::payment::pricing::calculate_price(4000), - crate::payment::pricing::calculate_price(6000), - ], - ); - mark_k_closest_paid_candidates(&verifier, &peer_quotes); - let expected_amount = expected_median_payment(&peer_quotes); - let paid_quote = median_test_candidates(&peer_quotes) - .first() - .expect("median candidate") - .1 - .clone(); - mark_candidate_paid(&verifier, &paid_quote, expected_amount); - - let proof_bytes = serialize_proof(peer_quotes); - let err = verifier - .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) - .await - .expect_err("above-median verifier should reject the client PUT"); - - assert!( - format!("{err}").contains("below local floor"), - "Error should mention paid-quote floor: {err}" - ); - } - #[tokio::test] async fn test_legacy_paid_median_issuer_k_closest_rejection() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); verifier.set_paid_quote_k_closest_for_tests(vec![rand::random()]); let xorname = [0xA4u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); @@ -2306,48 +3099,9 @@ mod tests { ); } - #[tokio::test] - async fn test_legacy_paid_median_floor_rejection() { - let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(6000); - let xorname = [0xA5u8; 32]; - let peer_quotes = make_signed_legacy_bundle( - xorname, - [ - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - ], - ); - mark_k_closest_paid_candidates(&verifier, &peer_quotes); - let expected_amount = expected_median_payment(&peer_quotes); - let paid_quote = median_test_candidates(&peer_quotes) - .first() - .expect("median candidate") - .1 - .clone(); - mark_candidate_paid(&verifier, &paid_quote, expected_amount); - - let proof_bytes = serialize_proof(peer_quotes); - let err = verifier - .verify_payment(&xorname, Some(&proof_bytes), VerificationContext::ClientPut) - .await - .expect_err("cheap paid median should be rejected"); - - assert!( - format!("{err}").contains("below local floor"), - "Error should mention local floor: {err}" - ); - } - #[tokio::test] async fn test_legacy_zero_price_median_rejected() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xA6u8; 32]; let peer_quotes = make_signed_legacy_bundle( xorname, @@ -2377,7 +3131,6 @@ mod tests { #[tokio::test] async fn test_legacy_paid_quote_content_mismatch_rejected() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xA7u8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let median_index = median_quote_index(peer_quotes.len()); @@ -2399,7 +3152,6 @@ mod tests { #[tokio::test] async fn test_legacy_unpaid_quote_content_mismatch_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xA8u8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].1.content = xor_name::XorName([0xE8u8; 32]); @@ -2426,7 +3178,6 @@ mod tests { #[tokio::test] async fn test_legacy_paid_quote_bad_signature_rejected() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xA9u8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); let median_index = median_quote_index(peer_quotes.len()); @@ -2455,7 +3206,6 @@ mod tests { #[tokio::test] async fn test_legacy_unpaid_quote_bad_signature_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xAAu8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].1.signature.push(0xFF); @@ -2482,7 +3232,6 @@ mod tests { #[tokio::test] async fn test_legacy_unpaid_peer_binding_mismatch_accepted() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xABu8; 32]; let mut peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); peer_quotes[0].0 = evmlib::EncodedPeerId::new(rand::random()); @@ -2509,7 +3258,6 @@ mod tests { #[tokio::test] async fn test_legacy_median_tie_accepts_paid_candidate() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); let xorname = [0xACu8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, tied_median_test_prices()); mark_k_closest_paid_candidates(&verifier, &peer_quotes); @@ -2536,7 +3284,6 @@ mod tests { #[tokio::test] async fn test_legacy_paid_list_admission_enforces_issuer_k_closest() { let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(0); verifier.set_paid_quote_k_closest_for_tests(Vec::new()); let xorname = [0xB5u8; 32]; let peer_quotes = make_signed_legacy_bundle(xorname, unique_test_prices()); @@ -2564,48 +3311,6 @@ mod tests { ); } - #[tokio::test] - async fn test_legacy_paid_list_admission_enforces_full_bundle_floor() { - let verifier = create_test_verifier(); - verifier.set_records_stored_for_tests(6000); - let xorname = [0xB6u8; 32]; - let peer_quotes = make_signed_legacy_bundle( - xorname, - [ - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - crate::payment::pricing::calculate_price(0), - ], - ); - mark_k_closest_paid_candidates(&verifier, &peer_quotes); - let expected_amount = expected_median_payment(&peer_quotes); - let paid_quote = median_test_candidates(&peer_quotes) - .first() - .expect("median candidate") - .1 - .clone(); - mark_candidate_paid(&verifier, &paid_quote, expected_amount); - - let proof_bytes = serialize_proof(peer_quotes); - let err = verifier - .verify_payment( - &xorname, - Some(&proof_bytes), - VerificationContext::PaidListAdmission, - ) - .await - .expect_err("paid-list admission must enforce the floor for full bundles"); - - assert!( - format!("{err}").contains("below local floor"), - "Error should mention the local price floor: {err}" - ); - } - #[test] fn test_cache_len_getter() { let verifier = create_test_verifier(); @@ -2710,6 +3415,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![FixedBytes::from([0xABu8; 32])], + commitment_sidecars: vec![], }; let proof_bytes = @@ -2748,6 +3454,8 @@ mod tests { timestamp: SystemTime::now(), price: Amount::from(1u64), rewards_address: RewardsAddress::new([1u8; 20]), + committed_key_count: 0, + commitment_pin: None, pub_key: vec![0u8; 64], signature: vec![0u8; 64], }; @@ -2761,6 +3469,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![], + commitment_sidecars: vec![], }; let proof_bytes = serialize_single_node_proof(&proof).expect("serialize proof"); @@ -2794,11 +3503,27 @@ mod tests { timestamp, price: Amount::from(1u64), rewards_address, + committed_key_count: 0, + commitment_pin: None, pub_key: vec![0u8; 64], signature: vec![0u8; 64], } } + /// Helper: create a fake quote priced on-curve at `records` stored records + /// (price = `calculate_price(records)`), reusing [`make_fake_quote`] for the + /// remaining fields. Used by the ADR-0004 arithmetic-gate tests. + fn make_fake_quote_at_records( + xorname: [u8; 32], + timestamp: SystemTime, + rewards_address: RewardsAddress, + records: usize, + ) -> evmlib::PaymentQuote { + let mut quote = make_fake_quote(xorname, timestamp, rewards_address); + quote.price = crate::payment::pricing::calculate_price(records); + quote + } + /// Helper: wrap quotes into a tagged serialized `PaymentProof`. fn serialize_proof(peer_quotes: Vec<(evmlib::EncodedPeerId, evmlib::PaymentQuote)>) -> Vec { use crate::payment::proof::{serialize_single_node_proof, PaymentProof}; @@ -2806,6 +3531,7 @@ mod tests { let proof = PaymentProof { proof_of_payment: ProofOfPayment { peer_quotes }, tx_hashes: vec![], + commitment_sidecars: vec![], }; serialize_single_node_proof(&proof).expect("serialize proof") } @@ -3141,6 +3867,7 @@ mod tests { peer_quotes: peer_quotes.clone(), }, tx_hashes: vec![], + commitment_sidecars: vec![], }; let tagged_bytes = serialize_single_node_proof(&proof).expect("serialize tagged proof"); @@ -3410,7 +4137,13 @@ mod tests { let price = evmlib::common::Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); + let msg = MerklePaymentCandidateNode::bytes_to_sign( + &price, + &reward_address, + timestamp, + 0, + &None, + ); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -3419,6 +4152,8 @@ mod tests { price, reward_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } }) @@ -4161,4 +4896,464 @@ mod tests { other => panic!("expected sparse-DHT rejection, got: {other:?}"), } } + + // ---------- ADR-0004: quote arithmetic re-check ---------- + + /// Curve canonicality: any price produced by `calculate_price(n)` is + /// on-curve by construction. We exercise a spread of `n` covering the + /// baseline floor (n=0), small counts, the pricing-curve knee + /// (`n=PRICING_DIVISOR=6000`), and a saturating-arithmetic regime. + #[test] + fn adr0004_on_curve_prices_round_trip() { + for &n in &[0usize, 1, 2, 100, 5999, 6000, 6001, 50_000, 1_000_000] { + let price = crate::payment::pricing::calculate_price(n); + assert!( + PaymentVerifier::quote_price_is_on_curve(&price), + "calculate_price({n}) = {price} must be on-curve" + ); + } + } + + /// Off-curve canonicality: a price one wei above or below an on-curve + /// point is between two adjacent curve values and must fail the + /// canonicality predicate. The check IS exact equality, not a tolerance. + #[test] + fn adr0004_off_curve_prices_rejected_by_predicate() { + // n=100 is well above baseline so price ± 1 is non-saturating. + let on = crate::payment::pricing::calculate_price(100); + let just_above = on + Amount::from(1u64); + let just_below = on - Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&just_above), + "price one wei above an on-curve point must be off-curve" + ); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&just_below), + "price one wei below an on-curve point must be off-curve" + ); + } + + /// A price strictly below the baseline floor is off-curve: the formula's + /// minimum value is `calculate_price(0) = BASELINE`, so any smaller value + /// has no corresponding `n`. + #[test] + fn adr0004_sub_baseline_price_is_off_curve() { + let baseline = crate::payment::pricing::calculate_price(0); + let sub_baseline = baseline - Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&sub_baseline), + "price strictly below baseline must be off-curve" + ); + } + + /// ADR-0004 storer-side gate: a bundle in which every quote is on-curve + /// passes the gate **and** the per-quote canonicality predicate. Runs in + /// every context (no `ClientPut` split): the rule depends only on the + /// bundle, not on per-peer state. The outer `validate_quote_arithmetic` + /// short-circuits to `Ok` under the observe-only rollout const, so the + /// per-quote diagnostics assertion is what proves the bundle is genuinely + /// on-curve regardless of how the const ships. + #[test] + fn adr0004_validate_quote_arithmetic_passes_for_honest_bundle() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let payment = ProofOfPayment { + peer_quotes: (0..crate::ant_protocol::CLOSE_GROUP_SIZE) + .map(|i| { + let id: [u8; 32] = rand::random(); + let byte = u8::try_from(i & 0xFF).unwrap_or(0); + let quote = make_fake_quote_at_records( + [0xC0u8; 32], + SystemTime::now(), + RewardsAddress::new([byte; 20]), + 100 * (i + 1), + ); + (EncodedPeerId::new(id), quote) + }) + .collect(), + }; + PaymentVerifier::validate_quote_arithmetic(&payment) + .expect("honest on-curve bundle must pass the gate (any const value)"); + for (_, quote) in &payment.peer_quotes { + assert!( + PaymentVerifier::price_off_curve_diagnostics("e.price).is_none(), + "every quote in honest bundle must be canonically on-curve" + ); + } + } + + /// Off-curve quote behaviour follows the rollout gate + /// [`QUOTE_ARITHMETIC_RECHECK_ENABLED`]. We assert the gate's current + /// observe-only stance: an off-curve quote is accepted with no error. + /// The enforcement-branch behaviour is exercised separately by + /// `adr0004_off_curve_diagnostics_yields_reject_payload` so both branches + /// of the const-gated split are covered in CI. + #[test] + fn adr0004_observe_only_does_not_reject_off_curve_quote() { + use evmlib::{EncodedPeerId, RewardsAddress}; + + let mut quote = make_fake_quote_at_records( + [0xC1u8; 32], + SystemTime::now(), + RewardsAddress::new([1u8; 20]), + 100, + ); + // Bump one wei off the curve. + quote.price += Amount::from(1u64); + + let id: [u8; 32] = rand::random(); + let payment = ProofOfPayment { + peer_quotes: vec![(EncodedPeerId::new(id), quote)], + }; + + // This test is only meaningful in the observe-only configuration + // (which is the default at slice ship). If a future change flips the + // const, the assertion documents the regression instead of silently + // changing semantics. + if !crate::replication::config::QUOTE_ARITHMETIC_RECHECK_ENABLED { + assert!( + PaymentVerifier::validate_quote_arithmetic(&payment).is_ok(), + "observe-only rollout must not reject off-curve quotes" + ); + } + } + + /// Enforcement-branch coverage: the rejection payload (peer id, candidate + /// `n`, recomputed price) is produced for off-curve prices independently + /// of the rollout const, so CI exercises the rejection code path even + /// while [`QUOTE_ARITHMETIC_RECHECK_ENABLED`] ships as `false`. Flipping + /// the const to `true` then merely wires this diagnostic into the outer + /// `Err` return, which is what `validate_quote_arithmetic` does. + #[test] + fn adr0004_off_curve_diagnostics_yields_reject_payload() { + let on = crate::payment::pricing::calculate_price(100); + let off = on + Amount::from(1u64); + + let diag = PaymentVerifier::price_off_curve_diagnostics(&off) + .expect("off-curve price must produce diagnostics"); + let (candidate_count, recomputed) = diag; + assert_eq!(candidate_count, 100, "floor candidate must be n=100"); + assert_eq!( + recomputed, on, + "recomputed price must equal the floor curve point" + ); + assert!( + recomputed < off, + "off-curve diagnostics' recomputed price must be strictly below the off-curve input" + ); + + // And an on-curve price must produce no diagnostics. + assert!( + PaymentVerifier::price_off_curve_diagnostics(&on).is_none(), + "on-curve price must yield no off-curve diagnostics" + ); + } + + /// Saturation regime: a price strictly above + /// `calculate_price(u64::MAX-equivalent saturating ceiling)` is rejected. + /// We do not have direct access to that ceiling, but `Amount::MAX` is + /// guaranteed above it (since `calculate_price(usize::MAX)` saturates to + /// some value strictly less than `Amount::MAX` due to the additive + /// baseline). The gate must reject it. + #[test] + fn adr0004_amount_max_price_is_off_curve() { + let price = Amount::MAX; + assert!( + !PaymentVerifier::quote_price_is_on_curve(&price), + "Amount::MAX must not be a valid on-curve price" + ); + } + + /// Merkle gate, predicate-level: the same canonicality rule applies to + /// `MerklePaymentCandidateNode.price`. We don't construct a full merkle + /// proof here (the test fixtures for that live elsewhere); we prove the + /// underlying decision matches the single-node side, so the Merkle gate + /// inherits the same correctness as `validate_quote_arithmetic`. + #[test] + fn adr0004_merkle_candidate_canonicality_matches_single_node() { + // Every on-curve `n` produces a price the predicate accepts; one wei + // off produces a price the predicate rejects. This is the entire + // contract; the Merkle gate's outer wrapper enforces the same const + // as the single-node gate, so the wrappers are mechanically + // equivalent. + for &n in &[0usize, 1, 100, 6000, 1_000_000] { + let on = crate::payment::pricing::calculate_price(n); + assert!( + PaymentVerifier::quote_price_is_on_curve(&on), + "merkle candidate price for n={n} must be on-curve" + ); + if n > 0 { + let off = on + Amount::from(1u64); + assert!( + !PaymentVerifier::quote_price_is_on_curve(&off), + "merkle candidate price one wei above n={n} must be off-curve" + ); + } + } + } + + /// Merkle gate, pool-level: build a real signed candidate pool, set every + /// candidate's price to the same on-curve value, and assert the gate + /// passes. Then bump one candidate's price one wei off-curve and assert + /// the per-candidate diagnostics correctly identify it. We use the + /// diagnostics predicate rather than the outer `validate_merkle_candidate_arithmetic` + /// because the outer wrapper short-circuits to `Ok` under the observe-only + /// rollout const; the diagnostics path is what carries the rejection + /// information when enforcement flips on. + #[test] + fn adr0004_merkle_pool_off_curve_candidate_caught_by_diagnostics() { + use evmlib::merkle_payments::MerklePaymentCandidatePool; + + let timestamp = 1_700_000_000u64; + let mut candidates = make_candidate_nodes(timestamp); + + // Set every candidate price to calculate_price(500) so the pool is + // honestly on-curve to start. + let on_curve = crate::payment::pricing::calculate_price(500); + for c in &mut candidates { + c.price = on_curve; + } + let pool = MerklePaymentCandidatePool { + midpoint_proof: fake_midpoint_proof(), + candidate_nodes: candidates, + }; + // The outer wrapper is rollout-gated, but a fully on-curve pool must + // pass it under any const value because the loop finds no off-curve + // candidate to reject. + PaymentVerifier::validate_merkle_candidate_arithmetic(&pool) + .expect("honest on-curve pool must pass merkle gate (any const value)"); + for c in &pool.candidate_nodes { + assert!( + PaymentVerifier::price_off_curve_diagnostics(&c.price).is_none(), + "every honest candidate must be canonically on-curve" + ); + } + + // Now bump exactly one candidate off-curve and check that the + // diagnostics path catches it. (The outer wrapper still short-circuits + // under observe-only; this proves the underlying detection works + // independently of the rollout const, exercising the rejection-payload + // path in CI.) + let mut tampered = pool; + tampered.candidate_nodes[3].price += Amount::from(1u64); + let mut off_curve_seen = 0; + for c in &tampered.candidate_nodes { + if PaymentVerifier::price_off_curve_diagnostics(&c.price).is_some() { + off_curve_seen += 1; + } + } + assert_eq!( + off_curve_seen, 1, + "exactly one tampered candidate must register as off-curve" + ); + } + + // === ADR-0004 binding-shape + cross-check unit tests === + + use crate::payment::pricing::calculate_price as cp; + + /// Build a real signed commitment over `n` synthetic keys for tests. + fn test_built_commitment(n: u32) -> crate::replication::commitment_state::BuiltCommitment { + use saorsa_pqc::api::sig::ml_dsa_65; + let (pk, sk) = ml_dsa_65().generate_keypair().expect("keypair"); + let pk_bytes = pk.to_bytes(); + let peer_id = blake3::hash(&pk_bytes); + let entries: Vec<([u8; 32], [u8; 32])> = (0..n) + .map(|i| { + let mut k = [0u8; 32]; + k[..4].copy_from_slice(&i.to_le_bytes()); + let mut b = [1u8; 32]; + b[..4].copy_from_slice(&i.to_le_bytes()); + (k, b) + }) + .collect(); + crate::replication::commitment_state::BuiltCommitment::build( + entries, + peer_id.as_bytes(), + &sk, + &pk_bytes, + ) + .expect("build commitment") + } + + #[test] + fn binding_baseline_ok_only_at_baseline_price() { + // (0, None) with calculate_price(0) is the valid baseline. + assert!(PaymentVerifier::binding_violation(0, None, &cp(0)).is_none()); + // (0, None) with a non-baseline price is REJECTED — this is the BLOCKER + // bypass the round-1 review found (unpinned quote priced above baseline). + assert!(PaymentVerifier::binding_violation(0, None, &cp(500)).is_some()); + } + + #[test] + fn binding_bound_ok_only_with_pin_and_exact_price() { + let pin = [9u8; 32]; + // (n>0, Some(pin)) priced exactly is valid. + assert!(PaymentVerifier::binding_violation(500, Some(pin), &cp(500)).is_none()); + // (n>0, Some(pin)) priced for a DIFFERENT count is rejected (on-curve + // but wrong count — stronger than canonicality). + assert!(PaymentVerifier::binding_violation(500, Some(pin), &cp(499)).is_some()); + } + + #[test] + fn binding_rejects_incoherent_shapes() { + let pin = [9u8; 32]; + // count > 0 but no pin: unauditable. + assert!(PaymentVerifier::binding_violation(500, None, &cp(500)).is_some()); + // count 0 but a pin: incoherent baseline. + assert!(PaymentVerifier::binding_violation(0, Some(pin), &cp(0)).is_some()); + } + + #[test] + fn binding_rejects_count_above_cap() { + let pin = [9u8; 32]; + let over = crate::replication::commitment::MAX_COMMITMENT_KEY_COUNT + 1; + assert!(PaymentVerifier::binding_violation(over, Some(pin), &cp(over as usize)).is_some()); + } + + #[test] + fn cross_check_match_when_pin_and_count_agree() { + let built = test_built_commitment(12); + let outcome = PaymentVerifier::cross_check_binding(12, built.hash(), built.commitment()); + assert_eq!(outcome, CrossCheck::Match); + } + + #[test] + fn cross_check_mismatch_when_count_inflated() { + let built = test_built_commitment(12); + // Quote claims 999 but the pinned commitment attests 12. + let outcome = PaymentVerifier::cross_check_binding(999, built.hash(), built.commitment()); + assert_eq!( + outcome, + CrossCheck::Mismatch { + quoted_key_count: 999, + committed_key_count: 12, + } + ); + } + + #[test] + fn cross_check_unresolved_when_pin_wrong() { + let built = test_built_commitment(12); + // Pin does not match the supplied commitment's hash: not evidence. + let outcome = PaymentVerifier::cross_check_binding(12, [0xFFu8; 32], built.commitment()); + assert_eq!(outcome, CrossCheck::PinDoesNotResolve); + } + + #[test] + fn fresh_cached_commitment_honours_ttl_boundary() { + use crate::replication::commitment_state::PeerCommitmentRecord; + let built = test_built_commitment(5); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let ttl = std::time::Duration::from_secs(3 * 3600); + let now = std::time::Instant::now(); + + // Fresh AND matching pin -> resolves to the commitment. + let fresh = PeerCommitmentRecord::from_verified(commitment.clone(), now); + assert!( + PaymentVerifier::fresh_cached_commitment(&fresh, pin, now, ttl).is_some(), + "a fresh cache entry whose hash matches the pin must resolve" + ); + + // Fresh but a DIFFERENT pin -> treated as not-cached (None), so the + // caller falls through to fetch the actually-quoted pin instead of + // mis-resolving against the peer's latest (different) commitment. + assert!( + PaymentVerifier::fresh_cached_commitment(&fresh, [0xEEu8; 32], now, ttl).is_none(), + "a fresh cache entry for a DIFFERENT pin must not resolve (fetch fallback runs)" + ); + + // Stale: received older than the TTL -> treated as unknown (None), the + // ADR-0004 false-positive guard against an aged cache entry. + // + // Advance the comparison clock PAST the TTL rather than subtracting the + // TTL from `now`: on Windows the monotonic `Instant` epoch can be + // younger than a multi-hour TTL, so `now.checked_sub(ttl + 1s)` + // underflows to `None` and panics. `checked_add` from the receipt time + // is always in range and is equivalent for the age comparison. + let received_at = now; + let now_after_ttl = received_at + .checked_add(ttl + std::time::Duration::from_secs(1)) + .expect("instant in range"); + let stale = PeerCommitmentRecord::from_verified(commitment, received_at); + assert!( + PaymentVerifier::fresh_cached_commitment(&stale, pin, now_after_ttl, ttl).is_none(), + "a cache entry older than the answerability TTL must be treated as unknown" + ); + } + + #[test] + fn fetched_commitment_must_be_bound_to_the_queried_peer() { + // A fetched commitment is accepted only when it is bound to the peer we + // asked (sender_peer_id == peer_id) and hashes to the requested pin. + let built = test_built_commitment(8); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let owner = PeerId::from_bytes(commitment.sender_peer_id); + + // Correct owner + correct pin -> accepted. + assert!( + PaymentVerifier::fetched_commitment_is_valid(&commitment, &owner, pin), + "a peer's own validly-signed commitment, hashing to the pin, must be accepted" + ); + + // Same (validly signed) commitment but attributed to a DIFFERENT peer -> + // rejected. This is the MAJOR fix: a peer must not be able to answer with + // someone else's commitment and have it pass as its own. + let other = PeerId::from_bytes([0xABu8; 32]); + assert!( + !PaymentVerifier::fetched_commitment_is_valid(&commitment, &other, pin), + "another peer's commitment must be rejected for the queried peer" + ); + + // Correct owner but wrong pin -> rejected. + assert!( + !PaymentVerifier::fetched_commitment_is_valid(&commitment, &owner, [0u8; 32]), + "a commitment that does not hash to the requested pin must be rejected" + ); + } + + #[tokio::test] + async fn emit_mismatch_evidence_without_p2p_does_not_panic() { + // Guards the "always best-effort" degrade path: with no P2P handle there + // is no trust engine to act on, so `emit_mismatch_evidence` must be a + // no-op that never panics or errors on the payment path. This is a + // no-panic guard on the degraded path — it does NOT exercise the + // evidence->action mapping (that needs a live trust engine). + let built = test_built_commitment(12); + let evidence = crate::replication::types::FailureEvidence::QuoteCommitmentMismatch { + peer: PeerId::from_bytes([1u8; 32]), + pinned_commitment: [2u8; 32], + quoted_key_count: 999, + committed_key_count: 12, + quote_artifact: vec![0xAA; 16], + commitment: Box::new(built.commitment().clone()), + }; + // No P2P -> no trust event even if enforce were on; must not panic. + PaymentVerifier::emit_mismatch_evidence(&evidence, None).await; + } + + #[test] + fn valid_sidecar_is_indexed_and_resolves_synchronously() { + // A valid sidecar blob is indexed under its own (peer, pin) so the + // cross-check resolves it synchronously — "the commitment arrived with + // the quote", no gossip-cache hit or fetch needed. + let built = test_built_commitment(9); + let commitment = built.commitment().clone(); + let pin = built.hash(); + let owner = PeerId::from_bytes(commitment.sender_peer_id); + let blob = rmp_serde::to_vec(&commitment).expect("serialize sidecar"); + + let map = PaymentVerifier::index_valid_sidecars(std::slice::from_ref(&blob)); + assert!( + map.contains_key(&(owner, pin)), + "a valid sidecar must be indexed under its own (peer, pin)" + ); + + // A garbage blob is silently skipped (resolution falls back), never a + // hard error. + let map2 = PaymentVerifier::index_valid_sidecars(&[vec![0xFF; 8]]); + assert!(map2.is_empty(), "an unparseable sidecar must be skipped"); + } } diff --git a/src/replication/commitment.rs b/src/replication/commitment.rs index 87d004e2..9bbfd3ee 100644 --- a/src/replication/commitment.rs +++ b/src/replication/commitment.rs @@ -23,21 +23,20 @@ //! reward-eligibility cache) lives here yet — that's the next phase. use blake3::Hasher; -use saorsa_pqc::api::sig::{ - ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature, MlDsaVariant, -}; -use serde::{Deserialize, Serialize}; +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaSecretKey}; use crate::ant_protocol::XorName; -/// Domain-separation tag for the commitment signature. -/// -/// Signed payload is BLAKE3 over (this tag || canonical commitment fields). -pub const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1"; - -/// Domain-separation tag for the auditor's pin: BLAKE3 over (this tag || -/// canonical commitment blob). -pub const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1"; +// ADR-0004: the commitment wire type, its pin (`commitment_hash`), its +// signature verification, and the key-count cap are the SINGLE SOURCE OF TRUTH +// in `ant-protocol` so the paying client and the node verify identically. +// Re-exported here so all existing `crate::replication::commitment::…` callers +// keep resolving. The Merkle tree, inclusion paths, and signing stay node-side +// below (the client never builds or signs a commitment, only verifies one). +pub use ::ant_protocol::payment::commitment::{ + commitment_hash, verify_commitment_signature, StorageCommitment, DOMAIN_COMMITMENT, + DOMAIN_COMMITMENT_HASH, MAX_COMMITMENT_KEY_COUNT, MAX_COMMITMENT_SIDECAR_BYTES, +}; /// Domain-separation tag for Merkle leaves: `BLAKE3(this || key || H(bytes))`. pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1"; @@ -45,56 +44,9 @@ pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1"; /// Domain-separation tag for Merkle internal nodes: `BLAKE3(this || left || right)`. pub const DOMAIN_NODE: &[u8] = b"autonomi.ant.replication.storage_node.v1"; -/// Maximum number of keys a single commitment may cover. -/// -/// Bounds the Merkle path depth (audit responses carry `O(log2 key_count)` -/// hashes per key) and the responder-side tree memory. A node storing more -/// keys than this would need to split its claim — out of scope for v1. -pub const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000; - -/// Signed storage commitment. -/// -/// Piggybacked on neighbour-sync gossip. The signature commits to the -/// Merkle root, key count, sender peer ID, **and the sender's ML-DSA-65 -/// public key** under [`DOMAIN_COMMITMENT`]. -/// -/// Embedding the public key lets any receiver verify the signature -/// without an external `PeerId → MlDsaPublicKey` lookup. Binding the -/// public key in the signed payload prevents a key-swap attack where an -/// adversary keeps the message body but re-signs it under a different key -/// to claim a different identity. The peer-id binding (gate 2a in -/// `verify_commitment_bound_response`) still ensures the embedded key -/// belongs to the gossiping peer. -/// -/// # Wire size -/// -/// One commitment is approximately 5.3 KiB: -/// - root: 32 B -/// - `key_count`: 4 B -/// - `sender_peer_id`: 32 B -/// - `sender_public_key`: 1952 B (ML-DSA-65 public key) -/// - signature: 3293 B (ML-DSA-65 signature) -/// -/// Piggybacked on every `NeighborSyncRequest`/`Response` (~1 h interval -/// per close-group peer at the neighbour-sync cooldown cadence). At a -/// realistic close-group size of 8 with bidirectional sync, that's -/// roughly 8 × 2 × 5.3 KiB / hour = ~85 KiB/h of additional gossip -/// per node. Negligible against typical chunk-transfer bandwidth. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct StorageCommitment { - /// Merkle root over the responder's claimed keys. - pub root: [u8; 32], - /// Number of leaves committed over. - pub key_count: u32, - /// Sender peer ID, bound to the signature. - pub sender_peer_id: [u8; 32], - /// Sender's ML-DSA-65 public key bytes (1952 bytes). Embedded so - /// receivers can verify the signature without a separate pubkey - /// directory. Bound by the signature. - pub sender_public_key: Vec, - /// ML-DSA-65 signature over canonical commitment fields. 3293 bytes. - pub signature: Vec, -} +// `MAX_COMMITMENT_KEY_COUNT` and `StorageCommitment` are re-exported from +// `ant-protocol` above (single source of truth); their fields and wire size are +// documented there. // --------------------------------------------------------------------------- // Hashing helpers @@ -123,33 +75,8 @@ pub fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { *h.finalize().as_bytes() } -/// The auditor's pin: `BLAKE3(DOMAIN_COMMITMENT_HASH || postcard(commitment))`. -/// -/// Equal commitments produce equal hashes; any change to `root`, `key_count`, -/// peer ID, or signature changes the hash because postcard's canonical -/// encoding includes a length prefix for `signature`. The audit challenge -/// carries this value; the audit response must include a commitment that -/// hashes to the same value, defeating fresh-commitment substitution. -/// -/// Postcard encoding is the same canonical wire form the rest of the -/// replication protocol uses (`MessageCodec::encode`), so an encoded -/// commitment from a `NeighborSyncRequest` produces the same hash as the -/// same commitment received in an `AuditResponse`. -/// -/// # Errors -/// -/// Returns `None` only if postcard fails to serialize the commitment, which -/// in practice means the signature is somehow `> isize::MAX` bytes — not -/// reachable for ML-DSA-65 (3293 bytes). Callers may safely treat `None` as -/// a malformed commitment and drop it. -#[must_use] -pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> { - let serialized = postcard::to_allocvec(c).ok()?; - let mut h = Hasher::new(); - h.update(DOMAIN_COMMITMENT_HASH); - h.update(&serialized); - Some(*h.finalize().as_bytes()) -} +// `commitment_hash` is re-exported from `ant-protocol` above (single source of +// truth for the pin), so the paying client and the node compute the same pin. /// Canonical bytes the ML-DSA signature covers: the commitment fields /// minus the signature itself. @@ -272,6 +199,15 @@ impl MerkleTree { u32::try_from(self.leaves.len()).unwrap_or(u32::MAX) } + /// The committed leaf keys, in the tree's sorted order. Lets a persisted + /// commitment be rebuilt from its key set alone — `MerkleTree::build(keys + /// .map(|k| (k, k)))` for content-addressed leaves — without re-reading + /// chunks, so a restart can restore the exact signed root (ADR-0004 A1). + #[must_use] + pub fn leaf_keys(&self) -> Vec { + self.leaves.iter().map(|(k, _)| *k).collect() + } + /// Inclusion path for `key` from its leaf up to (but not including) /// the root. /// @@ -474,48 +410,11 @@ pub fn sign_commitment( Ok(sig.to_bytes()) } -/// Verify a commitment's signature using the embedded `sender_public_key`. -/// -/// Returns `true` iff the signature is valid for `(root, key_count, -/// sender_peer_id, sender_public_key)` under `c.sender_public_key` and -/// [`DOMAIN_COMMITMENT`]. Returns `false` on key-format or signature-format -/// errors so the caller can simply drop the gossip. -/// -/// Verifying against the embedded key removes the need for an external -/// `PeerId → MlDsaPublicKey` lookup. The peer-id binding gate in -/// `ingest_peer_commitment` (and the auditor's `evaluate_subtree_structure`) -/// still ensures the embedded key belongs to the claimed peer. -#[must_use] -pub fn verify_commitment_signature(c: &StorageCommitment) -> bool { - let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key) - else { - return false; - }; - verify_commitment_signature_with_key(c, &public_key) -} - -/// Verify a commitment's signature against an externally provided key. -/// -/// Test-helper variant. Production code should use [`verify_commitment_signature`] -/// since the key is embedded in the commitment. -#[must_use] -pub fn verify_commitment_signature_with_key( - c: &StorageCommitment, - public_key: &MlDsaPublicKey, -) -> bool { - let payload = commitment_signed_payload( - &c.root, - c.key_count, - &c.sender_peer_id, - &c.sender_public_key, - ); - let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else { - return false; - }; - let dsa = ml_dsa_65(); - dsa.verify_with_context(public_key, &payload, &sig, DOMAIN_COMMITMENT) - .unwrap_or(false) -} +// `verify_commitment_signature` (embedded-key) is re-exported from +// `ant-protocol` above (single source of truth), so the paying client and the +// node accept exactly the same commitments. The externally-keyed variant was +// removed in the ADR-0004 move — it had no remaining callers once the embedded- +// key verify moved to `ant-protocol`. // --------------------------------------------------------------------------- // Errors @@ -546,6 +445,7 @@ pub enum CommitmentError { #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; + use saorsa_pqc::api::sig::MlDsaPublicKey; fn xn(byte: u8) -> XorName { [byte; 32] diff --git a/src/replication/commitment_state.rs b/src/replication/commitment_state.rs index a7663db0..2fdda12b 100644 --- a/src/replication/commitment_state.rs +++ b/src/replication/commitment_state.rs @@ -17,19 +17,26 @@ //! semantics specified in v6 §2: an in-flight reader holding its //! `Arc` is unaffected by a concurrent rotate). //! -//! No persistent disk state. Trees are rebuilt from `LmdbStorage` at -//! the next rotation tick. Memory cost is bounded by +//! Retention is persisted across restart (ADR-0004 A1): [`ResponderCommitmentState::snapshot`] +//! captures the signed commitments + their key sets + gossip stamps, and +//! [`ResponderCommitmentState::restore`] reloads them and rebuilds each tree from +//! its persisted key set — so an honest restarted node can answer every pin that +//! is still inside its answerability window, and an unanswerable pin is provable +//! misbehaviour rather than an honest crash-restart. Trees are otherwise rebuilt +//! from `LmdbStorage` at the next rotation tick. Memory cost is bounded by //! `2 × (key_count × ~64 bytes + signature_size)` — for 10k keys, ~1.3 MB. use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use parking_lot::RwLock; use saorsa_pqc::api::sig::MlDsaSecretKey; +use serde::{Deserialize, Serialize}; use crate::ant_protocol::XorName; use crate::replication::commitment::{ - commitment_hash, sign_commitment, CommitmentError, MerkleTree, StorageCommitment, + commitment_hash, sign_commitment, verify_commitment_signature, CommitmentError, MerkleTree, + StorageCommitment, }; /// Auditor-side per-peer commitment state. @@ -262,21 +269,57 @@ impl BuiltCommitment { pub fn contains_key(&self, key: &XorName) -> bool { self.tree.contains_key(key) } + + /// The committed leaf keys — the key set persisted so this commitment can be + /// rebuilt after a restart without re-reading chunks. + #[must_use] + pub fn leaf_keys(&self) -> Vec { + self.tree.leaf_keys() + } + + /// Reconstruct a `BuiltCommitment` from a persisted signed commitment and a + /// `tree` rebuilt from its leaf keys — WITHOUT re-signing, so the pin + /// (`commitment_hash`) is preserved exactly across a restart (ML-DSA + /// signatures are randomized, so re-signing would change the pin). + /// + /// Returns `None` (never trusts the blob) unless the rebuilt tree matches the + /// signed `root` and `key_count` AND the embedded-key ML-DSA signature still + /// verifies — so a corrupted or forged persisted commitment is rejected. + #[must_use] + pub fn from_persisted(commitment: StorageCommitment, tree: MerkleTree) -> Option { + if tree.root() != commitment.root || tree.key_count() != commitment.key_count { + return None; + } + if !verify_commitment_signature(&commitment) { + return None; + } + let cached_hash = commitment_hash(&commitment)?; + Some(Self { + commitment, + cached_hash, + tree, + }) + } } -/// Number of recently-gossiped commitments a responder stays answerable for -/// (ADR-0002 "you stay answerable for what you publish"). +/// Expected steady-state count of retained recently-gossiped commitments (the +/// last ~two, plus the current one) — used only as an initial `Vec` capacity +/// hint. Retention itself is TTL-based (see [`GOSSIP_ANSWERABILITY_TTL`] and +/// [`prune_slots`]), NOT a hard count: a commitment stays answerable for the +/// full TTL after its last gossip regardless of how many rotations occur. /// -/// The auditor only ever pins a commitment it received via gossip, so retaining -/// the last two **actually-gossiped** commitments (plus the current one) -/// guarantees an honest node can always answer a pin the auditor could have -/// formed. Two — not one — absorbs the race where the auditor pins the -/// commitment a node published just before its newest one. Retention is keyed on -/// gossip emission, NOT on the rotation timer: a node that rebuilds its tree -/// faster than it gossips never drops a commitment it actually put on the wire, -/// so it is never wrongly failed for "unknown commitment hash". +/// (A hard count cap was a flawed proxy — under a restart with a shifted +/// responsible range, an in-window root could be evicted by count before its +/// TTL, which after grace removal would be a false conviction.) const RETAINED_GOSSIPED_COMMITMENTS: usize = 2; +/// Hard upper bound on retained gossip records — a pure memory backstop against +/// pathological churn (e.g. an implausibly fast rotation producing many distinct +/// in-window roots). At the 1 h rotation cadence and 3 h TTL only ~3 distinct +/// roots are ever in-window, so this is never hit in practice; it exists solely +/// so `recently_gossiped` cannot grow unbounded. +const MAX_RETAINED_GOSSIPED_SLOTS: usize = 16; + /// How long a gossiped commitment stays answerable after it was last put on the /// wire. Retention (and therefore the pruner's `is_held` deletion veto) is /// anchored to gossip emission, not to the rotation timer or to distinct-hash @@ -292,24 +335,90 @@ const RETAINED_GOSSIPED_COMMITMENTS: usize = 2; /// interval = 3 h. pub(crate) const GOSSIP_ANSWERABILITY_TTL: Duration = Duration::from_secs(3 * 3600); +/// Extra answerability margin applied ONLY when reloading retention after a +/// restart (ADR-0004 A1). A gossip-stamp refresh in the last persist window may +/// not have been flushed before an unclean restart, so a persisted deadline can +/// be slightly early. Adding this margin on reload guarantees an honest node +/// never *under*-retains across a restart (it may over-retain by the margin, +/// which is harmless — it only makes the responder answer a little longer, and a +/// data-deleter still fails the round-2 byte challenge). Sized well above the +/// persist interval + gossip cadence, far below the TTL. +const RESTART_STAMP_GRACE: Duration = Duration::from_secs(5 * 60); + +/// One persisted retention slot (ADR-0004 A1): the signed commitment, its +/// committed key set (so the tree can be rebuilt without re-reading chunks), and +/// the wall-clock time its hash was last gossiped (`None` if never gossiped — +/// then it only survives reload while it is the current slot). +#[derive(Serialize, Deserialize)] +struct PersistedSlot { + commitment: StorageCommitment, + leaf_keys: Vec, + /// Absolute wall-clock time (unix secs) at which this slot's answerability + /// expires. Storing the ABSOLUTE deadline (not the last-gossip time) makes + /// downtime count against the TTL: a node down past the deadline reloads the + /// slot as already expired. `None` if the slot was never gossiped — then it + /// survives reload only while it is the current slot. + expires_at_unix: Option, +} + +/// Persisted-format version. Bump on any layout OR semantic change so an +/// incompatible on-disk snapshot is rejected (→ empty retention, which self-heals +/// via re-gossip) rather than silently misinterpreted (e.g. an old field read +/// under new semantics). +const RETENTION_FORMAT_VERSION: u32 = 1; + +/// The persisted responder retention. Slots are newest-first; `has_current` +/// says whether `slots[0]` was the live advertised commitment. +#[derive(Serialize, Deserialize)] +pub struct PersistedRetention { + /// Format version (see [`RETENTION_FORMAT_VERSION`]); a mismatch is rejected. + version: u32, + slots: Vec, + has_current: bool, +} + +impl PersistedRetention { + /// Serialize for durable persistence (caller writes it atomically). `None` + /// on a serialization error, so the caller can refuse to overwrite the + /// durable file rather than truncate it. + #[must_use] + pub fn to_bytes(&self) -> Option> { + postcard::to_allocvec(self).ok() + } + + /// Decode a persisted snapshot. `None` on a corrupt blob OR a version + /// mismatch — the caller then fails open LOCALLY (empty retention; the node + /// re-gossips a fresh root), which never grants a remote grace. + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Option { + let this: Self = postcard::from_bytes(bytes).ok()?; + (this.version == RETENTION_FORMAT_VERSION).then_some(this) + } +} + /// Responder retention state (ADR-0002). /// -/// Keeps the current (latest-rotated) commitment plus every commitment whose -/// hash is among the last `RETAINED_GOSSIPED_COMMITMENTS` *gossiped* hashes. -/// A built-but-never-gossiped commitment is dropped on the next rotation unless +/// Keeps the current (latest-rotated) commitment plus every commitment that was +/// recently gossiped and is still in-window (its `GOSSIP_ANSWERABILITY_TTL` has +/// not expired) — retention is TTL-based, not a fixed count. A +/// built-but-never-gossiped commitment is dropped on the next rotation unless /// it gets gossiped. Rotation and gossip are the only paths that mutate this. pub struct ResponderCommitmentState { inner: RwLock, } -/// A commitment hash that was emitted on the wire, with the wall-clock time it -/// was last gossiped. The `last_gossiped_at` is the answerability anchor: the -/// record (and any slot it retains) expires `GOSSIP_ANSWERABILITY_TTL` after -/// this instant, independent of rotation ticks or distinct-hash churn. +/// A commitment hash that was emitted on the wire, with the monotonic instant at +/// which its answerability EXPIRES (`last_gossiped_at + GOSSIP_ANSWERABILITY_TTL`). +/// +/// Storing the deadline rather than the last-gossip instant makes reload after a +/// restart robust: `restore` sets `expires_at = now + remaining` (pure addition), +/// so an OS reboot — where the monotonic clock resets and uptime can be far less +/// than the wall-clock age — cannot underflow and wrongly drop a still-in-window +/// root. #[derive(Clone, Copy)] struct GossipedAt { hash: [u8; 32], - last_gossiped_at: Instant, + expires_at: Instant, } struct Inner { @@ -326,10 +435,10 @@ struct Inner { /// decouples ADVERTISE (gossiped as current, refreshes the TTL) from ANSWER /// (still resolvable during the TTL window). has_current: bool, - /// The last `RETAINED_GOSSIPED_COMMITMENTS` commitments actually emitted on - /// the wire, newest-first, each stamped with when it was last gossiped. A - /// commitment is retained iff it is the live current one or its hash appears - /// here with an unexpired stamp. + /// Commitments recently emitted on the wire, newest-first, each stamped with + /// when it was last gossiped (in steady state the last ~two, but bounded by + /// the answerability TTL, not a fixed count). A commitment is retained iff it + /// is the live current one or its hash appears here with an unexpired stamp. recently_gossiped: Vec, } @@ -381,9 +490,9 @@ impl ResponderCommitmentState { prune_slots(&mut guard, Instant::now()); } - /// Record that `hash` was emitted on the wire (gossiped). Keeps the last - /// `RETAINED_GOSSIPED_COMMITMENTS` gossiped hashes so the matching - /// commitments stay answerable (ADR-0002). Call at every gossip-emit site. + /// Record that `hash` was emitted on the wire (gossiped). Keeps every + /// in-window gossiped hash (unexpired `GOSSIP_ANSWERABILITY_TTL`) so the + /// matching commitments stay answerable (ADR-0002). Call at every gossip-emit site. /// /// Re-gossiping a hash already present **refreshes** its answerability /// deadline to now and moves it to the front: every time the node actually @@ -422,6 +531,34 @@ impl ResponderCommitmentState { Some(current) } + /// Atomically snapshot the current commitment to PIN IN A QUOTE and refresh + /// its answerability, under a single lock. Returns the live current + /// commitment, or `None` if there is no live current (never rotated, or + /// retired) — in which case the caller must quote the baseline with no pin. + /// + /// ADR-0004 ("quoting is advertising"): issuing a quote that prices against + /// the current commitment must extend that commitment's answerability + /// exactly as gossiping it does, so a recently-quoted pin stays resolvable + /// for its TTL and a peer auditing it cannot false-fail an honest node. + /// This deliberately mirrors [`Self::current_for_gossip`]: same atomic + /// snapshot-and-stamp, same TOCTOU-free guarantee that anything a quote can + /// pin is simultaneously retained. It refreshes the CURRENT commitment only + /// — a retired or merely-retained-but-not-current commitment is never + /// returned here, so quote traffic can never keep a stale fat commitment + /// alive (it can only be answered, via `lookup_by_hash`, until its own + /// gossip/quote stamp lapses). + #[must_use] + pub fn current_for_quote(&self) -> Option> { + let now = Instant::now(); + let mut guard = self.inner.write(); + if !guard.has_current { + return None; + } + let current = guard.slots.first().map(Arc::clone)?; + mark_gossiped_locked(&mut guard, current.cached_hash, now); + Some(current) + } + /// Expire retention purely by the wall clock, without building, signing, or /// rotating anything. Call once per rotation tick so a gossiped commitment's /// answerability deadline advances even when the rotation no-op guard @@ -450,8 +587,8 @@ impl ResponderCommitmentState { } /// Whether `key` is committed under any retained slot (the current - /// commitment plus the last-2-gossiped ones) — i.e. whether a peer could - /// still pin a recently gossiped root and demand this key's bytes in a + /// commitment plus any still-in-window gossiped ones) — i.e. whether a peer + /// could still pin a recently gossiped root and demand this key's bytes in a /// round-2 byte challenge. /// /// This is the SAME predicate the round-2 responder uses to decide a key is @@ -510,6 +647,139 @@ impl ResponderCommitmentState { guard.has_current = false; guard.recently_gossiped.clear(); } + + /// Snapshot retention for durable persistence (ADR-0004 A1): each slot's + /// signed commitment + committed key set + wall-clock gossip stamp. Reloading + /// this after a restart makes every still-in-window pin answerable again, so + /// an unanswerable pin is provable misbehaviour, not an honest crash-restart. + #[must_use] + pub fn snapshot(&self) -> PersistedRetention { + let now_i = Instant::now(); + let now_s = SystemTime::now(); + let guard = self.inner.read(); + let slots = guard + .slots + .iter() + .map(|c| { + let expires_at_unix = guard + .recently_gossiped + .iter() + .find(|g| g.hash == c.cached_hash) + .and_then(|g| { + // Persist the ABSOLUTE wall-clock deadline = now + remaining, + // so a restart accounts for downtime. Skip if already expired. + let remaining = g.expires_at.saturating_duration_since(now_i); + if remaining.is_zero() { + return None; + } + now_s + .checked_add(remaining) + .and_then(|w| w.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + }); + PersistedSlot { + commitment: c.commitment().clone(), + leaf_keys: c.leaf_keys(), + expires_at_unix, + } + }) + .collect(); + PersistedRetention { + version: RETENTION_FORMAT_VERSION, + slots, + has_current: guard.has_current, + } + } + + /// Reload retention from a persisted snapshot at startup (ADR-0004 A1). + /// Rebuilds each slot's tree from its persisted (content-addressed) key set, + /// verifies it against the signed root, converts wall-clock gossip stamps + /// back to the monotonic clock, drops corrupt or already-expired slots, and + /// enforces retention. Replaces any existing state. + pub fn restore(&self, persisted: &PersistedRetention) { + let now_i = Instant::now(); + let now_s = SystemTime::now(); + let mut guard = self.inner.write(); + guard.slots.clear(); + guard.recently_gossiped.clear(); + guard.has_current = false; + // Track whether the FIRST persisted slot (the pre-restart current) + // restored successfully — `has_current` may only be honoured if it did, + // else a later slot would be wrongly promoted to current. + let mut first_slot_restored = false; + for (i, slot) in persisted.slots.iter().enumerate() { + let entries: Vec<_> = slot.leaf_keys.iter().map(|k| (*k, *k)).collect(); + let Ok(tree) = MerkleTree::build(entries) else { + continue; + }; + let Some(built) = BuiltCommitment::from_persisted(slot.commitment.clone(), tree) else { + continue; + }; + let hash = built.cached_hash; + if i == 0 { + first_slot_restored = true; + } + guard.slots.push(Arc::new(built)); + if let Some(exp_unix) = slot.expires_at_unix { + if let Some(expires_at) = wall_expiry_to_instant(exp_unix, now_s, now_i) { + guard + .recently_gossiped + .push(GossipedAt { hash, expires_at }); + } + } + } + guard.has_current = persisted.has_current && first_slot_restored; + prune_slots(&mut guard, now_i); + } +} + +/// Convert a persisted ABSOLUTE wall-clock expiry (unix secs) to a monotonic +/// [`Instant`] deadline, given the current wall-clock/monotonic pair. Returns +/// `None` if the deadline has already passed (downtime consumed the TTL). Uses +/// `now_i + remaining` (addition), so it never underflows across an OS reboot +/// where the monotonic clock has reset; `remaining` is clamped to the TTL so a +/// forward clock skew cannot over-extend answerability. +fn wall_expiry_to_instant(expires_unix: u64, now_s: SystemTime, now_i: Instant) -> Option { + let expires_wall = UNIX_EPOCH.checked_add(Duration::from_secs(expires_unix))?; + // Apply RESTART_STAMP_GRACE to the persisted deadline BEFORE deciding expiry: + // the persisted deadline can be slightly early (a stamp refresh lost in the + // last persist window may have already carried the true deadline past the + // persisted one — even past `now`). Treating `persisted + grace` as the + // effective deadline means an honest node never under-retains across a + // restart. A slot only drops here if it expired MORE than the grace ago + // (genuine expiry — downtime still counts, minus the grace margin). + let effective_wall = expires_wall.checked_add(RESTART_STAMP_GRACE)?; + let remaining = effective_wall.duration_since(now_s).ok()?; + if remaining.is_zero() { + return None; + } + // Clamp so a forward wall-clock skew cannot over-extend beyond one TTL + grace. + now_i.checked_add(remaining.min(GOSSIP_ANSWERABILITY_TTL + RESTART_STAMP_GRACE)) +} + +/// ADR-0004: the responder commitment state is the quote generator's commitment +/// source. `current_binding_for_quote` snapshots the live current commitment's +/// `(key_count, pin)` and refreshes its answerability in one atomic step (via +/// [`ResponderCommitmentState::current_for_quote`]), so a quote that prices +/// against the current commitment keeps it answerable for its TTL. +impl crate::payment::quote::CommitmentSource for ResponderCommitmentState { + fn current_binding_for_quote(&self) -> Option { + self.current_for_quote() + .map(|built| crate::payment::quote::QuoteBinding { + key_count: built.commitment().key_count, + pin: built.hash(), + }) + } + + fn commitment_blob_for_pin(&self, pin: [u8; 32]) -> Option> { + // rmp-encode the `StorageCommitment` itself — the EXACT form the storer's + // `index_valid_sidecars` deserializes (`rmp_serde::from_slice::`), + // so a sidecar shipped here resolves identically to one fetched via + // `GetCommitmentByPin`. Only retained pins resolve; a rotated-out pin + // yields `None` and the response simply carries no commitment. + let built = self.lookup_by_hash(&pin)?; + rmp_serde::to_vec(built.commitment()).ok() + } } /// Enforce retention as of `now`: first expire any gossip record older than @@ -533,22 +803,23 @@ fn mark_gossiped_locked(inner: &mut Inner, hash: [u8; 32], now: Instant) { 0, GossipedAt { hash, - last_gossiped_at: now, + expires_at: now + GOSSIP_ANSWERABILITY_TTL, }, ); + // Retention is TTL-based; truncation is only a memory backstop and must never + // drop an unexpired (still-in-window) record, so it caps at a value far above + // the number of roots that can be in-window at once. inner .recently_gossiped - .truncate(RETAINED_GOSSIPED_COMMITMENTS); + .truncate(MAX_RETAINED_GOSSIPED_SLOTS); prune_slots(inner, now); } fn prune_slots(inner: &mut Inner, now: Instant) { // 1. TTL-expire gossip records first (the answerability anchor). A record - // whose last gossip is older than the window no longer keeps anything + // whose answerability deadline has passed no longer keeps anything // answerable, regardless of distinct-hash churn or rotation ticks. - inner - .recently_gossiped - .retain(|g| now.duration_since(g.last_gossiped_at) < GOSSIP_ANSWERABILITY_TTL); + inner.recently_gossiped.retain(|g| g.expires_at > now); // 2. Keep the live current slot (only while has_current) + any slot still // covered by an unexpired record. Snapshot the live hashes first to avoid @@ -593,6 +864,113 @@ mod tests { ml_dsa_65().generate_keypair().unwrap() } + /// ADR-0004 A1: retention survives a restart. A snapshot → serialize → + /// deserialize → restore into a fresh state keeps the exact pre-restart pin + /// answerable (signature preserved, not re-signed) and its keys held — so an + /// honest restarted node is not falsely convicted once grace is removed. + #[test] + fn retention_survives_restart_via_snapshot_reload() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + // Content-addressed leaves (bytes_hash := key), matching production. + let entries: Vec<_> = (1..=5u8).map(|i| (key(i), key(i))).collect(); + let built = BuiltCommitment::build(entries, &[0xAB; 32], &sk, &pk_bytes).unwrap(); + let pin = built.hash(); + + let state = ResponderCommitmentState::new(); + state.rotate(built); + state.mark_gossiped(pin); + assert!(state.lookup_by_hash(&pin).is_some()); + assert!(state.is_held(&key(3))); + + // "Restart": snapshot -> bytes -> reload into a fresh state. + let bytes = state.snapshot().to_bytes().expect("serialize"); + let reloaded = PersistedRetention::from_bytes(&bytes).expect("deserialize"); + let fresh = ResponderCommitmentState::new(); + fresh.restore(&reloaded); + + // The pre-restart pin is still answerable, with the SAME hash, and its + // committed keys are still held. + let got = fresh.lookup_by_hash(&pin).expect("pin survives restart"); + assert_eq!(got.hash(), pin, "pin preserved (not re-signed)"); + assert!( + fresh.is_held(&key(3)), + "committed key still held after restart" + ); + } + + /// A corrupt snapshot blob decodes to `None`, so the caller fails open with + /// empty retention rather than trusting garbage. + #[test] + fn corrupt_retention_snapshot_is_rejected() { + assert!(PersistedRetention::from_bytes(&[0xffu8; 9]).is_none()); + } + + /// A snapshot from an incompatible format version is rejected (→ empty + /// retention), not silently misdecoded. + #[test] + fn wrong_format_version_is_rejected() { + let entries: Vec<_> = (1..=3u8).map(|i| (key(i), key(i))).collect(); + let (pk, sk) = keypair(); + let built = BuiltCommitment::build(entries, &[9; 32], &sk, &pk.to_bytes()).unwrap(); + let bad = PersistedRetention { + version: RETENTION_FORMAT_VERSION + 1, + slots: vec![PersistedSlot { + commitment: built.commitment().clone(), + leaf_keys: vec![key(1)], + expires_at_unix: None, + }], + has_current: true, + }; + let bytes = bad.to_bytes().expect("serialize"); + assert!(PersistedRetention::from_bytes(&bytes).is_none()); + } + + /// ADR-0004 A1 restart grace: a persisted deadline that is slightly in the + /// PAST (within `RESTART_STAMP_GRACE`, e.g. a stamp refresh lost in the last + /// persist window before an unclean restart) must still be answerable — an + /// honest node never under-retains across restart. A deadline older than the + /// grace is genuinely expired and dropped (downtime still counts). + #[test] + fn restore_grace_retains_slightly_stale_deadline_but_drops_expired() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let entries: Vec<_> = (1..=3u8).map(|i| (key(i), key(i))).collect(); + let built = BuiltCommitment::build(entries.clone(), &[0xCD; 32], &sk, &pk_bytes).unwrap(); + let pin = built.hash(); + let leaf_keys: Vec<_> = entries.iter().map(|(k, _)| *k).collect(); + let now_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("after epoch") + .as_secs(); + + let make = |expires_at_unix: Option| PersistedRetention { + version: RETENTION_FORMAT_VERSION, + slots: vec![PersistedSlot { + commitment: built.commitment().clone(), + leaf_keys: leaf_keys.clone(), + expires_at_unix, + }], + has_current: false, // not current -> retention depends on the stamp + }; + + // 60s past the persisted deadline — within the grace -> still answerable. + let within = ResponderCommitmentState::new(); + within.restore(&make(Some(now_unix.saturating_sub(60)))); + assert!( + within.lookup_by_hash(&pin).is_some(), + "a deadline within RESTART_STAMP_GRACE stays answerable across restart" + ); + + // 30 min past — well beyond the grace -> genuinely expired, dropped. + let beyond = ResponderCommitmentState::new(); + beyond.restore(&make(Some(now_unix.saturating_sub(30 * 60)))); + assert!( + beyond.lookup_by_hash(&pin).is_none(), + "a deadline older than the grace is expired and dropped" + ); + } + #[test] fn built_commitment_hash_matches_global_hash() { let (pk, sk) = keypair(); @@ -702,7 +1080,8 @@ mod tests { #[test] fn gossiped_commitment_stays_answerable_across_rotations() { // ADR-0002: a commitment that was actually gossiped stays answerable - // even after rotation, until it falls out of the last-2-gossiped window. + // even after rotation, for its gossip TTL (retention is TTL-based, not a + // fixed count). let (pk, sk) = keypair(); let pk_bytes = pk.to_bytes(); let state = ResponderCommitmentState::new(); @@ -712,7 +1091,7 @@ mod tests { state.rotate(c1); state.mark_gossiped(h1); // we put c1 on the wire - // Rotate to c2 and gossip it. c1 is still within the last-2-gossiped. + // Rotate to c2 and gossip it. c1's gossip is still in-window (unexpired TTL). let c2 = BuiltCommitment::build(vec![(key(2), bh(2))], &[0; 32], &sk, &pk_bytes).unwrap(); let h2 = c2.hash(); state.rotate(c2); @@ -723,38 +1102,40 @@ mod tests { ); assert!(state.lookup_by_hash(&h2).is_some()); - // Rotate to c3 and gossip it. Now the last-2-gossiped are {h3, h2}; - // h1 has fallen out of the window and is dropped. + // Rotate to c3 and gossip it. Retention is TTL-based, not a fixed count: + // no wall time has elapsed here, so c1 and c2 are still within their + // gossip TTL and MUST remain answerable — a rotation never evicts an + // in-window root (count-based eviction would be a false conviction once + // grace is removed). Aging out BY TTL is covered by the synthetic-clock + // prune_slots tests. let c3 = BuiltCommitment::build(vec![(key(3), bh(3))], &[0; 32], &sk, &pk_bytes).unwrap(); let h3 = c3.hash(); state.rotate(c3); state.mark_gossiped(h3); assert!( - state.lookup_by_hash(&h1).is_none(), - "c1 aged out of gossip window" + state.lookup_by_hash(&h1).is_some(), + "c1 stays answerable across rotations while within its gossip TTL" ); assert!(state.lookup_by_hash(&h2).is_some()); assert!(state.lookup_by_hash(&h3).is_some()); } #[test] - fn current_plus_last_two_gossiped_are_simultaneously_answerable() { - // ADR-0002 "Two, not one": the retention depth must keep BOTH of the - // last two gossiped commitments answerable at the same time, alongside - // the current one. This is the property that "absorbs the race where an - // auditor asks about the commitment a node published just before its - // newest one". The existing across-rotations test only ever checks two - // hashes at once; this one proves three DISTINCT commitments are live - // simultaneously and that the third-oldest gossiped root is dropped — - // i.e. RETAINED_GOSSIPED_COMMITMENTS is exactly 2, not 1 and not 3. + fn current_and_recently_gossiped_roots_stay_answerable_within_ttl() { + // Retention is TTL-based: the current commitment AND every distinct root + // gossiped within the answerability TTL are simultaneously answerable — + // which absorbs the race where an auditor asks about a root published + // just before the newest one, with NO count-based eviction (that was a + // false-conviction bug once grace is removed). No wall time elapses here, + // so all three distinct roots stay live; aging out is time-based and + // covered by the synthetic-clock prune_slots tests. let (pk, sk) = keypair(); let pk_bytes = pk.to_bytes(); let state = ResponderCommitmentState::new(); - // Gossip three commitments in order: c1, c2, c3. After this the current - // slot is c3 and the last-two-gossiped are {h3, h2}. But c2 and c1 also - // need to be checked relative to the window: once c3 is gossiped, the - // window is {h3, h2}; c1 (the 3rd-oldest gossiped) must be gone. + // Gossip three distinct commitments in order: c1, c2, c3. All were put + // on the wire within the TTL, so all three stay simultaneously + // answerable (current c3 plus the recently-gossiped c2 and c1). let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &[0; 32], &sk, &pk_bytes).unwrap(); let h1 = c1.hash(); state.rotate(c1); @@ -765,7 +1146,7 @@ mod tests { state.rotate(c2); state.mark_gossiped(h2); - // At this moment: current = c2, last-2-gossiped = {h2, h1}. Both the + // At this moment: current = c2, in-window gossiped = {h2, h1}. Both the // current AND the previously-gossiped c1 must be answerable — the "two, // not one" race window. c1 is the commitment "published just before the // newest one" and an auditor may still pin it. @@ -779,10 +1160,9 @@ mod tests { ); assert_ne!(h1, h2, "the two retained commitments must be distinct"); - // Now gossip a third distinct commitment c3. Window becomes {h3, h2}. - // c3 (current) + c2 + c1: c1 must now be dropped (3rd-oldest gossiped), - // while c2 and c3 remain. This proves depth is exactly 2 beyond... no: - // depth is 2 gossiped TOTAL including current's hash once gossiped. + // Now gossip a third distinct commitment c3. All of c1, c2, c3 were + // gossiped within the TTL (no wall time elapsed), so all three remain + // simultaneously answerable — retention is bounded by TTL, not a count. let c3 = BuiltCommitment::build(vec![(key(3), bh(3))], &[0; 32], &sk, &pk_bytes).unwrap(); let h3 = c3.hash(); state.rotate(c3); @@ -796,20 +1176,21 @@ mod tests { ); assert!( state.lookup_by_hash(&h2).is_some(), - "c2 (published just before newest) answerable — the race-absorbing slot" + "c2 answerable within its TTL" ); assert!( - state.lookup_by_hash(&h1).is_none(), - "c1 is the 3rd-oldest gossiped root and MUST be dropped — depth is exactly 2" + state.lookup_by_hash(&h1).is_some(), + "c1 also stays answerable within its TTL — no count-based eviction" ); } #[test] - fn is_held_tracks_keys_across_the_retention_window_and_ages_them_out() { + fn is_held_tracks_keys_across_the_retention_window() { // The pruner's deletion veto relies on `is_held`: a key committed under - // ANY retained slot (current + last-2-gossiped) must read held, and must - // stop reading held once its commitment ages out of the window — that is - // the bounded reprieve, not a permanent pin. This mirrors the + // ANY retained slot (current + any root gossiped within the TTL) must + // read held. It stops reading held once its commitment ages out BY TTL — + // a bounded reprieve, not a permanent pin (the time-based age-out is + // covered by the synthetic-clock prune_slots tests). This mirrors the // round-2 responder's `built.proof_for(key).is_some()` check folded over // the slots, so "pruner won't delete" == "responder owes an answer". let (pk, sk) = keypair(); @@ -840,20 +1221,19 @@ mod tests { ); assert!(state.is_held(&key(2)), "newly committed key is held"); - // c3 commits to key(3). Window becomes {h3, h2}; h1 ages out, so key(1) - // is no longer held anywhere -> the pruner may now reclaim it. + // c3 commits to key(3). No wall time has elapsed, so c1 and c2 are still + // within their gossip TTL: key(1), key(2), key(3) are all still held (no + // count-based age-out). key(1) is reclaimed only once c1's TTL lapses, + // which the synthetic-clock prune_slots tests cover. let c3 = BuiltCommitment::build(vec![(key(3), bh(3))], &[0; 32], &sk, &pk_bytes).unwrap(); let h3 = c3.hash(); state.rotate(c3); state.mark_gossiped(h3); assert!( - !state.is_held(&key(1)), - "key whose commitments all aged out of the retention window is no longer held" - ); - assert!( - state.is_held(&key(2)), - "key(2) still held via the previous gossiped slot" + state.is_held(&key(1)), + "key(1) still held within c1's gossip TTL (no count-based eviction)" ); + assert!(state.is_held(&key(2)), "key(2) still held"); assert!(state.is_held(&key(3)), "current key held"); } @@ -893,11 +1273,11 @@ mod tests { recently_gossiped: vec![ GossipedAt { hash: h_current, - last_gossiped_at: now, + expires_at: now + GOSSIP_ANSWERABILITY_TTL, }, GossipedAt { hash: h_stale, - last_gossiped_at: base, + expires_at: base + GOSSIP_ANSWERABILITY_TTL, }, ], }; @@ -942,12 +1322,12 @@ mod tests { recently_gossiped: vec![ GossipedAt { hash: h_current, - last_gossiped_at: now, + expires_at: now + GOSSIP_ANSWERABILITY_TTL, }, GossipedAt { // Gossiped a while ago, but still comfortably within the TTL. hash: h_prev, - last_gossiped_at: base, + expires_at: base + GOSSIP_ANSWERABILITY_TTL, }, ], }; @@ -1009,7 +1389,7 @@ mod tests { has_current: false, // already retired recently_gossiped: vec![GossipedAt { hash: h1, - last_gossiped_at: base, + expires_at: base + GOSSIP_ANSWERABILITY_TTL, }], }; @@ -1039,7 +1419,7 @@ mod tests { has_current: false, // retired recently_gossiped: vec![GossipedAt { hash: h1, - last_gossiped_at: base, + expires_at: base + GOSSIP_ANSWERABILITY_TTL, }], }; @@ -1132,11 +1512,101 @@ mod tests { BuiltCommitment::build(vec![(key(i), bh(i))], &[0; 32], &sk, &pk_bytes).unwrap(); state.rotate(c); } - // h1 was gossiped and is still within the last-2-gossiped window + // h1 was gossiped and is still in-window (unexpired TTL) // (nothing else was gossiped), so it must still be answerable. assert!( state.lookup_by_hash(&h1).is_some(), "gossiped commitment must survive ungossiped rebuilds" ); } + + // === ADR-0004: current_for_quote (quote-issuance answerability) === + + use crate::payment::quote::CommitmentSource; + + #[test] + fn current_for_quote_returns_current_binding_and_is_current_only() { + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + // No current yet -> baseline (None). + assert!(state.current_binding_for_quote().is_none()); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + state.mark_gossiped(h1); + let c2 = BuiltCommitment::build( + vec![(key(2), bh(2)), (key(3), bh(3))], + &peer_id, + &sk, + &pk_bytes, + ) + .unwrap(); + let h2 = c2.hash(); + state.rotate(c2); + + // current_for_quote returns the CURRENT (c2) binding, never the + // previous (c1) — a quote may pin only the live current commitment. + let binding = state + .current_binding_for_quote() + .expect("current binding present"); + assert_eq!( + binding.pin, h2, + "must bind the current commitment, not previous" + ); + assert_eq!(binding.key_count, 2, "current commitment's key count"); + assert_ne!( + binding.pin, h1, + "must never pin the retired/previous commitment" + ); + } + + #[test] + fn current_for_quote_refreshes_answerability() { + // Issuing a quote must refresh the current commitment's answerability, + // exactly like gossiping it (ADR-0004 "quoting is advertising"). + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + // NOT gossiped; instead "quote" it. The quote-issuance refresh must make + // it answerable just as a gossip emission would. + let binding = state.current_binding_for_quote().expect("binding"); + assert_eq!(binding.pin, h1); + assert!( + state.lookup_by_hash(&h1).is_some(), + "a quoted current pin must be answerable (issuance refreshed retention)" + ); + } + + #[test] + fn retired_current_cannot_be_quoted() { + // After retire_current (node has no responsible keys), there is no live + // current commitment, so current_for_quote yields baseline — a retired + // commitment can never be newly quoted. + let (pk, sk) = keypair(); + let pk_bytes = pk.to_bytes(); + let peer_id = *blake3::hash(&pk.to_bytes()).as_bytes(); + let state = ResponderCommitmentState::new(); + + let c1 = BuiltCommitment::build(vec![(key(1), bh(1))], &peer_id, &sk, &pk_bytes).unwrap(); + let h1 = c1.hash(); + state.rotate(c1); + state.mark_gossiped(h1); + state.retire_current(); + + assert!( + state.current_binding_for_quote().is_none(), + "a retired current commitment must not be quotable" + ); + // ...but it stays answerable for any in-flight pin until its TTL lapses. + assert!(state.lookup_by_hash(&h1).is_some()); + } } diff --git a/src/replication/config.rs b/src/replication/config.rs index 575008a7..f1544664 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -294,6 +294,67 @@ const _: () = assert!( "wire cap must fit at least one max-size chunk per byte-challenge response" ); +/// Rollout gate for ADR-0004 quote-arithmetic enforcement. +/// +/// When `true`, the payment verifier rejects any quote whose signed price does +/// not lie exactly on the public pricing curve, i.e. there is no integer +/// `key_count` for which `calculate_price(key_count) == quote.price`. The check +/// is exact recomputation against the curve, never price-inversion (which +/// rounds), per ADR-0004's "never by inverting the price" rule. +/// +/// When `false`, the check still runs and logs every would-be rejection, but +/// does not reject — matching the ADR-0004 rollout: ship observe-only first, +/// enforce only once the fleet has upgraded. Off-curve quotes are honest +/// errors, not signs of an old node (every honest implementation derives its +/// price from the same public formula), so flipping this to `true` does not +/// risk evicting un-upgraded peers; it only catches modified nodes that minted +/// prices off the curve. +/// +/// This is a reject-only gate: an off-curve quote produces no trust evidence +/// and no audit, per ADR-0004 ("an off-curve quote is reject-only"). It does +/// NOT gate the quote/commitment mismatch trust report — see +/// [`QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED`] for that, kept separate because +/// the two have different ADR-0004 contracts (this one rejects with no trust +/// action; that one reports a deterministic contradiction to the trust engine). +pub const QUOTE_ARITHMETIC_RECHECK_ENABLED: bool = false; + +/// Rollout gate for ADR-0004 quote/commitment **mismatch** trust reporting. +/// +/// When a client-put quote's signed `committed_key_count` contradicts the +/// `key_count` of the commitment it pinned (resolved from the gossip cache, a +/// sidecar, or a fetch), that is two artifacts signed by the same key that +/// contradict each other — a deterministic, first-occurrence misbehaviour. When +/// `true`, the cross-check reports it to the trust engine as an +/// `ApplicationFailure` (the same lane as a confirmed deterministic audit +/// failure, NOT the timeout silence lane). When `false`, the cross-check only +/// logs the would-be report (observe-only). +/// +/// Kept independent of [`QUOTE_ARITHMETIC_RECHECK_ENABLED`] (which is +/// reject-only with no trust action): a confirmed mismatch is not a mere +/// off-curve price, so it deserves its own dial. +pub const QUOTE_COMMITMENT_MISMATCH_TRUST_ENABLED: bool = false; + +/// ADR-0004: max unresolved quote pins to fetch per payment bundle. +/// +/// A bundle has at most `CLOSE_GROUP_SIZE` quotes; capping fetches per bundle +/// bounds the amplification a single malicious upload (many distinct unknown +/// pins) can drive. Excess unresolved pins in one bundle are dropped (left +/// unresolved, i.e. graced — the audit funnel still catches a serving cheater). +pub const MAX_PIN_FETCHES_PER_BUNDLE: usize = 3; + +/// ADR-0004: capacity of the per-peer negative cache for unresolved pin fetches. +/// +/// A pin that a peer answered `NotRetained` (or that timed out) is remembered so +/// repeated bundles citing the same unknown pin don't re-fetch. +pub const PIN_FETCH_NEGATIVE_CACHE_CAPACITY: usize = 4096; + +/// ADR-0004: timeout for a `GetCommitmentByPin` fetch. +/// +/// Sized for a single small round-trip plus the responder's bounded in-memory +/// lookup; a fetch is off the payment hot path, so this only bounds the +/// background cross-check. +pub const PIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10); + /// Verification request timeout (per-batch). const VERIFICATION_REQUEST_TIMEOUT_SECS: u64 = 15; /// Verification request timeout (per-batch). @@ -321,6 +382,22 @@ pub const AUDIT_ON_GOSSIP_PROBABILITY: f64 = 0.2; /// seconds. Bounds how often any one peer is audited regardless of gossip rate. pub const AUDIT_ON_GOSSIP_COOLDOWN_SECS: u64 = 30 * 60; +/// ADR-0004: first-audit drainer retry cadence for cooldown-pending pins. +/// +/// How often the drainer retries pins it kept pending because their peer was on +/// cooldown. Finer than the cooldown itself so a monetized commitment is +/// first-audited promptly after its peer's window reopens; the retry just +/// re-checks a small per-peer map, so the tick is cheap. +pub const FIRST_AUDIT_RETRY_INTERVAL: Duration = Duration::from_secs(60); + +/// ADR-0004: max monetized-pin events the first-audit drainer drains from its +/// channel per wake before it must run the audit-launch phase. +/// +/// Bounds the synchronous `try_recv` batch so a sustained producer flood cannot +/// starve audit launching by spinning in the drain loop forever. After this many +/// events the drainer processes audits, then loops back to drain more. +pub const FIRST_AUDIT_DRAIN_BATCH: usize = 64; + /// Number of subtree leaves spot-checked against real chunk bytes per audit /// (ADR-0002 real-bytes layer). /// diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 80776cdc..bd827210 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -34,10 +34,12 @@ pub mod subtree; pub mod types; use std::collections::{HashMap, HashSet}; -use std::path::Path; +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime}; +use lru::LruCache; use std::pin::Pin; use crate::logging::{debug, error, info, warn}; @@ -53,7 +55,9 @@ use crate::error::{Error, Result}; use crate::payment::{PaymentVerifier, VerificationContext}; use crate::replication::audit::AuditTickResult; use crate::replication::commitment::{commitment_hash, StorageCommitment}; -use crate::replication::commitment_state::{PeerCommitmentRecord, ResponderCommitmentState}; +use crate::replication::commitment_state::{ + PeerCommitmentRecord, PersistedRetention, ResponderCommitmentState, +}; use crate::replication::config::{ max_parallel_fetch, storage_admission_width, ReplicationConfig, MAX_AUDIT_RESPONSES_PER_PEER, MAX_CONCURRENT_AUDIT_RESPONSES, MAX_CONCURRENT_REPLICATION_SENDS, REPLICATION_PROTOCOL_ID, @@ -135,8 +139,8 @@ const BOOTSTRAP_DRAIN_CHECK_SECS: u64 = 5; /// Each rebuild scans LMDB to compute leaf hashes; for ~10k keys this is /// sub-100ms (BLAKE3 + tree build). Retention is gossip-anchored, NOT /// rotation-anchored: the responder stays answerable for the current -/// commitment plus the last `RETAINED_GOSSIPED_COMMITMENTS` (= 2) it -/// actually gossiped, each kept for `GOSSIP_ANSWERABILITY_TTL` (3 h) after +/// commitment plus every root it recently gossiped that is still in-window +/// (~2 in steady state), each kept for `GOSSIP_ANSWERABILITY_TTL` (3 h) after /// its last emission (see `commitment_state`). So the rotation cadence does /// not by itself bound answerability — a gossiped commitment stays /// answerable across rotations until its gossip TTL lapses. @@ -155,6 +159,44 @@ const BOOTSTRAP_DRAIN_CHECK_SECS: u64 = 5; /// "rotated past" case. const COMMITMENT_ROTATION_INTERVAL_SECS: u64 = 3600; +/// How often the responder retention snapshot is flushed to disk (ADR-0004 A1). +/// Short relative to the answerability TTL (3 h) so a gossip-stamp refresh is +/// durable well before it could matter to a restart, while the write-on-change +/// guard keeps idle nodes from needless disk writes. +const RETENTION_PERSIST_INTERVAL_SECS: u64 = 30; + +/// Maximum tolerated auditor↔responder wall-clock skew for the first-audit +/// in-window screen (ADR-0004 A1 guardrail A). The screen accepts a monetized pin +/// for first audit only if its SIGNED `quote_ts` lands in +/// `[now - (GOSSIP_ANSWERABILITY_TTL - MONETIZED_AUDIT_SKEW_MARGIN), now + MONETIZED_AUDIT_SKEW_MARGIN]` +/// — fail-closed on BOTH ends: a quote dated too far in the future (a +/// badly-skewed or replayed quote) and one too old (the responder may have aged +/// the pin out) are both skipped, so — with grace removed — a stale/skewed quote +/// cannot frame an honest node. This assumes bounded clock skew (nodes NTP-synced +/// within this margin); a legit first audit fires moments after payment +/// (`quote_ts ≈ now`), far from either bound. The gossip-lottery path (which pins +/// the responder's OWN freshly-gossiped root) is the clock-skew-immune backstop. +/// 30 min dwarfs any realistic honest skew while leaving a wide audit window. +const MONETIZED_AUDIT_SKEW_MARGIN: Duration = Duration::from_secs(30 * 60); + +/// ADR-0004 A1 (guardrail A): whether a monetized pin's SIGNED `quote_ts` lands +/// inside the answerability window relative to `now`, so first-auditing it cannot +/// false-convict once grace is removed. Fail-closed on BOTH ends (see +/// [`MONETIZED_AUDIT_SKEW_MARGIN`]): a quote more than the skew margin in the +/// future, or older than `GOSSIP_ANSWERABILITY_TTL - margin`, is out of window. +/// All comparisons use `duration_since` (no `Duration` overflow). +fn quote_within_audit_window(quote_ts: SystemTime, now: SystemTime) -> bool { + let too_future = quote_ts + .duration_since(now) + .is_ok_and(|ahead| ahead > MONETIZED_AUDIT_SKEW_MARGIN); + let audit_cutoff = crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL + .saturating_sub(MONETIZED_AUDIT_SKEW_MARGIN); + let too_old = now + .duration_since(quote_ts) + .is_ok_and(|age| age >= audit_cutoff); + !(too_future || too_old) +} + /// Minimum interval between commitment signature verifications for a /// single peer (v10/v12 §2 step 3 + §11 `DoS`). /// @@ -245,6 +287,12 @@ pub struct ReplicationEngine { /// outbound `NeighborSyncRequest`/`Response`; consulted by the /// commitment-bound audit handler. commitment_state: Arc, + /// Path to the persisted responder retention snapshot + /// (`{root_dir}/commitment_retention.bin`): reloaded on startup so an honest + /// node's answerability survives restart (ADR-0004 A1), which is what makes + /// removing audit grace safe (an unanswerable in-window pin is then provable + /// misbehaviour, not an honest crash-restart). + retention_path: PathBuf, /// Auditor-side per-peer commitment record (last known commitment + /// sticky `commitment_capable` flag). /// @@ -306,6 +354,13 @@ pub struct ReplicationEngine { possession_check_tx: mpsc::UnboundedSender, /// Receiver paired with `possession_check_tx`; taken by the scheduler task. possession_check_rx: Option>, + /// ADR-0004: sender the payment verifier clones to surface monetized pins + /// for a deterministic first audit. The matching receiver is drained by + /// `start_first_audit_drainer`. + monetized_pin_tx: mpsc::UnboundedSender, + /// ADR-0004: receiver half of the monetized-pin channel, taken by + /// `start_first_audit_drainer`. + monetized_pin_rx: Option>, /// Shutdown token. shutdown: CancellationToken, /// Background task handles. @@ -342,7 +397,10 @@ impl ReplicationEngine { let config = Arc::new(config); let (possession_check_tx, possession_check_rx) = mpsc::unbounded_channel(); - Ok(Self { + // ADR-0004: monetized-pin channel (verifier -> first-audit drainer). + let (monetized_pin_tx, monetized_pin_rx) = mpsc::unbounded_channel(); + + let engine = Self { config: Arc::clone(&config), p2p_node, storage, @@ -360,6 +418,7 @@ impl ReplicationEngine { bootstrap_complete_notify: Arc::new(Notify::new()), identity, commitment_state: Arc::new(ResponderCommitmentState::new()), + retention_path: root_dir.join("commitment_retention.bin"), last_commitment_by_peer: Arc::new(RwLock::new(HashMap::new())), ever_capable_peers: Arc::new(RwLock::new(HashSet::new())), recent_provers: Arc::new(RwLock::new(RecentProvers::new())), @@ -370,9 +429,25 @@ impl ReplicationEngine { fresh_write_rx: Some(fresh_write_rx), possession_check_tx, possession_check_rx: Some(possession_check_rx), + monetized_pin_tx, + monetized_pin_rx: Some(monetized_pin_rx), shutdown, task_handles: Vec::new(), - }) + }; + // ADR-0004 A1: reload persisted responder retention BEFORE any task + // spawns, so an honest restarted node is answerable for its pre-restart + // pins from the first audit it serves, and the persist loop never races + // an empty snapshot over the good on-disk file. + load_commitment_retention(&engine.commitment_state, &engine.retention_path).await; + Ok(engine) + } + + /// ADR-0004: a sender the payment verifier uses to surface monetized pins + /// (commitments that backed a payment) for a deterministic first audit. + /// Cloneable; the engine drains the matching receiver. + #[must_use] + pub fn monetized_pin_sender(&self) -> mpsc::UnboundedSender { + self.monetized_pin_tx.clone() } /// Get a reference to the `PaidList`. @@ -513,11 +588,15 @@ impl ReplicationEngine { // Audit #1 (storage-commitment) is gossip-triggered in the message // handler when a peer's commitment is ingested, not on a periodic tick. self.start_commitment_rotation_loop(); + self.start_retention_persist_loop(); self.start_fetch_worker(); self.start_verification_worker(); self.start_bootstrap_sync(dht_events); self.start_fresh_write_drainer(); self.start_possession_check_scheduler(); + // ADR-0004: deterministic first audit of commitments that backed a + // payment (surfaced by the verifier cross-check). + self.start_first_audit_drainer(); info!( "Replication engine started with {} background tasks", @@ -716,6 +795,170 @@ impl ReplicationEngine { self.task_handles.push(handle); } + /// ADR-0004: drain monetized pins surfaced by the verifier cross-check and + /// run a **deterministic first audit** of each — the same `run_subtree_audit` + /// as the gossip path, under the same per-peer cooldown and concurrency + /// caps, but with the probability lottery BYPASSED (the lottery governs + /// re-audits only). Deduped by pin via a bounded set so a pin gets one + /// deterministic first audit; a peer minting fresh pins faster than the + /// cooldown forfeits the older ones' coverage, never the newest's (the + /// channel surfaces newest pins as they are monetized). + #[allow(clippy::too_many_lines)] + fn start_first_audit_drainer(&mut self) { + let Some(mut rx) = self.monetized_pin_rx.take() else { + return; + }; + let gossip_audit = GossipAuditTrigger { + p2p_node: Arc::clone(&self.p2p_node), + config: Arc::clone(&self.config), + recent_provers: Arc::clone(&self.recent_provers), + sync_state: Arc::clone(&self.sync_state), + cooldown: Arc::clone(&self.audit_on_gossip_cooldown), + }; + let shutdown = self.shutdown.clone(); + + let handle = tokio::spawn(async move { + // Bounded dedup of pins that have ALREADY been given their + // deterministic first audit. A pin is inserted ONLY when an audit is + // actually launched (never on a cooldown skip), so a pin skipped now + // can still be first-audited later. + let mut first_audited: LruCache<[u8; 32], ()> = LruCache::new( + NonZeroUsize::new(MAX_LAST_COMMITMENT_BY_PEER).unwrap_or(NonZeroUsize::MIN), + ); + // PERSISTENT pending queue: the most-recently-monetized pin per peer + // that has NOT yet been first-audited. A pin stays here until it is + // ACTUALLY first-audited (enters `first_audited`) — never removed for + // any weaker reason (e.g. a cooldown stamp), so an unaudited monetized + // pin is never silently forgotten. Newest-per-peer: a fresher pin for + // the same peer replaces the older one. Memory is bounded by an LRU: + // each entry needs a SETTLED on-chain payment, so the realistic count + // is tiny; the LRU is a pure DoS backstop that, only under an absurd + // flood, evicts the LEAST-RECENTLY-MONETIZED peer (the one most likely + // already superseded) — never the newest. + let mut pending: LruCache = LruCache::new( + NonZeroUsize::new(MAX_LAST_COMMITMENT_BY_PEER).unwrap_or(NonZeroUsize::MIN), + ); + // Periodic retry tick for pending (cooldown-blocked) pins. Created + // once; `Skip` so a backlog of missed ticks collapses to one. + let mut tick = tokio::time::interval(config::FIRST_AUDIT_RETRY_INTERVAL); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + // Wake on: shutdown, a new monetized pin, OR a periodic tick so + // pending (cooldown-blocked) pins get retried once their window + // reopens even if no new pin arrives. + let drained_new = tokio::select! { + () = shutdown.cancelled() => break, + event = rx.recv() => match event { + Some(e) => { + // Newest-per-peer: a fresher pin replaces the older one, + // BUT only if it is not already first-audited — an + // already-audited duplicate must never overwrite an + // unaudited pending pin for the same peer (it would then + // be dropped as "done" and the unaudited pin lost). Cap + // the per-wake batch drain (FIRST_AUDIT_DRAIN_BATCH) so a + // sustained flood can't starve the audit-launch phase. + if !first_audited.contains(&e.pin) { + pending.put(e.peer, e); + } + let mut drained = 1usize; + while drained < config::FIRST_AUDIT_DRAIN_BATCH { + match rx.try_recv() { + Ok(e) => { + if !first_audited.contains(&e.pin) { + pending.put(e.peer, e); + } + drained += 1; + } + Err(_) => break, + } + } + true + } + None => break, + }, + _ = tick.tick() => false, + }; + let _ = drained_new; + + if pending.is_empty() { + continue; + } + + // Try to launch an audit for each pending peer; keep the ones + // still blocked by cooldown for the next tick. Drain into a vec + // first so we can re-insert the still-blocked ones afterwards + // (LruCache has no drain). `iter()` yields most- to least-recently- + // used; we reverse so re-inserting blocked entries below restores + // their relative recency (oldest re-put first → stays the eviction + // victim, newest stays most-recently-used). + let snapshot: Vec<(PeerId, MonetizedPinEvent)> = + pending.iter().rev().map(|(p, e)| (*p, *e)).collect(); + pending.clear(); + for (peer, event) in snapshot { + // Dedup: a pin already first-audited is dropped (done). + if first_audited.contains(&event.pin) { + continue; + } + // ADR-0004 A1 (guardrail A): only first-audit a pin whose SIGNED + // quote_ts lands inside the answerability window. With grace + // removed, auditing an out-of-window pin the responder may have + // aged out would false-convict, so a stale OR far-future/skewed + // quote is skipped. Legit first-audits fire moments after + // payment (quote_ts ≈ now); a skipped pin can still be + // gossip-lottery audited. + if !quote_within_audit_window(event.quote_ts, SystemTime::now()) { + debug!( + "First audit skipped for {peer}: quote_ts outside the answerability window" + ); + continue; + } + // Cooldown: if the peer's per-peer audit window is closed, keep + // this pin pending and retry on a later tick once it reopens. + // We do NOT treat "cooldown closed" as "already audited" (a + // losing gossip lottery can stamp the window without auditing), + // so the pin stays pending until it gets a REAL first audit; it + // is only ever evicted by the LRU memory backstop above, which + // drops the least-recently-monetized peer, not this newest one. + { + let now = Instant::now(); + let mut map = gossip_audit.cooldown.write().await; + if !cooldown_allows_audit(&mut map, &peer, now) { + pending.put(peer, event); + continue; + } + } + // Audit is launching: now mark the pin first-audited. + first_audited.put(event.pin, ()); + let trigger = gossip_audit.clone(); + tokio::spawn(async move { + let credit = storage_commitment_audit::AuditCredit { + recent_provers: &trigger.recent_provers, + }; + let result = storage_commitment_audit::run_subtree_audit( + &trigger.p2p_node, + &trigger.config, + &event.peer, + event.pin, + event.key_count, + Some(&credit), + ) + .await; + handle_subtree_audit_result( + &result, + &trigger.p2p_node, + &trigger.sync_state, + &trigger.recent_provers, + &trigger.config, + ) + .await; + }); + } + } + debug!("First-audit drainer shut down"); + }); + self.task_handles.push(handle); + } + #[allow(clippy::too_many_lines)] fn start_message_handler(&mut self) { let mut p2p_events = self.p2p_node.subscribe_events(); @@ -1105,6 +1348,11 @@ impl ReplicationEngine { // ML-DSA signatures are randomized so we cannot reproduce // the pre-restart hash; the only honest path to recovery // is fast re-gossip. + // ADR-0004 A1: retention was reloaded in `new()` (before any task + // spawned), so this initial rebuild no-ops when the key set is + // unchanged — preserving the reloaded current pin; otherwise the + // reloaded roots stay answerable as retained slots until their gossip + // TTL lapses. Persistence is handled by the retention-persist loop. if let Err(e) = rebuild_and_rotate_commitment(&storage, &identity, &commitment_state, &p2p, &config) .await @@ -1147,6 +1395,37 @@ impl ReplicationEngine { self.task_handles.push(handle); } + /// ADR-0004 A1: periodically flush the responder retention snapshot to disk + /// (write-on-change) so answerability — including gossip-stamp refreshes and + /// rotations — survives a restart. Flushes once immediately, then every + /// `RETENTION_PERSIST_INTERVAL_SECS`, and once more on shutdown. + fn start_retention_persist_loop(&mut self) { + let commitment_state = Arc::clone(&self.commitment_state); + let retention_path = self.retention_path.clone(); + let shutdown = self.shutdown.clone(); + let handle = tokio::spawn(async move { + let mut last: Option> = None; + persist_retention_if_changed(&commitment_state, &retention_path, &mut last).await; + loop { + tokio::select! { + () = shutdown.cancelled() => { + persist_retention_if_changed(&commitment_state, &retention_path, &mut last) + .await; + break; + } + () = tokio::time::sleep(std::time::Duration::from_secs( + RETENTION_PERSIST_INTERVAL_SECS, + )) => { + persist_retention_if_changed(&commitment_state, &retention_path, &mut last) + .await; + } + } + } + debug!("Commitment retention persist loop shut down"); + }); + self.task_handles.push(handle); + } + #[allow(clippy::too_many_lines, clippy::option_if_let_else)] fn start_fetch_worker(&mut self) { let p2p = Arc::clone(&self.p2p_node); @@ -1938,6 +2217,41 @@ async fn handle_replication_message( }); Ok(()) } + ReplicationMessageBody::GetCommitmentByPin(ref request) => { + // ADR-0004: answer a commitment-by-pin fetch from the retained set + // only. `lookup_by_hash` is an allocation-light read over the + // bounded slot set; it returns the live current commitment or any + // still-answerable recently-gossiped/quoted one. A miss is reported + // as `NotRetained` (graced, never confirmed) rather than an error, + // so an aged-out pin can never brand an honest node. + // + // Reuse the audit-responder admission guard (global ceiling + per-peer + // cap) so a flood of fetches cannot drive unbounded commitment + // clone/encode/send work; over-limit is dropped, which the fetching + // peer graces exactly like a missed audit response. + let Some(_guard) = + admit_audit_responder(audit_responder_semaphore, audit_responder_inflight, source) + .await + else { + debug!("GetCommitmentByPin from {source} dropped: responder capacity reached"); + return Ok(()); + }; + let response = my_commitment_state.lookup_by_hash(&request.pin).map_or( + protocol::GetCommitmentByPinResponse::NotRetained { pin: request.pin }, + |built| protocol::GetCommitmentByPinResponse::Found { + commitment: built.commitment().clone(), + }, + ); + send_replication_response( + source, + p2p_node, + msg.request_id, + ReplicationMessageBody::GetCommitmentByPinResponse(response), + rr_message_id, + ) + .await; + Ok(()) + } // Response messages are handled by their respective request initiators. ReplicationMessageBody::FreshReplicationResponse(_) | ReplicationMessageBody::NeighborSyncResponse(_) @@ -1945,7 +2259,8 @@ async fn handle_replication_message( | ReplicationMessageBody::FetchResponse(_) | ReplicationMessageBody::AuditResponse(_) | ReplicationMessageBody::SubtreeAuditResponse(_) - | ReplicationMessageBody::SubtreeByteResponse(_) => Ok(()), + | ReplicationMessageBody::SubtreeByteResponse(_) + | ReplicationMessageBody::GetCommitmentByPinResponse(_) => Ok(()), } } @@ -2110,9 +2425,9 @@ async fn handle_fresh_offer( // part of the immediate write fan-out: this receiver is about to store the // record as if the client had PUT it here directly. Storage admission // was checked above before proof work. ClientPut verification applies - // store-strength cache semantics, paid-quote issuer K-closeness and local - // price floor checks for single-node proofs, and merkle candidate - // closeness for merkle proofs. + // store-strength cache semantics, paid-quote issuer K-closeness checks + // for single-node proofs, and merkle candidate closeness for merkle + // proofs. match payment_verifier .verify_payment( &offer.key, @@ -4077,6 +4392,29 @@ struct AuditTarget { key_count: u32, } +/// ADR-0004: a commitment that backed a payment, surfaced by the payment +/// verifier's cross-check so it can receive a **deterministic first audit**. +/// +/// Sent from the verifier to the replication engine's first-audit drainer. The +/// drainer dedups by `pin` (a pin gets one deterministic first audit; later +/// audits of the same peer revert to the gossip lottery), orders most-recently- +/// monetized first, and runs the same `run_subtree_audit` under the same +/// per-peer cooldown and concurrency caps — only the lottery is bypassed. +#[derive(Debug, Clone, Copy)] +pub struct MonetizedPinEvent { + /// The peer whose commitment backed the payment. + pub peer: PeerId, + /// The pinned commitment hash. + pub pin: [u8; 32], + /// The committed key count (sizes the audit deadline). + pub key_count: u32, + /// The accused's own SIGNED quote timestamp. The first-audit drainer skips a + /// pin whose quote is older than the answerability window (ADR-0004 A1 + /// guardrail A): with grace removed, challenging an aged-out pin would + /// false-convict, so a stale client-forwarded quote must not trigger an audit. + pub quote_ts: SystemTime, +} + /// Per-peer audit cooldown check-and-stamp (ADR-0002 "occasional surprise /// exams, keeps load low"). Returns `true` if `peer` may be audited now (and /// stamps `now`), `false` if it was audited within @@ -4457,6 +4795,99 @@ async fn ingest_peer_commitment( // Storage-bound audit (v12) — responder commitment rotation // --------------------------------------------------------------------------- +/// Reload persisted responder retention at startup (ADR-0004 A1). A missing file +/// is a normal fresh start; a corrupt snapshot is logged and skipped (fail-open +/// LOCALLY — the node re-gossips a fresh root — which never grants a remote grace). +async fn load_commitment_retention(state: &ResponderCommitmentState, path: &Path) { + let bytes = match tokio::fs::read(path).await { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + debug!( + "Commitment retention: no snapshot at {} (fresh start)", + path.display() + ); + return; + } + Err(e) => { + warn!( + "Commitment retention: failed to read {}: {e}", + path.display() + ); + return; + } + }; + if let Some(persisted) = PersistedRetention::from_bytes(&bytes) { + state.restore(&persisted); + info!( + "Commitment retention: reloaded {} slot(s) from {}", + state.retained_slot_count(), + path.display() + ); + } else { + warn!( + "Commitment retention: corrupt snapshot at {}; starting with empty retention", + path.display() + ); + } +} + +/// Persist the responder retention snapshot IF it changed since `last` (ADR-0004 +/// A1). Write-on-change keeps frequent gossip-stamp refreshes durable without +/// needless disk writes on idle nodes. On success updates `last` to the bytes +/// written; on a serialization/write error the existing on-disk snapshot is left +/// intact (never truncated). +async fn persist_retention_if_changed( + state: &ResponderCommitmentState, + path: &Path, + last: &mut Option>, +) { + let Some(bytes) = state.snapshot().to_bytes() else { + warn!("Commitment retention: serialization failed; keeping previous snapshot"); + return; + }; + if last.as_deref() == Some(bytes.as_slice()) { + return; + } + if write_retention_atomic(path, bytes.clone()).await { + *last = Some(bytes); + } +} + +/// Durably write `bytes` to `path`: temp file → fsync temp → atomic rename → +/// fsync parent dir (so the rename itself survives a crash). Returns `true` on +/// success. Only the retention-persist loop writes this path, so a fixed temp +/// name is safe. +async fn write_retention_atomic(path: &Path, bytes: Vec) -> bool { + let path = path.to_path_buf(); + let res = tokio::task::spawn_blocking(move || -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &bytes)?; + std::fs::File::open(&tmp)?.sync_all()?; + std::fs::rename(&tmp, &path)?; + // Fsync the directory so the rename (the durable-commit point) is not + // lost on a crash right after it. An empty parent (relative filename) + // means the current directory. + let dir = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::File::open(dir)?.sync_all()?; + Ok(()) + }) + .await; + match res { + Ok(Ok(())) => true, + Ok(Err(e)) => { + warn!("Commitment retention: persist failed: {e}"); + false + } + Err(e) => { + warn!("Commitment retention: persist task join failed: {e}"); + false + } + } +} + /// Read the current LMDB key set, build + sign a fresh /// `StorageCommitment`, and rotate it into `state` as the new `current`. /// The prior `current` is demoted to `previous`; the prior `previous` is @@ -4486,9 +4917,9 @@ async fn rebuild_and_rotate_commitment( // Commit only to keys we are still RESPONSIBLE for ("want-to-hold"), not // everything currently on disk ("hold"). This is the half of the retention // contract that lets out-of-range chunks age out: a key that has left our - // close group is excluded from the NEXT commitment, so within at most - // RETAINED_GOSSIPED_COMMITMENTS gossip rotations it falls out of the - // last-2-gossiped window, `ResponderCommitmentState::is_held` goes false, + // close group is excluded from the NEXT commitment, so once its last gossip + // ages past GOSSIP_ANSWERABILITY_TTL it falls out of the in-window retained + // set, `ResponderCommitmentState::is_held` goes false, // and the pruner (which until then vetoes its deletion) reclaims it. Without // this filter the pruner's reprieve would keep re-committing stale keys // forever (the rebuild reads all_keys, so a retained-on-disk key would be @@ -4627,6 +5058,7 @@ mod tests { apply_audit_failure_credit_revocation, audit_failure_clears_bootstrap_claim, audit_failure_revokes_holder_credit, audit_launch_decision, config, cooldown_allows_audit, first_failed_key_label, fresh_offer_payment_context, paid_notify_payment_context, + quote_within_audit_window, MONETIZED_AUDIT_SKEW_MARGIN, }; use crate::payment::VerificationContext; use crate::replication::recent_provers::RecentProvers; @@ -4635,6 +5067,7 @@ mod tests { use std::collections::HashMap; use std::time::Duration; use std::time::Instant; + use std::time::SystemTime; fn test_peer(b: u8) -> PeerId { let mut bytes = [0u8; 32]; @@ -4664,6 +5097,35 @@ mod tests { ); } + /// ADR-0004 A1 (guardrail A): the monetized first-audit only fires for a + /// signed quote inside the answerability window, fail-closed on BOTH ends so a + /// stale or future/skewed client-forwarded quote cannot frame an honest node. + #[test] + fn monetized_quote_audit_window_fails_closed_both_ends() { + use crate::replication::commitment_state::GOSSIP_ANSWERABILITY_TTL; + let now = SystemTime::now(); + // Fresh (just quoted) and small future/past skew -> audited. + assert!(quote_within_audit_window(now, now)); + assert!(quote_within_audit_window( + now + Duration::from_secs(60), + now + )); + assert!(quote_within_audit_window( + now - Duration::from_secs(3600), + now + )); + // Far future (badly-skewed / replayed) -> skipped. + assert!(!quote_within_audit_window( + now + MONETIZED_AUDIT_SKEW_MARGIN + Duration::from_secs(60), + now + )); + // Older than the window -> skipped (pin may have aged out). + assert!(!quote_within_audit_window( + now - GOSSIP_ANSWERABILITY_TTL, + now + )); + } + #[test] fn audit_timeout_preserves_active_bootstrap_claim() { assert!(!audit_failure_clears_bootstrap_claim( diff --git a/src/replication/protocol.rs b/src/replication/protocol.rs index 1181de54..7a26c835 100644 --- a/src/replication/protocol.rs +++ b/src/replication/protocol.rs @@ -125,6 +125,19 @@ pub enum ReplicationMessageBody { SubtreeByteChallenge(SubtreeByteChallenge), /// Response carrying the requested chunks' original bytes (round 2). SubtreeByteResponse(SubtreeByteResponse), + + // === Commitment fetch by pin (ADR-0004) === + // APPENDED at the end so postcard variant discriminants of all the + // pre-existing variants are unchanged — old nodes keep decoding every + // message they already understood; only these two new indices are unknown + // to them (and they never receive them, since old nodes never send the + // matching request). + /// Fetch a retained commitment by its pin (ADR-0004): used to resolve a + /// quote's `commitment_pin` when the sidecar is absent and the gossip cache + /// has no fresh copy. + GetCommitmentByPin(GetCommitmentByPin), + /// Response to [`Self::GetCommitmentByPin`]. + GetCommitmentByPinResponse(GetCommitmentByPinResponse), } // --------------------------------------------------------------------------- @@ -290,6 +303,41 @@ pub enum FetchResponse { }, } +// --------------------------------------------------------------------------- +// Commitment fetch by pin (ADR-0004) +// --------------------------------------------------------------------------- + +/// Request a retained commitment by its pin (commitment hash). +/// +/// ADR-0004: a storer cross-checking a quote whose `commitment_pin` it does not +/// already hold (no sidecar, no fresh gossip copy) fetches the signed +/// commitment so it can verify the binding and route the commitment into audit. +/// The responder answers only from its retained set, so this never forces a +/// node to reconstruct or re-sign anything. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetCommitmentByPin { + /// The commitment hash (pin) being resolved. + pub pin: [u8; 32], +} + +/// Response to [`GetCommitmentByPin`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum GetCommitmentByPinResponse { + /// The pin resolved to a retained, signed commitment. + Found { + /// The signed commitment matching the requested pin. The fetcher + /// re-verifies its signature and peer binding before trusting it. + commitment: crate::replication::commitment::StorageCommitment, + }, + /// The pin is not among the responder's retained commitments (rotated/aged + /// out, or never held). ADR-0004 treats this as graced, never confirmed: + /// an unanswerable pin is indistinguishable from an honest crash-restart. + NotRetained { + /// Echo of the requested pin, for matching. + pin: [u8; 32], + }, +} + // --------------------------------------------------------------------------- // Audit Messages // --------------------------------------------------------------------------- @@ -419,55 +467,36 @@ pub enum SubtreeAuditResponse { /// Why a responder rejected an audit challenge, in a form the auditor can act /// on without string-matching. /// -/// The distinction matters for accounting: a responder that no longer RETAINS -/// the pinned commitment may simply have rotated past it legitimately — the -/// auditor can pin a root it gossiped a while ago, or one whose newer -/// replacements the auditor never observed (retention is capped at the last two -/// *gossiped* roots, so a peer that rotated several times within the -/// answerability window can honestly drop an older one). That is NOT provable -/// misbehaviour, so it is GRACED like a timeout. Every other rejection is a -/// genuine protocol fault the auditor confirms. -/// -/// **Self-grace is bounded and does not preserve stale credit.** A Byzantine -/// peer can deliberately claim `UnknownCommitment`/`Transient` to dodge the -/// confirmed-failure *trust penalty*. But the auditor still REVOKES the holder -/// credit for the PINNED commitment on any graced rejection (it answered and -/// could not prove possession now — see the credit revocation in -/// `storage_commitment_audit`'s rejection handling). The revocation is scoped to -/// the pinned commitment hash, so it strips exactly the credit for the root the -/// peer would not prove without touching credit it legitimately re-earned for a -/// newer commitment. Lying therefore does not let a deleter keep "proven holder" -/// status for that root until the credit TTL — the loophole a plain timeout -/// would leave. A peer that self-graces on every audit remains uncredited for the -/// pinned commitments it refuses to prove; an honest peer that genuinely rotated -/// simply re-earns credit on the next audit of its current commitment. The grace -/// removes only the false TRUST PENALTY for the genuinely-ambiguous -/// rotated/transient case; it does not remove the possession requirement. +/// ADR-0004 Amendment 1: audit **grace is removed**. Answerability is now +/// restart-durable (the responder persists and reloads its commitment retention) +/// and the auditor only pins roots inside the answerability window, so an honest +/// node can always answer a pin it could be challenged on. The auditor therefore +/// grades a responsive rejection purely by kind, with no grace: +/// - `UnknownCommitment` / `Protocol` → **confirmed failure**: repudiating a +/// pinned root the node published (and may have been paid for), or an explicit +/// protocol fault. +/// - `Transient` → routed to the **non-response/timeout lane** (no trust penalty, +/// but the holder credit for the pinned commitment IS revoked). The responder +/// retries reads before emitting `Transient`, so one that still reaches the +/// auditor means the node could not serve data it committed to. A +/// `Transient`-spammer thus gains no positive standing; deterministically +/// distinguishing malicious from genuine transient IO network-wide is the +/// out-of-scope distributed non-response problem. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum RejectKind { - /// The responder does not retain the pinned commitment (rotated past it). - /// GRACED — indistinguishable from legitimate rotation the auditor missed. + /// The responder does not retain the pinned commitment. With restart-durable + /// retention and in-window auditing this is provable repudiation of a root + /// the node published → CONFIRMED failure. UnknownCommitment, - /// A transient, recoverable condition on the responder (e.g. a storage read - /// error) that is NOT evidence of missing data. GRACED like a timeout so a - /// flaky disk never manufactures a confirmed possession failure. + /// A transient, recoverable local condition (e.g. a storage read error), + /// emitted only after the responder's read retries failed. Routed to the + /// timeout lane (holder credit revoked, no trust penalty). Transient, /// Any other rejection (wrong target peer, no commitment state, malformed /// proof plan, oversized byte challenge, …). CONFIRMED failure. Protocol, } -impl RejectKind { - /// Whether the auditor should GRACE this rejection (treat like a timeout — - /// no confirmed penalty, no holder-credit revocation) rather than confirm - /// it. Only genuine protocol faults are confirmed; rotation/transient - /// conditions are graced because they are not provable misbehaviour. - #[must_use] - pub fn is_graced(self) -> bool { - matches!(self, Self::UnknownCommitment | Self::Transient) - } -} - /// Round 2 of the storage audit (ADR-0002): the **surprise byte challenge**. /// /// After the auditor has structurally verified a [`SubtreeAuditResponse::Proof`] @@ -547,8 +576,9 @@ pub enum SubtreeByteResponse { challenge_id: u64, }, /// The responder rejects the byte challenge outright. `kind` drives the - /// auditor's accounting: [`RejectKind::UnknownCommitment`] (rotated past the - /// pin) is graced; everything else is a confirmed failure, like round 1. + /// auditor's accounting (ADR-0004 A1: grace removed): [`RejectKind::Transient`] + /// routes to the timeout lane (no trust penalty, holder credit revoked); every + /// other kind is a confirmed failure, like round 1. Rejected { /// The challenge this response answers. challenge_id: u64, diff --git a/src/replication/storage_commitment_audit.rs b/src/replication/storage_commitment_audit.rs index 9923a3a5..14e5104f 100644 --- a/src/replication/storage_commitment_audit.rs +++ b/src/replication/storage_commitment_audit.rs @@ -10,6 +10,7 @@ //! [`crate::replication::subtree`]. use std::sync::Arc; +use std::time::Duration; use crate::logging::{debug, info, warn}; use rand::Rng; @@ -51,6 +52,64 @@ use crate::replication::audit::AuditTickResult; const BYTE_SPOTCHECK_MIN: u32 = 3; const BYTE_SPOTCHECK_MAX: u32 = 5; +/// ADR-0004 A1: with grace removed, the responder retries a TRANSIENT chunk-read +/// error a few times before rejecting `Transient` (which routes to the timeout +/// lane). A momentary disk blip usually clears within these attempts; only a +/// persistent read failure — the node genuinely cannot serve committed bytes — +/// falls through. Total added latency ((attempts − 1) × backoff) stays well inside the +/// audit response deadline. +const AUDIT_READ_RETRY_ATTEMPTS: u32 = 3; +const AUDIT_READ_RETRY_BACKOFF: Duration = Duration::from_millis(200); + +/// Read a committed chunk's bytes, retrying a transient read error up to +/// [`AUDIT_READ_RETRY_ATTEMPTS`] times with [`AUDIT_READ_RETRY_BACKOFF`] between +/// tries. `Ok(None)` (bytes definitively absent — real loss) is NOT retried; only +/// an `Err` (transient IO) is. A persistent `Err` is returned so the caller emits +/// `RejectKind::Transient` (timeout lane). +async fn get_raw_retrying( + storage: &LmdbStorage, + key: &XorName, +) -> crate::error::Result>> { + let mut attempt = 1u32; + loop { + match storage.get_raw(key).await { + Ok(v) => return Ok(v), + Err(e) if attempt < AUDIT_READ_RETRY_ATTEMPTS => { + debug!( + "Audit: transient read error for {} (attempt {attempt}/{AUDIT_READ_RETRY_ATTEMPTS}): {e}; retrying", + hex::encode(key) + ); + attempt += 1; + tokio::time::sleep(AUDIT_READ_RETRY_BACKOFF).await; + } + Err(e) => return Err(e), + } + } +} + +/// How the auditor grades a *responsive* audit rejection (ADR-0004 A1: grace +/// removed). The decision is a pure function of the [`RejectKind`] so it can be +/// unit-tested independently of the P2P/side-effect machinery. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RejectGrade { + /// Provable misbehaviour → confirmed failure (trust penalty + credit + /// revocation downstream). + Confirmed, + /// Non-response/timeout lane: no trust penalty, but the pinned commitment's + /// holder credit is revoked (the peer answered but could not prove possession). + TimeoutLane, +} + +/// Grade a responsive rejection. Repudiating a pinned root (`UnknownCommitment`) +/// or an explicit protocol fault is a confirmed failure; a `Transient` read error +/// (already retried by the responder) routes to the timeout lane. +const fn grade_reject(kind: RejectKind) -> RejectGrade { + match kind { + RejectKind::UnknownCommitment | RejectKind::Protocol => RejectGrade::Confirmed, + RejectKind::Transient => RejectGrade::TimeoutLane, + } +} + /// Holder-eligibility cache the auditor credits on a passing audit. /// /// Owned by [`crate::replication::ReplicationEngine`]; borrowed here so a @@ -179,12 +238,12 @@ enum ByteRound { /// The responder rejected the byte challenge (confirmed failure for a /// recently pinned commitment). Rejected, - /// The responder rejected with a GRACED kind (`UnknownCommitment`/ - /// `Transient`): no trust penalty, but holder credit is revoked — the peer - /// answered and could not prove possession, so it must not keep stale credit - /// (codex-r2 C). Distinct from a silent network `Timeout`, which keeps credit - /// (a dropped packet is not evidence of loss). - GracedReject, + /// The responder rejected with `Transient` (a local read error): routed to + /// the non-response/timeout lane — no trust penalty, but holder credit is + /// revoked, because the peer answered and could not prove possession, so it + /// must not keep stale credit. Distinct from a silent network `Timeout`, + /// which keeps credit (a dropped packet is not evidence of loss). + TransientReject, /// No response within the byte deadline, or a transport error (graced /// timeout). Keeps holder credit. Timeout, @@ -262,22 +321,26 @@ async fn request_byte_proof(ctx: &AuditCtx<'_>, keys: &[XorName]) -> ByteRound { kind, reason, }) if challenge_id == ctx.challenge_id => { - // Graced kinds (rotated past the pin / transient read error, §6/§7) - // are not a confirmed cheat — no trust penalty — but the peer DID - // answer and could not prove possession, so credit is revoked - // (GracedReject), unlike a silent network timeout. - if kind.is_graced() { - debug!( - "Audit: {} rejected byte challenge (graced, {kind:?}): {reason}", - ctx.challenged_peer - ); - ByteRound::GracedReject - } else { - warn!( - "Audit: {} rejected byte challenge: {reason}", - ctx.challenged_peer - ); - ByteRound::Rejected + // ADR-0004 A1: grace removed. UnknownCommitment/Protocol repudiation + // of a pinned root is a confirmed failure; a Transient read error + // routes to the timeout lane (credit revoked, no trust penalty) — the + // responder retries reads first, so a Transient reaching round 2 means + // it still could not serve committed bytes. + match grade_reject(kind) { + RejectGrade::Confirmed => { + warn!( + "Audit: {} rejected byte challenge ({kind:?}; confirmed): {reason}", + ctx.challenged_peer + ); + ByteRound::Rejected + } + RejectGrade::TimeoutLane => { + debug!( + "Audit: {} returned Transient for byte challenge (timeout lane): {reason}", + ctx.challenged_peer + ); + ByteRound::TransientReject + } } } // A node claiming bootstrap MID-AUDIT (it answered round 1) is treated @@ -325,42 +388,45 @@ async fn dispatch_subtree_response( if resp_id != challenge_id { return malformed(); } - // A genuine protocol rejection of a freshly pinned root is a - // confirmed failure (repudiating what you just published). But an - // `UnknownCommitment`/`Transient` rejection is GRACED (§6/§7): the - // peer may have legitimately rotated past a root the auditor still - // had cached (retention is capped at the last two gossiped roots), - // or hit a transient read error — neither is provable misbehaviour, - // so we do NOT apply the trust penalty (return a graced Timeout). - if kind.is_graced() { - // BUT revoke the holder credit for THIS pinned commitment - // (codex-r2 C): the peer did not prove possession of it NOW, so - // it must not keep "proven holder" credit for it until the TTL - // lapses. Closes the loophole where a deleter lies - // `Transient`/`UnknownCommitment` to dodge the confirmed-failure - // path and PRESERVE stale credit. - // - // Scoped to the pinned commitment hash, NOT the whole peer - // (codex-r3): a commitment hash is peer-specific (it signs over - // `sender_peer_id`), so this revokes exactly this peer's credit - // for this commitment. A delayed/stale audit of an OLD commitment - // C1 therefore cannot erase the valid credit an honest rotated - // peer already re-earned for its CURRENT commitment C2. - if let Some(credit) = ctx.credit { - credit - .recent_provers - .write() - .await - .forget_commitment(&ctx.expected_commitment_hash); + // ADR-0004 A1: audit grace is REMOVED. Answerability is now + // restart-durable (persisted retention) and the auditor only pins + // in-window roots, so an honest node can always answer a pin it could + // be challenged on. A responsive rejection is therefore graded on the + // kind, with no grace: + match grade_reject(kind) { + // Repudiating a pinned root the node published (`UnknownCommitment`) + // or an explicit protocol fault is provable misbehaviour → + // confirmed failure (trust penalty + credit revocation happen + // downstream in handle_subtree_failed_audit). + RejectGrade::Confirmed => { + warn!( + "Audit: peer {challenged_peer} rejected subtree challenge \ + ({kind:?}; confirmed — grace removed): {reason}" + ); + failed(challenged_peer, challenge_id, AuditFailureReason::Rejected) + } + // A transient local read error (already retried by the responder) + // is not a provable cheat, but not graced-with-standing either: + // route it to the non-response/timeout lane — no trust penalty, but + // revoke the holder credit for THIS pinned commitment so it gains + // no positive standing (a Transient-spammer profits nothing). + // Scoped to the commitment hash, not the whole peer, so a stale + // audit of an old commitment cannot erase credit re-earned for a + // newer one. + RejectGrade::TimeoutLane => { + if let Some(credit) = ctx.credit { + credit + .recent_provers + .write() + .await + .forget_commitment(&ctx.expected_commitment_hash); + } + debug!( + "Audit: peer {challenged_peer} returned Transient for subtree challenge \ + (timeout lane; credit for the pinned commitment revoked): {reason}" + ); + failed(challenged_peer, challenge_id, AuditFailureReason::Timeout) } - debug!( - "Audit: peer {challenged_peer} rejected subtree challenge \ - (graced, {kind:?}; credit for the pinned commitment revoked): {reason}" - ); - failed(challenged_peer, challenge_id, AuditFailureReason::Timeout) - } else { - warn!("Audit: peer {challenged_peer} rejected subtree challenge: {reason}"); - failed(challenged_peer, challenge_id, AuditFailureReason::Rejected) } } ReplicationMessageBody::SubtreeAuditResponse(SubtreeAuditResponse::Proof { @@ -606,11 +672,11 @@ async fn verify_subtree_response( // CRITICAL: verify each batch's served bytes AS IT ARRIVES, against that // batch's own sampled leaves, and return a CONFIRMED failure immediately. // Deferring all verification until every batch is collected would let a - // later batch's graced Timeout (`round_failure`) mask a deterministic + // later batch's timeout-lane Timeout (`round_failure`) mask a deterministic // failure already proven by an earlier batch (an absent committed key or a - // hash mismatch) — a confirmed cheat would be downgraded to a graced - // timeout. A Timeout/Rejected/Malformed only becomes the verdict if NO - // earlier batch already produced confirmed bad bytes. + // hash mismatch) — a confirmed cheat would be downgraded to a timeout. A + // Timeout/Rejected/Malformed only becomes the verdict if NO earlier batch + // already produced confirmed bad bytes. let verdict = 'rounds: { for batch in sampled.chunks(MAX_BYTE_CHALLENGE_KEYS) { let batch_keys: Vec = batch.iter().map(|l| l.key).collect(); @@ -641,13 +707,13 @@ async fn verify_subtree_response( ByteRound::Rejected => { break 'rounds AuditVerdict::Fail(AuditFailureReason::Rejected) } - // Graced reject (rotated past the pin / transient): no trust - // penalty, but the peer answered and could not prove possession, - // so revoke the holder credit for THIS pinned commitment - // (codex-r2 C) before taking the graced Timeout verdict. Scoped - // to the commitment hash, not the whole peer (codex-r3), so it - // never erases credit the peer re-earned for a newer commitment. - ByteRound::GracedReject => { + // Transient reject (a local read error): ADR-0004 A1 routes it to + // the timeout lane — no trust penalty, but revoke the holder + // credit for THIS pinned commitment (the peer answered and could + // not prove possession) before taking the Timeout verdict. Scoped + // to the commitment hash, not the whole peer, so it never erases + // credit the peer re-earned for a newer commitment. + ByteRound::TransientReject => { if let Some(credit) = ctx.credit { credit .recent_provers @@ -787,8 +853,8 @@ fn failed( /// Map a subtree-audit `reason` to a single-failure [`AuditFailureSummary`]. /// -/// A `Timeout` is not (yet) a confirmed failure (it is graced), so it rolls up -/// as zero confirmed failures; every other reason is one confirmed failure, +/// A `Timeout` is not a confirmed failure (it is the non-response/timeout lane), +/// so it rolls up as zero confirmed failures; every other reason is one confirmed failure, /// categorised where the category is meaningful (byte/nonce/root mismatch → /// `digest_mismatch_keys`; explicit absent → `absent_keys`). fn subtree_failure_summary(reason: &AuditFailureReason) -> AuditFailureSummary { @@ -820,10 +886,10 @@ fn subtree_failure_summary(reason: &AuditFailureReason) -> AuditFailureSummary { /// Handle an incoming subtree audit challenge (responder side). /// /// Validates the challenge targets this node, looks up the pinned commitment in -/// the retained (last-two-gossiped) set, and builds the subtree proof for the +/// the retained (in-window) set, and builds the subtree proof for the /// nonce-selected branch. If this node is bootstrapping it says so; if it -/// genuinely does not retain the pinned commitment it rejects (which the -/// auditor treats as a confirmed failure for a recently gossiped root). +/// genuinely does not retain the pinned commitment it rejects (which, with audit +/// grace removed, the auditor treats as a confirmed failure for an in-window pin). pub async fn handle_subtree_challenge( challenge: &SubtreeAuditChallenge, storage: &LmdbStorage, @@ -858,10 +924,12 @@ pub async fn handle_subtree_challenge( }; }; - // Look up the pinned commitment among the last-two-gossiped retained set. - // A miss is `UnknownCommitment` — the auditor GRACES it (the peer may have - // legitimately rotated past a root the auditor still had cached), rather - // than treating legitimate rotation as a confirmed repudiation (§6). + // Look up the pinned commitment among the in-window retained set (TTL-bounded + // answerability with a MAX_RETAINED_GOSSIPED_SLOTS backstop). + // A miss is `UnknownCommitment`. With audit grace removed (ADR-0004 A1) the + // auditor treats a responsive miss on an in-window pin as a CONFIRMED failure: + // answerability is restart-durable and pins are challenged only while in + // window, so failing to answer is a real repudiation, not benign rotation. let Some(built) = state.lookup_by_hash(&challenge.expected_commitment_hash) else { return SubtreeAuditResponse::Rejected { challenge_id: challenge.challenge_id, @@ -888,7 +956,7 @@ pub async fn handle_subtree_challenge( // of subtree size, hashing each into its plain + nonced leaf. let mut leaves = Vec::with_capacity(plan.leaf_keys.len()); for key in &plan.leaf_keys { - let bytes = match storage.get_raw(key).await { + let bytes = match get_raw_retrying(storage, key).await { Ok(Some(bytes)) => bytes, // Key is in our committed tree but definitively NOT stored — real // storage loss / the classic deleter. For a recently gossiped pin @@ -904,9 +972,10 @@ pub async fn handle_subtree_challenge( reason: format!("missing bytes for committed key: {}", hex::encode(key)), }; } - // Transient storage read error — NOT evidence of missing data (§7). - // Reject as graced (timeout-class) so a flaky disk never brands an - // honest holder a deleter. + // Persistent transient read error after retries — NOT proof of missing + // data. Reject `Transient`; the auditor routes it to the timeout lane + // (no confirmed penalty) so a genuinely flaky disk is not branded a + // deleter, while gaining no positive standing. Err(e) => { warn!( "Subtree audit: storage read error for committed key {}: {e} \ @@ -996,10 +1065,11 @@ pub async fn handle_subtree_byte_challenge( }; }; // Resolve the SAME commitment the auditor pinned in round 1. If we no longer - // retain it (rotated past it), reject as `UnknownCommitment` — the auditor - // GRACES that (legitimate rotation it may not have observed, §6), rather - // than confirming a failure. We serve bytes only for keys committed under - // this pin. + // retain it (rotated past it), reject as `UnknownCommitment`. With audit + // grace removed (ADR-0004 A1) the auditor treats a responsive miss on an + // in-window pin as a confirmed failure — answerability is restart-durable and + // pins are challenged only in-window. We serve bytes only for keys committed + // under this pin. let Some(built) = state.lookup_by_hash(&challenge.expected_commitment_hash) else { return SubtreeByteResponse::Rejected { challenge_id: challenge.challenge_id, @@ -1019,7 +1089,7 @@ pub async fn handle_subtree_byte_challenge( items.push(SubtreeByteItem::Absent { key: *key }); continue; } - match storage.get_raw(key).await { + match get_raw_retrying(storage, key).await { // Committed key, bytes present → serve them. Ok(Some(bytes)) => items.push(SubtreeByteItem::Present { key: *key, bytes }), // Committed key, definitively absent → provable failure (§7: this is @@ -1031,10 +1101,10 @@ pub async fn handle_subtree_byte_challenge( ); items.push(SubtreeByteItem::Absent { key: *key }); } - // Transient storage read error → do NOT brand the peer a deleter - // (§7). Reject the whole challenge as a graced (timeout-class) - // outcome so a flaky LMDB read never manufactures a confirmed - // possession failure on an honest holder. + // Persistent transient read error after retries → do NOT brand the + // peer a deleter. Reject `Transient`; the auditor routes it to the + // timeout lane so a flaky LMDB read never manufactures a confirmed + // possession failure on an honest holder (which also gains no credit). Err(e) => { warn!( "Subtree byte audit: storage read error for committed key {}: {e} \ @@ -1064,6 +1134,26 @@ mod tests { use crate::replication::subtree::{build_subtree_proof, nonced_leaf_hash, SubtreeLeaf}; use saorsa_pqc::api::sig::ml_dsa_65; + /// ADR-0004 A1 grade flip (grace removed): a responsive `UnknownCommitment` + /// or `Protocol` rejection is a CONFIRMED failure; only `Transient` routes to + /// the timeout lane. This pure decision backs both audit rounds + /// (`Confirmed → AuditFailureReason::Rejected` / `ByteRound::Rejected`; + /// `TimeoutLane → AuditFailureReason::Timeout` + pinned-credit revocation). + #[test] + fn grade_reject_removes_grace_for_unknown_commitment() { + assert_eq!( + grade_reject(RejectKind::UnknownCommitment), + RejectGrade::Confirmed, + "an unanswerable pinned root is now a confirmed failure, not graced" + ); + assert_eq!(grade_reject(RejectKind::Protocol), RejectGrade::Confirmed); + assert_eq!( + grade_reject(RejectKind::Transient), + RejectGrade::TimeoutLane, + "a transient read error routes to the timeout lane (no confirmed penalty)" + ); + } + // The two-round audit splits into SHIPPED pure functions exercised directly // here (no reimplementation that could drift): // - round 1: `evaluate_subtree_structure` (pin/identity/signature + diff --git a/src/replication/types.rs b/src/replication/types.rs index 59e6b417..d084d25f 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -225,6 +225,38 @@ pub enum FailureEvidence { /// When this peer was first seen. first_seen: Instant, }, + /// ADR-0004: a quote's claimed committed key count contradicts the signed + /// commitment it pinned. The quote and the commitment are both signed by + /// the same key, so this is a deterministic, first-occurrence + /// contradiction — not bad luck — and lands in the same trust lane as a + /// confirmed deterministic audit failure. Reported only in the + /// client-put context (a replication receipt's pin has legitimately aged + /// out). The fields are the portable contradiction: any third party + /// holding the two signed artifacts can recompute it. + QuoteCommitmentMismatch { + /// The peer whose quote and commitment disagree. + peer: PeerId, + /// The commitment hash the quote pinned (identifies the signed + /// commitment artifact); equals `commitment.hash()`. + pinned_commitment: [u8; 32], + /// The key count the quote claimed (and priced against). + quoted_key_count: u32, + /// The key count the pinned commitment actually attests. + committed_key_count: u32, + /// The signed quote artifact that carries the contradicting + /// `committed_key_count` + pin, ML-DSA-signed by `peer`, as canonical + /// serialized bytes. Carried opaquely (rmp of a `PaymentQuote` on the + /// single-node path, or a `MerklePaymentCandidateNode` on the merkle + /// path) so the one variant serves both paths; a third party + /// deserializes and re-verifies it against `peer`. + quote_artifact: Vec, + /// The signed commitment artifact the quote pinned (ML-DSA-signed by + /// the same `peer`). Together with `quote_artifact`, this is the + /// PORTABLE evidence: any third party re-verifies both signatures and + /// recomputes `quoted_key_count != commitment.key_count` without + /// trusting the reporter. + commitment: Box, + }, } /// Reason for audit failure. diff --git a/src/storage/handler.rs b/src/storage/handler.rs index 13c52d08..9e5e7d0c 100644 --- a/src/storage/handler.rs +++ b/src/storage/handler.rs @@ -93,15 +93,20 @@ impl AntProtocol { payment_verifier: Arc, quote_generator: Arc, ) -> Self { - // Keep the PaymentVerifier's paid-quote price floor and the - // QuoteGenerator's pricing wired to the same authoritative store used - // by this protocol handler. Both must read the same record count: the - // generator prices quotes from current_chunks(), and the verifier later - // checks the paid median quote against current_chunks(). Attaching both - // here makes the invariant automatic for every AntProtocol - // construction path, including tests and future startup variants. + // Wire the PaymentVerifier to the same authoritative store used by this + // protocol handler. Historically the verifier read `current_chunks()` + // for the paid-quote price floor; ADR-0004 retired that gate (price is + // bound to the live storage commitment, not the local record count), so + // the store is no longer consulted for pricing. The attachment is kept + // so any future store-backed verifier check reads the same record count + // this handler serves, for every AntProtocol construction path. + // + // ADR-0004: the QuoteGenerator no longer prices off `current_chunks()` + // — its price is bound to the live storage commitment (see + // `attach_commitment_source`, wired by the node once the replication + // engine exists), or baseline when none — so it is NOT attached to the + // store here. payment_verifier.attach_storage(Arc::clone(&storage)); - quote_generator.attach_storage(Arc::clone(&storage)); Self { storage, @@ -157,6 +162,52 @@ impl AntProtocol { Arc::clone(&self.payment_verifier) } + /// ADR-0004: attach the replication engine's commitment state as the quote + /// generator's commitment source, so quotes force their price from the live + /// storage commitment and refresh its answerability on issuance. + /// + /// Called once, after both the protocol and the replication engine exist + /// (the engine owns the [`ResponderCommitmentState`](crate::replication::commitment_state::ResponderCommitmentState)). + /// Until this is wired, the quote generator has no commitment source and + /// falls back to baseline (no-pin) pricing. + pub fn attach_commitment_source( + &self, + source: Arc, + ) { + self.quote_generator.attach_commitment_source(source); + } + + /// ADR-0004: return the proof with any commitment sidecars stripped, so a + /// replicated/persisted receipt carries only the pin and count (stored + /// proofs do not grow). Handles BOTH proof types — single-node and + /// merkle-batch — since both now carry sidecars. An unknown-tagged or + /// unparseable proof is returned unchanged. Best-effort: any + /// deserialize/reserialize failure returns the original bytes rather than + /// corrupting the proof. + fn strip_commitment_sidecars(proof: Vec) -> Vec { + use crate::payment::proof::{ + deserialize_merkle_proof, deserialize_single_node_proof, detect_proof_type, + serialize_merkle_proof, serialize_single_node_proof, ProofType, + }; + match detect_proof_type(&proof) { + Some(ProofType::SingleNode) => match deserialize_single_node_proof(&proof) { + Ok(mut parsed) if !parsed.commitment_sidecars.is_empty() => { + parsed.commitment_sidecars.clear(); + serialize_single_node_proof(&parsed).unwrap_or(proof) + } + _ => proof, // already sidecar-free, or unparseable: leave as-is + }, + Some(ProofType::Merkle) => match deserialize_merkle_proof(&proof) { + Ok(mut parsed) if !parsed.commitment_sidecars.is_empty() => { + parsed.commitment_sidecars.clear(); + serialize_merkle_proof(&parsed).unwrap_or(proof) + } + _ => proof, + }, + _ => proof, // unknown tag: nothing to strip + } + } + /// Handle an incoming request and produce a response. /// /// Decodes the raw message, processes it if it is a request variant, @@ -362,6 +413,16 @@ impl AntProtocol { // PUTs have no proof to forward, and the chunk would have // already replicated on the original write that carried one. if let (Some(ref tx), Some(proof)) = (&self.fresh_write_tx, request.payment_proof) { + // ADR-0004: strip any commitment sidecars before forwarding + // to replication — the ADR specifies persisted/replicated + // receipts "keep only the pin and count, so stored proofs do + // not grow." The sidecars were already consumed by this + // node's client-put cross-check; replicas resolve pins via + // gossip/fetch (their context is Replication, which skips the + // cross-check anyway). Best-effort: if sanitisation fails, + // fall back to the original proof rather than dropping the + // replication entirely. + let proof = Self::strip_commitment_sidecars(proof); // `request.content` is now `bytes::Bytes`; FreshWriteEvent // still carries the chunk as `Vec` for compatibility // with the replication wire format, so materialise once @@ -512,11 +573,22 @@ impl AntProtocol { .create_quote(request.address, data_size_usize, request.data_type) { Ok(quote) => { + // ADR-0004: ship the signed commitment the price was bound to + // alongside the quote ("the commitment arrived with the quote"), + // so the client can verify the binding before paying and forward + // it as a sidecar in the PUT bundle. Only a commitment-bound + // quote pins anything; a baseline `(0, None)` quote carries no + // commitment. A pin that has rotated out resolves to `None` and + // the client falls back to gossip/fetch. + let commitment = quote + .commitment_pin + .and_then(|pin| self.quote_generator.commitment_blob_for_pin(pin)); // Serialize the quote match rmp_serde::to_vec("e) { Ok(quote_bytes) => ChunkQuoteResponse::Success { quote: quote_bytes, already_stored, + commitment, }, Err(e) => ChunkQuoteResponse::Error(ProtocolError::QuoteFailed(format!( "Failed to serialize quote: {e}" @@ -560,14 +632,23 @@ impl AntProtocol { request.data_type, request.merkle_payment_timestamp, ) { - Ok(candidate_node) => match rmp_serde::to_vec(&candidate_node) { - Ok(bytes) => MerkleCandidateQuoteResponse::Success { - candidate_node: bytes, - }, - Err(e) => MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed(format!( - "Failed to serialize merkle candidate node: {e}" - ))), - }, + Ok(candidate_node) => { + // ADR-0004: ship the signed commitment this candidate priced + // against, so the client can verify the binding before paying + // and forward it as a sidecar. Baseline candidates ship none. + let commitment = candidate_node + .commitment_pin + .and_then(|pin| self.quote_generator.commitment_blob_for_pin(pin)); + match rmp_serde::to_vec(&candidate_node) { + Ok(bytes) => MerkleCandidateQuoteResponse::Success { + candidate_node: bytes, + commitment, + }, + Err(e) => MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed( + format!("Failed to serialize merkle candidate node: {e}"), + )), + } + } Err(e) => { MerkleCandidateQuoteResponse::Error(ProtocolError::QuoteFailed(e.to_string())) } @@ -1121,7 +1202,7 @@ mod tests { assert_eq!(response.request_id, 600); match response.body { ChunkMessageBody::MerkleCandidateQuoteResponse( - MerkleCandidateQuoteResponse::Success { candidate_node }, + MerkleCandidateQuoteResponse::Success { candidate_node, .. }, ) => { let candidate: MerklePaymentCandidateNode = rmp_serde::from_slice(&candidate_node).expect("deserialize candidate node"); @@ -1329,4 +1410,34 @@ mod tests { (full={price_full:?}, after={price_after:?})" ); } + + /// ADR-0004: `strip_commitment_sidecars` removes sidecars from a single-node + /// proof before replication/persistence (stored proofs do not grow), and is + /// a no-op on a sidecar-free proof. + #[test] + fn strip_commitment_sidecars_clears_single_node_sidecars() { + use crate::payment::proof::{ + deserialize_single_node_proof, serialize_single_node_proof, PaymentProof, + }; + use evmlib::ProofOfPayment; + + let with_sidecars = PaymentProof { + proof_of_payment: ProofOfPayment { + peer_quotes: vec![], + }, + tx_hashes: vec![], + commitment_sidecars: vec![vec![1u8; 16], vec![2u8; 16]], + }; + let bytes = serialize_single_node_proof(&with_sidecars).expect("serialize"); + let stripped = AntProtocol::strip_commitment_sidecars(bytes); + let parsed = deserialize_single_node_proof(&stripped).expect("deserialize stripped"); + assert!( + parsed.commitment_sidecars.is_empty(), + "sidecars must be stripped before replication" + ); + + // Idempotent: stripping an already-sidecar-free proof returns it intact. + let again = AntProtocol::strip_commitment_sidecars(stripped.clone()); + assert_eq!(again, stripped, "stripping a sidecar-free proof is a no-op"); + } } diff --git a/tests/e2e/merkle_payment.rs b/tests/e2e/merkle_payment.rs index 960fc988..df01e219 100644 --- a/tests/e2e/merkle_payment.rs +++ b/tests/e2e/merkle_payment.rs @@ -213,7 +213,8 @@ fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDA let price = Amount::from(1024u64); #[allow(clippy::cast_possible_truncation)] let reward_address = RewardsAddress::new([i as u8; 20]); - let msg = MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp); + let msg = + MerklePaymentCandidateNode::bytes_to_sign(&price, &reward_address, timestamp, 0, &None); let sk = MlDsaSecretKey::from_bytes(secret_key.as_bytes()).expect("sk"); let signature = ml_dsa.sign(&sk, &msg).expect("sign").as_bytes().to_vec(); @@ -222,6 +223,8 @@ fn build_candidate_nodes(timestamp: u64) -> [MerklePaymentCandidateNode; CANDIDA price, reward_address, merkle_payment_timestamp: timestamp, + committed_key_count: 0, + commitment_pin: None, signature, } }) diff --git a/tests/e2e/security_attacks.rs b/tests/e2e/security_attacks.rs index 528609b4..62230bb5 100644 --- a/tests/e2e/security_attacks.rs +++ b/tests/e2e/security_attacks.rs @@ -14,7 +14,7 @@ use ant_node::ant_protocol::{ ChunkMessage, ChunkMessageBody, ChunkPutRequest, ChunkPutResponse, ProtocolError, }; use ant_node::compute_address; -use ant_node::payment::PaymentProof; +use ant_node::payment::{PaymentProof, MAX_PAYMENT_PROOF_SIZE_BYTES}; use bytes::Bytes; use evmlib::testnet::Testnet; use evmlib::ProofOfPayment; @@ -187,6 +187,7 @@ async fn test_attack_valid_msgpack_empty_quotes() -> Result<(), Box Result<(), Box Result<(), Box> { - info!("ATTACK TEST: proof too large (200KB)"); + info!("ATTACK TEST: proof too large ({MAX_PAYMENT_PROOF_SIZE_BYTES} bytes + 1)"); let (harness, _testnet) = setup_enforcement_env().await?; let test_data = b"Attack: oversized proof bytes"; let address = compute_address(test_data); - let oversized: Vec = vec![0xAA; 200 * 1024]; // 200KB of junk + let oversized: Vec = vec![0xAA; MAX_PAYMENT_PROOF_SIZE_BYTES + 1]; let request = ChunkPutRequest::with_payment(address, Bytes::copy_from_slice(test_data), oversized); @@ -236,6 +237,12 @@ async fn test_attack_proof_too_large() -> Result<(), Box> is_payment_rejection(&response.body), "Attack MUST be rejected with payment error, got: {response:?}" ); + // The rejection must be the SIZE gate specifically, not a downstream + // tag/deserialization failure — that is what caps pre-parse work. + assert!( + format!("{:?}", response.body).contains("too large"), + "Rejection must name the size gate, got: {response:?}" + ); info!("Correctly rejected: proof too large"); harness.teardown().await?; diff --git a/tests/poc_commitment_audit_attacks.rs b/tests/poc_commitment_audit_attacks.rs index 848664cf..bc67bc77 100644 --- a/tests/poc_commitment_audit_attacks.rs +++ b/tests/poc_commitment_audit_attacks.rs @@ -895,13 +895,15 @@ fn repudiating_a_gossiped_pin_is_detectable_via_lookup_miss() { "a gossiped commitment must survive one rotation (no false repudiation)" ); - // Rotate + gossip c3. Now the last-2-gossiped are {h3, h2}; h1 has aged out - // and is legitimately dropped (the auditor would no longer pin it). + // Rotate + gossip c3. Retention is TTL-based (not a fixed count) and no wall + // time has elapsed, so every gossiped root stays answerable — a rotation + // never repudiates an in-window root an honest node published (that would be + // a false conviction once grace is removed). Aging out is time-based (TTL). let h3 = r.commit_to_range(24); state.mark_gossiped(h3); assert!( - state.lookup_by_hash(&h1).is_none(), - "h1 aged out of the gossip window" + state.lookup_by_hash(&h1).is_some(), + "h1 stays answerable within its gossip TTL (no count-based eviction)" ); assert!(state.lookup_by_hash(&h2).is_some()); assert!(state.lookup_by_hash(&h3).is_some());