Skip to content

Commit f46bbf0

Browse files
authored
fix(contracts): encode set-valued state and summaries canonically (#54)
* fix(contracts): encode set-valued state and summaries canonically `ReputationStateV1.used_nonces` (contract STATE), `ReputationSummary` and `MailboxSummaryV2` were `HashSet`s, so their CBOR encoding was not a function of their contents. A `HashSet` draws a fresh random seed per INSTANCE, so this is sharper than "two peers disagree": two sets holding identical members, inserted in the same order, in the same process, encode to different bytes. Measured, not inferred. The clearest statement of the defect is inside the reputation contract itself. `apply_delta` sorts `feedback` with the comment "Sort deterministically by nonce for CRDT convergence" -- and the set immediately beside it in the same struct re-randomised its own order on every encode, spending that sort. WHAT THIS DOES AND DOES NOT BREAK. It is not a correctness failure on the network today, and the PR body says why: freenet-core carries two mitigations built after real incidents (`byte_multiset_eq`, and deferring to the contract's semantic `get_state_delta` before declaring a peer stale), and a permutation of the same bytes is precisely the case they tolerate. What it costs is avoidable overhead -- the byte-identical fast paths never fire, so redundant pushes take extra `update_state` calls, extra state stores and extra broadcasts, and anti-entropy digest mismatches force `get_state_delta` instead of a cheap byte comparison. Local scratch sets are deliberately left as `HashSet`. The rule is that SERIALIZED positions must be deterministic, not that `HashSet` is banned, and collapsing the two would lose that distinction. Tests: four added, each verified red under the defect and green with the fix. Note the shape they are forced into. `HashSet::clone` copies the hasher, so `ReputationStateV1::summarize` -- which returns `used_nonces.clone()` -- yields identical bytes when called twice on ONE state even under the defect; the obvious test is vacuous. Both summary tests therefore compare two INDEPENDENTLY built states, which is the only shape that fails. The existing suite was blind to this: under the reverted defect, 134 pre-existing tests pass and only the new ones fail. `merging_is_order_independent` asserts byte-identical mailbox STATE and never looks at a summary, and reputation -- the one contract whose state held the bad set -- had no determinism test at all. Re-keys the store, reputation and mailbox contracts. It does NOT re-key the delegate: the changed code is not reachable from it, so its hash is unmoved and it takes no registry row. Outgoing hashes were verified against the committed artifacts out of git history, the documented way, and all four matched a fresh build exactly. Old state still decodes -- a CBOR array of 32-byte items reads into a `BTreeSet` unchanged -- so the fold from V9 is pure data transfer; that is pinned by a test rather than left as a registry claim. Claude-Session: https://claude.ai/code/session_01Rw6QPkkPyb6aE8jE5LBbmA * fix(review): repair a vacuous test, a stolen doc comment, and a wrong mechanism Findings from the four-lens review plus codex. Every one is addressed here except the empty-summary decode defect, which is filed separately and says why below. THE TEST THAT COULD NOT FAIL. Three lenses independently found that `predecessor_state_in_any_member_order_decodes_and_normalises` was vacuous. It encoded a `Vec` and decoded into a `BTreeSet` NAMED IN ITS OWN BODY, so the type under test was supplied by the test rather than by the code, and no revert could ever move it. It was green under every mutation including the full one. That matters more than a wasted test: `legacy/reputation_contract.toml` asserts the V9 fold decodes, and the previous commit message claimed that assertion was "pinned by a test rather than left as a registry claim". It was not pinned. Replaced by `predecessor_state_decodes_through_the_real_type_and_normalises`, which decodes V9's actual on-the-wire shape into `ReputationStateV1` itself. Verified RED under the reputation revert in the same run where the old test passed. Ironic detail worth keeping: the previous commit carried a long comment about vacuousness traps while shipping one. THE DOC COMMENT I STOLE. The new tests were inserted between a 26-line "KNOWN GAP" block and the `#[test]` it belonged to, so the record of an unfixed, security-relevant convergence gap (a seller pushing a neutered feedback variant to peers that do not hold the original) became the header of an encoding test, and `known_gap_two_feedback_variants_sharing_a_token_do_not_converge` was left undocumented. Nothing in CI notices. The tests now sit after it. THE MECHANISM WAS WRONG, AND IT WAS GOING INTO A PERMANENT REGISTRY ROW. The previous text said a `HashSet` "draws a fresh random seed per instance". That is not what happens on `wasm32-unknown-unknown`, which is what these contracts are. Verified in the pinned toolchain's own std sources: that target routes to `sys::random::unsupported`, whose `hashmap_random_keys` returns a stack address and a heap address with the comment that it "isn't particularly secure, but there isn't really an alternative". There is no entropy on the target. The defect is still real, and the correct statement is stronger for being accurate: `RandomState::new` bumps a per-thread key on EVERY construction, target-independently and deliberately, so two peers that have built different numbers of sets encode differently. Their operation history is exactly what differs. Said the old way, a future reader who checks whether `HashSet` is random on wasm would find that it is not and could reasonably revert this. Also: "loses nothing" in the reputation V10 row is scoped to what this change does, because the V9 fold has a pre-existing #22 exclusion path that this does not touch; "Measured, not inferred" was a HOST measurement and is no longer claimed for the target; the reputation fixtures go from 5 nonces to 32, since two small `HashSet`s can agree on an order by luck; and the summary test is relabelled documentary rather than counted as a guard, because `summarize` returns a clone so the field type and the alias cannot disagree and still compile. ONE PRE-EXISTING BUG FIXED IN PASSING. `tests/rehearsal/src/main.rs`'s `ENCODING_BY_GENERATION` covered V1..V7 while the store lineage was already 9, so its length assertion panicked. Stale since V8 on 2026-09-06 and unnoticed because that crate is not a workspace member and needs a live node. Extended to V10. Its own comment claimed looking generations up by number meant "adding V7 cannot break it again"; that fixed the index-shift bug it was written for and never removed the need for a row per generation, which is now said out loud. NOT FIXED HERE, filed instead: the reputation and store contracts' `get_state_delta` cannot decode an empty summary, so a new subscriber's very first exchange is answered with a decode error. The mailbox contract fixed and documented exactly this. It is pre-existing, it is a behaviour change rather than an encoding one, it affects two contracts, and bundling it would re-stale a review that has already run. Contract WASM is byte-identical to the previous commit: only comments, tests and registry prose changed, so the recorded V10 hashes and the moved addresses already posted on the PR still stand. Claude-Session: https://claude.ai/code/session_01Rw6QPkkPyb6aE8jE5LBbmA * test(reputation-contract): cover the entry point, where the defect actually bit Closes the one review finding the previous commit missed. Two lenses raised it and I addressed neither, while claiming on the PR that every finding was addressed. That claim was wrong and this makes it true. `summarize_state` DECODES the state before summarizing it, and every one of the four tests added so far builds state in-process, one layer below that. The decode is where this defect was sharpest: each call through the entry point decoded the same unchanging bytes into a FRESH collection, and `RandomState::new` bumps a per-thread key on every construction, so one node holding one unchanging state answered two `summarize_state` calls with DIFFERENT summary bytes. That is the shape behind the known freenet-core incident for this class, where a contract's anti-entropy could never agree even with itself. `contracts/reputation-contract` had NO tests at all, which is why nothing here was covered. The two added are verified RED under the V9 revert and green with the fix: summarize_state_is_byte_stable_across_calls_on_one_state two_peers_holding_the_same_nonces_summarize_identically `ed25519-dalek` is a dev-dependency for the same reason and with the same reasoning as `contracts/mailbox-contract`: `ReputationParameters::new` is the single construction site on purpose, because the field set is hashed into the address, so a test needs a real key. A dev-dependency is not linked into the `cdylib`, so it does not move the code hash -- confirmed by running `cargo make code-hashes` and seeing `eef8685c...` unchanged, not assumed. The contract WASM is untouched by this commit and the recorded V10 hashes still stand. Fixture size is 32 nonces rather than a handful, for the same reason the `harvest-common` fixtures were raised: two small collections can agree on an order by luck and pass without meaning to. One correction to the review record, from re-measuring rather than from argument. A reviewer's mutation table reported that reverting `used_nonces` alone, leaving `ReputationSummary` a `BTreeSet`, compiles and isolates that one test. It does not compile: `summarize` returns `used_nonces.clone()` as a `ReputationSummary`, so the field and the alias cannot disagree. Observed as `error[E0308]: mismatched types`. The minimal reputation mutation is both types together, which is also the real V9 shape. Claude-Session: https://claude.ai/code/session_01Rw6QPkkPyb6aE8jE5LBbmA
1 parent 83521fc commit f46bbf0

13 files changed

Lines changed: 385 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/src/mailbox.rs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use chrono::{DateTime, Utc};
22
use ed25519_dalek::VerifyingKey;
33
use serde::{Deserialize, Serialize};
4-
use std::collections::HashSet;
4+
use std::collections::{BTreeSet, HashSet};
55

66
/// How many messages one mailbox contract will hold.
77
///
@@ -627,7 +627,17 @@ pub fn entry_digest(message: &EncryptedMessage) -> [u8; 32] {
627627
/// delegate store on receipt AND paying only after the write is confirmed,
628628
/// which is recorded in `docs/buyer-conversation-persistence.md` and not built
629629
/// here.
630-
pub type MailboxSummaryV2 = HashSet<[u8; 32]>;
630+
/// ---
631+
///
632+
/// **`BTreeSet`, not `HashSet`.** A summary is encoded and sent, so its bytes
633+
/// must be a function of its contents alone. `RandomState::new` bumps a
634+
/// per-thread counter on every construction, and `summarize` below rebuilds
635+
/// the set with `collect()` on every call, so under a `HashSet` this encoded
636+
/// differently each time for the same messages. Pinned by
637+
/// `mailbox_summary_encoding_is_deterministic`. See
638+
/// [`crate::reputation::ReputationStateV1::used_nonces`] for why the mechanism
639+
/// is a counter rather than randomness, which is what it is on wasm32.
640+
pub type MailboxSummaryV2 = BTreeSet<[u8; 32]>;
631641

632642
/// Delta: new messages to add. Unchanged in shape -- it always carried whole
633643
/// messages, and only what counts as "already held" moved.
@@ -1217,6 +1227,46 @@ mod determinism_tests {
12171227
);
12181228
}
12191229

1230+
/// **A summary must encode as a function of its contents alone.**
1231+
///
1232+
/// The two tests above pin the STATE bytes and were the only determinism
1233+
/// guards here; the SUMMARY had none, and [`MailboxSummaryV2`] was a
1234+
/// `HashSet` -- which draws a fresh random seed per instance, so it
1235+
/// encoded differently on every call for the same contents.
1236+
///
1237+
/// [`MailboxStateV1::summarize`] rebuilds the set with `collect()` each
1238+
/// time, so under the defect even summarising ONE state twice differed.
1239+
/// This still uses two independently-built states, to match the shape the
1240+
/// reputation contract is forced into -- there `summarize` returns a
1241+
/// `clone()`, and cloning a `HashSet` copies its hasher, so the
1242+
/// same-state form is green under the defect and pins nothing. Keeping
1243+
/// one shape across both files means the weaker form cannot be copied
1244+
/// here by someone reading this as the template.
1245+
#[test]
1246+
fn mailbox_summary_encoding_is_deterministic() {
1247+
let base = 1_700_000_000;
1248+
let forward: Vec<_> = (0..12u32).map(|i| indexed(i, base + i as i64)).collect();
1249+
let backward: Vec<_> = forward.iter().rev().cloned().collect();
1250+
1251+
let mut a = MailboxStateV1::default();
1252+
a.apply_delta(&Some(forward)).unwrap();
1253+
1254+
let mut b = MailboxStateV1::default();
1255+
b.apply_delta(&Some(backward)).unwrap();
1256+
1257+
assert_eq!(
1258+
a.summarize(),
1259+
b.summarize(),
1260+
"the two mailboxes must hold the same digests, or this test is \
1261+
measuring the wrong thing"
1262+
);
1263+
assert_eq!(
1264+
crate::to_cbor(&a.summarize()).unwrap(),
1265+
crate::to_cbor(&b.summarize()).unwrap(),
1266+
"two peers holding the same messages must send the same summary bytes"
1267+
);
1268+
}
1269+
12201270
/// Age is not a reason to drop anything any more. The message here is from
12211271
/// the epoch and the mailbox's other traffic is from 2023; under the TTL
12221272
/// rule this replaced, the old one was discarded.

common/src/reputation.rs

Lines changed: 169 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use chrono::{DateTime, Utc};
22
use ed25519_dalek::VerifyingKey;
33
use serde::{Deserialize, Serialize};
4-
use std::collections::HashSet;
4+
use std::collections::{BTreeSet, HashSet};
55

66
use crate::feedback::{FeedbackCategory, FeedbackToken};
77

@@ -57,11 +57,42 @@ pub struct ReputationStateV1 {
5757
/// Append-only list of negative feedback entries.
5858
pub feedback: Vec<FeedbackEntry>,
5959
/// Used nonces for replay prevention (mirrors nonces from feedback entries).
60-
pub used_nonces: HashSet<[u8; 32]>,
60+
///
61+
/// **`BTreeSet`, not `HashSet`, and that is load-bearing.** This field is
62+
/// part of the contract STATE, so its CBOR encoding is bytes peers compare,
63+
/// and it has to be a function of the contents alone.
64+
///
65+
/// The mechanism is a COUNTER, not randomness, and the distinction matters
66+
/// because this contract runs on `wasm32-unknown-unknown`. `RandomState::
67+
/// new` caches two keys per thread and does `keys.set((k0.wrapping_add(1),
68+
/// k1))` on every construction, precisely so each `HashMap` gets a
69+
/// different iteration order. That bump is target-independent. What IS
70+
/// target-specific is the base: on wasm32 `std` routes to
71+
/// `sys::random::unsupported`, whose `hashmap_random_keys` returns a stack
72+
/// address and a heap address with the comment "this isn't particularly
73+
/// secure, but there isn't really an alternative". So there is no entropy
74+
/// on the target at all.
75+
///
76+
/// Do not read that as "so it was deterministic anyway and this change was
77+
/// unnecessary". Two peers differ in how many sets they have built, which
78+
/// is their operation history, so they still encode differently. The
79+
/// counter is the whole defect and it survives having no entropy.
80+
///
81+
/// Note what is NOT true: two `to_cbor` calls on ONE `HashSet` value give
82+
/// identical bytes. Only independently-built instances diverge, which is
83+
/// the shape `used_nonces_encoding_is_deterministic` uses.
84+
///
85+
/// `feedback` below is sorted for exactly this reason; a `HashSet` beside
86+
/// it silently spent that sort.
87+
pub used_nonces: BTreeSet<[u8; 32]>,
6188
}
6289

6390
/// Summary for delta computation: the set of known nonces.
64-
pub type ReputationSummary = HashSet<[u8; 32]>;
91+
///
92+
/// `BTreeSet` for the same reason as [`ReputationStateV1::used_nonces`]: a
93+
/// summary is encoded and sent, so its bytes must be a function of its
94+
/// contents alone.
95+
pub type ReputationSummary = BTreeSet<[u8; 32]>;
6596

6697
/// Delta: new feedback entries to add.
6798
pub type ReputationDelta = Vec<FeedbackEntry>;
@@ -410,4 +441,139 @@ mod tests {
410441
correct the commutativity claim on `ReputationStateV1`"
411442
);
412443
}
444+
445+
/// **Two peers given the same feedback in different orders must hold
446+
/// byte-identical state.**
447+
///
448+
/// This is the property `mailbox::determinism_tests::
449+
/// merging_is_order_independent` pins for the mailbox. Reputation had no
450+
/// equivalent -- and reputation is the contract whose STATE actually held
451+
/// a nondeterministically-encoded collection, so the guard existed on the
452+
/// one of the two that did not need it.
453+
///
454+
/// Red before `used_nonces` became a `BTreeSet`. `RandomState::new` bumps
455+
/// a per-thread counter on EVERY construction, so two independently-built
456+
/// `HashSet`s iterate differently and therefore CBOR-encode differently.
457+
/// `apply_delta` already sorts `feedback` "deterministically by nonce for
458+
/// CRDT convergence"; the set beside it silently spent that sort.
459+
///
460+
/// 32 nonces, not a handful: with only a few members two `HashSet`s can
461+
/// coincidentally agree on an order and the guard passes by luck. RSA
462+
/// signing dominates the runtime either way.
463+
#[test]
464+
fn used_nonces_encoding_is_deterministic() {
465+
let (private, params) = key_pair();
466+
let entries: Vec<_> = (1u8..33).map(|n| signed_entry(&private, n)).collect();
467+
let reversed: Vec<_> = entries.iter().rev().cloned().collect();
468+
469+
let mut forward = ReputationStateV1::default();
470+
forward.apply_delta(&params, &Some(entries)).expect("apply");
471+
472+
let mut backward = ReputationStateV1::default();
473+
backward
474+
.apply_delta(&params, &Some(reversed))
475+
.expect("apply");
476+
477+
assert_eq!(
478+
forward.used_nonces, backward.used_nonces,
479+
"the two states must hold the same nonces, or this test is \
480+
measuring the wrong thing"
481+
);
482+
assert_eq!(
483+
crate::to_cbor(&forward).expect("encode"),
484+
crate::to_cbor(&backward).expect("encode"),
485+
"the same feedback in a different order must produce identical bytes"
486+
);
487+
}
488+
489+
/// **State written by a predecessor generation still decodes, and
490+
/// normalises on the way in.**
491+
///
492+
/// `legacy/reputation_contract.toml`'s V10 row asserts this, and a
493+
/// registry claim that is only reasoned about is how a migration seals
494+
/// over data.
495+
///
496+
/// **The decode target is the real type, and that is the whole test.** An
497+
/// earlier version of this test decoded into a `BTreeSet` it named in its
498+
/// own body, so no change to the production types could ever move it: it
499+
/// tested `ciborium` and `std`. It was green under every revert, including
500+
/// the full one. Review caught it. If you edit this test, keep
501+
/// [`ReputationStateV1`] as the thing being decoded INTO.
502+
///
503+
/// V9 wrote `used_nonces` as a `HashSet`, whose CBOR is a plain array
504+
/// (major type 4) in whatever order that instance chose, so both orders
505+
/// below are valid V9 bytes for the same state. It re-encodes rather than
506+
/// only comparing the decoded values, because `PartialEq` on the struct
507+
/// compares sets and passes under the defect; the bytes are the claim.
508+
#[test]
509+
fn predecessor_state_decodes_through_the_real_type_and_normalises() {
510+
/// V9's on-the-wire shape: `used_nonces` as a bare CBOR array.
511+
#[derive(serde::Serialize)]
512+
struct V9State {
513+
owner_certificate_pem: String,
514+
feedback: Vec<FeedbackEntry>,
515+
used_nonces: Vec<[u8; 32]>,
516+
}
517+
518+
let ascending: Vec<[u8; 32]> = (1u8..33).map(|n| [n; 32]).collect();
519+
let descending: Vec<[u8; 32]> = ascending.iter().rev().copied().collect();
520+
521+
let v9 = |used_nonces: Vec<[u8; 32]>| V9State {
522+
owner_certificate_pem: String::new(),
523+
feedback: Vec::new(),
524+
used_nonces,
525+
};
526+
527+
let a: ReputationStateV1 =
528+
crate::from_cbor(&crate::to_cbor(&v9(ascending)).expect("encode")).expect("decode");
529+
let b: ReputationStateV1 =
530+
crate::from_cbor(&crate::to_cbor(&v9(descending)).expect("encode")).expect("decode");
531+
532+
assert_eq!(
533+
a, b,
534+
"the same members in a different order are the same state"
535+
);
536+
assert_eq!(
537+
crate::to_cbor(&a).expect("encode"),
538+
crate::to_cbor(&b).expect("encode"),
539+
"predecessor bytes in any order must fold to one canonical encoding"
540+
);
541+
}
542+
543+
/// **Documentary, not a guard, and labelled so deliberately.**
544+
///
545+
/// [`ReputationStateV1::summarize`] returns `used_nonces.clone()`, so the
546+
/// field type and the summary alias must agree or the crate does not
547+
/// compile. There is therefore NO revert under which
548+
/// `used_nonces_encoding_is_deterministic` passes and this one fails, and
549+
/// it should not be counted as separate coverage.
550+
///
551+
/// It is kept for the trap it records. Cloning a `HashSet` copies its
552+
/// hasher along with its contents, so summarising ONE state twice yields
553+
/// identical bytes even under the defect: the obvious form of this test is
554+
/// vacuous here. Measured, not assumed. The mailbox is not like this --
555+
/// its `summarize` rebuilds with `collect()`, drawing a fresh key each
556+
/// call -- so the same shape is a real guard there and a note here. Both
557+
/// files use the two-independent-states form so the weaker one cannot be
558+
/// copied from either.
559+
#[test]
560+
fn the_summary_encodes_the_same_for_two_independently_built_states() {
561+
let (private, params) = key_pair();
562+
let entries: Vec<_> = (1u8..33).map(|n| signed_entry(&private, n)).collect();
563+
let reversed: Vec<_> = entries.iter().rev().cloned().collect();
564+
565+
let mut forward = ReputationStateV1::default();
566+
forward.apply_delta(&params, &Some(entries)).expect("apply");
567+
568+
let mut backward = ReputationStateV1::default();
569+
backward
570+
.apply_delta(&params, &Some(reversed))
571+
.expect("apply");
572+
573+
assert_eq!(
574+
crate::to_cbor(&forward.summarize()).expect("encode"),
575+
crate::to_cbor(&backward.summarize()).expect("encode"),
576+
"two peers holding the same nonces must send the same summary bytes"
577+
);
578+
}
413579
}

