Skip to content
Closed
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
1,947 changes: 921 additions & 1,026 deletions Cargo.lock

Large diffs are not rendered by default.

29 changes: 14 additions & 15 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,21 @@ members = ["crates/*"]

[workspace.dependencies]
scribe = { path = "./crates/scribe" }
alloy = { version = "1.0", features = ["full", "signer-keystore", "node-bindings", "rpc-types-mev"] }
chrono = "0.4.38"
env_logger = "0.11.3"
eyre = "0.6.8"
log = { version = "0.4.20", features = ["kv"] }
metrics = "0.24.1"
metrics-exporter-prometheus = { version = "0.16.1", features = [
alloy = { version = "1.6", features = ["full", "signer-keystore", "node-bindings", "rpc-types-mev"] }
chrono = "0.4.43"
env_logger = "0.11.9"
eyre = "0.6.12"
log = { version = "0.4.29", features = ["kv"] }
metrics = "0.24.3"
metrics-exporter-prometheus = { version = "0.18.1", features = [
"http-listener",
] }
metrics-process = "2.4.0"
mockall = "0.13.1"
metrics-process = "2.4.3"
mockall = "0.14.0"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7.12"
thiserror = "2.0.11"
serde_json = "1.0.138"
tokio-util = "0.7.18"
thiserror = "2.0.18"
serde_json = "1.0.149"

[dependencies]
scribe = { workspace = true }
Expand All @@ -39,7 +39,6 @@ metrics-process = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true }

clap = { version = "4.3.21", features = ["derive", "env"] }
rpassword = "7.2.0"
clap = { version = "4.5.58", features = ["derive", "env"] }
rpassword = "7.4.0"
futures-util = "0.3.31"
testing_logger = "0.1.1"
165 changes: 124 additions & 41 deletions crates/scribe/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub trait ScribeContract: Send + Sync {

/// Returns true if given `OpPoked` event is challengeable.
/// It checks if the event is stale and if the signature is valid.
fn is_op_poke_challangeble(
fn is_op_poke_challengeable(
&self,
op_poked: &Log<ScribeOptimistic::OpPoked>,
challenge_period: u64,
Expand Down Expand Up @@ -89,6 +89,14 @@ impl<P: Provider> ScribeContractInstance<P> {
}
}

/// Helper method to wrap contract errors with address context
fn wrap_contract_error<T>(&self, result: Result<T, alloy::contract::Error>) -> ContractResult<T> {
result.map_err(|e| ContractError::AlloyContractError {
address: *self.address(),
source: e,
})
}

// Checks if the log is stale, i.e. if the event is outside of the challenge period.
// If the block timestamp is missing, it is fetched from the block number.
// If the block number is also missing, an error is returned.
Expand All @@ -105,9 +113,8 @@ impl<P: Provider> ScribeContractInstance<P> {
return Err(ContractError::NoBlockNumberInLog(log.transaction_hash));
}

self
.get_timestamp_from_block(log.block_number.unwrap())
.await?
let block_number = log.block_number.ok_or(ContractError::MissingBlockNumber)?;
self.get_timestamp_from_block(block_number).await?
}
};

Expand Down Expand Up @@ -142,25 +149,21 @@ impl<P: Provider> ScribeContractInstance<P> {
self.address()
);

let message = self
.contract
.constructPokeMessage(op_poked.pokeData)
.call()
.await
.map_err(|e| ContractError::AlloyContractError {
address: *self.address(),
source: e,
})?;

let acceptable = self
.contract
.isAcceptableSchnorrSignatureNow(message, op_poked.schnorrData)
.call()
.await
.map_err(|e| ContractError::AlloyContractError {
address: *self.address(),
source: e,
})?;
let message = self.wrap_contract_error(
self
.contract
.constructPokeMessage(op_poked.pokeData)
.call()
.await,
)?;

let acceptable = self.wrap_contract_error(
self
.contract
.isAcceptableSchnorrSignatureNow(message, op_poked.schnorrData)
.call()
.await,
)?;

Ok(acceptable)
}
Expand All @@ -173,14 +176,7 @@ impl<P: Provider> ScribeContractInstance<P> {
);

