use crate::error::{Error, Result};
use crate::query::JsonRpcClient;
use crate::utils::keccak256;
use serde_json::{json, Value};
pub const AI_RISK_SCORE_PRECOMPILE: &str = "0x0000000000000000000000000000000000000B01";
pub const AI_ANOMALY_CHECK_PRECOMPILE: &str = "0x0000000000000000000000000000000000000B02";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Score {
pub bytes: [u8; 32],
}
impl Score {
pub fn from_word(bytes: [u8; 32]) -> Self {
Self { bytes }
}
pub fn as_u128(&self) -> Option<u128> {
if self.bytes[..16].iter().any(|&b| b != 0) {
return None;
}
let mut low = [0u8; 16];
low.copy_from_slice(&self.bytes[16..]);
Some(u128::from_be_bytes(low))
}
pub fn to_hex(&self) -> String {
format!("0x{}", hex::encode(self.bytes))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RiskScore {
pub score: Score,
pub level: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Anomaly {
pub anomaly_score: Score,
pub flagged: bool,
}
#[derive(Debug, Clone, Default)]
pub struct PreflightTx {
pub from: String,
pub to: String,
pub data: Vec<u8>,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Preflight {
pub gas: u64,
pub risk: RiskScore,
pub anomaly: Anomaly,
pub safe: bool,
}
#[derive(Debug, Clone)]
pub struct AiClient {
rpc: JsonRpcClient,
}
impl AiClient {
pub fn new(url: impl Into<String>) -> Self {
Self {
rpc: JsonRpcClient::new(url),
}
}
pub fn from_jsonrpc(rpc: JsonRpcClient) -> Self {
Self { rpc }
}
pub fn rpc(&self) -> &JsonRpcClient {
&self.rpc
}
pub async fn ai_risk_score(&self, tx_data: &[u8]) -> Result<RiskScore> {
let calldata = encode_risk_score_call(tx_data);
let ret = self
.eth_call(AI_RISK_SCORE_PRECOMPILE, &calldata)
.await?;
let (w0, w1) = decode_two_words(&ret)?;
Ok(RiskScore {
score: Score::from_word(w0),
level: word_to_u8(&w1),
})
}
pub async fn ai_anomaly_check(&self, sender: &str, amount: u128) -> Result<Anomaly> {
let calldata = encode_anomaly_check_call(sender, amount)?;
let ret = self
.eth_call(AI_ANOMALY_CHECK_PRECOMPILE, &calldata)
.await?;
let (w0, w1) = decode_two_words(&ret)?;
Ok(Anomaly {
anomaly_score: Score::from_word(w0),
flagged: word_to_bool(&w1),
})
}
pub async fn simulate_with_risk_score(&self, tx: PreflightTx) -> Result<Preflight> {
let gas = self.estimate_gas(&tx).await?;
let risk = self.ai_risk_score(&tx.data).await?;
let amount = parse_wei_u128(&tx.value)?;
let anomaly = self.ai_anomaly_check(&tx.from, amount).await?;
let safe = risk.level < 3 && !anomaly.flagged;
Ok(Preflight {
gas,
risk,
anomaly,
safe,
})
}
async fn eth_call(&self, to: &str, calldata: &[u8]) -> Result<Vec<u8>> {
let params = json!([
{ "to": to, "data": bytes_to_hex(calldata) },
"latest"
]);
let ret = self.rpc.call("eth_call", params).await?;
hex_value_to_bytes(&ret)
}
async fn estimate_gas(&self, tx: &PreflightTx) -> Result<u64> {
let mut call = serde_json::Map::new();
if !tx.from.is_empty() {
call.insert("from".into(), Value::String(tx.from.clone()));
}
if !tx.to.is_empty() {
call.insert("to".into(), Value::String(tx.to.clone()));
}
if !tx.data.is_empty() {
call.insert("data".into(), Value::String(bytes_to_hex(&tx.data)));
}
let value_u128 = parse_wei_u128(&tx.value)?;
if value_u128 != 0 {
call.insert("value".into(), Value::String(format!("0x{value_u128:x}")));
}
let ret = self
.rpc
.call("eth_estimateGas", json!([Value::Object(call)]))
.await?;
parse_hex_quantity(&ret)
}
}
fn selector(signature: &str) -> [u8; 4] {
let hash = keccak256(signature.as_bytes());
let mut out = [0u8; 4];
out.copy_from_slice(&hash[..4]);
out
}
fn encode_risk_score_call(data: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(4 + 64 + data.len() + 31);
out.extend_from_slice(&selector("aiRiskScore(bytes)"));
out.extend_from_slice(&u256_word(0x20));
out.extend_from_slice(&u256_word(data.len() as u128));
out.extend_from_slice(data);
let rem = data.len() % 32;
if rem != 0 {
out.extend(std::iter::repeat(0u8).take(32 - rem));
}
out
}
fn encode_anomaly_check_call(sender: &str, amount: u128) -> Result<Vec<u8>> {
let addr = parse_address_20(sender)?;
let mut out = Vec::with_capacity(4 + 64);
out.extend_from_slice(&selector("aiAnomalyCheck(address,uint256)"));
let mut addr_word = [0u8; 32];
addr_word[12..].copy_from_slice(&addr);
out.extend_from_slice(&addr_word);
out.extend_from_slice(&u256_word(amount));
Ok(out)
}
fn u256_word(value: u128) -> [u8; 32] {
let mut word = [0u8; 32];
word[16..].copy_from_slice(&value.to_be_bytes());
word
}
fn decode_two_words(ret: &[u8]) -> Result<([u8; 32], [u8; 32])> {
if ret.len() < 64 {
return Err(Error::InvalidResponse(format!(
"eth_call returned {} bytes, expected at least 64 (two words)",
ret.len()
)));
}
let mut w0 = [0u8; 32];
let mut w1 = [0u8; 32];
w0.copy_from_slice(&ret[..32]);
w1.copy_from_slice(&ret[32..64]);
Ok((w0, w1))
}
fn word_to_u8(word: &[u8; 32]) -> u8 {
word[31]
}
fn word_to_bool(word: &[u8; 32]) -> bool {
word.iter().any(|&b| b != 0)
}
fn bytes_to_hex(data: &[u8]) -> String {
format!("0x{}", hex::encode(data))
}
fn hex_value_to_bytes(v: &Value) -> Result<Vec<u8>> {
let s = v
.as_str()
.ok_or_else(|| Error::InvalidResponse(format!("expected hex string, got {v}")))?;
let body = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
hex::decode(body).map_err(|e| Error::InvalidResponse(format!("invalid hex in eth_call result: {e}")))
}
fn parse_hex_quantity(v: &Value) -> Result<u64> {
let s = v
.as_str()
.ok_or_else(|| Error::InvalidResponse(format!("expected hex quantity, got {v}")))?;
let body = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
if body.is_empty() {
return Err(Error::InvalidResponse("empty hex quantity".into()));
}
u64::from_str_radix(body, 16)
.map_err(|e| Error::InvalidResponse(format!("invalid hex quantity {s:?}: {e}")))
}
fn parse_address_20(s: &str) -> Result<[u8; 20]> {
let body = s
.strip_prefix("0x")
.or_else(|| s.strip_prefix("0X"))
.ok_or_else(|| Error::Address(format!("EVM address must be 0x-prefixed: {s}")))?;
if body.len() != 40 {
return Err(Error::Address(format!(
"EVM address must be 20 bytes (40 hex chars): {s}"
)));
}
let raw = hex::decode(body).map_err(|e| Error::Address(format!("invalid EVM address {s}: {e}")))?;
let mut out = [0u8; 20];
out.copy_from_slice(&raw);
Ok(out)
}
fn parse_wei_u128(s: &str) -> Result<u128> {
let t = s.trim();
if t.is_empty() {
return Ok(0);
}
t.parse::<u128>()
.map_err(|_| Error::Denom(format!("invalid wei amount: {s:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selectors_match_keccak() {
assert_eq!(selector("aiRiskScore(bytes)"), {
let h = keccak256(b"aiRiskScore(bytes)");
[h[0], h[1], h[2], h[3]]
});
assert_eq!(selector("aiAnomalyCheck(address,uint256)"), {
let h = keccak256(b"aiAnomalyCheck(address,uint256)");
[h[0], h[1], h[2], h[3]]
});
}
#[test]
fn risk_score_encoding_layout() {
let enc = encode_risk_score_call(&[0xaa, 0xbb, 0xcc]);
assert_eq!(enc.len(), 4 + 32 + 32 + 32);
assert_eq!(enc[4 + 31], 0x20);
assert_eq!(enc[4 + 32 + 31], 3);
assert_eq!(&enc[4 + 64..4 + 64 + 3], &[0xaa, 0xbb, 0xcc]);
assert!(enc[4 + 64 + 3..].iter().all(|&b| b == 0));
}
#[test]
fn risk_score_empty_data_has_no_tail_padding() {
let enc = encode_risk_score_call(&[]);
assert_eq!(enc.len(), 4 + 32 + 32);
assert_eq!(enc[4 + 32 + 31], 0);
}
#[test]
fn anomaly_encoding_layout() {
let enc =
encode_anomaly_check_call("0x000000000000000000000000000000000000dEaD", 1_000_000)
.unwrap();
assert_eq!(enc.len(), 4 + 32 + 32);
assert!(enc[4..4 + 12].iter().all(|&b| b == 0));
assert_eq!(enc[4 + 30], 0xde);
assert_eq!(enc[4 + 31], 0xad);
let mut low = [0u8; 16];
low.copy_from_slice(&enc[4 + 32 + 16..]);
assert_eq!(u128::from_be_bytes(low), 1_000_000);
}
#[test]
fn score_u128_and_overflow() {
let small = Score::from_word(u256_word(42));
assert_eq!(small.as_u128(), Some(42));
let mut big = [0u8; 32];
big[0] = 1; assert_eq!(Score::from_word(big).as_u128(), None);
}
#[test]
fn word_decoders() {
let mut w = [0u8; 32];
w[31] = 4;
assert_eq!(word_to_u8(&w), 4);
assert!(word_to_bool(&w));
assert!(!word_to_bool(&[0u8; 32]));
}
#[test]
fn parse_address_rejects_bad_input() {
assert!(parse_address_20("0xdead").is_err());
assert!(parse_address_20("000000000000000000000000000000000000dEaD").is_err());
assert!(parse_address_20("0x000000000000000000000000000000000000dEaD").is_ok());
}
}