use alloy::{
primitives::{keccak256, Address, Bytes, Signature, B256, U256},
signers::{local::PrivateKeySigner, SignerSync},
};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
pub const OPERATOR_RPC_DOMAIN_NAME: &str = "Newton Operator RPC";
pub const OPERATOR_RPC_DOMAIN_VERSION: &str = "1";
pub const MAX_EXPIRY_WINDOW_SECS: u64 = 120;
pub const DEFAULT_EXPIRY_SECS: u64 = 60;
const OPERATOR_RPC_CALL_TYPE: &[u8] =
b"OperatorRpcCall(string method,bytes32 paramsHash,uint64 chainId,uint64 expiresAt,address taskManager)";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OperatorRpcCall {
pub method: String,
pub params_hash: B256,
pub chain_id: u64,
pub expires_at: u64,
pub task_manager: Address,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OperatorRpcAuth {
pub call: OperatorRpcCall,
pub signature: Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Authenticated<T> {
pub auth: OperatorRpcAuth,
pub inner: T,
}
#[derive(Debug, Error)]
pub enum OperatorRpcAuthError {
#[error("Invalid signature format: {0}")]
InvalidSignature(String),
#[error("Failed to recover signer: {0}")]
SignerRecoveryFailed(String),
#[error("Signer {0} is not an authorized task generator")]
NotAuthorizedTaskGenerator(Address),
#[error("Failed to check task generator: {0}")]
TaskGeneratorCheckFailed(String),
#[error("Method mismatch: envelope claims '{claimed}', actual '{actual}'")]
MethodMismatch {
claimed: String,
actual: String,
},
#[error("Chain ID mismatch: envelope claims {claimed}, expected {expected}")]
ChainIdMismatch {
claimed: u64,
expected: u64,
},
#[error("Envelope expired at {expires_at}, current time is {now}")]
Expired {
expires_at: u64,
now: u64,
},
#[error("Envelope expiry window too far in future: expiresAt={expires_at}, now+max={max_allowed}")]
ExpiryTooFar {
expires_at: u64,
max_allowed: u64,
},
#[error("Params hash mismatch: envelope claims {claimed}, computed {computed}")]
ParamsHashMismatch {
claimed: B256,
computed: B256,
},
#[error("Failed to serialize params for hashing: {0}")]
ParamsSerializationFailed(String),
#[error("System clock failure: {0}")]
ClockFailure(String),
}
#[derive(Debug, Clone)]
pub struct OperatorRpcEip712Domain {
pub name: String,
pub version: String,
pub chain_id: u64,
pub verifying_contract: Address,
}
impl OperatorRpcEip712Domain {
pub fn new(chain_id: u64, verifying_contract: Address) -> Self {
Self {
name: OPERATOR_RPC_DOMAIN_NAME.to_string(),
version: OPERATOR_RPC_DOMAIN_VERSION.to_string(),
chain_id,
verifying_contract,
}
}
}
fn operator_rpc_call_struct_hash(call: &OperatorRpcCall) -> B256 {
let type_hash = keccak256(OPERATOR_RPC_CALL_TYPE);
let method_hash = keccak256(call.method.as_bytes());
let mut buf = Vec::with_capacity(192);
buf.extend_from_slice(&type_hash[..]);
buf.extend_from_slice(&method_hash[..]);
buf.extend_from_slice(&call.params_hash[..]);
buf.extend_from_slice(&U256::from(call.chain_id).to_be_bytes::<32>());
buf.extend_from_slice(&U256::from(call.expires_at).to_be_bytes::<32>());
let mut padded = [0u8; 32];
padded[12..].copy_from_slice(&call.task_manager.into_array());
buf.extend_from_slice(&padded);
keccak256(&buf)
}
pub fn compute_operator_rpc_call_hash(call: &OperatorRpcCall, domain: &OperatorRpcEip712Domain) -> B256 {
let domain_type_hash =
keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
let name_hash = keccak256(domain.name.as_bytes());
let version_hash = keccak256(domain.version.as_bytes());
let mut domain_data = Vec::with_capacity(192);
domain_data.extend_from_slice(&domain_type_hash[..]);
domain_data.extend_from_slice(&name_hash[..]);
domain_data.extend_from_slice(&version_hash[..]);
domain_data.extend_from_slice(&U256::from(domain.chain_id).to_be_bytes::<32>());
let mut padded_contract = [0u8; 32];
padded_contract[12..].copy_from_slice(&domain.verifying_contract.into_array());
domain_data.extend_from_slice(&padded_contract);
let domain_separator = keccak256(&domain_data);
let struct_hash = operator_rpc_call_struct_hash(call);
let mut message_data = Vec::with_capacity(66);
message_data.push(0x19u8);
message_data.push(0x01u8);
message_data.extend_from_slice(&domain_separator[..]);
message_data.extend_from_slice(&struct_hash[..]);
keccak256(&message_data)
}
pub fn recover_operator_rpc_signer(
call: &OperatorRpcCall,
domain: &OperatorRpcEip712Domain,
signature_bytes: &Bytes,
) -> Result<Address, OperatorRpcAuthError> {
let eip712_hash = compute_operator_rpc_call_hash(call, domain);
let signature = Signature::try_from(signature_bytes.as_ref())
.map_err(|e| OperatorRpcAuthError::InvalidSignature(e.to_string()))?;
signature
.recover_address_from_prehash(&eip712_hash)
.map_err(|e| OperatorRpcAuthError::SignerRecoveryFailed(e.to_string()))
}
pub fn compute_params_hash<T: Serialize>(request: &T) -> Result<B256, OperatorRpcAuthError> {
let bytes =
bincode::serialize(request).map_err(|e| OperatorRpcAuthError::ParamsSerializationFailed(e.to_string()))?;
Ok(keccak256(&bytes))
}
pub fn validate_operator_rpc_call(
call: &OperatorRpcCall,
expected_method: &str,
expected_chain_id: u64,
now_secs: u64,
computed_params_hash: B256,
) -> Result<(), OperatorRpcAuthError> {
if call.method != expected_method {
return Err(OperatorRpcAuthError::MethodMismatch {
claimed: call.method.clone(),
actual: expected_method.to_string(),
});
}
if call.chain_id != expected_chain_id {
return Err(OperatorRpcAuthError::ChainIdMismatch {
claimed: call.chain_id,
expected: expected_chain_id,
});
}
if call.expires_at <= now_secs {
return Err(OperatorRpcAuthError::Expired {
expires_at: call.expires_at,
now: now_secs,
});
}
let max_allowed = now_secs.saturating_add(MAX_EXPIRY_WINDOW_SECS);
if call.expires_at > max_allowed {
return Err(OperatorRpcAuthError::ExpiryTooFar {
expires_at: call.expires_at,
max_allowed,
});
}
if call.params_hash != computed_params_hash {
return Err(OperatorRpcAuthError::ParamsHashMismatch {
claimed: call.params_hash,
computed: computed_params_hash,
});
}
Ok(())
}
pub fn sign_authenticated<T: Serialize>(
signer: &PrivateKeySigner,
method: &'static str,
chain_id: u64,
task_manager: Address,
expiry_secs: u64,
inner: T,
) -> Result<Authenticated<T>, OperatorRpcAuthError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.map_err(|e| OperatorRpcAuthError::ClockFailure(e.to_string()))?;
let expires_at = now.saturating_add(expiry_secs);
let params_hash = compute_params_hash(&inner)?;
let call = OperatorRpcCall {
method: method.to_string(),
params_hash,
chain_id,
expires_at,
task_manager,
};
let domain = OperatorRpcEip712Domain::new(chain_id, task_manager);
let digest = compute_operator_rpc_call_hash(&call, &domain);
let signature = signer
.sign_hash_sync(&digest)
.map_err(|e| OperatorRpcAuthError::SignerRecoveryFailed(e.to_string()))?;
let signature_bytes = Bytes::from(signature.as_bytes().to_vec());
Ok(Authenticated {
auth: OperatorRpcAuth {
call,
signature: signature_bytes,
},
inner,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_domain() -> OperatorRpcEip712Domain {
OperatorRpcEip712Domain::new(31337, Address::repeat_byte(0xab))
}
fn test_call() -> OperatorRpcCall {
OperatorRpcCall {
method: "newt_simulatePolicyData".to_string(),
params_hash: B256::repeat_byte(0x42),
chain_id: 31337,
expires_at: 1_700_000_060,
task_manager: Address::repeat_byte(0xab),
}
}
#[test]
fn struct_hash_is_deterministic() {
let call = test_call();
let h1 = operator_rpc_call_struct_hash(&call);
let h2 = operator_rpc_call_struct_hash(&call);
assert_eq!(h1, h2);
}
#[test]
fn message_hash_changes_with_method() {
let call_a = test_call();
let mut call_b = call_a.clone();
call_b.method = "newt_signStateCommit".to_string();
let domain = test_domain();
assert_ne!(
compute_operator_rpc_call_hash(&call_a, &domain),
compute_operator_rpc_call_hash(&call_b, &domain)
);
}
#[test]
fn message_hash_changes_with_chain_id() {
let call = test_call();
let domain_a = OperatorRpcEip712Domain::new(31337, Address::repeat_byte(0xab));
let domain_b = OperatorRpcEip712Domain::new(1, Address::repeat_byte(0xab));
assert_ne!(
compute_operator_rpc_call_hash(&call, &domain_a),
compute_operator_rpc_call_hash(&call, &domain_b)
);
}
#[test]
fn message_hash_changes_with_task_manager() {
let call = test_call();
let domain_a = OperatorRpcEip712Domain::new(31337, Address::repeat_byte(0xab));
let domain_b = OperatorRpcEip712Domain::new(31337, Address::repeat_byte(0xcd));
assert_ne!(
compute_operator_rpc_call_hash(&call, &domain_a),
compute_operator_rpc_call_hash(&call, &domain_b)
);
}
#[test]
fn message_hash_changes_with_params_hash() {
let call_a = test_call();
let mut call_b = call_a.clone();
call_b.params_hash = B256::repeat_byte(0x43);
let domain = test_domain();
assert_ne!(
compute_operator_rpc_call_hash(&call_a, &domain),
compute_operator_rpc_call_hash(&call_b, &domain)
);
}
#[test]
fn params_hash_deterministic_for_same_input() {
#[derive(Serialize)]
struct Dummy {
chain_id: u64,
value: String,
}
let req = Dummy {
chain_id: 1,
value: "hello".to_string(),
};
let h1 = compute_params_hash(&req).unwrap();
let h2 = compute_params_hash(&req).unwrap();
assert_eq!(h1, h2);
}
#[test]
fn params_hash_changes_with_field_value() {
#[derive(Serialize)]
struct Dummy {
value: String,
}
let h1 = compute_params_hash(&Dummy { value: "a".to_string() }).unwrap();
let h2 = compute_params_hash(&Dummy { value: "b".to_string() }).unwrap();
assert_ne!(h1, h2);
}
#[test]
fn validate_happy_path() {
let call = test_call();
validate_operator_rpc_call(
&call,
"newt_simulatePolicyData",
31337,
1_700_000_000,
B256::repeat_byte(0x42),
)
.expect("happy path validates");
}
#[test]
fn validate_rejects_method_mismatch() {
let call = test_call();
let err = validate_operator_rpc_call(&call, "newt_signStateCommit", 31337, 1_700_000_000, call.params_hash)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::MethodMismatch { .. }));
}
#[test]
fn validate_rejects_chain_mismatch() {
let call = test_call();
let err = validate_operator_rpc_call(&call, &call.method, 1, 1_700_000_000, call.params_hash).unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::ChainIdMismatch { .. }));
}
#[test]
fn validate_rejects_expired_envelope() {
let mut call = test_call();
call.expires_at = 1_699_999_999;
let err = validate_operator_rpc_call(&call, &call.method, call.chain_id, 1_700_000_000, call.params_hash)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::Expired { .. }));
}
#[test]
fn validate_rejects_envelope_at_exact_expiry() {
let mut call = test_call();
call.expires_at = 1_700_000_000;
let err = validate_operator_rpc_call(&call, &call.method, call.chain_id, 1_700_000_000, call.params_hash)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::Expired { .. }));
}
#[test]
fn validate_rejects_far_future_expiry() {
let mut call = test_call();
call.expires_at = 1_700_000_000 + MAX_EXPIRY_WINDOW_SECS + 1;
let err = validate_operator_rpc_call(&call, &call.method, call.chain_id, 1_700_000_000, call.params_hash)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::ExpiryTooFar { .. }));
}
#[test]
fn validate_rejects_params_hash_mismatch() {
let call = test_call();
let err = validate_operator_rpc_call(
&call,
&call.method,
call.chain_id,
1_700_000_000,
B256::repeat_byte(0xff),
)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::ParamsHashMismatch { .. }));
}
#[test]
fn validate_accepts_expiry_at_max_window() {
let mut call = test_call();
call.expires_at = 1_700_000_000 + MAX_EXPIRY_WINDOW_SECS;
validate_operator_rpc_call(&call, &call.method, call.chain_id, 1_700_000_000, call.params_hash)
.expect("expiry at exact max should be accepted");
}
#[test]
fn sign_authenticated_round_trips_through_recovery() {
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Inner {
chain_id: u64,
payload: String,
}
let signer = PrivateKeySigner::random();
let expected_signer = signer.address();
let task_manager = Address::repeat_byte(0xab);
let chain_id = 31337;
let inner = Inner {
chain_id,
payload: "round-trip".to_string(),
};
let env = sign_authenticated(
&signer,
"newt_simulatePolicyData",
chain_id,
task_manager,
DEFAULT_EXPIRY_SECS,
inner,
)
.expect("sign succeeds");
let domain = OperatorRpcEip712Domain::new(chain_id, task_manager);
let recovered =
recover_operator_rpc_signer(&env.auth.call, &domain, &env.auth.signature).expect("recover succeeds");
assert_eq!(recovered, expected_signer);
let recomputed = compute_params_hash(&env.inner).expect("hash inner");
assert_eq!(env.auth.call.params_hash, recomputed);
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
validate_operator_rpc_call(&env.auth.call, "newt_simulatePolicyData", chain_id, now, recomputed)
.expect("validate succeeds");
}
#[test]
fn sign_authenticated_inner_tamper_is_detected() {
#[derive(Serialize, Deserialize)]
struct Inner {
payload: String,
}
let signer = PrivateKeySigner::random();
let task_manager = Address::repeat_byte(0xab);
let chain_id = 31337;
let env = sign_authenticated(
&signer,
"newt_simulatePolicyData",
chain_id,
task_manager,
DEFAULT_EXPIRY_SECS,
Inner {
payload: "original".to_string(),
},
)
.expect("sign succeeds");
let tampered = Inner {
payload: "tampered".to_string(),
};
let tampered_hash = compute_params_hash(&tampered).expect("hash tampered");
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let err = validate_operator_rpc_call(&env.auth.call, "newt_simulatePolicyData", chain_id, now, tampered_hash)
.unwrap_err();
assert!(matches!(err, OperatorRpcAuthError::ParamsHashMismatch { .. }));
}
#[test]
fn authenticated_envelope_round_trips_via_json() {
#[derive(Serialize, Deserialize, Debug, PartialEq)]
struct Inner {
chain_id: u64,
payload: String,
}
let envelope = Authenticated {
auth: OperatorRpcAuth {
call: test_call(),
signature: Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]),
},
inner: Inner {
chain_id: 31337,
payload: "hi".to_string(),
},
};
let json = serde_json::to_string(&envelope).expect("serialize");
let decoded: Authenticated<Inner> = serde_json::from_str(&json).expect("deserialize");
assert_eq!(decoded.inner, envelope.inner);
assert_eq!(decoded.auth.call.method, envelope.auth.call.method);
assert_eq!(decoded.auth.signature, envelope.auth.signature);
}
}