Node Reputation Score Update Race in Concurrent Attestation Processing
Problem Statement
The src/core/reputation/score.rs module maintains a reputation score per node (reputation: Map<NodeId, i64>). The update_reputation() function at line 55 adjusts a node's score based on attestation outcomes: +10 for successful attestation, -50 for failed attestation, and -500 for slashing event. When two attestations for the same node are processed concurrently (e.g., one successful attestation from validators and one slashing event from the slashing condition monitor), both read the current reputation score (e.g., 750), compute their respective adjustments (+10 → 760, -500 → 250), and write back. The final stored score depends on which write completes last. If the slashing event writes last, the score is 250 (correct — the node should be slashed). If the attestation writes last, the score is 760 (incorrect — the slash was lost). The slashing event is supposed to be authoritative: a slashed node's reputation should be reduced regardless of concurrent attestations.
State Invariants & Parameters
- Attestation reward: +10 points
- Attestation failure penalty: -50 points
- Slashing penalty: -500 points
- Reputation range: [-1000, 1000]
- Slashing authority: absolute (overrides concurrent attestations)
- Invariant:
∀ slashing_event: reputation[node] == reputation_prior - 500 (slash always applied)
Affected Code Paths
src/core/reputation/score.rs:50-85 — Read-compute-write race
src/core/reputation/storage.rs:30-50 — Non-atomic reputation write
src/core/slashing/executor.rs:60-90 — Slashing event processing path
src/core/reputation/tests/score_tests.rs — No concurrent attestation-and-slash test
Resolution Blueprint
- Use an atomic increment via
storage_update() pattern: instead of read-compute-write, use storage_set(&score_key, &(current_score + adjustment)) where the current_score is read inside a Soroban try_run context that retries on concurrent modification.
- Implement a write priority system: slashing events use a
PRIORITY_LOCK key. When update_reputation() is called from the slashing path, it acquires the lock. Other reputation updates check the lock and, if held, queue for the next ledger.
- Store reputation as a per-event log: instead of a single score, store a
Vec<ReputationEvent> that records every adjustment with its source (attestation, slashing, reward). The current score is computed as Σ(events.adjustment). Since events are appended (not overwritten), race conditions are eliminated — both events are recorded, and the score correctly reflects both.
- Add a slashing monotonic counter: each node has a
slash_count that increments on every slashing event. update_reputation() from the slashing path uses slash_count as a version: require!(new_slash_count == current_slash_count + 1, "slash race"). If another slash already incremented the count, this is a duplicate — skip it.
- Add a concurrent test that starts a successful attestation update and a slashing event simultaneously for the same node, verifying the final reputation is
750 + 10 - 500 = 260 (both applied) and never 760 or 250.
Labels
Complexity: Hardcore
Layer: Core-Engine
Type: Race-Condition
Node Reputation Score Update Race in Concurrent Attestation Processing
Problem Statement
The
src/core/reputation/score.rsmodule maintains a reputation score per node (reputation: Map<NodeId, i64>). Theupdate_reputation()function at line 55 adjusts a node's score based on attestation outcomes:+10for successful attestation,-50for failed attestation, and-500for slashing event. When two attestations for the same node are processed concurrently (e.g., one successful attestation from validators and one slashing event from the slashing condition monitor), both read the current reputation score (e.g., 750), compute their respective adjustments (+10 → 760, -500 → 250), and write back. The final stored score depends on which write completes last. If the slashing event writes last, the score is 250 (correct — the node should be slashed). If the attestation writes last, the score is 760 (incorrect — the slash was lost). The slashing event is supposed to be authoritative: a slashed node's reputation should be reduced regardless of concurrent attestations.State Invariants & Parameters
∀ slashing_event: reputation[node] == reputation_prior - 500(slash always applied)Affected Code Paths
src/core/reputation/score.rs:50-85— Read-compute-write racesrc/core/reputation/storage.rs:30-50— Non-atomic reputation writesrc/core/slashing/executor.rs:60-90— Slashing event processing pathsrc/core/reputation/tests/score_tests.rs— No concurrent attestation-and-slash testResolution Blueprint
storage_update()pattern: instead of read-compute-write, usestorage_set(&score_key, &(current_score + adjustment))where thecurrent_scoreis read inside a Sorobantry_runcontext that retries on concurrent modification.PRIORITY_LOCKkey. Whenupdate_reputation()is called from the slashing path, it acquires the lock. Other reputation updates check the lock and, if held, queue for the next ledger.Vec<ReputationEvent>that records every adjustment with its source (attestation, slashing, reward). The current score is computed asΣ(events.adjustment). Since events are appended (not overwritten), race conditions are eliminated — both events are recorded, and the score correctly reflects both.slash_countthat increments on every slashing event.update_reputation()from the slashing path usesslash_countas a version:require!(new_slash_count == current_slash_count + 1, "slash race"). If another slash already incremented the count, this is a duplicate — skip it.750 + 10 - 500 = 260(both applied) and never760or250.Labels
Complexity: HardcoreLayer: Core-EngineType: Race-Condition