diff --git a/CHANGELOG.md b/CHANGELOG.md index d024aec..75a97f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.0.9] - 2025-01-22 ### Added + - Initial release of Blocknative Gas Agent - Real-time gas price estimation for the Gas Network - EIP-1559 transaction handling and gas estimation @@ -18,18 +19,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - RESTful API endpoints for gas price queries ### Changed + - Improved error handling by replacing anyhow with concrete ModelError types - Enhanced pending floor settlement changed to Fast settlement - Optimized network requests by reusing single Reqwest client - Updated model functions to return FromBlock values ### Fixed + - Fixed clippy warnings and code quality issues - Resolved EIP-1559 handling edge cases - Improved model prediction error handling ### Technical Details + - Built with Rust and async/await patterns using Tokio - Supports multiple blockchain networks and gas estimation models - Comprehensive testing suite with unit and integration tests -- Production-ready with optimized release profile settings \ No newline at end of file +- Production-ready with optimized release profile settings diff --git a/Cargo.lock b/Cargo.lock index cdcb9d1..8ef79dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,7 +43,10 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b4ae82946772d69f868b9ef81fc66acb1b149ef9b4601849bec4bcf5da6552e" dependencies = [ + "alloy-consensus", "alloy-core", + "alloy-eips", + "alloy-serde", "alloy-signer", "alloy-signer-local", ] @@ -1525,7 +1528,7 @@ dependencies = [ [[package]] name = "gas-agent" -version = "0.0.9" +version = "0.1.0" dependencies = [ "alloy", "anyhow", @@ -1544,6 +1547,7 @@ dependencies = [ "rust_decimal_macros", "serde", "serde_json", + "sha2", "strum", "strum_macros", "thiserror 2.0.12", diff --git a/Cargo.toml b/Cargo.toml index d68fca9..3bd2962 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gas-agent" -version = "0.0.9" +version = "0.1.0" edition = "2021" description = "Blocknative Gas Agent - Generate real-time gas price estimates for the Gas Network" homepage = "https://gas.network" @@ -21,6 +21,7 @@ strip = true [dependencies] alloy = { version = "~0.12.0", default-features = false, features = [ "signer-local", + "serde", ] } tokio = { version = "~1.44.0", features = ["full"] } @@ -43,6 +44,7 @@ dotenv = "~0.15.0" opentelemetry-prometheus = "~0.17.0" bytes = "~1.10.1" hex = "~0.4.3" +sha2 = "~0.10.8" opentelemetry_sdk = { version = "~0.24.1", default-features = false, features = [ "metrics", diff --git a/EVALUATION.md b/EVALUATION.md index 0fbe102..3958365 100644 --- a/EVALUATION.md +++ b/EVALUATION.md @@ -1,6 +1,6 @@ # Gas Agent Evaluation -Gas Agents submit gas price predictions for EVM networks to the [Gas Network](https://gas.network/) for evaluation. The *Evaluation Function* scores each agent to determine which is providing the best prediction relative to onchain truth. Since the comparison uses the actual onchain non-zero minimum gas price, the evalution is a lagging measurement in order to wait for the comparison block to arrive. +Gas Agents submit gas price predictions for EVM networks to the [Gas Network](https://gas.network/) for evaluation. The _Evaluation Function_ scores each agent to determine which is providing the best prediction relative to onchain truth. Since the comparison uses the actual onchain non-zero minimum gas price, the evalution is a lagging measurement in order to wait for the comparison block to arrive. ## Evaluation Criteria @@ -14,7 +14,7 @@ This results in the following evaluation criteria: 4. **Stability Around Overpayment**: The standard deviation of the overpayment rates of the agent's predictions in the evaluation window. 5. **Liveliness**: The consistency of the agent in delivery predictions in the evaluation window. -# Score Function +## Score Function The score function is a weighted sum of utility functions, with each feature represented by its own utility function and corresponding weight. @@ -22,19 +22,19 @@ $$ \text{TotalScore}= w_{0}*u_{0}(x_{0})+w_{1}*u_{1}(x_{1})+w_{2}*u_{2}(x_{2})+w_{3}*u_{3}(x_{3})+w_{4}*u_{4}(x_{4}) $$ -Each utility function output is bounded from [0,1]. The sum of all weights equal to 1. Thus the TotalScore is bounded from [0,1] A perfect score is 1. Higher is better. +Each utility function output is bounded from [0,1]. The sum of all weights equal to 1. Thus the TotalScore is bounded from [0,1] A perfect score is 1. Higher is better. -| | weight | utility function | raw input (with example) | -| --- | --- | --- | --- | -| inclusion mean | $w_0=0.5$ | $u_0()$ | $x_0=0.9$ | -| stability for inclusion | $w_1 = 0.15$ | $u_1() = e^{-\beta_1*x}$ where $\beta_1=3.2$ | $x_1 = 2.5$ | -| overpayment mean | $w_2=0.15$ | $u_2()=e^{-\beta_2*x}$ where $\beta_2=3.2$ | $x_2 = 1.2$ | -| stability for overpayment | $w_3=0.10$ | $u_3()=e^{-\beta_3*x}$ where $\beta_3=3.2$ | $x_3=3.2$ | -| liveliness | $w_4=0.10$ | $u_4()$ | $x_4=0.9$ | +| | weight | utility function | raw input (with example) | +| ------------------------- | ------------ | -------------------------------------------- | ------------------------ | +| inclusion mean | $w_0=0.5$ | $u_0()$ | $x_0=0.9$ | +| stability for inclusion | $w_1 = 0.15$ | $u_1() = e^{-\beta_1*x}$ where $\beta_1=3.2$ | $x_1 = 2.5$ | +| overpayment mean | $w_2=0.15$ | $u_2()=e^{-\beta_2*x}$ where $\beta_2=3.2$ | $x_2 = 1.2$ | +| stability for overpayment | $w_3=0.10$ | $u_3()=e^{-\beta_3*x}$ where $\beta_3=3.2$ | $x_3=3.2$ | +| liveliness | $w_4=0.10$ | $u_4()$ | $x_4=0.9$ | Note that the utility functions $u_0()$ and $u_4()$ are redundant, as the inclusion mean is already constrained to the interval [0, 1]. -# Score Calculation Breakdown +## Score Calculation Breakdown A block window is a range of blocks for which an estimate is guaranteed to land on chain. As an example, the block window for GasAgents Ethereum is 1 block. (next block prediction) @@ -44,9 +44,9 @@ After a block window is closed the following metrics are calculated and inserted - overpayment error (estimate - on chain block window minimum) - timestamp of estimate (used for liveliness) -Inclusion & Overpayment +### Inclusion & Overpayment -Once the array is filled, the performance metrics are calculated. For each subsequent block window, the oldest value in the memory array is replaced, and a new performance metric is calculated after the update. +Once the array is filled, the performance metrics are calculated. For each subsequent block window, the oldest value in the memory array is replaced, and a new performance metric is calculated after the update. - inclusion rate (number of included estimates / array size (default 10)) - average overpayment error (sum of errors/array size) @@ -58,7 +58,7 @@ These performance metrics are inserted into a AgentHistory array. The score metr - average overpayment averages - standard deviation of overpayment averages -Liveliness +### Liveliness For each block window in the fixed-size memory array, a 1 or 0 is assigned depending on whether a prediction was submitted. The liveliness metric is then calculated by summing these values and dividing by the size of the memory array. @@ -74,7 +74,7 @@ The utility function transforms these values bounds to [0,1] $u = exp(- \beta * x)$ where $\beta=3.2$ -We selected $\beta$ based on the following constraints. +We selected $\beta$ based on the following constraints. $$ \begin{cases}u=1 \hspace{0.2em}\text{when}\hspace{0.2em}x=0\hspace{3.6em}(1)\\ @@ -85,19 +85,18 @@ $u$ represents the utility produced by the given metric. Constraint (1) ✅ - $u = 1$ is the highest possible utility and occurs when +$u = 1$ is the highest possible utility and occurs when - **The average overpayment is 0**, which means that the estimated gas price exactly matched the true on-chain minimum. There was neither overestimation nor underestimation overall. - **The coefficient of variation (CV) is 0**, which indicates that the **standard deviation is 0**. There is **no variation** in the measured values. - For example, if the **standard deviation of inclusion** is 0, this means that all inclusion metrics (e.g., inclusion times or probabilities) are **identical** across the dataset. - + For example, if the **standard deviation of inclusion** is 0, this means that all inclusion metrics (e.g., inclusion times or probabilities) are **identical** across the dataset. → satisfied as $u = exp(0)=1$ Constraint (2) ✅ - $u = 0.2$ indicates a low utility and occurs when +$u = 0.2$ indicates a low utility and occurs when - **The average overpayment is 50% (0.5),** which means that the estimate gas price was 50% more expensive then the on-chain minimum. - **The coefficient of variation (CV) is 0.5,** which indicates that the spread is half as large as the mean. While a CV of 0.5 typically indicates moderate variation, in our context we aim to reward agents with minimal or no variability in their metric performance. diff --git a/README.md b/README.md index 03c1a00..7a4c109 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ To register your agent and get your signing addresses whitelisted: 2. **Save Your Keys**: Securely store the private key for your agent configuration and note the corresponding public address 3. **Submit Whitelist Request**: Contact the Blocknative team with your public address(es): - - **Email**: [support@blocknative.com](mailto:support@blocknative.com) - **Discord**: Join our community at [https://discord.com/invite/KZaBVME](https://discord.com/invite/KZaBVME) @@ -185,11 +184,13 @@ cargo run --release -- start --chains 'YOUR-CONFIG-JSON' ### Development Workflow 1. **Create a feature branch**: + ```bash git checkout -b feature/your-feature-name ``` 2. **Make your changes** and ensure code quality: + ```bash # Check for compilation errors cargo check @@ -205,6 +206,7 @@ cargo run --release -- start --chains 'YOUR-CONFIG-JSON' ``` 3. **Commit your changes**: + ```bash git add . git commit -m "Add your feature description" @@ -261,19 +263,15 @@ The chain configuration is specified as a JSON array where each object represent #### ChainConfig Fields - **`system`** (required): The blockchain system to connect to - - Available options: `"ethereum"`, `"base"`, `"polygon"` - **`network`** (required): The network within the system - - Available options: `"mainnet"` - **`json_rpc_url`** (required): The JSON-RPC endpoint URL to poll for new blocks - - Example: `"https://ethereum-rpc.publicnode.com"` - **`pending_block_data_source`** (optional): Configuration for fetching pending-block (mempool) data - - See [Pending Block Data Source](#pending-block-data-source) section below - **`agents`** (required): Array of agent configurations to run on this chain @@ -359,7 +357,6 @@ Each agent in the `agents` array supports the following configuration: **Fields:** - **`kind`** (required): The type of agent to run - - `"node"`: Publishes the standard estimate from the node - `"target"`: Publishes the actual minimum price for new blocks - Model-based agents: @@ -481,10 +478,14 @@ Your Custom Model Description Explain what your model does and how it works. */ +use crate::models::{FromBlock, ModelError, Prediction}; use crate::types::Settlement; use crate::{distribution::BlockDistribution, utils::round_to_9_places}; -pub fn get_prediction_your_custom_model(block_distributions: &[BlockDistribution], pending_block_distribution: &Option) -> (f64, Settlement) { +pub fn get_prediction_your_custom_model( + block_distributions: &[BlockDistribution], + latest_block: u64, +) -> Result<(Prediction, Settlement, FromBlock), ModelError> { // Your model logic here // // block_distributions is a Vec where: @@ -494,26 +495,33 @@ pub fn get_prediction_your_custom_model(block_distributions: &[BlockDistribution // Each BlockDistribution represents gas price buckets from a block // sorted from oldest to newest blocks - // Example: Get the most recent block - let latest_block = block_distributions.last().unwrap(); + let Some(latest_block_distribution) = block_distributions.last() else { + return Err(ModelError::insufficient_data( + "YourCustomModel requires at least one block distribution", + )); + }; - // Example: Calculate some prediction logic let mut total_gas_price = 0.0; let mut total_transactions = 0u32; - for bucket in latest_block { + for bucket in latest_block_distribution { total_gas_price += bucket.gwei * bucket.count as f64; total_transactions += bucket.count; } - let predicted_price = if total_transactions > 0 { - total_gas_price / total_transactions as f64 - } else { - 1.0 // fallback price - }; + if total_transactions == 0 { + return Err(ModelError::insufficient_data( + "YourCustomModel requires blocks with transactions", + )); + } - // Return the prediction and settlement time - (round_to_9_places(predicted_price), Settlement::Fast) + let predicted_price = total_gas_price / total_transactions as f64; + + Ok(( + round_to_9_places(predicted_price), + Settlement::Fast, + latest_block + 1, + )) } ``` @@ -531,12 +539,15 @@ pub async fn apply_model( model: &ModelKind, block_distributions: &[BlockDistribution], pending_block_distribution: Option, -) -> Result<(f64, Settlement)> { + latest_block: u64, +) -> Result<(Prediction, Settlement, FromBlock), ModelError> { // ... existing code ... match model { // ... existing cases ... - ModelKind::YourCustomModel => Ok(get_prediction_your_custom_model(block_distributions, pending_block_distribution)), + ModelKind::YourCustomModel => { + get_prediction_your_custom_model(block_distributions, latest_block) + } } } ``` @@ -564,10 +575,9 @@ gas-agent start --chains '[{ 1. **Understand the Data Structure**: Each `BlockDistribution` contains buckets of gas prices with transaction counts, representing the gas price distribution for that block. -2. **Handle Edge Cases**: Always check for empty distributions and provide fallback values. +2. **Handle Edge Cases**: Validate inputs and return descriptive `ModelError`s when data is insufficient. 3. **Consider Settlement Times**: Choose appropriate `Settlement` values: - - `Immediate`: Next block - `Fast`: ~15 seconds - `Medium`: ~15 minutes @@ -644,7 +654,7 @@ When you submit a gas price prediction, the Gas Network evaluates its accuracy w - **Inclusion Rate**: Did your prediction price get onchain within the block window - **Cost Efficiency**: Percentage overpayment -For details on the *Evaluation Function* used to score your predictions, see the [Evaluation Function](EVALUATION.md). +For details on the _Evaluation Function_ used to score your predictions, see the [Evaluation Function](EVALUATION.md). ## Building for Production @@ -679,17 +689,21 @@ Update `CHANGELOG.md` following [Keep a Changelog](https://keepachangelog.com/) ## [0.1.0] - 2025-01-22 ### Added + - New feature descriptions - New model implementations ### Changed + - Breaking changes or significant modifications - Performance improvements ### Fixed + - Bug fixes and error handling improvements ### Removed + - Deprecated features that were removed ``` diff --git a/src/agent.rs b/src/agent.rs index 77c8cd6..6127459 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -4,9 +4,7 @@ use crate::distribution::BlockDistribution; use crate::models::{apply_model, ModelError}; use crate::publish::publish_agent_payload; use crate::rpc::{get_latest_block, get_rpc_client, Block, BlockHeader, RpcClient}; -use crate::types::{ - AgentKind, AgentPayload, AgentPayloadKind, FeeUnit, Settlement, SystemNetworkKey, -}; +use crate::types::{AgentKind, AgentPayload, PriceUnit, Settlement, SystemNetworkKey}; use anyhow::{Context, Result}; use chrono::Utc; use reqwest::Url; @@ -99,15 +97,16 @@ impl GasAgent { Err(e) => return Err(e.into()), }; + let price_wei = (price * 1_000_000_000f64).round() as u128; let payload = AgentPayload { + schema_version: "1".to_string(), from_block, settlement, timestamp: Utc::now(), - unit: FeeUnit::Gwei, system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price, - kind: AgentPayloadKind::Estimate, + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( @@ -131,15 +130,16 @@ impl GasAgent { if let Some(node_price) = node_price { let chain_tip = self.chain_tip.read().await.clone(); + let price_wei = (node_price * 1_000_000_000f64).round() as u128; let payload = AgentPayload { + schema_version: "1".to_string(), from_block: chain_tip.number + 1, settlement: Settlement::Fast, timestamp: Utc::now(), - unit: FeeUnit::Gwei, system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price: node_price, - kind: AgentPayloadKind::Estimate, + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( @@ -153,15 +153,16 @@ impl GasAgent { } AgentKind::Target => { let chain_tip = self.chain_tip.read().await.clone(); + let price_wei = (actual_min * 1_000_000_000f64).round() as u128; let payload = AgentPayload { + schema_version: "1".to_string(), from_block: chain_tip.number, settlement: Settlement::Immediate, timestamp: Utc::now(), - unit: FeeUnit::Gwei, system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price: actual_min, - kind: AgentPayloadKind::Target, + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( diff --git a/src/chain/types.rs b/src/chain/types.rs index f464754..b54d9b0 100644 --- a/src/chain/types.rs +++ b/src/chain/types.rs @@ -1,7 +1,10 @@ use super::super::types::AgentPayload; use crate::types::{Network, System, SystemNetworkKey}; use alloy::{ - primitives::aliases::{U240, U48}, + primitives::{ + aliases::{U240, U48}, + U256, + }, signers::Signature, }; @@ -56,7 +59,15 @@ impl From for OraclePayloadV2 { }, records: vec![OraclePayloadRecordV2 { typ: 340, // Hardcoded into type 340 - Max Priority Fee Per Gas 99th. - value: U240::from(payload.price), + value: { + // Convert decimal string price (wei) to uint240 by truncating high 16 bits (should be zero for realistic prices) + let wei = U256::from_str_radix(&payload.price, 10) + .expect("valid decimal string for wei price"); + let bytes32 = wei.to_be_bytes::<32>(); + let mut arr30 = [0u8; 30]; + arr30.copy_from_slice(&bytes32[2..]); + U240::from_be_bytes::<30>(arr30) + }, }], } } diff --git a/src/types.rs b/src/types.rs index 6482b76..890f430 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,17 +1,17 @@ +use crate::chain::{sign::PayloadSigner, types::SignedOraclePayloadV2}; +#[cfg(test)] +use alloy::signers::Signature; use alloy::{ hex, - primitives::keccak256, + primitives::{keccak256, B256}, signers::{local::PrivateKeySigner, Signer}, }; -use anyhow::Result; +use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::{fmt, str::FromStr}; use strum_macros::{Display, EnumString}; -use crate::chain::{sign::PayloadSigner, types::SignedOraclePayloadV2}; - #[derive(Debug, Clone, EnumString, Display, Deserialize, Serialize)] #[strum(serialize_all = "snake_case")] #[serde(rename_all = "snake_case")] @@ -71,65 +71,71 @@ impl From for AgentKind { } } -#[derive(Debug, Clone, EnumString, Display, Deserialize, Serialize)] -#[strum(serialize_all = "UPPERCASE")] -#[serde(rename_all = "UPPERCASE")] -pub enum FeeUnit { - Gwei, -} - -#[derive(Debug, Clone, EnumString, Display, Deserialize, Serialize)] -#[strum(serialize_all = "lowercase")] -#[serde(rename_all = "lowercase")] -pub enum AgentPayloadKind { - Estimate, - Target, -} - #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AgentPayload { + /// Schema version string for signed payloads + #[serde(default = "AgentPayload::schema_version")] + pub schema_version: String, /// The block height this payload is valid from pub from_block: u64, /// How fast the settlement time is for this payload pub settlement: Settlement, - /// The exact time the prediction is captured UTC. + /// The exact time the estimate is captured UTC. pub timestamp: DateTime, - /// The unit the fee is denominated in (e.g. gwei, sats) - pub unit: FeeUnit, - /// The name of the chain the estimations are for (eg. ethereum, bitcoin, base) + /// The name of the chain the estimations are for (eg. ethereum, base) pub system: System, /// mainnet, etc. pub network: Network, - /// The estimated price - pub price: f64, - pub kind: AgentPayloadKind, + /// The unit of the `price` field (currently only wei) + #[serde(default = "PriceUnit::default_wei")] + pub unit: PriceUnit, + /// The estimated price as a decimal string. Interpretation depends on `unit`. + /// For `wei`, this MUST be an integer decimal string with no leading zeros (except "0"). + pub price: String, } impl AgentPayload { - /// Hashes (keccak256) the payload and returns as bytes - pub fn hash(&self) -> Vec { - let json = json!({ - "timestamp": self.timestamp, - "system": self.system, - "network": self.network, - "settlement": self.settlement, - "from_block": self.from_block, - "price": self.price, - "unit": self.unit, - "kind": self.kind, - }); - - let bytes = json.to_string().as_bytes().to_vec(); - let message_hash = keccak256(&bytes).to_string(); - message_hash.as_bytes().to_vec() + fn schema_version() -> String { + "1".to_string() } + // --- Canonical JSON signing helpers --- + fn timestamp_ns_string(&self) -> String { + let secs = self.timestamp.timestamp() as i128; + let nanos = self.timestamp.timestamp_subsec_nanos() as i128; + let ts_ns: i128 = secs * 1_000_000_000 + nanos; + assert!(ts_ns >= 0, "negative timestamps are not supported"); + ts_ns.to_string() + } + + /// Build minified canonical JSON with lexicographically sorted keys including exactly the AgentPayload fields. + pub fn canonical_json_string(&self) -> String { + let schema_version = self.schema_version.clone(); + let from_block = self.from_block.to_string(); + let settlement = self.settlement.to_string().to_lowercase(); + let timestamp = self.timestamp_ns_string(); + let system = self.system.to_string().to_lowercase(); + let network = self.network.to_string().to_lowercase(); + let price = self.price.clone(); + let unit = self.unit.to_string().to_lowercase(); + + format!( + "{{\"from_block\":\"{}\",\"network\":\"{}\",\"price\":\"{}\",\"schema_version\":\"{}\",\"settlement\":\"{}\",\"system\":\"{}\",\"timestamp\":\"{}\",\"unit\":\"{}\"}}", + from_block, network, price, schema_version, settlement, system, timestamp, unit + ) + } + + fn canonical_digest(&self) -> B256 { + let json = self.canonical_json_string(); + keccak256(json.as_bytes()) + } + + /// Sign the Keccak-256 digest of the canonical JSON per spec v1.0.0. pub async fn sign(&self, signer_key: &str) -> Result { - let message = self.hash(); let signer: PrivateKeySigner = signer_key.parse()?; - let signature = signer.sign_message(&message).await?; + let digest = self.canonical_digest(); + let signature = signer.sign_hash(&digest).await?; let hex_signature = hex::encode(signature.as_bytes()); - Ok(format!("0x{hex_signature}")) } @@ -143,7 +149,12 @@ impl AgentPayload { let signer: PrivateKeySigner = signer_key.parse()?; opv2.to_signed_payload(&mut buf, signer)?; - let hex_signature = hex::encode(opv2.signature.unwrap().as_bytes()); + let hex_signature = hex::encode( + opv2.signature + .as_ref() + .context("signature should be set after signing")? + .as_bytes(), + ); Ok(format!("0x{hex_signature}")) } @@ -165,6 +176,67 @@ pub enum Network { Mainnet, } +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] +#[strum(serialize_all = "lowercase")] +#[serde(rename_all = "lowercase")] +/// Unit for the price field in AgentPayload +pub enum PriceUnit { + Wei, +} + +impl PriceUnit { + pub fn default_wei() -> Self { + PriceUnit::Wei + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::{primitives::Address, signers::local::PrivateKeySigner}; + use chrono::{DateTime, Utc}; + + // Tests-only helper to validate a canonical JSON signature against this payload + impl AgentPayload { + pub fn validate_signature(&self, signature: &str) -> anyhow::Result
{ + let sig_bytes = signature.strip_prefix("0x").unwrap_or(signature); + let sig_bytes = alloy::hex::decode(sig_bytes)?; + let signature = Signature::from_raw(&sig_bytes)?; + let digest = self.canonical_digest(); + let recovered = signature.recover_address_from_prehash(&digest)?; + Ok(recovered) + } + } + + #[tokio::test] + async fn test_canonical_sign_and_recover_roundtrip() { + let timestamp = DateTime::parse_from_rfc3339("2024-01-01T12:00:00.500000000Z") + .unwrap() + .with_timezone(&Utc); + // Fixed private key for reproducibility (DO NOT USE IN PROD) + let sk_hex = "0x59c6995e998f97a5a0044976f3ac3b8c9f27a7d9b3bcd2b0d7aeb5f3e9eae7c6"; + let signer: PrivateKeySigner = sk_hex.parse().unwrap(); + let payload = AgentPayload { + schema_version: "1".to_string(), + from_block: 12345, + settlement: Settlement::Fast, + timestamp, + system: System::Ethereum, + network: Network::Mainnet, + unit: PriceUnit::Wei, + price: "20000000000".to_string(), + }; + + // Sign + let sig = payload.sign(sk_hex).await.unwrap(); + assert!(sig.starts_with("0x")); + + // Recover and ensure matches the signer address + let recovered = payload.validate_signature(&sig).unwrap(); + assert_eq!(recovered, signer.address()); + } +} + #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct SystemNetworkKey { pub system: System, @@ -211,25 +283,6 @@ impl SystemNetworkKey { } } -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -pub struct SystemNetworkSettlementKey { - pub system: System, - pub network: Network, - pub settlement: Settlement, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct BlockWindow { - pub start: u64, - pub end: u64, -} - -impl fmt::Display for BlockWindow { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}-{}", self.start, self.end) - } -} - #[derive(Debug, Clone, EnumString, Display, Deserialize, Serialize, Hash, PartialEq, Eq)] #[strum(serialize_all = "lowercase")] #[serde(rename_all = "lowercase")] @@ -244,21 +297,3 @@ pub enum Settlement { /// 1 hour Slow, } - -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize)] -pub struct AgentKey { - pub settlement: Settlement, - pub agent_id: String, - pub system: System, - pub network: Network, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AgentScore { - pub agent_id: String, - pub inclusion_cov: Option, - pub overpayment_cov: Option, - pub total_score: Option, - pub inclusion_rate: f64, - pub avg_overpayment: Option, -}