Skip to content

feat: val config v2 - separate vals into active and inactive#2890

Closed
howydev wants to merge 3 commits intomainfrom
howy/update-val-config-v2-storage-and-interfaces
Closed

feat: val config v2 - separate vals into active and inactive#2890
howydev wants to merge 3 commits intomainfrom
howy/update-val-config-v2-storage-and-interfaces

Conversation

@howydev
Copy link
Contributor

@howydev howydev commented Feb 26, 2026

Changes

  1. Separate validator arrays into active and inactive vals so active validator array reads are ungriefable
  2. Add pagination to inactive validator reads for safety
  3. State changing functions for validators now operate on index
  4. _byIndex view functions now return 0 if the validator associated with that value is deactivated

Note: On correctness of migration, there's a future PR that will overhaul migration. So any security issues around migration should be checked in that PR

Callouts:

  1. There's a tiny footgun in which the owner accidentally front runs a validator state change call with a deactivate validator call. In this case, there's a non-zero chance that the swap-and-pop affects the index of the validator. This is a extremely low chance though - the validator has to be the most recent validator, the call has to happen in the same block and the owner's call has to happen first. There's no danger in these cases - the validator's call reverts and they'll need to retry the call - so we'll leave this as a note within the security section of the 1017 spec.
  2. Invariant changes - pubkeys can now be duplicated between active and inactive sets. But because a signature is required to add a key, any duplications must be from the same entity, so we dont need to consider malicious duplications

@github-actions
Copy link

📊 Tempo Precompiles Coverage

📦 Download full HTML report

@howydev howydev marked this pull request as ready for review February 27, 2026 05:48
@howydev howydev changed the title [draft] feat: val config v2 - separate vals into active and inactive feat: val config v2 - separate vals into active and inactive Feb 27, 2026
@tempoxyz-bot
Copy link

tempoxyz-bot commented Feb 27, 2026

🐺 Ralph Security Review

7893dab

Worker Engine Progress Status
pr-2890-w1 gemini-3.1-pro-preview 🚨 thread-1 ✅ thread-2 🚨 thread-3 Done
pr-2890-w2 amp/deep 🚨 thread-1thread-2 🚨 thread-3 Done

Findings

# Finding Severity Verification Threads
1 Index-Only Validator Mutations Can Be Retargeted By Reordering High ✅ Verified audit · verify
2 TOCTOU via Volatile Validator Indices High ⏩ Dup audit · verify
3 getInactiveValidators preallocates unbounded memory before gas truncation High ⏩ Dup audit · verify
4 Solidity Reference Implementation Divergence Prevents Rotating Validators With Unchanged IPs Medium ✅ Verified audit · verify

📋 Consolidation complete · Thread


📜 28 events

🔍 pr-2890-w1 iter 1/3 [audit-ripple.md]
🔍 pr-2890-w2 iter 1/3 [audit-focused.md]
🚨 pr-2890-w2 iter 1 — finding | Thread
🚨 Finding: Index-Only Validator Mutations Can Be Retargeted By Reordering (High) | Thread
🔍 pr-2890-w2 iter 2/3 [audit-ripple.md]
🔬 Verifying: Index-Only Validator Mutations Can Be Retargeted By Reordering | Thread
📋 Verify: Index-Only Validator Mutations Can Be Retargeted By Reordering → ✅ Verified | Thread
🚨 pr-2890-w1 iter 1 — finding | Thread
🚨 Finding: TOCTOU via Volatile Validator Indices (High) | Thread
🔍 pr-2890-w1 iter 2/3 [audit-historical.md]
🔬 Verifying: TOCTOU via Volatile Validator Indices | Thread
📋 Verify: TOCTOU via Volatile Validator Indices → ⏩ Dup | Thread
pr-2890-w2 iter 2 — clear | Thread
🔍 pr-2890-w2 iter 3/3 [audit-historical.md]
pr-2890-w1 iter 2 — clear | Thread
🔍 pr-2890-w1 iter 3/3 [audit-focused.md]
🚨 pr-2890-w2 iter 3 — finding | Thread
🚨 Finding: getInactiveValidators preallocates unbounded memory before gas truncation (High) | Thread
🏁 pr-2890-w2 done
🔬 Verifying: getInactiveValidators preallocates unbounded memory before gas truncation | Thread
📋 Verify: getInactiveValidators preallocates unbounded memory before gas truncation → ⏩ Dup | Thread
🚨 pr-2890-w1 iter 3 — finding | Thread
🚨 Finding: Solidity Reference Implementation Divergence Prevents Rotating Validators With Unchanged IPs (Medium) | Thread
🏁 pr-2890-w1 done
🔬 Verifying: Solidity Reference Implementation Divergence Prevents Rotating Validators With Unchanged IPs | Thread
📋 Verify: Solidity Reference Implementation Divergence Prevents Rotating Validators With Unchanged IPs → ✅ Verified | Thread
📦 Consolidation: running | Thread
📦 Consolidation: complete | Thread

Copy link

@tempoxyz-bot tempoxyz-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐺 Ralph Review

