Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions book/fault_proofs/challenger.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Either `PRIVATE_KEY` or both `SIGNER_URL` and `SIGNER_ADDRESS` must be set for t
| `FETCH_INTERVAL` | Polling interval in seconds | `30` |
| `CHALLENGER_METRICS_PORT` | The port to expose metrics on. Update prometheus.yml to use this port, if using docker compose. | `9001` |
| `MALICIOUS_CHALLENGE_PERCENTAGE` | Percentage (0.0-100.0) of valid games to challenge for testing defense mechanisms | `0.0` |
| `CHALLENGER_BACKUP_PATH` | Path to backup file for persisting challenger state across restarts. Enables faster recovery by restoring cached state instead of re-syncing from the factory. | (disabled) |

```env
# Required Configuration
Expand All @@ -72,6 +73,7 @@ CHALLENGER_METRICS_PORT=9001 # The port to expose metrics on

# Testing Configuration (Optional)
MALICIOUS_CHALLENGE_PERCENTAGE=0.0 # Percentage of valid games to challenge for testing (0.0 = disabled)
CHALLENGER_BACKUP_PATH= # persist state across restarts (e.g. /backup/challenger_state.json)
```

## Running
Expand Down
1 change: 1 addition & 0 deletions book/fault_proofs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ PRIVATE_KEY= # Private key for transaction signing

