Skip to main content

x402_identity/
lib.rs

1//! # tempo-x402-identity
2//!
3//! Identity management for x402 node instances.
4//!
5//! Handles the full identity lifecycle: wallet key generation, filesystem persistence
6//! (with restricted file permissions), faucet funding, and parent node registration.
7//!
8//! With the `erc8004` feature (default), adds on-chain agent identity via ERC-8004 NFTs:
9//! contract deployment, identity minting, reputation feedback, peer discovery, and recovery proofs.
10//!
11//! ## Bootstrap
12//!
13//! Call [`bootstrap()`] at startup to generate or load an identity. It injects
14//! `EVM_ADDRESS`, `FACILITATOR_PRIVATE_KEY`, and `FACILITATOR_SHARED_SECRET` as
15//! environment variables (only if not already set).
16//!
17//! Part of the [`tempo-x402`](https://docs.rs/tempo-x402) workspace.
18
19use alloy::primitives::Address;
20
21// ── ERC-8004 on-chain identity modules (feature-gated) ──────────────────
22
23/// Solidity ABI bindings for ERC-8004 contracts.
24#[cfg(feature = "erc8004")]
25pub mod contracts;
26/// Self-deployment of ERC-8004 contracts from embedded bytecode.
27#[cfg(feature = "erc8004")]
28pub mod deploy;
29/// Decentralized peer discovery via on-chain agent registry.
30#[cfg(feature = "erc8004")]
31pub mod discovery;
32/// On-chain agent NFT operations (mint, metadata, recovery).
33#[cfg(feature = "erc8004")]
34pub mod onchain;
35/// Recovery proof construction and verification.
36#[cfg(feature = "erc8004")]
37pub mod recovery;
38/// Reputation feedback submission and queries.
39#[cfg(feature = "erc8004")]
40pub mod reputation;
41/// Domain types: AgentId, ReputationScore, AgentMetadata.
42#[cfg(feature = "erc8004")]
43pub mod types;
44/// Validator hooks (deferred — contracts are complex).
45#[cfg(feature = "erc8004")]
46pub mod validation;
47
48// Re-exports
49use alloy::signers::local::PrivateKeySigner;
50use chrono::{DateTime, Utc};
51#[cfg(feature = "erc8004")]
52pub use discovery::PeerInfo;
53use serde::{Deserialize, Serialize};
54use std::env;
55use std::path::Path;
56#[cfg(feature = "erc8004")]
57pub use types::{AgentId, AgentMetadata, ReputationScore};
58
59/// Core identity for a running instance.
60#[derive(Clone, Debug, Serialize, Deserialize)]
61pub struct InstanceIdentity {
62    /// Hex-encoded private key (0x-prefixed). NEVER log this field.
63    #[serde(skip_serializing)]
64    pub private_key: String,
65    /// EVM address derived from the private key.
66    pub address: Address,
67    /// Unique instance identifier (Railway service ID or UUID).
68    pub instance_id: String,
69    /// URL of the parent instance that spawned this one (if any).
70    pub parent_url: Option<String>,
71    /// Address of the parent that paid for this instance (owner).
72    pub parent_address: Option<Address>,
73    /// When this identity was created.
74    pub created_at: DateTime<Utc>,
75    /// ERC-8004 agent token ID (if minted on-chain).
76    #[serde(default)]
77    pub agent_token_id: Option<String>,
78    /// Separate facilitator private key (0x-prefixed). Each node gets its own
79    /// so that on-chain tx nonces don't collide across nodes.
80    /// Generated on first bootstrap, persisted in identity.json.
81    #[serde(skip_serializing, default)]
82    pub facilitator_private_key: Option<String>,
83}
84
85/// On-disk format that includes the private key for persistence.
86#[derive(Serialize, Deserialize)]
87struct PersistedIdentity {
88    private_key: String,
89    address: String,
90    instance_id: String,
91    parent_url: Option<String>,
92    parent_address: Option<String>,
93    created_at: String,
94    /// ERC-8004 agent token ID (if minted).
95    #[serde(default)]
96    agent_token_id: Option<String>,
97    /// Per-node facilitator private key (generated on first bootstrap).
98    #[serde(default)]
99    facilitator_private_key: Option<String>,
100}
101
102impl From<&InstanceIdentity> for PersistedIdentity {
103    fn from(id: &InstanceIdentity) -> Self {
104        Self {
105            private_key: id.private_key.clone(),
106            address: format!("{:#x}", id.address),
107            instance_id: id.instance_id.clone(),
108            parent_url: id.parent_url.clone(),
109            parent_address: id.parent_address.map(|a| format!("{:#x}", a)),
110            created_at: id.created_at.to_rfc3339(),
111            agent_token_id: id.agent_token_id.clone(),
112            facilitator_private_key: id.facilitator_private_key.clone(),
113        }
114    }
115}
116
117impl TryFrom<PersistedIdentity> for InstanceIdentity {
118    type Error = IdentityError;
119
120    fn try_from(p: PersistedIdentity) -> Result<Self, Self::Error> {
121        let address: Address = p
122            .address
123            .parse()
124            .map_err(|e| IdentityError::ParseError(format!("invalid address: {e}")))?;
125        let parent_address = p
126            .parent_address
127            .map(|a| {
128                a.parse::<Address>()
129                    .map_err(|e| IdentityError::ParseError(format!("invalid parent address: {e}")))
130            })
131            .transpose()?;
132        let created_at = DateTime::parse_from_rfc3339(&p.created_at)
133            .map(|dt| dt.with_timezone(&Utc))
134            .map_err(|e| IdentityError::ParseError(format!("invalid created_at: {e}")))?;
135
136        Ok(InstanceIdentity {
137            private_key: p.private_key,
138            address,
139            instance_id: p.instance_id,
140            parent_url: p.parent_url,
141            parent_address,
142            created_at,
143            agent_token_id: p.agent_token_id,
144            facilitator_private_key: p.facilitator_private_key,
145        })
146    }
147}
148
149#[derive(Debug, thiserror::Error)]
150pub enum IdentityError {
151    #[error("I/O error: {0}")]
152    IoError(#[from] std::io::Error),
153
154    #[error("parse error: {0}")]
155    ParseError(String),
156
157    #[error("faucet error: {0}")]
158    FaucetError(String),
159
160    #[error("registration error: {0}")]
161    RegistrationError(String),
162}
163
164/// Bootstrap an instance identity.
165///
166/// 1. If `identity_path` exists, load and return the persisted identity.
167/// 2. Otherwise, generate a new random keypair, persist it, and return it.
168/// 3. Inject environment variables (`EVM_ADDRESS`, `FACILITATOR_PRIVATE_KEY`,
169///    `EVM_PRIVATE_KEY`, `FACILITATOR_SHARED_SECRET`) so downstream config (e.g. `GatewayConfig::from_env()`)
170///    picks them up automatically.
171pub fn bootstrap(identity_path: &str) -> Result<InstanceIdentity, IdentityError> {
172    let path = Path::new(identity_path);
173
174    let mut identity = if path.exists() {
175        tracing::info!("Loading existing identity from {}", identity_path);
176        let data = std::fs::read_to_string(path)?;
177        let persisted: PersistedIdentity = serde_json::from_str(&data)
178            .map_err(|e| IdentityError::ParseError(format!("invalid identity JSON: {e}")))?;
179        InstanceIdentity::try_from(persisted)?
180    } else {
181        tracing::info!("Generating new identity at {}", identity_path);
182        let signer = PrivateKeySigner::random();
183        let private_key = format!("0x{}", alloy::hex::encode(signer.to_bytes()));
184        let address = signer.address();
185
186        let instance_id = env::var("INSTANCE_ID")
187            .or_else(|_| env::var("RAILWAY_SERVICE_ID"))
188            .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
189
190        let parent_url = env::var("PARENT_URL").ok().filter(|s| !s.is_empty());
191
192        // Validate PARENT_URL uses HTTPS (prevent SSRF to internal services)
193        if let Some(ref url) = parent_url {
194            if !url.starts_with("https://") {
195                tracing::warn!(
196                    "PARENT_URL must use HTTPS — ignoring insecure value: {}",
197                    &url[..url.len().min(20)]
198                );
199                // Continue without parent_url rather than failing bootstrap
200            }
201        }
202        let parent_url = parent_url.filter(|u| u.starts_with("https://"));
203        let parent_address = env::var("PARENT_ADDRESS")
204            .ok()
205            .and_then(|s| s.parse::<Address>().ok());
206
207        // Generate a separate facilitator key so each node has its own on-chain
208        // nonce space — prevents tx nonce collisions when multiple nodes settle
209        // payments concurrently.
210        let fac_signer = PrivateKeySigner::random();
211        let facilitator_private_key = format!("0x{}", alloy::hex::encode(fac_signer.to_bytes()));
212
213        let identity = InstanceIdentity {
214            private_key,
215            address,
216            instance_id,
217            parent_url,
218            parent_address,
219            created_at: Utc::now(),
220            agent_token_id: None,
221            facilitator_private_key: Some(facilitator_private_key),
222        };
223
224        // Ensure parent directory exists
225        if let Some(parent_dir) = path.parent() {
226            std::fs::create_dir_all(parent_dir)?;
227        }
228
229        // Write identity file
230        let persisted = PersistedIdentity::from(&identity);
231        let json = serde_json::to_string_pretty(&persisted)
232            .map_err(|e| IdentityError::ParseError(format!("serialize failed: {e}")))?;
233        std::fs::write(path, json)?;
234
235        // Set restrictive permissions on Unix
236        #[cfg(unix)]
237        {
238            use std::os::unix::fs::PermissionsExt;
239            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
240        }
241
242        tracing::info!("New identity created: {:#x}", address);
243        identity
244    };
245
246    // Migrate: generate facilitator key if existing identity lacks one
247    if identity.facilitator_private_key.is_none() {
248        tracing::info!("Generating separate facilitator key for existing identity");
249        let fac_signer = PrivateKeySigner::random();
250        identity.facilitator_private_key =
251            Some(format!("0x{}", alloy::hex::encode(fac_signer.to_bytes())));
252
253        // Re-persist with the new facilitator key
254        let persisted = PersistedIdentity::from(&identity);
255        let json = serde_json::to_string_pretty(&persisted)
256            .map_err(|e| IdentityError::ParseError(format!("serialize failed: {e}")))?;
257        std::fs::write(path, json)?;
258        tracing::info!("Identity updated with separate facilitator key");
259    }
260
261    // Inject env vars for downstream config consumers
262    inject_env_vars(&identity);
263
264    Ok(identity)
265}
266
267/// Inject identity-derived environment variables into the current process.
268///
269/// Only sets variables that are not already set, so explicit env config
270/// always takes precedence over auto-bootstrapped values.
271fn inject_env_vars(identity: &InstanceIdentity) {
272    let address_str = format!("{:#x}", identity.address);
273
274    if env::var("EVM_ADDRESS").is_err() {
275        env::set_var("EVM_ADDRESS", &address_str);
276        tracing::debug!("Injected EVM_ADDRESS={}", address_str);
277    }
278
279    // Always inject the per-node facilitator key (overrides any shared env var).
280    // Each node MUST have its own facilitator key to avoid on-chain tx nonce collisions.
281    let fac_key = identity
282        .facilitator_private_key
283        .as_deref()
284        .unwrap_or(&identity.private_key);
285    env::set_var("FACILITATOR_PRIVATE_KEY", fac_key);
286    tracing::debug!("Injected FACILITATOR_PRIVATE_KEY (per-node)");
287
288    // The node's wallet key is also used as the client signing key for x402 payments.
289    // Without this, `call_paid_endpoint` and `register_endpoint` tools fail.
290    if env::var("EVM_PRIVATE_KEY").is_err() {
291        env::set_var("EVM_PRIVATE_KEY", &identity.private_key);
292        tracing::debug!("Injected EVM_PRIVATE_KEY");
293    }
294
295    if env::var("FACILITATOR_SHARED_SECRET").is_err() {
296        // Generate a deterministic-but-unique HMAC secret from the facilitator key.
297        // This is safe because the secret only needs to be shared between the
298        // gateway and its embedded facilitator (same process).
299        let secret = x402::hmac::compute_hmac(fac_key.as_bytes(), b"x402-bootstrap-hmac-secret");
300        env::set_var("FACILITATOR_SHARED_SECRET", &secret);
301        tracing::debug!("Injected FACILITATOR_SHARED_SECRET (auto-generated)");
302    }
303}
304
305/// Update the persisted identity file with a new agent token ID.
306///
307/// Called after successful ERC-8004 minting to persist the token ID.
308pub fn save_agent_token_id(
309    identity_path: &str,
310    identity: &mut InstanceIdentity,
311    token_id: &str,
312) -> Result<(), IdentityError> {
313    identity.agent_token_id = Some(token_id.to_string());
314    let persisted = PersistedIdentity::from(&*identity);
315    let json = serde_json::to_string_pretty(&persisted)
316        .map_err(|e| IdentityError::ParseError(format!("serialize failed: {e}")))?;
317    std::fs::write(identity_path, json)?;
318    Ok(())
319}
320
321/// Request faucet funding via the Tempo `tempo_fundAddress` JSON-RPC method.
322///
323/// Best-effort with retries. Logs warnings on failure but does not propagate
324/// errors since funding is not critical for bootstrap.
325pub async fn request_faucet_funds(rpc_url: &str, address: Address) -> Result<(), IdentityError> {
326    let client = reqwest::Client::builder()
327        .redirect(reqwest::redirect::Policy::none())
328        .build()
329        .expect("failed to create HTTP client");
330    let address_str = format!("{:#x}", address);
331
332    let body = serde_json::json!({
333        "jsonrpc": "2.0",
334        "method": "tempo_fundAddress",
335        "params": [address_str],
336        "id": 1
337    });
338
339    let mut last_err = String::new();
340    for attempt in 0..3 {
341        if attempt > 0 {
342            let delay = std::time::Duration::from_secs(2u64.pow(attempt));
343            tokio::time::sleep(delay).await;
344        }
345
346        match client
347            .post(rpc_url)
348            .header("Content-Type", "application/json")
349            .json(&body)
350            .send()
351            .await
352        {
353            Ok(resp) if resp.status().is_success() => {
354                tracing::info!("Faucet funding requested for {}", address_str);
355                return Ok(());
356            }
357            Ok(resp) => {
358                last_err = format!("HTTP {}", resp.status());
359                tracing::warn!(
360                    "Faucet request attempt {} failed: {}",
361                    attempt + 1,
362                    last_err
363                );
364            }
365            Err(e) => {
366                last_err = e.to_string();
367                tracing::warn!(
368                    "Faucet request attempt {} failed: {}",
369                    attempt + 1,
370                    last_err
371                );
372            }
373        }
374    }
375
376    Err(IdentityError::FaucetError(format!(
377        "faucet funding failed after 3 attempts: {}",
378        last_err
379    )))
380}
381
382/// Register this instance with its parent by POSTing to `{parent_url}/instance/register`.
383///
384/// Retries with exponential backoff. The parent uses this callback to track children.
385pub async fn register_with_parent(
386    parent_url: &str,
387    identity: &InstanceIdentity,
388    self_url: &str,
389) -> Result<(), IdentityError> {
390    let client = reqwest::Client::builder()
391        .redirect(reqwest::redirect::Policy::none())
392        .build()
393        .expect("failed to create HTTP client");
394    let url = format!("{}/instance/register", parent_url.trim_end_matches('/'));
395
396    let body = serde_json::json!({
397        "instance_id": identity.instance_id,
398        "address": format!("{:#x}", identity.address),
399        "url": self_url,
400    });
401
402    let mut last_err = String::new();
403    for attempt in 0..5 {
404        if attempt > 0 {
405            let delay = std::time::Duration::from_secs(2u64.pow(attempt));
406            tokio::time::sleep(delay).await;
407        }
408
409        match client
410            .post(&url)
411            .header("Content-Type", "application/json")
412            .json(&body)
413            .send()
414            .await
415        {
416            Ok(resp) if resp.status().is_success() => {
417                tracing::info!("Registered with parent at {}", parent_url);
418                return Ok(());
419            }
420            Ok(resp) => {
421                last_err = format!("HTTP {}", resp.status());
422                tracing::warn!(
423                    "Parent registration attempt {} failed: {}",
424                    attempt + 1,
425                    last_err
426                );
427            }
428            Err(e) => {
429                last_err = e.to_string();
430                tracing::warn!(
431                    "Parent registration attempt {} failed: {}",
432                    attempt + 1,
433                    last_err
434                );
435            }
436        }
437    }
438
439    Err(IdentityError::RegistrationError(format!(
440        "parent registration failed after 5 attempts: {}",
441        last_err
442    )))
443}
444
445// ── ERC-8004 registry configuration ──────────────────────────────────────
446
447/// Get the identity registry contract address.
448///
449/// Reads from `ERC8004_IDENTITY_REGISTRY` env var, defaults to `Address::ZERO`.
450#[cfg(feature = "erc8004")]
451pub fn identity_registry() -> Address {
452    std::env::var("ERC8004_IDENTITY_REGISTRY")
453        .ok()
454        .and_then(|s| s.parse().ok())
455        .unwrap_or(Address::ZERO)
456}
457
458/// Get the reputation registry contract address.
459#[cfg(feature = "erc8004")]
460pub fn reputation_registry() -> Address {
461    std::env::var("ERC8004_REPUTATION_REGISTRY")
462        .ok()
463        .and_then(|s| s.parse().ok())
464        .unwrap_or(Address::ZERO)
465}
466
467/// Get the validation registry contract address.
468#[cfg(feature = "erc8004")]
469pub fn validation_registry() -> Address {
470    std::env::var("ERC8004_VALIDATION_REGISTRY")
471        .ok()
472        .and_then(|s| s.parse().ok())
473        .unwrap_or(Address::ZERO)
474}
475
476/// Check whether ERC-8004 identity minting is enabled.
477#[cfg(feature = "erc8004")]
478pub fn auto_mint_enabled() -> bool {
479    std::env::var("ERC8004_AUTO_MINT")
480        .map(|v| v == "true" || v == "1")
481        .unwrap_or(false)
482}
483
484/// Check whether reputation submission is enabled.
485#[cfg(feature = "erc8004")]
486pub fn reputation_enabled() -> bool {
487    std::env::var("ERC8004_REPUTATION_ENABLED")
488        .map(|v| v == "true" || v == "1")
489        .unwrap_or(false)
490}
491
492/// Get the configured recovery address (if any).
493#[cfg(feature = "erc8004")]
494pub fn recovery_address() -> Option<Address> {
495    std::env::var("ERC8004_RECOVERY_ADDRESS")
496        .ok()
497        .and_then(|s| s.parse().ok())
498}
499
500/// Load previously deployed registry addresses from a JSON file and inject as env vars.
501#[cfg(feature = "erc8004")]
502pub fn load_persisted_registries(path: &str) -> bool {
503    let Ok(data) = std::fs::read_to_string(path) else {
504        return false;
505    };
506    let Ok(json) = serde_json::from_str::<serde_json::Value>(&data) else {
507        return false;
508    };
509
510    let mut loaded = false;
511    for (key, env_var) in [
512        ("identity", "ERC8004_IDENTITY_REGISTRY"),
513        ("reputation", "ERC8004_REPUTATION_REGISTRY"),
514        ("validation", "ERC8004_VALIDATION_REGISTRY"),
515    ] {
516        if let Some(addr) = json.get(key).and_then(|v| v.as_str()) {
517            if std::env::var(env_var).is_err() || std::env::var(env_var).ok().as_deref() == Some("")
518            {
519                std::env::set_var(env_var, addr);
520                loaded = true;
521            }
522        }
523    }
524    loaded
525}
526
527/// Persist deployed registry addresses to a JSON file.
528#[cfg(feature = "erc8004")]
529pub fn save_deployed_registries(
530    path: &str,
531    registries: &deploy::DeployedRegistries,
532) -> Result<(), std::io::Error> {
533    let json = serde_json::json!({
534        "identity": format!("{:#x}", registries.identity),
535        "reputation": format!("{:#x}", registries.reputation),
536        "validation": format!("{:#x}", registries.validation),
537    });
538    if let Some(parent) = std::path::Path::new(path).parent() {
539        std::fs::create_dir_all(parent)?;
540    }
541    std::fs::write(path, serde_json::to_string_pretty(&json).unwrap())?;
542    Ok(())
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use std::io::Write;
549
550    #[test]
551    fn test_bootstrap_creates_new_identity() {
552        let dir = tempfile::tempdir().unwrap();
553        let path = dir.path().join("identity.json");
554        let path_str = path.to_str().unwrap();
555
556        let identity = bootstrap(path_str).unwrap();
557        assert_ne!(identity.address, Address::ZERO);
558        assert!(!identity.instance_id.is_empty());
559        assert!(path.exists());
560    }
561
562    #[test]
563    fn test_bootstrap_loads_existing_identity() {
564        let dir = tempfile::tempdir().unwrap();
565        let path = dir.path().join("identity.json");
566
567        // Create an identity first
568        let signer = PrivateKeySigner::random();
569        let private_key = format!("0x{}", alloy::hex::encode(signer.to_bytes()));
570        let persisted = serde_json::json!({
571            "private_key": private_key,
572            "address": format!("{:#x}", signer.address()),
573            "instance_id": "test-instance",
574            "parent_url": null,
575            "parent_address": null,
576            "created_at": "2025-01-01T00:00:00Z",
577        });
578        let mut file = std::fs::File::create(&path).unwrap();
579        file.write_all(serde_json::to_string_pretty(&persisted).unwrap().as_bytes())
580            .unwrap();
581
582        let path_str = path.to_str().unwrap();
583        let identity = bootstrap(path_str).unwrap();
584        assert_eq!(identity.address, signer.address());
585        assert_eq!(identity.instance_id, "test-instance");
586    }
587
588    #[test]
589    fn test_persisted_roundtrip() {
590        let signer = PrivateKeySigner::random();
591        let identity = InstanceIdentity {
592            private_key: format!("0x{}", alloy::hex::encode(signer.to_bytes())),
593            address: signer.address(),
594            instance_id: "test-123".to_string(),
595            parent_url: Some("https://parent.example.com".to_string()),
596            parent_address: None,
597            created_at: Utc::now(),
598            agent_token_id: None,
599            facilitator_private_key: None,
600        };
601
602        let persisted = PersistedIdentity::from(&identity);
603        let json = serde_json::to_string(&persisted).unwrap();
604        let loaded: PersistedIdentity = serde_json::from_str(&json).unwrap();
605        let restored = InstanceIdentity::try_from(loaded).unwrap();
606
607        assert_eq!(restored.address, identity.address);
608        assert_eq!(restored.instance_id, identity.instance_id);
609        assert_eq!(restored.parent_url, identity.parent_url);
610    }
611}