This PR refactors ValidatorConfigV2 from a single validators array to separate active_validators / inactive_validators lists with swap_remove for O(1) deactivation. State-changing APIs now take a mutable idx instead of validatorAddress. The refactor is well-tested, but three findings survived deduplication and verification across audit workers.

Reviewer Callouts:

  • Gas-Bounded Pagination: getInactiveValidators silently truncates output based on gas_remaining() — callers may unknowingly process incomplete data.
  • Read-Only Behavior Drift: ReadOnlyStorageProvider::gas_remaining() returns u64::MAX, silently disabling the pagination guard in eth_call contexts.
  • V2 Integration Boundary: When peer_manager/actor.rs switches from V1 to V2 reads, re-check active-only index space and pubkey/address reuse semantics.

Err(ValidatorConfigV2Error::validator_not_found())?
}

let mut to_deactivate = self.active_validators[call.idx as usize].read()?;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Index-Only Mutations Are Reorderable And Can Deactivate The Wrong Validator

State-changing APIs (deactivate_validator, rotate_validator, set_ip_addresses, transfer_validator_ownership) identify targets only by mutable active-array idx. Because deactivate_validator uses swap-and-pop (L455–L466), pending owner transactions can be silently retargeted to a different validator if another deactivation changes active-set ordering first.

Attack path: Owner submits deactivateValidator(idx=0) to remove attacker. Attacker front-runs with self-authorized deactivateValidator(idx=0). Swap-and-pop moves an innocent validator into idx=0. Owner tx executes and deactivates the wrong validator.

Recommended Fix: Require an immutable identity binding in mutating calls — e.g. (idx, expectedValidatorAddress) — and revert if the entry at idx doesn't match. This preserves O(1) access while preventing silent retargeting.

if start_index >= count {
return Ok(Vec::new());
}
let mut out = Vec::with_capacity((count - start_index) as usize);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Unmetered Host Preallocation From Unbounded Historical Length

Vec::with_capacity((count - start_index) as usize) allocates host memory proportional to the full inactive set size before the gas-bounded loop at L219. count is attacker-growable via repeated rotate_validator/deactivate_validator. This creates unmetered host-memory pressure independent of EVM gas budget.

Additionally, in read-only contexts (eth_call), gas_remaining() returns u64::MAX (see crates/revm/src/common.rs:401), entirely disabling the pagination guard and attempting a full historical copy.

Recommended Fix: Replace with_capacity(count - start_index) with bounded growth — compute affordable_items from available gas and per-item cost, then cap both allocation and loop iteration by that bound.

}
_checkOnlyOwnerOrValidator(oldValidator.validatorAddress);

_validateRotateParams(publicKey, ingress, egress);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [SECURITY] Solidity rotateValidator Incorrectly Reverts When Keeping Same Ingress IP

_validateRotateParams unconditionally calls _requireUniqueIngressIp(ingress), which reverts with IngressAlreadyExists when a validator rotates their key while keeping the same ingress IP — because the validator is still active when the check runs. The Rust precompile (update_ingress_ip_tracking at mod.rs:275-289) correctly short-circuits when old == new. This breaks legitimate key rotation simulation/testing against the Solidity mock.

Recommended Fix: Remove _requireUniqueIngressIp(ingress) from _validateRotateParams. The subsequent _updateIngressIp call already enforces uniqueness correctly by skipping when old and new hashes match.

}

fn gas_remaining(&self) -> u64 {
u64::MAX

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ [DEFENSE-IN-DEPTH] gas_remaining() returns u64::MAX in read-only context

This effectively disables the gas-based pagination guard in get_inactive_validators during eth_call queries, causing full historical copy attempts. Consider plumbing actual call gas limits or making pagination explicitly cap-based (e.g., max_items argument) independent of gas.

Copy link
Contributor

@SuperFluffy SuperFluffy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do a second pass, but the unstable indices raise concern.

If your data structure gives out indices as part of its public API, we better make sure they don't become invalid.

Instead of splitting the the vector into two, why don't we maintain a single vector of all validators (as before), and have a second array of active validators that only contains indices into the global array?

That way your indices are stable and cannot be invalidated.

/// Rotate a validator to new identity (owner or validator)
function rotateValidator(
address validatorAddress,
uint64 idx,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the reasoning behind this. Why does this need an index now?

Is the index stable? IIRC, we had a mapping address -> index.

internal
{
uint64 idx = uint64(validatorsArray.length);
uint64 idx = deactivatedAtHeight == 0 ? uint64(activeValidatorsArray.length) : 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These indices are unstable. That's a very dangerous design.

@SuperFluffy
Copy link
Contributor

I propose this alternative: #2900

Copy link
Contributor

@SuperFluffy SuperFluffy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This design breaks CL, see specifically this PR #2872 how validatorByPublicKey will be used.

revert ValidatorNotFound();
}
return validatorsArray[idx - 1];
return activeValidatorsArray[idx - 1];
Copy link
Contributor

@SuperFluffy SuperFluffy Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On DKG failure CL reverts to the previous set (to use DKG nomenclature - the committee of epoch E+1 will be the dealers of E because the resharing process failed and the players of E do not have valid shares). This means that for a given epoch validators can be in the committee but no longer active.

This design breaks CL and will cause an unrecoverable chainhalt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants