Skip to main content

self_agent_sdk/
ed25519_agent.rs

1// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
2// SPDX-License-Identifier: BUSL-1.1
3// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
4
5//! Agent-side SDK for Self Agent ID using Ed25519 key pairs.
6//!
7//! The agent's on-chain identity is its raw 32-byte Ed25519 public key:
8//!   `agentKey = "0x" + hex(publicKey)`
9//!
10//! For off-chain authentication, the agent signs each request with Ed25519.
11//! Services verify the signature using the public key and check on-chain status.
12
13use alloy::primitives::{keccak256, Address, B256, U256};
14use alloy::providers::ProviderBuilder;
15use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
16use reqwest::{Client, Method, RequestBuilder, Response};
17use std::collections::HashMap;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use crate::agent::{compute_signing_message, AgentInfo};
21use crate::constants::{
22    headers, network_config, IAgentRegistry, NetworkName, DEFAULT_NETWORK,
23};
24
25/// Configuration for creating an [`Ed25519Agent`].
26#[derive(Debug, Clone)]
27pub struct Ed25519AgentConfig {
28    /// Ed25519 private key (hex, with or without 0x prefix). 32 bytes.
29    pub private_key: String,
30    /// Network to use: Mainnet (default) or Testnet.
31    pub network: Option<NetworkName>,
32    /// Override: custom registry address.
33    pub registry_address: Option<Address>,
34    /// Override: custom RPC URL.
35    pub rpc_url: Option<String>,
36}
37
38/// Agent-side SDK for Self Agent ID using Ed25519 key pairs.
39///
40/// The agent's on-chain identity is its raw 32-byte Ed25519 public key.
41/// For off-chain authentication, the agent signs each request with Ed25519.
42/// Services verify the signature using the public key and check on-chain status.
43///
44/// # Example
45///
46/// ```no_run
47/// use self_agent_sdk::{Ed25519Agent, Ed25519AgentConfig, NetworkName};
48///
49/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
50/// # tokio::runtime::Runtime::new()?.block_on(async {
51/// let agent = Ed25519Agent::new(Ed25519AgentConfig {
52///     private_key: "0x...".to_string(),
53///     network: Some(NetworkName::Testnet),
54///     registry_address: None,
55///     rpc_url: None,
56/// })?;
57///
58/// let registered = agent.is_registered().await?;
59/// let response = agent.fetch("https://api.example.com/data", None, None).await?;
60/// # let _ = (registered, response);
61/// # Ok::<(), Box<dyn std::error::Error>>(())
62/// # })?;
63/// # Ok(())
64/// # }
65/// ```
66pub struct Ed25519Agent {
67    signing_key: SigningKey,
68    registry_address: Address,
69    rpc_url: String,
70    agent_key: B256,
71    address: Address,
72    http_client: Client,
73}
74
75impl Ed25519Agent {
76    /// Create a new Ed25519 agent instance.
77    pub fn new(config: Ed25519AgentConfig) -> Result<Self, crate::Error> {
78        let network_name = config.network.unwrap_or(DEFAULT_NETWORK);
79        let net = network_config(network_name);
80
81        let priv_hex = config
82            .private_key
83            .strip_prefix("0x")
84            .unwrap_or(&config.private_key);
85
86        let key_bytes = hex::decode(priv_hex).map_err(|_| crate::Error::InvalidPrivateKey)?;
87        let key_array: [u8; 32] = key_bytes
88            .try_into()
89            .map_err(|_| crate::Error::InvalidPrivateKey)?;
90
91        let signing_key = SigningKey::from_bytes(&key_array);
92        let verifying_key: VerifyingKey = signing_key.verifying_key();
93        let pubkey_bytes = verifying_key.to_bytes();
94
95        // Agent key = raw 32-byte public key (already bytes32)
96        let agent_key = B256::from(pubkey_bytes);
97
98        // Derive deterministic Ethereum-style address: keccak256(pubkey), last 20 bytes
99        let address = derive_address_from_pubkey(&pubkey_bytes);
100
101        Ok(Self {
102            signing_key,
103            registry_address: config.registry_address.unwrap_or(net.registry_address),
104            rpc_url: config.rpc_url.unwrap_or_else(|| net.rpc_url.to_string()),
105            agent_key,
106            address,
107            http_client: Client::new(),
108        })
109    }
110
111    /// The agent's deterministic Ethereum-style address derived from keccak256(pubkey).
112    pub fn address(&self) -> Address {
113        self.address
114    }
115
116    /// The agent's on-chain key (bytes32) — raw Ed25519 public key.
117    pub fn agent_key(&self) -> B256 {
118        self.agent_key
119    }
120
121    /// The agent's raw 32-byte Ed25519 public key as 0x-prefixed hex.
122    pub fn agent_key_hex(&self) -> String {
123        format!("0x{}", hex::encode(self.agent_key.as_slice()))
124    }
125
126    fn make_provider(
127        &self,
128    ) -> Result<impl alloy::providers::Provider + Clone, crate::Error> {
129        let url: reqwest::Url = self
130            .rpc_url
131            .parse()
132            .map_err(|_| crate::Error::InvalidRpcUrl)?;
133        Ok(ProviderBuilder::new().connect_http(url))
134    }
135
136    /// Check if this agent is registered and verified on-chain.
137    pub async fn is_registered(&self) -> Result<bool, crate::Error> {
138        let provider = self.make_provider()?;
139        let registry = IAgentRegistry::new(self.registry_address, provider);
140        let result = registry
141            .isVerifiedAgent(self.agent_key)
142            .call()
143            .await
144            .map_err(|e| crate::Error::RpcError(e.to_string()))?;
145        Ok(result)
146    }
147
148    /// Get full agent info from the registry.
149    pub async fn get_info(&self) -> Result<AgentInfo, crate::Error> {
150        let provider = self.make_provider()?;
151        let registry = IAgentRegistry::new(self.registry_address, provider);
152
153        let agent_id = registry
154            .getAgentId(self.agent_key)
155            .call()
156            .await
157            .map_err(|e| crate::Error::RpcError(e.to_string()))?;
158
159        if agent_id == U256::ZERO {
160            return Ok(AgentInfo {
161                address: self.address,
162                agent_key: self.agent_key,
163                agent_id: U256::ZERO,
164                is_verified: false,
165                nullifier: U256::ZERO,
166                agent_count: U256::ZERO,
167            });
168        }
169
170        let is_verified = registry
171            .hasHumanProof(agent_id)
172            .call()
173            .await
174            .map_err(|e| crate::Error::RpcError(e.to_string()))?;
175        let nullifier = registry
176            .getHumanNullifier(agent_id)
177            .call()
178            .await
179            .map_err(|e| crate::Error::RpcError(e.to_string()))?;
180        let agent_count = registry
181            .getAgentCountForHuman(nullifier)
182            .call()
183            .await
184            .map_err(|e| crate::Error::RpcError(e.to_string()))?;
185
186        Ok(AgentInfo {
187            address: self.address,
188            agent_key: self.agent_key,
189            agent_id,
190            is_verified,
191            nullifier,
192            agent_count,
193        })
194    }
195
196    /// Generate authentication headers for a request.
197    ///
198    /// Signature covers: `keccak256(timestamp + METHOD + canonicalPathAndQuery + bodyHash)`
199    /// Signed with Ed25519 instead of ECDSA.
200    pub fn sign_request(
201        &self,
202        method: &str,
203        url: &str,
204        body: Option<&str>,
205    ) -> HashMap<String, String> {
206        let timestamp = now_millis().to_string();
207        self.sign_request_with_timestamp(method, url, body, &timestamp)
208    }
209
210    /// Sign a request with a specific timestamp (useful for testing).
211    pub fn sign_request_with_timestamp(
212        &self,
213        method: &str,
214        url: &str,
215        body: Option<&str>,
216        timestamp: &str,
217    ) -> HashMap<String, String> {
218        let message = compute_signing_message(timestamp, method, url, body);
219
220        // Sign the raw 32-byte keccak256 hash with Ed25519 (no EIP-191 prefix)
221        let signature = self.signing_key.sign(message.as_ref());
222        let sig_hex = format!("0x{}", hex::encode(signature.to_bytes()));
223
224        let mut headers_map = HashMap::new();
225        headers_map.insert(
226            headers::KEY.to_string(),
227            self.agent_key_hex(),
228        );
229        headers_map.insert(
230            headers::KEYTYPE.to_string(),
231            "ed25519".to_string(),
232        );
233        headers_map.insert(headers::SIGNATURE.to_string(), sig_hex);
234        headers_map.insert(headers::TIMESTAMP.to_string(), timestamp.to_string());
235
236        headers_map
237    }
238
239    /// Wrapper around reqwest that automatically adds agent signature headers.
240    pub async fn fetch(
241        &self,
242        url: &str,
243        method: Option<Method>,
244        body: Option<String>,
245    ) -> Result<Response, crate::Error> {
246        let method = method.unwrap_or(Method::GET);
247        let method_str = method.as_str();
248        let body_ref = body.as_deref();
249
250        let auth_headers = self.sign_request(method_str, url, body_ref);
251
252        let mut request: RequestBuilder = self.http_client.request(method, url);
253        for (k, v) in &auth_headers {
254            request = request.header(k, v);
255        }
256        if let Some(b) = body {
257            request = request.header("content-type", "application/json");
258            request = request.body(b);
259        }
260
261        request
262            .send()
263            .await
264            .map_err(|e| crate::Error::HttpError(e.to_string()))
265    }
266}
267
268/// Derive a deterministic Ethereum-style address from an Ed25519 public key.
269///
270/// Matches the on-chain `Ed25519Verifier.deriveAddress()`:
271///   `address(uint160(uint256(keccak256(pubkey))))`
272pub fn derive_address_from_pubkey(pubkey: &[u8; 32]) -> Address {
273    Address::from_slice(&keccak256(pubkey)[12..])
274}
275
276/// Current time in milliseconds since Unix epoch.
277fn now_millis() -> u64 {
278    SystemTime::now()
279        .duration_since(UNIX_EPOCH)
280        .unwrap()
281        .as_millis() as u64
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn ed25519_agent_creation() {
290        // Known test key (32 bytes of zeros is a valid Ed25519 private key for testing)
291        let config = Ed25519AgentConfig {
292            private_key: "0x9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
293                .to_string(),
294            network: Some(NetworkName::Testnet),
295            registry_address: None,
296            rpc_url: None,
297        };
298
299        let agent = Ed25519Agent::new(config).unwrap();
300
301        // Agent key should be 0x-prefixed 64 hex chars (32 bytes)
302        let key_hex = agent.agent_key_hex();
303        assert!(key_hex.starts_with("0x"));
304        assert_eq!(key_hex.len(), 66); // "0x" + 64 hex chars
305
306        // Address should be a valid 20-byte address
307        assert_ne!(agent.address(), Address::ZERO);
308    }
309
310    #[test]
311    fn ed25519_sign_request_headers() {
312        let config = Ed25519AgentConfig {
313            private_key: "0x9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
314                .to_string(),
315            network: Some(NetworkName::Testnet),
316            registry_address: None,
317            rpc_url: None,
318        };
319
320        let agent = Ed25519Agent::new(config).unwrap();
321        let headers = agent.sign_request_with_timestamp("GET", "/api/test", None, "1700000000000");
322
323        assert!(headers.contains_key(headers::KEY));
324        assert_eq!(headers.get(headers::KEYTYPE).unwrap(), "ed25519");
325        assert!(headers.get(headers::SIGNATURE).unwrap().starts_with("0x"));
326        assert_eq!(headers.get(headers::TIMESTAMP).unwrap(), "1700000000000");
327
328        // Signature should be 64 bytes (128 hex chars + "0x" prefix)
329        let sig = headers.get(headers::SIGNATURE).unwrap();
330        assert_eq!(sig.len(), 130); // "0x" + 128 hex chars
331    }
332
333    #[test]
334    fn ed25519_sign_request_deterministic() {
335        let config = Ed25519AgentConfig {
336            private_key: "0x9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
337                .to_string(),
338            network: Some(NetworkName::Testnet),
339            registry_address: None,
340            rpc_url: None,
341        };
342
343        let agent = Ed25519Agent::new(config).unwrap();
344
345        let h1 = agent.sign_request_with_timestamp("POST", "/api/data", Some(r#"{"test":true}"#), "1700000000000");
346        let h2 = agent.sign_request_with_timestamp("POST", "/api/data", Some(r#"{"test":true}"#), "1700000000000");
347
348        // Same input should produce the same signature (Ed25519 is deterministic)
349        assert_eq!(
350            h1.get(headers::SIGNATURE),
351            h2.get(headers::SIGNATURE),
352        );
353    }
354
355    #[test]
356    fn ed25519_derive_address() {
357        let config = Ed25519AgentConfig {
358            private_key: "0x9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
359                .to_string(),
360            network: Some(NetworkName::Testnet),
361            registry_address: None,
362            rpc_url: None,
363        };
364
365        let agent = Ed25519Agent::new(config).unwrap();
366
367        // Verify the address derivation is consistent
368        let pubkey_bytes: [u8; 32] = agent.agent_key().0;
369        let derived = derive_address_from_pubkey(&pubkey_bytes);
370        assert_eq!(derived, agent.address());
371    }
372
373    #[test]
374    fn ed25519_invalid_key_length() {
375        let config = Ed25519AgentConfig {
376            private_key: "0xdeadbeef".to_string(), // too short
377            network: None,
378            registry_address: None,
379            rpc_url: None,
380        };
381        assert!(Ed25519Agent::new(config).is_err());
382    }
383
384    #[test]
385    fn ed25519_invalid_key_hex() {
386        let config = Ed25519AgentConfig {
387            private_key: "not-hex-at-all".to_string(),
388            network: None,
389            registry_address: None,
390            rpc_url: None,
391        };
392        assert!(Ed25519Agent::new(config).is_err());
393    }
394
395    #[test]
396    fn ed25519_sign_verify_roundtrip() {
397        use ed25519_dalek::Verifier;
398
399        let config = Ed25519AgentConfig {
400            private_key: "0x9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"
401                .to_string(),
402            network: Some(NetworkName::Testnet),
403            registry_address: None,
404            rpc_url: None,
405        };
406
407        let agent = Ed25519Agent::new(config).unwrap();
408        let headers = agent.sign_request_with_timestamp("GET", "/api/test", None, "1700000000000");
409
410        // Reconstruct the message the same way verifier would
411        let message = compute_signing_message("1700000000000", "GET", "/api/test", None);
412
413        // Parse signature
414        let sig_hex = headers.get(headers::SIGNATURE).unwrap();
415        let sig_bytes = hex::decode(sig_hex.strip_prefix("0x").unwrap()).unwrap();
416        let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes.try_into().unwrap());
417
418        // Parse public key from the KEY header
419        let key_hex = headers.get(headers::KEY).unwrap();
420        let key_bytes = hex::decode(key_hex.strip_prefix("0x").unwrap()).unwrap();
421        let pubkey = VerifyingKey::from_bytes(&key_bytes.try_into().unwrap()).unwrap();
422
423        // Verify
424        assert!(pubkey.verify(message.as_ref(), &signature).is_ok());
425    }
426}