Skip to main content

chainstream_sdk/
wallet_auth.rs

1//! Authentication middleware for ChainStream API.
2//!
3//! Supports three modes:
4//! - API Key: `X-API-KEY` header (via `ApiKeyAuthMiddleware`)
5//! - Wallet signature: `X-Wallet-*` headers for x402 (via `WalletAuthMiddleware`)
6//! - Bearer token: `Authorization: Bearer` header (via `BearerAuthMiddleware` in tests)
7
8use async_trait::async_trait;
9use rand::Rng;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12const SIGNATURE_PREFIX: &str = "chainstream";
13
14/// Trait for wallet signers (EVM or Solana).
15#[async_trait]
16pub trait WalletSigner: Send + Sync {
17    /// Returns "evm" or "solana".
18    fn chain(&self) -> &str;
19    /// Returns the wallet address (hex for EVM, Base58 for Solana).
20    fn address(&self) -> &str;
21    /// Sign a message and return the signature string.
22    async fn sign_message(
23        &self,
24        message: &str,
25    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>>;
26}
27
28/// Build the message that must be signed for wallet auth.
29pub fn build_sign_message(chain: &str, address: &str, timestamp: &str, nonce: &str) -> String {
30    format!("{SIGNATURE_PREFIX}:{chain}:{address}:{timestamp}:{nonce}")
31}
32
33fn generate_nonce() -> String {
34    let bytes: [u8; 16] = rand::thread_rng().gen();
35    hex::encode(bytes)
36}
37
38/// Wallet auth headers for HTTP requests.
39pub struct WalletAuthHeaders {
40    pub address: String,
41    pub chain: String,
42    pub signature: String,
43    pub timestamp: String,
44    pub nonce: String,
45}
46
47/// Generate X-Wallet-* headers for a request.
48pub async fn create_wallet_auth_headers(
49    signer: &dyn WalletSigner,
50) -> Result<WalletAuthHeaders, Box<dyn std::error::Error + Send + Sync>> {
51    let timestamp = SystemTime::now()
52        .duration_since(UNIX_EPOCH)?
53        .as_secs()
54        .to_string();
55    let nonce = generate_nonce();
56    let message = build_sign_message(signer.chain(), signer.address(), &timestamp, &nonce);
57    let signature = signer.sign_message(&message).await?;
58
59    Ok(WalletAuthHeaders {
60        address: signer.address().to_string(),
61        chain: signer.chain().to_string(),
62        signature,
63        timestamp,
64        nonce,
65    })
66}
67
68/// Build a SIWX (EIP-4361) message for authentication.
69pub fn build_siwx_message(
70    address: &str,
71    chain: &str,
72    nonce: &str,
73    issued_at: &str,
74    expires_at: &str,
75) -> String {
76    let chain_label = if chain == "solana" {
77        "Solana"
78    } else {
79        "Ethereum"
80    };
81    let chain_id = if chain == "solana" { "mainnet" } else { "8453" };
82
83    format!(
84        "{domain} wants you to sign in with your {chain_label} account:\n\
85         {address}\n\n\
86         Sign in to ChainStream API\n\n\
87         URI: https://{domain}\n\
88         Version: 1\n\
89         Chain ID: {chain_id}\n\
90         Nonce: {nonce}\n\
91         Issued At: {issued_at}\n\
92         Expiration Time: {expires_at}",
93        domain = "api.chainstream.io",
94        chain_label = chain_label,
95        address = address,
96        chain_id = chain_id,
97        nonce = nonce,
98        issued_at = issued_at,
99        expires_at = expires_at,
100    )
101}
102
103/// Middleware for reqwest that adds Authorization: SIWX header.
104/// Uses SIWX token (sign once, reuse for 1h) instead of per-request X-Wallet-* headers.
105pub struct SiwxAuthMiddleware {
106    signer: Box<dyn WalletSigner>,
107}
108
109impl SiwxAuthMiddleware {
110    pub fn new(signer: Box<dyn WalletSigner>) -> Self {
111        Self { signer }
112    }
113}
114
115#[async_trait]
116impl reqwest_middleware::Middleware for SiwxAuthMiddleware {
117    async fn handle(
118        &self,
119        mut req: reqwest::Request,
120        extensions: &mut http::Extensions,
121        next: reqwest_middleware::Next<'_>,
122    ) -> reqwest_middleware::Result<reqwest::Response> {
123        let nonce = generate_nonce();
124        let now = chrono::Utc::now();
125        let expires_at = now + chrono::Duration::hours(1);
126        let issued_at_str = now.to_rfc3339();
127        let expires_at_str = expires_at.to_rfc3339();
128
129        let message = build_siwx_message(
130            self.signer.address(),
131            self.signer.chain(),
132            &nonce,
133            &issued_at_str,
134            &expires_at_str,
135        );
136
137        let signature = self
138            .signer
139            .sign_message(&message)
140            .await
141            .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("{e}")))?;
142
143        use base64::Engine;
144        let message_b64 = base64::engine::general_purpose::STANDARD.encode(message.as_bytes());
145        let token = format!("{}.{}", message_b64, signature);
146
147        req.headers_mut()
148            .insert("Authorization", format!("SIWX {}", token).parse().unwrap());
149
150        next.run(req, extensions).await
151    }
152}
153
154/// Legacy middleware for reqwest that adds X-Wallet-* headers (deprecated).
155pub struct WalletAuthMiddleware {
156    signer: Box<dyn WalletSigner>,
157}
158
159impl WalletAuthMiddleware {
160    pub fn new(signer: Box<dyn WalletSigner>) -> Self {
161        Self { signer }
162    }
163}
164
165#[async_trait]
166impl reqwest_middleware::Middleware for WalletAuthMiddleware {
167    async fn handle(
168        &self,
169        mut req: reqwest::Request,
170        extensions: &mut http::Extensions,
171        next: reqwest_middleware::Next<'_>,
172    ) -> reqwest_middleware::Result<reqwest::Response> {
173        let headers = create_wallet_auth_headers(self.signer.as_ref())
174            .await
175            .map_err(|e| reqwest_middleware::Error::Middleware(anyhow::anyhow!("{e}")))?;
176
177        let h = req.headers_mut();
178        h.insert("X-Wallet-Address", headers.address.parse().unwrap());
179        h.insert("X-Wallet-Chain", headers.chain.parse().unwrap());
180        h.insert("X-Wallet-Signature", headers.signature.parse().unwrap());
181        h.insert("X-Wallet-Timestamp", headers.timestamp.parse().unwrap());
182        h.insert("X-Wallet-Nonce", headers.nonce.parse().unwrap());
183
184        next.run(req, extensions).await
185    }
186}
187
188/// Middleware for reqwest that adds X-API-KEY header.
189pub struct ApiKeyAuthMiddleware {
190    api_key: String,
191}
192
193impl ApiKeyAuthMiddleware {
194    pub fn new(api_key: impl Into<String>) -> Self {
195        Self {
196            api_key: api_key.into(),
197        }
198    }
199}
200
201#[async_trait]
202impl reqwest_middleware::Middleware for ApiKeyAuthMiddleware {
203    async fn handle(
204        &self,
205        mut req: reqwest::Request,
206        extensions: &mut http::Extensions,
207        next: reqwest_middleware::Next<'_>,
208    ) -> reqwest_middleware::Result<reqwest::Response> {
209        req.headers_mut()
210            .insert("X-API-KEY", self.api_key.parse().unwrap());
211        next.run(req, extensions).await
212    }
213}