Skip to main content

agent_first_pay/provider/
mod.rs

1#[cfg(any(
2    feature = "btc-esplora",
3    feature = "btc-core",
4    feature = "btc-electrum"
5))]
6pub mod btc;
7#[cfg(feature = "cashu")]
8pub mod cashu;
9#[cfg(feature = "evm")]
10pub mod evm;
11#[cfg(any(feature = "ln-nwc", feature = "ln-phoenixd", feature = "ln-lnbits"))]
12pub mod ln;
13#[cfg(feature = "rpc")]
14pub mod remote;
15#[cfg(feature = "sol")]
16pub mod sol;
17
18use crate::types::*;
19use async_trait::async_trait;
20use std::fmt;
21
22// ═══════════════════════════════════════════
23// PayError
24// ═══════════════════════════════════════════
25
26#[derive(Debug)]
27#[allow(dead_code)]
28pub enum PayError {
29    NotImplemented(String),
30    WalletNotFound(String),
31    InvalidAmount(String),
32    NetworkError(String),
33    InternalError(String),
34    LimitExceeded {
35        rule_id: String,
36        scope: SpendScope,
37        scope_key: String,
38        spent: u64,
39        max_spend: u64,
40        token: Option<String>,
41        remaining_s: u64,
42        /// Which node rejected: None = local, Some(endpoint) = remote.
43        origin: Option<String>,
44    },
45}
46
47impl fmt::Display for PayError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::NotImplemented(msg) => write!(f, "{msg}"),
51            Self::WalletNotFound(msg) => write!(f, "{msg}"),
52            Self::InvalidAmount(msg) => write!(f, "{msg}"),
53            Self::NetworkError(msg) => write!(f, "{msg}"),
54            Self::InternalError(msg) => write!(f, "{msg}"),
55            Self::LimitExceeded {
56                scope,
57                scope_key,
58                spent,
59                max_spend,
60                token,
61                origin,
62                ..
63            } => {
64                let token_str = token.as_deref().unwrap_or("base-units");
65                if let Some(node) = origin {
66                    write!(
67                        f,
68                        "spend limit exceeded at {node} ({scope:?}:{scope_key}): spent {spent} of {max_spend} {token_str}"
69                    )
70                } else {
71                    write!(
72                        f,
73                        "spend limit exceeded ({scope:?}:{scope_key}): spent {spent} of {max_spend} {token_str}"
74                    )
75                }
76            }
77        }
78    }
79}
80
81impl PayError {
82    pub fn error_code(&self) -> &'static str {
83        match self {
84            Self::NotImplemented(_) => "not_implemented",
85            Self::WalletNotFound(_) => "wallet_not_found",
86            Self::InvalidAmount(_) => "invalid_amount",
87            Self::NetworkError(_) => "network_error",
88            Self::InternalError(_) => "internal_error",
89            Self::LimitExceeded { .. } => "limit_exceeded",
90        }
91    }
92
93    pub fn retryable(&self) -> bool {
94        matches!(self, Self::NetworkError(_))
95    }
96
97    pub fn hint(&self) -> Option<String> {
98        match self {
99            Self::NotImplemented(_) => Some("enable redb or postgres storage backend".to_string()),
100            Self::WalletNotFound(_) => Some("list wallets with: afpay wallet list".to_string()),
101            Self::LimitExceeded { .. } => Some("check limits with: afpay limit list".to_string()),
102            _ => None,
103        }
104    }
105}
106
107// ═══════════════════════════════════════════
108// PayProvider Trait
109// ═══════════════════════════════════════════
110
111#[derive(Debug, Clone, Copy, Default)]
112pub struct HistorySyncStats {
113    pub records_scanned: usize,
114    pub records_added: usize,
115    pub records_updated: usize,
116}
117
118#[async_trait]
119pub trait PayProvider: Send + Sync {
120    #[allow(dead_code)]
121    fn network(&self) -> Network;
122
123    /// Whether this provider writes to local disk (needs data-dir lock).
124    fn writes_locally(&self) -> bool {
125        false
126    }
127
128    /// Connectivity check. Remote providers ping the RPC endpoint; local providers no-op.
129    #[allow(dead_code)]
130    async fn ping(&self) -> Result<(), PayError> {
131        Ok(())
132    }
133
134    async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError>;
135    async fn create_ln_wallet(
136        &self,
137        _request: LnWalletCreateRequest,
138    ) -> Result<WalletInfo, PayError> {
139        Err(PayError::NotImplemented(
140            "ln wallet creation not supported".to_string(),
141        ))
142    }
143    async fn close_wallet(&self, wallet: &str) -> Result<(), PayError>;
144    async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError>;
145    async fn balance(&self, wallet: &str) -> Result<BalanceInfo, PayError>;
146    async fn check_balance(&self, _wallet: &str) -> Result<BalanceInfo, PayError> {
147        Err(PayError::NotImplemented(
148            "check_balance not supported".to_string(),
149        ))
150    }
151    async fn restore(&self, _wallet: &str) -> Result<RestoreResult, PayError> {
152        Err(PayError::NotImplemented(
153            "restore not supported".to_string(),
154        ))
155    }
156    async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError>;
157    async fn receive_info(
158        &self,
159        wallet: &str,
160        amount: Option<Amount>,
161    ) -> Result<ReceiveInfo, PayError>;
162    async fn receive_claim(&self, wallet: &str, quote_id: &str) -> Result<u64, PayError>;
163
164    #[cfg(feature = "interactive")]
165    async fn cashu_send_quote(
166        &self,
167        _wallet: &str,
168        _amount: &Amount,
169    ) -> Result<CashuSendQuoteInfo, PayError> {
170        Err(PayError::NotImplemented(
171            "cashu_send_quote not supported".to_string(),
172        ))
173    }
174    async fn cashu_send(
175        &self,
176        wallet: &str,
177        amount: Amount,
178        onchain_memo: Option<&str>,
179        mints: Option<&[String]>,
180    ) -> Result<CashuSendResult, PayError>;
181    async fn cashu_receive(
182        &self,
183        wallet: &str,
184        token: &str,
185    ) -> Result<CashuReceiveResult, PayError>;
186    async fn send(
187        &self,
188        wallet: &str,
189        to: &str,
190        onchain_memo: Option<&str>,
191        mints: Option<&[String]>,
192    ) -> Result<SendResult, PayError>;
193
194    async fn send_quote(
195        &self,
196        _wallet: &str,
197        _to: &str,
198        _mints: Option<&[String]>,
199    ) -> Result<SendQuoteInfo, PayError> {
200        Err(PayError::NotImplemented(
201            "send_quote not supported".to_string(),
202        ))
203    }
204
205    async fn history_list(
206        &self,
207        wallet: &str,
208        limit: usize,
209        offset: usize,
210    ) -> Result<Vec<HistoryRecord>, PayError>;
211    async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError>;
212    /// Optional provider-specific on-chain memo decoding for a transaction.
213    /// Returns `Ok(None)` when memo cannot be decoded or is absent.
214    async fn history_onchain_memo(
215        &self,
216        _wallet: &str,
217        _transaction_id: &str,
218    ) -> Result<Option<String>, PayError> {
219        Ok(None)
220    }
221    async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
222        let items = self.history_list(wallet, limit, 0).await?;
223        Ok(HistorySyncStats {
224            records_scanned: items.len(),
225            records_added: 0,
226            records_updated: 0,
227        })
228    }
229}
230
231// ═══════════════════════════════════════════
232// StubProvider
233// ═══════════════════════════════════════════
234
235pub struct StubProvider {
236    #[allow(dead_code)]
237    network: Network,
238}
239
240impl StubProvider {
241    pub fn new(network: Network) -> Self {
242        Self { network }
243    }
244}
245
246#[async_trait]
247impl PayProvider for StubProvider {
248    fn network(&self) -> Network {
249        self.network
250    }
251
252    async fn create_wallet(&self, _request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
253        Err(PayError::NotImplemented("network not enabled".to_string()))
254    }
255
256    async fn create_ln_wallet(
257        &self,
258        _request: LnWalletCreateRequest,
259    ) -> Result<WalletInfo, PayError> {
260        Err(PayError::NotImplemented("network not enabled".to_string()))
261    }
262
263    async fn close_wallet(&self, _wallet: &str) -> Result<(), PayError> {
264        Err(PayError::NotImplemented("network not enabled".to_string()))
265    }
266
267    async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
268        Err(PayError::NotImplemented("network not enabled".to_string()))
269    }
270
271    async fn balance(&self, _wallet: &str) -> Result<BalanceInfo, PayError> {
272        Err(PayError::NotImplemented("network not enabled".to_string()))
273    }
274
275    async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
276        Err(PayError::NotImplemented("network not enabled".to_string()))
277    }
278
279    async fn receive_info(
280        &self,
281        _wallet: &str,
282        _amount: Option<Amount>,
283    ) -> Result<ReceiveInfo, PayError> {
284        Err(PayError::NotImplemented("network not enabled".to_string()))
285    }
286
287    async fn receive_claim(&self, _wallet: &str, _quote_id: &str) -> Result<u64, PayError> {
288        Err(PayError::NotImplemented("network not enabled".to_string()))
289    }
290
291    async fn cashu_send(
292        &self,
293        _wallet: &str,
294        _amount: Amount,
295        _onchain_memo: Option<&str>,
296        _mints: Option<&[String]>,
297    ) -> Result<CashuSendResult, PayError> {
298        Err(PayError::NotImplemented("network not enabled".to_string()))
299    }
300
301    async fn cashu_receive(
302        &self,
303        _wallet: &str,
304        _token: &str,
305    ) -> Result<CashuReceiveResult, PayError> {
306        Err(PayError::NotImplemented("network not enabled".to_string()))
307    }
308
309    async fn send(
310        &self,
311        _wallet: &str,
312        _to: &str,
313        _onchain_memo: Option<&str>,
314        _mints: Option<&[String]>,
315    ) -> Result<SendResult, PayError> {
316        Err(PayError::NotImplemented("network not enabled".to_string()))
317    }
318
319    async fn history_list(
320        &self,
321        _wallet: &str,
322        _limit: usize,
323        _offset: usize,
324    ) -> Result<Vec<HistoryRecord>, PayError> {
325        Err(PayError::NotImplemented("network not enabled".to_string()))
326    }
327
328    async fn history_status(&self, _transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
329        Err(PayError::NotImplemented("network not enabled".to_string()))
330    }
331}