Commit f46bbf0
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_01Rw6QPkkPyb6aE8jE5LBbmA1 parent 83521fc commit f46bbf0
13 files changed
Lines changed: 385 additions & 5 deletions
File tree
- common/src
- contracts/reputation-contract
- src
- legacy
- tests/rehearsal/src
- ui
- public/contracts
- src/migrate
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
627 | 627 | | |
628 | 628 | | |
629 | 629 | | |
630 | | - | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
631 | 641 | | |
632 | 642 | | |
633 | 643 | | |
| |||
1217 | 1227 | | |
1218 | 1228 | | |
1219 | 1229 | | |
| 1230 | + | |
| 1231 | + | |
| 1232 | + | |
| 1233 | + | |
| 1234 | + | |
| 1235 | + | |
| 1236 | + | |
| 1237 | + | |
| 1238 | + | |
| 1239 | + | |
| 1240 | + | |
| 1241 | + | |
| 1242 | + | |
| 1243 | + | |
| 1244 | + | |
| 1245 | + | |
| 1246 | + | |
| 1247 | + | |
| 1248 | + | |
| 1249 | + | |
| 1250 | + | |
| 1251 | + | |
| 1252 | + | |
| 1253 | + | |
| 1254 | + | |
| 1255 | + | |
| 1256 | + | |
| 1257 | + | |
| 1258 | + | |
| 1259 | + | |
| 1260 | + | |
| 1261 | + | |
| 1262 | + | |
| 1263 | + | |
| 1264 | + | |
| 1265 | + | |
| 1266 | + | |
| 1267 | + | |
| 1268 | + | |
| 1269 | + | |
1220 | 1270 | | |
1221 | 1271 | | |
1222 | 1272 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
57 | 57 | | |
58 | 58 | | |
59 | 59 | | |
60 | | - | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
61 | 88 | | |
62 | 89 | | |
63 | 90 | | |
64 | | - | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
65 | 96 | | |
66 | 97 | | |
67 | 98 | | |
| |||
410 | 441 | | |
411 | 442 | | |
412 | 443 | | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
413 | 579 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
15 | 15 | | |
16 | 16 | | |
17 | 17 | | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
18 | 30 | | |
19 | 31 | | |
20 | 32 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
0 commit comments