let tx = self
.contract
.opChallenge(schnorr_data.clone())
.send()
.await
.map_err(|e| ContractError::AlloyContractError {
address: *self.address(),
source: e,
})?
.wrap_contract_error(self.contract.opChallenge(schnorr_data.clone()).send().await)?
.with_timeout(Some(TX_CONFIRMATION_TIMEOUT))
.watch()
.await
Expand Down Expand Up @@ -251,15 +247,7 @@ impl<P: Provider> ScribeContract for ScribeContractInstance<P> {

/// Returns challenge period from ScribeOptimistic smart contract deployed to `address`.
async fn get_challenge_period(&self) -> ContractResult<u16> {
self
.contract
.opChallengePeriod()
.call()
.await
.map_err(|e| ContractError::AlloyContractError {
address: *self.address(),
source: e,
})
self.wrap_contract_error(self.contract.opChallengePeriod().call().await)
}

/// Challenges given `OpPoked` event with given `schnorr_data`.
Expand Down Expand Up @@ -291,7 +279,7 @@ impl<P: Provider> ScribeContract for ScribeContractInstance<P> {
}

/// Returns true if given `OpPoked` event is challengeable.
async fn is_op_poke_challangeble(
async fn is_op_poke_challengeable(
&self,
op_poked_log: &Log<ScribeOptimistic::OpPoked>,
challenge_period: u64,
Expand Down Expand Up @@ -342,4 +330,99 @@ mod tests {
};
assert!(contract.is_log_stale(&log, 100).await.is_err());
}

#[tokio::test]
async fn test_is_log_stale_boundary_exactly_at_period() {
let provider = new_provider("http://localhost:8545");
let contract = ScribeContractInstance::new(Address::random(), provider.clone(), None);

// Event age exactly equals challenge period → should NOT be stale (uses > not >=)
let now = chrono::Utc::now().timestamp() as u64;
let challenge_period = 100;
let log = Log {
block_number: Some(1),
block_timestamp: Some(now - challenge_period),
..Default::default()
};

assert!(
!contract.is_log_stale(&log, challenge_period).await.unwrap(),
"Event exactly at challenge period boundary should NOT be stale (> not >=)"
);

// One second past → should be stale
let log = Log {
block_number: Some(1),
block_timestamp: Some(now - challenge_period - 1),
..Default::default()
};
assert!(
contract.is_log_stale(&log, challenge_period).await.unwrap(),
"Event one second past challenge period should be stale"
);
}

#[tokio::test]
async fn test_is_log_stale_with_block_timestamp_none_and_no_block_number() {
let provider = new_provider("http://localhost:8545");
let contract = ScribeContractInstance::new(Address::random(), provider.clone(), None);

// Log with block_timestamp: None and block_number: None → should return NoBlockNumberInLog error
let log = Log {
block_number: None,
block_timestamp: None,
..Default::default()
};

let result = contract.is_log_stale(&log, 100).await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), ContractError::NoBlockNumberInLog(..)),
"Should return NoBlockNumberInLog error when both timestamp and block number are None"
);
}

#[tokio::test]
async fn test_is_log_stale_with_block_timestamp_none_fetches_block() {
let provider = new_provider("http://localhost:8545");
let contract = ScribeContractInstance::new(Address::random(), provider.clone(), None);

// Log with block_timestamp: None but valid block_number → tries to fetch block
// This will fail with RPC error since no real node is running, verifying the fallback path
let log = Log {
block_number: Some(1),
block_timestamp: None,
..Default::default()
};

let result = contract.is_log_stale(&log, 100).await;
// Should fail because there's no real node to fetch the block from
assert!(
result.is_err(),
"Should fail trying to fetch block from non-existent node"
);
}

#[tokio::test]
async fn test_is_op_poke_challengeable_stale_returns_false() {
let provider = new_provider("http://localhost:8545");
let contract = ScribeContractInstance::new(Address::random(), provider.clone(), None);

// Fresh log with old timestamp + short challenge period → stale → returns Ok(false)
// without ever calling signature validation
let old_timestamp = chrono::Utc::now().timestamp() as u64 - 1000;
let log = Log {
block_number: Some(1),
block_timestamp: Some(old_timestamp),
..Default::default()
};

// Challenge period of 1 second means the event (1000s old) is definitely stale
let result = contract.is_op_poke_challengeable(&log, 1).await;
assert!(result.is_ok());
assert!(
!result.unwrap(),
"Stale event should return false without calling signature validation"
);
}
}
14 changes: 7 additions & 7 deletions crates/scribe/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub enum ContractError {
source: TransactionBuilderError<Ethereum>,
},

#[error("failed to build transaction for private mempool address {address:?}")]
#[error("failed to build transaction envelope for private mempool address {address:?}")]
PrivateTransactionBuildError { address: Address },

#[error("RPC transport error: {0}")]
Expand All @@ -84,6 +84,9 @@ pub enum ContractError {

#[error("failed to fetch block with number {0}")]
FailedToFetchBlock(u64),

#[error("missing block number in log")]
MissingBlockNumber,
}

/// Dynamic event processor result type.
Expand All @@ -98,9 +101,6 @@ pub enum ProcessorError {
#[error("RPC transport error: {0}")]
RpcError(#[from] RpcError<TransportErrorKind>),

#[error("failed to fetch block with number {0}")]
FailedToFetchBlock(u64),

#[error("failed to execute challenge on address {address:?}: {source}")]
ChallengeError {
address: Address,
Expand All @@ -113,9 +113,6 @@ pub enum ProcessorError {

#[error("address {address:?} challenge cancelled after attempt: {attempt}")]
ChallengeCancelled { address: Address, attempt: u16 },

#[error("missing block number in log for transaction {0:?}")]
NoBlockNumberInLog(Option<TxHash>),
}

/// Dynamic event polling result type.
Expand All @@ -139,6 +136,9 @@ pub enum PollerError {

#[error(transparent)]
ContractError(#[from] ContractError),

#[error("no channel found for address {address:?}")]
ChannelNotFound { address: Address },
}

/// Dynamic event polling result type.
Expand Down
6 changes: 3 additions & 3 deletions crates/scribe/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ pub enum Event {
}

impl Event {
pub fn title(&self) -> String {
pub fn title(&self) -> &'static str {
match self {
Self::OpPoked(_) => "OpPoked".to_string(),
Self::OpPokeChallengedSuccessfully(_) => "OpPokeChallengedSuccessfully".to_string(),
Self::OpPoked(_) => "OpPoked",
Self::OpPokeChallengedSuccessfully(_) => "OpPokeChallengedSuccessfully",
}
}

Expand Down
Loading