Skip to main content

x402_rs/
config.rs

1//! Configuration module for the x402 facilitator server.
2
3use alloy_primitives::B256;
4use clap::Parser;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::net::IpAddr;
8use std::ops::{Deref, DerefMut};
9use std::path::{Path, PathBuf};
10use std::str::FromStr;
11use url::Url;
12
13use crate::chain::eip155;
14use crate::chain::solana;
15use crate::chain::{ChainId, ChainIdPattern};
16
17/// CLI arguments for the x402 facilitator server.
18#[derive(Parser, Debug)]
19#[command(name = "x402-rs")]
20#[command(about = "x402 Facilitator HTTP server")]
21struct CliArgs {
22    /// Path to the JSON configuration file
23    #[arg(long, short, env = "CONFIG", default_value = "config.json")]
24    config: PathBuf,
25}
26
27/// Server configuration.
28///
29/// Fields use serde defaults that fall back to environment variables,
30/// then to hardcoded defaults.
31#[derive(Debug, Clone, Deserialize)]
32pub struct Config<TChainsConfig = ChainsConfig> {
33    #[serde(default = "config_defaults::default_port")]
34    port: u16,
35    #[serde(default = "config_defaults::default_host")]
36    host: IpAddr,
37    #[serde(default)]
38    chains: TChainsConfig,
39    #[serde(default)]
40    schemes: Vec<SchemeConfig>,
41}
42
43/// Configuration for a specific scheme.
44///
45/// Each scheme entry specifies which scheme to use and which chains it applies to.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SchemeConfig {
48    /// Whether this scheme is enabled (defaults to true).
49    #[serde(default = "scheme_config_defaults::default_enabled")]
50    pub enabled: bool,
51    /// The scheme id (e.g., "v1-eip155-exact").
52    pub id: String,
53    /// The chain pattern this scheme applies to (e.g., "eip155:84532", "eip155:*", "eip155:{1,8453}").
54    pub chains: ChainIdPattern,
55    /// Scheme-specific configuration (optional).
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub config: Option<serde_json::Value>,
58}
59
60mod scheme_config_defaults {
61    pub fn default_enabled() -> bool {
62        true
63    }
64}
65
66/// Configuration for a specific chain.
67///
68/// This enum represents chain-specific configuration that varies by chain family
69/// (EVM vs Solana). The chain family is determined by the CAIP-2 prefix of the
70/// chain identifier key (e.g., "eip155:" for EVM, "solana:" for Solana).
71#[derive(Debug, Clone)]
72pub enum ChainConfig {
73    /// EVM chain configuration (for chains with "eip155:" prefix).
74    Eip155(Box<Eip155ChainConfig>),
75    /// Solana chain configuration (for chains with "solana:" prefix).
76    Solana(Box<SolanaChainConfig>),
77}
78
79/// RPC provider configuration for a single provider.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct RpcConfig {
82    /// HTTP URL for the RPC endpoint.
83    pub http: Url,
84    /// Rate limit for requests per second (optional).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub rate_limit: Option<u32>,
87}
88
89// ============================================================================
90// Environment Variable Resolution
91// ============================================================================
92
93/// A transparent wrapper that resolves environment variables during deserialization.
94///
95/// Supports both literal values and environment variable references:
96/// - Literal: `"http://localhost:8083"`
97/// - Simple env var: `"$TREASURY_URL"`
98/// - Braced env var: `"${TREASURY_URL}"`
99///
100/// The wrapper implements `Deref` to provide transparent access to the inner type.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct LiteralOrEnv<T>(T);
103
104impl<T> LiteralOrEnv<T> {
105    /// Get a reference to the inner value
106    #[allow(dead_code)]
107    pub fn inner(&self) -> &T {
108        &self.0
109    }
110
111    /// Consume the wrapper and return the inner value
112    #[allow(dead_code)]
113    pub fn into_inner(self) -> T {
114        self.0
115    }
116
117    /// Parse environment variable syntax from a string.
118    /// Returns the variable name if the string matches `$VAR` or `${VAR}` syntax.
119    fn parse_env_var_syntax(s: &str) -> Option<String> {
120        if s.starts_with("${") && s.ends_with('}') {
121            // ${VAR} syntax
122            Some(s[2..s.len() - 1].to_string())
123        } else if s.starts_with('$') && s.len() > 1 {
124            // $VAR syntax - extract until first non-alphanumeric/underscore character
125            let var_name = &s[1..];
126            if var_name.chars().all(|c| c.is_alphanumeric() || c == '_') {
127                Some(var_name.to_string())
128            } else {
129                None
130            }
131        } else {
132            None
133        }
134    }
135}
136
137impl<T> Deref for LiteralOrEnv<T> {
138    type Target = T;
139
140    fn deref(&self) -> &Self::Target {
141        &self.0
142    }
143}
144
145impl<T> DerefMut for LiteralOrEnv<T> {
146    fn deref_mut(&mut self) -> &mut Self::Target {
147        &mut self.0
148    }
149}
150
151impl<'de, T> Deserialize<'de> for LiteralOrEnv<T>
152where
153    T: FromStr,
154    T::Err: std::fmt::Display,
155{
156    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
157    where
158        D: serde::Deserializer<'de>,
159    {
160        let s = String::deserialize(deserializer)?;
161
162        // Check if it's an environment variable reference
163        let value = if let Some(var_name) = Self::parse_env_var_syntax(&s) {
164            std::env::var(&var_name).map_err(|_| {
165                serde::de::Error::custom(format!(
166                    "Environment variable '{}' not found (referenced as '{}')",
167                    var_name, s
168                ))
169            })?
170        } else {
171            s
172        };
173
174        // Parse the value as type T
175        let parsed = value
176            .parse::<T>()
177            .map_err(|e| serde::de::Error::custom(format!("Failed to parse value: {}", e)))?;
178
179        Ok(LiteralOrEnv(parsed))
180    }
181}
182
183impl<T> serde::Serialize for LiteralOrEnv<T>
184where
185    T: Serialize,
186{
187    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
188    where
189        S: serde::Serializer,
190    {
191        self.0.serialize(serializer)
192    }
193}
194
195// impl<T: PartialEq> PartialEq for LiteralOrEnv<T> {
196//     fn eq(&self, other: &Self) -> bool {
197//         self.0 == other.0
198//     }
199// }
200
201// ============================================================================
202// EVM Private Key
203// ============================================================================
204
205/// A validated EVM private key (32 bytes).
206///
207/// This type represents a raw private key that has been validated as a proper
208/// 32-byte hex value. It can be converted to a `PrivateKeySigner` when needed.
209#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
210pub struct EvmPrivateKey(B256);
211
212impl EvmPrivateKey {
213    /// Get the raw 32 bytes of the private key.
214    pub fn as_bytes(&self) -> &[u8; 32] {
215        self.0.as_ref()
216    }
217}
218
219impl PartialEq for EvmPrivateKey {
220    fn eq(&self, other: &Self) -> bool {
221        self.0 == other.0
222    }
223}
224
225impl FromStr for EvmPrivateKey {
226    type Err = String;
227
228    fn from_str(s: &str) -> Result<Self, Self::Err> {
229        B256::from_str(s)
230            .map(Self)
231            .map_err(|e| format!("Invalid evm private key: {}", e))
232    }
233}
234
235// ============================================================================
236// EVM Signers Configuration
237// ============================================================================
238
239/// Configuration for EVM signers.
240///
241/// Deserializes an array of private key strings (hex format, 0x-prefixed) and
242/// validates them as valid 32-byte private keys. The `EthereumWallet` is created
243/// lazily when needed via the `wallet()` method.
244///
245/// Each string can be:
246/// - A literal hex private key: `"0xcafe..."`
247/// - An environment variable reference: `"$PRIVATE_KEY"` or `"${PRIVATE_KEY}"`
248///
249/// Example JSON:
250/// ```json
251/// {
252///   "signers": [
253///     "$HOT_WALLET_KEY",
254///     "0xcafe000000000000000000000000000000000000000000000000000000000001"
255///   ]
256/// }
257/// ```
258pub type Eip155SignersConfig = Vec<LiteralOrEnv<EvmPrivateKey>>;
259
260// ============================================================================
261// Solana Private Key
262// ============================================================================
263
264/// A validated Solana private key (64 bytes in standard Solana format).
265///
266/// This type represents a standard Solana keypair in its 64-byte format:
267/// - First 32 bytes: the Ed25519 secret key (seed)
268/// - Last 32 bytes: the Ed25519 public key
269///
270/// The key is stored and parsed as a base58-encoded 64-byte array,
271/// which is the standard format used by Solana CLI and wallets.
272#[derive(Clone, Debug, PartialEq, Eq)]
273pub struct SolanaPrivateKey([u8; 64]);
274
275impl SolanaPrivateKey {
276    /// Parse a base58 string into a private key (64 bytes in standard Solana format).
277    ///
278    /// The standard Solana keypair format is 64 bytes:
279    /// - First 32 bytes: secret key (seed)
280    /// - Last 32 bytes: public key
281    pub fn from_base58(s: &str) -> Result<Self, String> {
282        let bytes = bs58::decode(s)
283            .into_vec()
284            .map_err(|e| format!("Invalid base58: {}", e))?;
285
286        if bytes.len() != 64 {
287            return Err(format!(
288                "Private key must be 64 bytes (standard Solana format), got {} bytes",
289                bytes.len()
290            ));
291        }
292
293        let mut arr = [0u8; 64];
294        arr.copy_from_slice(&bytes);
295        Ok(Self(arr))
296    }
297
298    /// Encode the keypair back to base58.
299    pub fn to_base58(&self) -> String {
300        bs58::encode(&self.0).into_string()
301    }
302}
303
304impl Serialize for SolanaPrivateKey {
305    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
306    where
307        S: serde::Serializer,
308    {
309        serializer.serialize_str(&self.to_base58())
310    }
311}
312
313impl FromStr for SolanaPrivateKey {
314    type Err = String;
315
316    fn from_str(s: &str) -> Result<Self, Self::Err> {
317        Self::from_base58(s)
318    }
319}
320
321impl std::fmt::Display for SolanaPrivateKey {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        write!(f, "{}", self.to_base58())
324    }
325}
326
327/// Type alias for Solana signer configuration.
328///
329/// Uses `LiteralOrEnv` to support both literal base58 keys and environment variable references.
330///
331/// Example JSON:
332/// ```json
333/// {
334///   "signer": "$SOLANA_FACILITATOR_KEY"
335/// }
336/// ```
337#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
338pub struct SolanaSignerConfig(LiteralOrEnv<SolanaPrivateKey>);
339
340impl Deref for SolanaSignerConfig {
341    type Target = SolanaPrivateKey;
342
343    fn deref(&self) -> &Self::Target {
344        self.0.inner()
345    }
346}
347
348// ============================================================================
349// Chain Configurations
350// ============================================================================
351
352#[derive(Debug, Clone)]
353pub struct Eip155ChainConfig {
354    pub chain_reference: eip155::Eip155ChainReference,
355    pub inner: Eip155ChainConfigInner,
356}
357
358impl Eip155ChainConfig {
359    pub fn chain_id(&self) -> ChainId {
360        self.chain_reference.into()
361    }
362    pub fn eip1559(&self) -> bool {
363        self.inner.eip1559
364    }
365    pub fn flashblocks(&self) -> bool {
366        self.inner.flashblocks
367    }
368    pub fn receipt_timeout_secs(&self) -> u64 {
369        self.inner.receipt_timeout_secs
370    }
371    pub fn signers(&self) -> &Eip155SignersConfig {
372        &self.inner.signers
373    }
374    pub fn rpc(&self) -> &Vec<RpcConfig> {
375        &self.inner.rpc
376    }
377    pub fn chain_reference(&self) -> eip155::Eip155ChainReference {
378        self.chain_reference
379    }
380}
381
382#[derive(Debug, Clone)]
383pub struct SolanaChainConfig {
384    pub chain_reference: solana::SolanaChainReference,
385    pub inner: SolanaChainConfigInner,
386}
387
388impl SolanaChainConfig {
389    pub fn signer(&self) -> &SolanaSignerConfig {
390        &self.inner.signer
391    }
392    pub fn rpc(&self) -> &Url {
393        &self.inner.rpc
394    }
395    pub fn max_compute_unit_limit(&self) -> u32 {
396        self.inner.max_compute_unit_limit
397    }
398    pub fn max_compute_unit_price(&self) -> u64 {
399        self.inner.max_compute_unit_price
400    }
401    pub fn chain_reference(&self) -> solana::SolanaChainReference {
402        self.chain_reference
403    }
404    pub fn chain_id(&self) -> ChainId {
405        self.chain_reference.into()
406    }
407    pub fn pubsub(&self) -> &Option<Url> {
408        &self.inner.pubsub
409    }
410}
411
412/// Configuration specific to EVM-compatible chains.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct Eip155ChainConfigInner {
415    /// Whether the chain supports EIP-1559 gas pricing.
416    #[serde(default = "eip155_chain_config::default_eip1559")]
417    pub eip1559: bool,
418    /// Whether the chain supports flashblocks.
419    #[serde(default = "eip155_chain_config::default_flashblocks")]
420    pub flashblocks: bool,
421    /// Signer configuration for this chain (required).
422    /// Array of private keys (hex format) or env var references.
423    pub signers: Eip155SignersConfig,
424    /// RPC provider configuration for this chain (required).
425    pub rpc: Vec<RpcConfig>,
426    /// How long to wait till the transaction receipt is available (optional)
427    #[serde(default = "eip155_chain_config::default_receipt_timeout_secs")]
428    pub receipt_timeout_secs: u64,
429}
430
431mod eip155_chain_config {
432    pub fn default_eip1559() -> bool {
433        true
434    }
435    pub fn default_flashblocks() -> bool {
436        false
437    }
438    pub fn default_receipt_timeout_secs() -> u64 {
439        30
440    }
441}
442
443/// Configuration specific to Solana chains.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct SolanaChainConfigInner {
446    /// Signer configuration for this chain (required).
447    /// A single private key (base58 format, 64 bytes) or env var reference.
448    pub signer: SolanaSignerConfig,
449    /// RPC provider configuration for this chain (required).
450    pub rpc: Url,
451    /// RPC pubsub provider endpoint (optional)
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub pubsub: Option<Url>,
454    /// Maximum compute unit limit for transactions (optional)
455    #[serde(default = "solana_chain_config::default_max_compute_unit_limit")]
456    pub max_compute_unit_limit: u32,
457    /// Maximum compute unit price for transactions (optional)
458    #[serde(default = "solana_chain_config::default_max_compute_unit_price")]
459    pub max_compute_unit_price: u64,
460}
461
462mod solana_chain_config {
463    pub fn default_max_compute_unit_limit() -> u32 {
464        400_000
465    }
466    pub fn default_max_compute_unit_price() -> u64 {
467        1_000_000
468    }
469}
470
471/// Configuration for chains.
472///
473/// This is a wrapper around Vec<ChainConfig> that provides custom serialization
474/// as a map where keys are CAIP-2 chain identifiers.
475#[derive(Debug, Clone, Default)]
476pub struct ChainsConfig(pub Vec<ChainConfig>);
477
478impl Deref for ChainsConfig {
479    type Target = Vec<ChainConfig>;
480
481    fn deref(&self) -> &Self::Target {
482        &self.0
483    }
484}
485
486impl Serialize for ChainsConfig {
487    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
488    where
489        S: serde::Serializer,
490    {
491        use serde::ser::SerializeMap;
492
493        let chains = &self.0;
494        let mut map = serializer.serialize_map(Some(chains.len()))?;
495        for chain_config in chains {
496            match chain_config {
497                ChainConfig::Eip155(config) => {
498                    let chain_id = config.chain_id();
499                    let inner = &config.inner;
500                    map.serialize_entry(&chain_id, inner)?;
501                }
502                ChainConfig::Solana(config) => {
503                    let chain_id = config.chain_id();
504                    let inner = &config.inner;
505                    map.serialize_entry(&chain_id, inner)?;
506                }
507            }
508        }
509        map.end()
510    }
511}
512
513impl<'de> Deserialize<'de> for ChainsConfig {
514    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
515    where
516        D: serde::Deserializer<'de>,
517    {
518        use serde::de::{MapAccess, Visitor};
519        use std::fmt;
520
521        struct ChainsVisitor;
522
523        impl<'de> Visitor<'de> for ChainsVisitor {
524            type Value = ChainsConfig;
525
526            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
527                formatter.write_str("a map of chain identifiers to chain configurations")
528            }
529
530            fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
531            where
532                M: MapAccess<'de>,
533            {
534                let mut chains = Vec::with_capacity(access.size_hint().unwrap_or(0));
535
536                while let Some(chain_id) = access.next_key::<ChainId>()? {
537                    let namespace = chain_id.namespace();
538                    let config = match namespace {
539                        eip155::EIP155_NAMESPACE => {
540                            let inner: Eip155ChainConfigInner = access.next_value()?;
541                            let config = Eip155ChainConfig {
542                                chain_reference: chain_id
543                                    .try_into()
544                                    .map_err(|e| serde::de::Error::custom(format!("{}", e)))?,
545                                inner,
546                            };
547                            ChainConfig::Eip155(Box::new(config))
548                        }
549                        solana::SOLANA_NAMESPACE => {
550                            let inner: SolanaChainConfigInner = access.next_value()?;
551                            let config = SolanaChainConfig {
552                                chain_reference: chain_id
553                                    .try_into()
554                                    .map_err(|e| serde::de::Error::custom(format!("{}", e)))?,
555                                inner,
556                            };
557                            ChainConfig::Solana(Box::new(config))
558                        }
559                        _ => {
560                            return Err(serde::de::Error::custom(format!(
561                                "Unexpected namespace: {}",
562                                namespace
563                            )));
564                        }
565                    };
566                    chains.push(config)
567                }
568
569                Ok(ChainsConfig(chains))
570            }
571        }
572
573        deserializer.deserialize_map(ChainsVisitor)
574    }
575}
576
577impl<TChainsConfig> Default for Config<TChainsConfig>
578where
579    TChainsConfig: Default,
580{
581    fn default() -> Self {
582        Config {
583            port: config_defaults::default_port(),
584            host: config_defaults::default_host(),
585            chains: TChainsConfig::default(),
586            schemes: Vec::new(),
587        }
588    }
589}
590
591pub mod config_defaults {
592    use std::env;
593    use std::net::IpAddr;
594
595    pub const DEFAULT_PORT: u16 = 8080;
596    pub const DEFAULT_HOST: &str = "0.0.0.0";
597
598    /// Returns the default port value with fallback: $PORT env var -> 8080
599    pub fn default_port() -> u16 {
600        env::var("PORT")
601            .ok()
602            .and_then(|s| s.parse().ok())
603            .unwrap_or(DEFAULT_PORT)
604    }
605
606    /// Returns the default host value with fallback: $HOST env var -> "0.0.0.0"
607    pub fn default_host() -> IpAddr {
608        env::var("HOST")
609            .ok()
610            .and_then(|s| s.parse().ok())
611            .unwrap_or(IpAddr::V4(DEFAULT_HOST.parse().unwrap()))
612    }
613}
614
615/// Configuration error types.
616#[derive(Debug, thiserror::Error)]
617pub enum ConfigError {
618    #[error("Failed to read config file at {0}: {1}")]
619    FileRead(PathBuf, std::io::Error),
620    #[error("Failed to parse config file: {0}")]
621    JsonParse(#[from] serde_json::Error),
622}
623
624impl<TChainsConfig> Config<TChainsConfig> {
625    /// Get the port value.
626    pub fn port(&self) -> u16 {
627        self.port
628    }
629
630    /// Get the host value as an IpAddr.
631    ///
632    /// Returns an error if the host string cannot be parsed as an IP address.
633    pub fn host(&self) -> IpAddr {
634        self.host
635    }
636
637    /// Get the schemes configuration list.
638    ///
639    /// Each entry specifies a scheme and the chains it applies to.
640    pub fn schemes(&self) -> &Vec<SchemeConfig> {
641        &self.schemes
642    }
643
644    /// Get the chains configuration map.
645    ///
646    /// Keys are CAIP-2 chain identifiers (e.g., "eip155:84532", "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp").
647    pub fn chains(&self) -> &TChainsConfig {
648        &self.chains
649    }
650}
651
652impl<TChainsConfig> Config<TChainsConfig>
653where
654    TChainsConfig: Default + for<'de> Deserialize<'de>,
655{
656    /// Load configuration from CLI arguments and JSON file.
657    ///
658    /// The config file path is determined by:
659    /// 1. `--config <path>` CLI argument
660    /// 2. `./config.json` (if it exists)
661    ///
662    /// Values not present in the config file will be resolved via
663    /// environment variables or defaults during deserialization.
664    pub fn load() -> Result<Self, ConfigError> {
665        let cli_args = CliArgs::parse();
666        let config_path = Path::new(&cli_args.config)
667            .canonicalize()
668            .map_err(|e| ConfigError::FileRead(cli_args.config, e))?;
669        Self::load_from_path(config_path)
670    }
671
672    /// Load configuration from a specific path (or use defaults if None).
673    fn load_from_path(path: PathBuf) -> Result<Self, ConfigError> {
674        let content = fs::read_to_string(&path).map_err(|e| ConfigError::FileRead(path, e))?;
675        let config: Config<TChainsConfig> = serde_json::from_str(&content)?;
676        Ok(config)
677    }
678}