From 62ff677e43b456dd6b6b88baef2d6889c27a91d7 Mon Sep 17 00:00:00 2001 From: Aaron Date: Wed, 10 Sep 2025 14:02:21 +1000 Subject: [PATCH 1/7] Update payload signing to use EIP712 --- Cargo.lock | 3 + Cargo.toml | 1 + src/agent.rs | 23 ++--- src/chain/types.rs | 8 +- src/publish.rs | 2 +- src/types.rs | 252 ++++++++++++++++++++++++++++++--------------- 6 files changed, 190 insertions(+), 99 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cdcb9d1..614ae6f 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", ] diff --git a/Cargo.toml b/Cargo.toml index d68fca9..1b71db0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/agent.rs b/src/agent.rs index 77c8cd6..46dd020 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -4,9 +4,8 @@ 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, Settlement, SystemNetworkKey}; +use alloy::primitives::U256; use anyhow::{Context, Result}; use chrono::Utc; use reqwest::Url; @@ -99,15 +98,15 @@ 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, + price: U256::from(price_wei), }; publish_agent_payload( @@ -131,15 +130,15 @@ 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, + price: U256::from(price_wei), }; publish_agent_payload( @@ -153,15 +152,15 @@ 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, + price: U256::from(price_wei), }; publish_agent_payload( diff --git a/src/chain/types.rs b/src/chain/types.rs index f464754..07663e8 100644 --- a/src/chain/types.rs +++ b/src/chain/types.rs @@ -56,7 +56,13 @@ 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 uint256 price to uint240 by truncating high 16 bits (should be zero for realistic prices) + let bytes32 = payload.price.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/publish.rs b/src/publish.rs index f674ffc..3492ea6 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -10,7 +10,7 @@ pub async fn publish_agent_payload( signer_key: &str, payload: &AgentPayload, ) -> Result<()> { - let signature = payload.sign(signer_key).await?; + let signature = payload.sign(signer_key)?; let network_signature = payload.clone().network_signature(signer_key)?; let json = json!({ diff --git a/src/types.rs b/src/types.rs index 6482b76..0534f1b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,17 +1,17 @@ +use crate::chain::{sign::PayloadSigner, types::SignedOraclePayloadV2}; use alloy::{ hex, - primitives::keccak256, - signers::{local::PrivateKeySigner, Signer}, + primitives::{keccak256, Address, B256, U256}, + signers::{local::PrivateKeySigner, SignerSync}, }; -use anyhow::Result; +#[cfg(test)] +use alloy::signers::Signature; +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,131 @@ 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 for signed payloads (EIP-712 domain version) + #[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) pub system: System, /// mainnet, etc. pub network: Network, - /// The estimated price - pub price: f64, - pub kind: AgentPayloadKind, + /// The estimated price in wei (always denominated in wei) + pub price: U256, } 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() + } + + // --- EIP-712 helpers --- + fn typehash_domain() -> B256 { + // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + .as_bytes(), + ) + } + + fn typehash_agent_payload() -> B256 { + // keccak256("AgentPayload(string schema_version,uint256 timestamp,string system,string network,string settlement,uint256 from_block,uint256 price)") + keccak256( + "AgentPayload(string schema_version,uint256 timestamp,string system,string network,string settlement,uint256 from_block,uint256 price)" + .as_bytes(), + ) + } + + fn keccak_string(val: &str) -> B256 { + keccak256(val.as_bytes()) + } + + fn encode_u256_bytes_be(x: impl Into) -> [u8; 32] { + let v: u128 = x.into(); + let mut out = [0u8; 32]; + out[16..].copy_from_slice(&v.to_be_bytes()); + out + } + + fn encode_u256_u64(x: u64) -> [u8; 32] { + Self::encode_u256_bytes_be(x as u128) + } + + fn encode_address(addr: Address) -> [u8; 32] { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(addr.as_slice()); + out + } + + fn domain_separator(&self, chain_id: u64) -> B256 { + let typehash = Self::typehash_domain(); + let name_hash = Self::keccak_string("Gas Network AgentPayload"); + let version_hash = Self::keccak_string(&self.schema_version); + let chain_id_enc = Self::encode_u256_u64(chain_id); + let verifying = Self::encode_address(Address::ZERO); + + let mut enc = Vec::with_capacity(32 * 5); + enc.extend_from_slice(typehash.as_slice()); + enc.extend_from_slice(name_hash.as_slice()); + enc.extend_from_slice(version_hash.as_slice()); + enc.extend_from_slice(&chain_id_enc); + enc.extend_from_slice(&verifying); + keccak256(&enc) + } + + fn struct_hash(&self) -> B256 { + let typehash = Self::typehash_agent_payload(); + + // timestamp in ns since epoch + 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; + let ts_ns_u: u128 = ts_ns as u128; + + // lowercase strings + let system = self.system.to_string().to_lowercase(); + let network = self.network.to_string().to_lowercase(); + let settlement = self.settlement.to_string().to_lowercase(); + + // price from field + let price_bytes = self.price.to_be_bytes::<32>(); + + let mut enc = Vec::with_capacity(32 * 8); + enc.extend_from_slice(typehash.as_slice()); + enc.extend_from_slice(Self::keccak_string(&self.schema_version).as_slice()); + enc.extend_from_slice(&Self::encode_u256_bytes_be(ts_ns_u)); + enc.extend_from_slice(Self::keccak_string(&system).as_slice()); + enc.extend_from_slice(Self::keccak_string(&network).as_slice()); + enc.extend_from_slice(Self::keccak_string(&settlement).as_slice()); + enc.extend_from_slice(&Self::encode_u256_u64(self.from_block)); + enc.extend_from_slice(&price_bytes); + keccak256(&enc) } - pub async fn sign(&self, signer_key: &str) -> Result { - let message = self.hash(); + fn eip712_digest(&self, chain_id: u64) -> B256 { + let domain_sep = self.domain_separator(chain_id); + let struct_hash = self.struct_hash(); + let mut buf = Vec::with_capacity(2 + 32 + 32); + buf.extend_from_slice(&[0x19, 0x01]); + buf.extend_from_slice(domain_sep.as_slice()); + buf.extend_from_slice(struct_hash.as_slice()); + keccak256(&buf) + } + + /// EIP-712 signing over the AgentPayload typed data. + pub fn sign(&self, signer_key: &str) -> Result { let signer: PrivateKeySigner = signer_key.parse()?; - let signature = signer.sign_message(&message).await?; + let chain_id = + SystemNetworkKey::new(self.system.clone(), self.network.clone()).to_chain_id(); + let digest = self.eip712_digest(chain_id); + let signature = signer.sign_hash_sync(&digest)?; let hex_signature = hex::encode(signature.as_bytes()); - Ok(format!("0x{hex_signature}")) } @@ -143,10 +209,29 @@ 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}")) } + + /// Validates an EIP-712 signature against the payload and returns recovered signer address. Used for round trip test. + #[cfg(test)] + pub fn validate_signature(&self, signature: &str) -> Result
{ + let sig_bytes = signature.strip_prefix("0x").unwrap_or(signature); + let sig_bytes = hex::decode(sig_bytes)?; + let signature = Signature::from_raw(&sig_bytes)?; + let chain_id = + SystemNetworkKey::new(self.system.clone(), self.network.clone()).to_chain_id(); + let digest = self.eip712_digest(chain_id); + let recovered = signature.recover_address_from_prehash(&digest)?; + Ok(recovered) + } + } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] @@ -165,6 +250,40 @@ pub enum Network { Mainnet, } +#[cfg(test)] +mod tests { + use super::*; + use alloy::signers::local::PrivateKeySigner; + use chrono::{DateTime, Utc}; + + #[test] + fn test_eip712_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, + price: U256::from(20_000_000_000u128), // 20 gwei in wei + }; + + // Sign + let sig = payload.sign(sk_hex).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 +330,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 +344,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, -} From 55f07e174358f35464de786b6baeafb8db6af162 Mon Sep 17 00:00:00 2001 From: Aaron Date: Fri, 12 Sep 2025 15:30:24 +1000 Subject: [PATCH 2/7] Replace EIP712 signing with canonical JSON signing for agent payloads Changed the payload signing method from EIP712 typed data to canonical JSON with keccak256 hashing. This simplifies the signing process and removes the dependency on chain-specific domain separators. Key changes: - Replace U256 price field with string representation and add unit field for clarity - Remove EIP712 domain separator and struct hash implementation - Add canonical JSON serialization with lexicographically sorted keys - Sign keccak256 hash of canonical JSON directly instead of EIP712 digest - Update tests to validate canonical JSON signatures - Add sha2 dependency for hashing operations --- Cargo.lock | 1 + Cargo.toml | 1 + src/agent.rs | 12 ++-- src/chain/types.rs | 11 ++- src/types.rs | 172 +++++++++++++++++---------------------------- 5 files changed, 80 insertions(+), 117 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 614ae6f..8792c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1547,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 1b71db0..9d7da35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,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/src/agent.rs b/src/agent.rs index 46dd020..6127459 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -4,8 +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, Settlement, SystemNetworkKey}; -use alloy::primitives::U256; +use crate::types::{AgentKind, AgentPayload, PriceUnit, Settlement, SystemNetworkKey}; use anyhow::{Context, Result}; use chrono::Utc; use reqwest::Url; @@ -106,7 +105,8 @@ impl GasAgent { timestamp: Utc::now(), system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price: U256::from(price_wei), + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( @@ -138,7 +138,8 @@ impl GasAgent { timestamp: Utc::now(), system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price: U256::from(price_wei), + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( @@ -160,7 +161,8 @@ impl GasAgent { timestamp: Utc::now(), system: self.chain_config.system.clone(), network: self.chain_config.network.clone(), - price: U256::from(price_wei), + unit: PriceUnit::Wei, + price: price_wei.to_string(), }; publish_agent_payload( diff --git a/src/chain/types.rs b/src/chain/types.rs index 07663e8..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, }; @@ -57,8 +60,10 @@ impl From for OraclePayloadV2 { records: vec![OraclePayloadRecordV2 { typ: 340, // Hardcoded into type 340 - Max Priority Fee Per Gas 99th. value: { - // Convert uint256 price to uint240 by truncating high 16 bits (should be zero for realistic prices) - let bytes32 = payload.price.to_be_bytes::<32>(); + // 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 0534f1b..461982c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1,11 +1,11 @@ use crate::chain::{sign::PayloadSigner, types::SignedOraclePayloadV2}; +#[cfg(test)] +use alloy::signers::Signature; use alloy::{ hex, - primitives::{keccak256, Address, B256, U256}, + primitives::{keccak256, B256}, signers::{local::PrivateKeySigner, SignerSync}, }; -#[cfg(test)] -use alloy::signers::Signature; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -73,7 +73,7 @@ impl From for AgentKind { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct AgentPayload { - /// Schema/version for signed payloads (EIP-712 domain version) + /// Schema version string for signed payloads #[serde(default = "AgentPayload::schema_version")] pub schema_version: String, /// The block height this payload is valid from @@ -82,12 +82,16 @@ pub struct AgentPayload { pub settlement: Settlement, /// The exact time the estimate is captured UTC. pub timestamp: DateTime, - /// 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 in wei (always denominated in wei) - pub price: U256, + /// 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 { @@ -95,105 +99,41 @@ impl AgentPayload { "1".to_string() } - // --- EIP-712 helpers --- - fn typehash_domain() -> B256 { - // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") - keccak256( - "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" - .as_bytes(), - ) - } - - fn typehash_agent_payload() -> B256 { - // keccak256("AgentPayload(string schema_version,uint256 timestamp,string system,string network,string settlement,uint256 from_block,uint256 price)") - keccak256( - "AgentPayload(string schema_version,uint256 timestamp,string system,string network,string settlement,uint256 from_block,uint256 price)" - .as_bytes(), - ) - } - - fn keccak_string(val: &str) -> B256 { - keccak256(val.as_bytes()) - } - - fn encode_u256_bytes_be(x: impl Into) -> [u8; 32] { - let v: u128 = x.into(); - let mut out = [0u8; 32]; - out[16..].copy_from_slice(&v.to_be_bytes()); - out - } - - fn encode_u256_u64(x: u64) -> [u8; 32] { - Self::encode_u256_bytes_be(x as u128) - } - - fn encode_address(addr: Address) -> [u8; 32] { - let mut out = [0u8; 32]; - out[12..].copy_from_slice(addr.as_slice()); - out - } - - fn domain_separator(&self, chain_id: u64) -> B256 { - let typehash = Self::typehash_domain(); - let name_hash = Self::keccak_string("Gas Network AgentPayload"); - let version_hash = Self::keccak_string(&self.schema_version); - let chain_id_enc = Self::encode_u256_u64(chain_id); - let verifying = Self::encode_address(Address::ZERO); - - let mut enc = Vec::with_capacity(32 * 5); - enc.extend_from_slice(typehash.as_slice()); - enc.extend_from_slice(name_hash.as_slice()); - enc.extend_from_slice(version_hash.as_slice()); - enc.extend_from_slice(&chain_id_enc); - enc.extend_from_slice(&verifying); - keccak256(&enc) - } - - fn struct_hash(&self) -> B256 { - let typehash = Self::typehash_agent_payload(); - - // timestamp in ns since epoch + // --- 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; - let ts_ns_u: u128 = ts_ns as u128; + assert!(ts_ns >= 0, "negative timestamps are not supported"); + ts_ns.to_string() + } - // lowercase strings + /// 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 settlement = self.settlement.to_string().to_lowercase(); + let price = self.price.clone(); + let unit = self.unit.to_string().to_lowercase(); - // price from field - let price_bytes = self.price.to_be_bytes::<32>(); - - let mut enc = Vec::with_capacity(32 * 8); - enc.extend_from_slice(typehash.as_slice()); - enc.extend_from_slice(Self::keccak_string(&self.schema_version).as_slice()); - enc.extend_from_slice(&Self::encode_u256_bytes_be(ts_ns_u)); - enc.extend_from_slice(Self::keccak_string(&system).as_slice()); - enc.extend_from_slice(Self::keccak_string(&network).as_slice()); - enc.extend_from_slice(Self::keccak_string(&settlement).as_slice()); - enc.extend_from_slice(&Self::encode_u256_u64(self.from_block)); - enc.extend_from_slice(&price_bytes); - keccak256(&enc) + format!( + "{{\"from_block\":\"{}\",\"network\":\"{}\",\"price\":\"{}\",\"schema_version\":\"{}\",\"settlement\":\"{}\",\"system\":\"{}\",\"timestamp\":\"{}\",\"unit\":\"{}\"}}", + from_block, network, price, schema_version, settlement, system, timestamp, unit + ) } - fn eip712_digest(&self, chain_id: u64) -> B256 { - let domain_sep = self.domain_separator(chain_id); - let struct_hash = self.struct_hash(); - let mut buf = Vec::with_capacity(2 + 32 + 32); - buf.extend_from_slice(&[0x19, 0x01]); - buf.extend_from_slice(domain_sep.as_slice()); - buf.extend_from_slice(struct_hash.as_slice()); - keccak256(&buf) + fn canonical_digest(&self) -> B256 { + let json = self.canonical_json_string(); + keccak256(json.as_bytes()) } - /// EIP-712 signing over the AgentPayload typed data. + /// Sign the Keccak-256 digest of the canonical JSON per spec v1.0.0. pub fn sign(&self, signer_key: &str) -> Result { let signer: PrivateKeySigner = signer_key.parse()?; - let chain_id = - SystemNetworkKey::new(self.system.clone(), self.network.clone()).to_chain_id(); - let digest = self.eip712_digest(chain_id); + let digest = self.canonical_digest(); let signature = signer.sign_hash_sync(&digest)?; let hex_signature = hex::encode(signature.as_bytes()); Ok(format!("0x{hex_signature}")) @@ -219,19 +159,6 @@ impl AgentPayload { Ok(format!("0x{hex_signature}")) } - /// Validates an EIP-712 signature against the payload and returns recovered signer address. Used for round trip test. - #[cfg(test)] - pub fn validate_signature(&self, signature: &str) -> Result
{ - let sig_bytes = signature.strip_prefix("0x").unwrap_or(signature); - let sig_bytes = hex::decode(sig_bytes)?; - let signature = Signature::from_raw(&sig_bytes)?; - let chain_id = - SystemNetworkKey::new(self.system.clone(), self.network.clone()).to_chain_id(); - let digest = self.eip712_digest(chain_id); - let recovered = signature.recover_address_from_prehash(&digest)?; - Ok(recovered) - } - } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)] @@ -250,14 +177,40 @@ 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::signers::local::PrivateKeySigner; + 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) + } + } + #[test] - fn test_eip712_sign_and_recover_roundtrip() { + fn test_canonical_sign_and_recover_roundtrip() { let timestamp = DateTime::parse_from_rfc3339("2024-01-01T12:00:00.500000000Z") .unwrap() .with_timezone(&Utc); @@ -271,7 +224,8 @@ mod tests { timestamp, system: System::Ethereum, network: Network::Mainnet, - price: U256::from(20_000_000_000u128), // 20 gwei in wei + unit: PriceUnit::Wei, + price: "20000000000".to_string(), }; // Sign From 39341174633cfe7924f402f8b33dc7ed78bf9938 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 9 Oct 2025 15:43:20 +1100 Subject: [PATCH 3/7] Async sign --- src/publish.rs | 2 +- src/types.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/publish.rs b/src/publish.rs index 3492ea6..f674ffc 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -10,7 +10,7 @@ pub async fn publish_agent_payload( signer_key: &str, payload: &AgentPayload, ) -> Result<()> { - let signature = payload.sign(signer_key)?; + let signature = payload.sign(signer_key).await?; let network_signature = payload.clone().network_signature(signer_key)?; let json = json!({ diff --git a/src/types.rs b/src/types.rs index 461982c..350fe49 100644 --- a/src/types.rs +++ b/src/types.rs @@ -4,7 +4,7 @@ use alloy::signers::Signature; use alloy::{ hex, primitives::{keccak256, B256}, - signers::{local::PrivateKeySigner, SignerSync}, + signers::{local::PrivateKeySigner, Signer}, }; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; @@ -131,10 +131,10 @@ impl AgentPayload { } /// Sign the Keccak-256 digest of the canonical JSON per spec v1.0.0. - pub fn sign(&self, signer_key: &str) -> Result { + pub async fn sign(&self, signer_key: &str) -> Result { let signer: PrivateKeySigner = signer_key.parse()?; let digest = self.canonical_digest(); - let signature = signer.sign_hash_sync(&digest)?; + let signature = signer.sign_hash(&digest).await?; let hex_signature = hex::encode(signature.as_bytes()); Ok(format!("0x{hex_signature}")) } @@ -209,8 +209,8 @@ mod tests { } } - #[test] - fn test_canonical_sign_and_recover_roundtrip() { + #[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); @@ -229,7 +229,7 @@ mod tests { }; // Sign - let sig = payload.sign(sk_hex).unwrap(); + let sig = payload.sign(sk_hex).await.unwrap(); assert!(sig.starts_with("0x")); // Recover and ensure matches the signer address From 7a83e1d27897c0e54f00c84477b3a63556829102 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 9 Oct 2025 15:43:36 +1100 Subject: [PATCH 4/7] Inc version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8792c94..8ef79dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,7 +1528,7 @@ dependencies = [ [[package]] name = "gas-agent" -version = "0.0.9" +version = "0.1.0" dependencies = [ "alloy", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 9d7da35..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" From 2e92c7805349b6f32b2439c020437bdafccd5285 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 9 Oct 2025 15:45:31 +1100 Subject: [PATCH 5/7] formatting and doc updates --- EVALUATION.md | 37 ++++++++++++++++++------------------- README.md | 16 ++++++++-------- 2 files changed, 26 insertions(+), 27 deletions(-) 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..3251f11 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: @@ -567,7 +564,6 @@ gas-agent start --chains '[{ 2. **Handle Edge Cases**: Always check for empty distributions and provide fallback values. 3. **Consider Settlement Times**: Choose appropriate `Settlement` values: - - `Immediate`: Next block - `Fast`: ~15 seconds - `Medium`: ~15 minutes @@ -644,7 +640,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 +675,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 ``` From 938f20a7a1859e7f1fd895aef90c6ab7d85a9966 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 9 Oct 2025 15:56:04 +1100 Subject: [PATCH 6/7] Fix docs --- CHANGELOG.md | 6 +++++- README.md | 44 +++++++++++++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 16 deletions(-) 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/README.md b/README.md index 3251f11..7a4c109 100644 --- a/README.md +++ b/README.md @@ -478,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: @@ -491,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", + )); + } + + let predicted_price = total_gas_price / total_transactions as f64; - // Return the prediction and settlement time - (round_to_9_places(predicted_price), Settlement::Fast) + Ok(( + round_to_9_places(predicted_price), + Settlement::Fast, + latest_block + 1, + )) } ``` @@ -528,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) + } } } ``` @@ -561,7 +575,7 @@ 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 From 2611fc3e56a666b747e2ed8cd2ec43792053ac29 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 9 Oct 2025 16:00:51 +1100 Subject: [PATCH 7/7] Fix formatting --- src/types.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/types.rs b/src/types.rs index 350fe49..890f430 100644 --- a/src/types.rs +++ b/src/types.rs @@ -158,7 +158,6 @@ impl AgentPayload { Ok(format!("0x{hex_signature}")) } - } #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Display, EnumString)]