# Optional Configuration
FETCH_INTERVAL=30 # Polling interval in seconds
CHALLENGER_BACKUP_PATH= # persist state across restarts (e.g. /backup/challenger_state.json)
```

2. Start the services:
Expand Down
150 changes: 136 additions & 14 deletions fault-proof/src/backup.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Simple file-based state persistence for proposer recovery.
//! Simple file-based state persistence for proposer and challenger recovery.
//!
//! On restart, the proposer can restore its cursor and game cache from a backup file,
//! On restart, the proposer/challenger can restore its cursor and game cache from a backup file,
//! avoiding a full re-sync from the factory contract.

use std::{io::Write, path::Path};
Expand All @@ -11,7 +11,23 @@ use alloy_primitives::U256;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};

use crate::proposer::Game;
use crate::{challenger::Game as ChallengerGame, proposer::Game};

/// Atomically save a serializable value as pretty-printed JSON (temp file + fsync + rename).
fn save_json(value: &impl Serialize, path: &Path, label: &str) -> Result<()> {
let json = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize {label} backup"))?;
let dir = path.parent().unwrap_or(Path::new("."));
let mut temp = NamedTempFile::new_in(dir)
.with_context(|| format!("failed to create {label} backup temp file"))?;
temp.write_all(json.as_bytes())
.with_context(|| format!("failed to write {label} backup temp file"))?;
temp.as_file()
.sync_all()
.with_context(|| format!("failed to sync {label} backup temp file"))?;
temp.persist(path).with_context(|| format!("failed to persist {label} backup file"))?;
Ok(())
}

/// Current backup format version. Increment when making breaking changes.
pub const BACKUP_VERSION: u32 = 1;
Expand Down Expand Up @@ -52,18 +68,8 @@ impl ProposerBackup {
Ok(())
}

/// Save the backup to a file as JSON (atomic via temp file + rename with fsync).
pub fn save(&self, path: &Path) -> Result<()> {
let json =
serde_json::to_string_pretty(self).context("failed to serialize proposer backup")?;

let dir = path.parent().unwrap_or(Path::new("."));
let mut temp =
NamedTempFile::new_in(dir).context("failed to create proposer backup temp file")?;
temp.write_all(json.as_bytes()).context("failed to write proposer backup temp file")?;
temp.as_file().sync_all().context("failed to sync proposer backup temp file")?;
temp.persist(path).context("failed to persist proposer backup file")?;

save_json(self, path, "proposer")?;
tracing::debug!(?path, games = self.games.len(), "Proposer state backed up");
Ok(())
}
Expand Down Expand Up @@ -106,6 +112,71 @@ impl ProposerBackup {
}
}

// ==================== Challenger Backup ====================

/// Current challenger backup format version. Increment when making breaking changes.
pub const CHALLENGER_BACKUP_VERSION: u32 = 1;

/// Serializable backup of the challenger state.
#[derive(Serialize, Deserialize)]
pub struct ChallengerBackup {
pub version: u32,
pub cursor: U256,
pub games: Vec<ChallengerGame>,
}

impl ChallengerBackup {
pub fn new(cursor: U256, games: Vec<ChallengerGame>) -> Self {
Self { version: CHALLENGER_BACKUP_VERSION, cursor, games }
}

/// Validate backup integrity.
pub fn validate(&self) -> Result<()> {
// Cursor with no games indicates a stale or corrupted backup.
if self.games.is_empty() && self.cursor > U256::ZERO {
bail!("cursor exists but no games");
}
Ok(())
}

pub fn save(&self, path: &Path) -> Result<()> {
save_json(self, path, "challenger")?;
tracing::debug!(?path, games = self.games.len(), "Challenger state backed up");
Ok(())
}

/// Load and validate a backup from file. Returns None if unavailable or invalid.
pub fn load(path: &Path) -> Option<Self> {
let json = std::fs::read_to_string(path).ok()?;

let backup = match serde_json::from_str::<Self>(&json) {
Ok(b) => b,
Err(e) => {
tracing::warn!(?path, error = %e, "Failed to parse challenger backup, starting fresh");
return None;
}
};

if backup.version != CHALLENGER_BACKUP_VERSION {
tracing::warn!(
?path,
backup_version = backup.version,
current_version = CHALLENGER_BACKUP_VERSION,
"Challenger backup version mismatch, starting fresh"
);
return None;
}

if let Err(e) = backup.validate() {
tracing::warn!(?path, error = %e, "Challenger backup validation failed, starting fresh");
return None;
}

tracing::info!(?path, games = backup.games.len(), "Challenger backup loaded");
Some(backup)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -169,4 +240,55 @@ mod tests {
"ProposerBackup schema changed! Bump BACKUP_VERSION in backup.rs"
);
}

#[test]
fn challenger_backup_schema_guard() {
use crate::contract::{GameStatus, ProposalStatus};
use alloy_primitives::Address;

let game = ChallengerGame {
index: U256::ZERO,
address: Address::ZERO,
parent_index: 0,
l2_block_number: U256::ZERO,
is_invalid: false,
status: GameStatus::IN_PROGRESS,
proposal_status: ProposalStatus::Unchallenged,
should_attempt_to_challenge: false,
should_attempt_to_resolve: false,
should_attempt_to_claim_bond: false,
};

let json = serde_json::to_value(&game).unwrap();
let mut keys: Vec<_> = json.as_object().unwrap().keys().cloned().collect();
keys.sort();

assert_eq!(
keys,
vec![
"address",
"index",
"is_invalid",
"l2_block_number",
"parent_index",
"proposal_status",
"should_attempt_to_challenge",
"should_attempt_to_claim_bond",
"should_attempt_to_resolve",
"status",
],
"ChallengerGame schema changed! Bump CHALLENGER_BACKUP_VERSION in backup.rs"
);

let backup = ChallengerBackup::new(U256::ZERO, vec![]);
let json = serde_json::to_value(&backup).unwrap();
let mut keys: Vec<_> = json.as_object().unwrap().keys().cloned().collect();
keys.sort();

assert_eq!(
keys,
vec!["cursor", "games", "version"],
"ChallengerBackup schema changed! Bump CHALLENGER_BACKUP_VERSION in backup.rs"
);
}
}
70 changes: 68 additions & 2 deletions fault-proof/src/challenger.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{
collections::HashMap,
path::Path,
sync::{Arc, OnceLock},
time::Duration,
};
Expand All @@ -9,9 +10,11 @@ use alloy_primitives::{Address, U256};
use alloy_provider::{Provider, ProviderBuilder};
use anyhow::{bail, Context, Result};
use rand::{rngs::StdRng, Rng, SeedableRng};
use serde::{Deserialize, Serialize};
use tokio::{sync::Mutex, time};

use crate::{
backup::ChallengerBackup,
config::ChallengerConfig,
contract::{
AnchorStateRegistry::AnchorStateRegistryInstance,
Expand Down Expand Up @@ -97,6 +100,8 @@ where
continue
}

self.backup().await;

if let Err(e) = self.handle_game_challenging().await {
tracing::warn!("Failed to handle game challenging: {:?}", e);
}
Expand Down Expand Up @@ -137,10 +142,31 @@ where
Ok(())
}

/// Validates startup and initializes state.
/// Validates startup, initializes state, and restores from backup if available.
async fn validate_and_init(&self) -> Result<()> {
let bond = self.startup_validations().await?;
self.init_state(bond);

if let Some(path) = &self.config.backup_path {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
anyhow::bail!("backup path parent directory does not exist: {:?}", parent);
}
}

let dir = path.parent().unwrap_or(Path::new("."));
tempfile::NamedTempFile::new_in(dir)
.with_context(|| format!("backup path is not writable: {:?}", path))?;

if let Some(restored) = ChallengerState::try_restore(path) {
let mut state = self.state.lock().await;
state.cursor = restored.cursor;
state.games = restored.games;
} else if path.exists() {
tracing::warn!(?path, "Failed to restore challenger state from backup");
}
}

Ok(())
}

Expand Down Expand Up @@ -648,6 +674,21 @@ where
Ok(())
}

/// Backup challenger state to disk. No-op if backup_path is not configured.
async fn backup(&self) {
let Some(path) = &self.config.backup_path else { return };
let backup = {
let state = self.state.lock().await;
state.to_backup()
};
let path = path.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = backup.save(&path) {
tracing::warn!("Failed to backup challenger state: {:?}", e);
}
});
}

// ==================== Integration Test Helpers ====================

/// Returns a copy of a game's full internal state for testing.
Expand All @@ -672,7 +713,7 @@ where
}
}

#[derive(Clone)]
#[derive(Clone, Serialize, Deserialize)]
pub struct Game {
pub index: U256,
pub address: Address,
Expand All @@ -690,3 +731,28 @@ pub struct ChallengerState {
cursor: U256,
games: HashMap<U256, Game>,
}

impl ChallengerState {
fn to_backup(&self) -> ChallengerBackup {
ChallengerBackup::new(self.cursor, self.games.values().cloned().collect())
}

fn from_backup(backup: ChallengerBackup) -> Self {
Self {
cursor: backup.cursor,
games: backup.games.into_iter().map(|g| (g.index, g)).collect(),
}
}

pub fn try_restore(path: &Path) -> Option<Self> {
let backup = ChallengerBackup::load(path)?;
let state = Self::from_backup(backup);
tracing::info!(
?path,
games = state.games.len(),
cursor = %state.cursor,
"Challenger state restored from backup"
);
Some(state)
}
}
5 changes: 5 additions & 0 deletions fault-proof/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ pub struct ChallengerConfig {
/// Set to 0.0 (default) for production use (honest challenging only).
/// Set to >0.0 for testing defense mechanisms.
pub malicious_challenge_percentage: f64,

/// Path to backup file for persisting challenger state across restarts.
pub backup_path: Option<PathBuf>,
}

impl ChallengerConfig {
Expand All @@ -316,6 +319,7 @@ impl ChallengerConfig {
malicious_challenge_percentage: env::var("MALICIOUS_CHALLENGE_PERCENTAGE")
.unwrap_or("0.0".to_string())
.parse()?,
backup_path: env::var("CHALLENGER_BACKUP_PATH").ok().map(PathBuf::from),
})
}

Expand All @@ -330,6 +334,7 @@ impl ChallengerConfig {
fetch_interval = self.fetch_interval,
metrics_port = self.metrics_port,
malicious_challenge_percentage = self.malicious_challenge_percentage,
backup_path = ?self.backup_path,
"Challenger configuration loaded"
);
}
Expand Down
Loading
Loading