contracts/reputation-contract/Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ rsa = { workspace = true }
1515
sha2 = { workspace = true }
1616
getrandom = { workspace = true }
1717

18+
[dev-dependencies]
19+
# The contract's entry points take `ReputationParameters`, which can only be
20+
# built through `ReputationParameters::new` from a real `VerifyingKey` -- that
21+
# is the single construction site on purpose, because the field set is hashed
22+
# into the reputation contract's address. A test therefore needs a key, and
23+
# only a test does. Same reasoning, and same dependency, as
24+
# `contracts/mailbox-contract`.
25+
#
26+
# A dev-dependency is not linked into the `cdylib`, so this does not move the
27+
# code hash. Verified with `cargo make code-hashes` rather than assumed.
28+
ed25519-dalek = { workspace = true }
29+
1830
[lib]
1931
crate-type = ["cdylib", "rlib"]
2032

contracts/reputation-contract/src/lib.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,97 @@ impl ContractInterface for Contract {
145145
}
146146
}
147147
}
148+
149+
#[cfg(test)]
150+
mod tests {
151+
use super::*;
152+
153+
/// `summarize_state` DECODES the state before summarizing it, which is why
154+
/// the tests in `harvest-common` do not reach the sharpest form of the
155+
/// encoding defect: they build state in-process, one layer below here.
156+
///
157+
/// Every call through this entry point decodes the same bytes into a fresh
158+
/// collection. Under a `HashSet` that meant one node, holding one
159+
/// unchanging state, answered two `summarize_state` calls with DIFFERENT
160+
/// summary bytes -- because `RandomState::new` bumps a per-thread key on
161+
/// every construction, so each decode produced a differently-ordered set.
162+
///
163+
/// This crate had no tests at all before this one, which is why the gap
164+
/// existed. Found by review of #54, not by CI.
165+
fn parameters() -> Parameters<'static> {
166+
let owner = ed25519_dalek::SigningKey::from_bytes(&[4u8; 32]).verifying_key();
167+
// `summarize_state` discards the decoded parameters, so only the SHAPE
168+
// has to decode; this DER is never parsed.
169+
let params = ReputationParameters::new(vec![9u8; 32], owner);
170+
let mut bytes = vec![];
171+
into_writer(&params, &mut bytes).expect("encode parameters");
172+
Parameters::from(bytes)
173+
}
174+
175+
/// 32 nonces, not a handful: two small collections can agree on an order
176+
/// by luck, which would make the guard below pass without meaning to.
177+
fn encoded_state() -> State<'static> {
178+
let mut state = ReputationStateV1::default();
179+
for i in 1u8..33 {
180+
state.used_nonces.insert([i; 32]);
181+
}
182+
let mut bytes = vec![];
183+
into_writer(&state, &mut bytes).expect("encode state");
184+
State::from(bytes)
185+
}
186+
187+
/// **One node, one unchanging state, two calls: the same summary bytes.**
188+
///
189+
/// This is the form the network actually exercises, and it is the one that
190+
/// produced the freenet-core incident this class of defect is known for --
191+
/// a contract whose `summarize_state` serialized a hash collection emitted
192+
/// different summary bytes for the same logical state, so anti-entropy
193+
/// could never agree with itself.
194+
#[test]
195+
fn summarize_state_is_byte_stable_across_calls_on_one_state() {
196+
let state = encoded_state();
197+
198+
let first = <Contract as ContractInterface>::summarize_state(parameters(), state.clone())
199+
.expect("summarize");
200+
let second = <Contract as ContractInterface>::summarize_state(parameters(), state)
201+
.expect("summarize");
202+
203+
assert_eq!(
204+
first.as_ref(),
205+
second.as_ref(),
206+
"two summaries of one unchanged state must be byte-identical"
207+
);
208+
}
209+
210+
/// The same property across two peers: the same members reached by
211+
/// different insertion orders must summarize to the same bytes through the
212+
/// entry point, not merely through the in-process helper.
213+
#[test]
214+
fn two_peers_holding_the_same_nonces_summarize_identically() {
215+
let mut ascending = ReputationStateV1::default();
216+
for i in 1u8..33 {
217+
ascending.used_nonces.insert([i; 32]);
218+
}
219+
let mut descending = ReputationStateV1::default();
220+
for i in (1u8..33).rev() {
221+
descending.used_nonces.insert([i; 32]);
222+
}
223+
224+
let encode = |s: &ReputationStateV1| {
225+
let mut bytes = vec![];
226+
into_writer(s, &mut bytes).expect("encode state");
227+
State::from(bytes)
228+
};
229+
230+
let a = <Contract as ContractInterface>::summarize_state(parameters(), encode(&ascending))
231+
.expect("summarize");
232+
let b = <Contract as ContractInterface>::summarize_state(parameters(), encode(&descending))
233+
.expect("summarize");
234+
235+
assert_eq!(
236+
a.as_ref(),
237+
b.as_ref(),
238+
"two peers holding the same nonces must send the same summary bytes"
239+
);
240+
}
241+
}

0 commit comments

Comments
 (0)