Skip to main content

circle_developer_controlled_wallets/
client.rs

1//! HTTP client for the Developer-Controlled Wallets API.
2
3use crate::{
4    error::Error,
5    models::{
6        common::ApiErrorBody,
7        signing::{
8            SignMessageRequest, SignTransactionRequest, SignTransactionResponse,
9            SignTypedDataRequest, SignatureResponse,
10        },
11        token::TokenResponse,
12        transaction::{
13            AccelerateTxRequest, CancelTxRequest, CreateContractExecutionTxRequest,
14            CreateTransferTxRequest, EstimateFeeResponse, EstimateTransferFeeRequest,
15            ListTransactionsParams, TransactionResponse, Transactions, ValidateAddressRequest,
16            ValidateAddressResponse,
17        },
18        wallet::{
19            Balances, CreateWalletsRequest, ListWalletBalancesParams, ListWalletNftsParams,
20            ListWalletsParams, Nfts, UpdateWalletRequest, WalletNftsParams, WalletResponse,
21            Wallets, WalletsWithBalances,
22        },
23        wallet_set::{
24            CreateWalletSetRequest, ListWalletSetsParams, UpdateWalletSetRequest,
25            WalletSetResponse, WalletSets,
26        },
27    },
28};
29
30/// Async HTTP client for the Circle W3S Developer-Controlled Wallets API.
31pub struct DeveloperWalletsClient {
32    base_url: String,
33    api_key: String,
34    http: hpx::Client,
35}
36
37impl std::fmt::Debug for DeveloperWalletsClient {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("DeveloperWalletsClient")
40            .field("base_url", &self.base_url)
41            .field("api_key", &"<redacted>")
42            .finish_non_exhaustive()
43    }
44}
45
46impl DeveloperWalletsClient {
47    /// Creates a new client using the Circle production base URL.
48    pub fn new(api_key: impl Into<String>) -> Self {
49        Self::with_base_url(api_key, "https://api.circle.com")
50    }
51
52    /// Creates a new client with a custom base URL (useful for Prism mock servers).
53    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
54        Self { base_url: base_url.into(), api_key: api_key.into(), http: hpx::Client::new() }
55    }
56
57    /// Dispatch a GET request and decode the JSON response.
58    async fn get<T, P>(&self, path: &str, params: &P) -> Result<T, Error>
59    where
60        T: serde::de::DeserializeOwned,
61        P: serde::Serialize + ?Sized,
62    {
63        let url = format!("{}{}", self.base_url, path);
64        let resp = self
65            .http
66            .get(&url)
67            .header("Authorization", format!("Bearer {}", self.api_key))
68            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
69            .query(params)
70            .send()
71            .await
72            .map_err(|e| Error::Http(e.to_string()))?;
73
74        if resp.status().is_success() {
75            resp.json::<T>().await.map_err(|e| Error::Http(e.to_string()))
76        } else {
77            let err: ApiErrorBody = resp.json().await.map_err(|e| Error::Http(e.to_string()))?;
78            Err(Error::Api { code: err.code, message: err.message })
79        }
80    }
81
82    /// Dispatch a POST request with a JSON body and decode the JSON response.
83    async fn post<T, B>(&self, path: &str, body: &B) -> Result<T, Error>
84    where
85        T: serde::de::DeserializeOwned,
86        B: serde::Serialize + ?Sized,
87    {
88        let url = format!("{}{}", self.base_url, path);
89        let resp = self
90            .http
91            .post(&url)
92            .header("Authorization", format!("Bearer {}", self.api_key))
93            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
94            .json(body)
95            .send()
96            .await
97            .map_err(|e| Error::Http(e.to_string()))?;
98
99        if resp.status().is_success() {
100            resp.json::<T>().await.map_err(|e| Error::Http(e.to_string()))
101        } else {
102            let err: ApiErrorBody = resp.json().await.map_err(|e| Error::Http(e.to_string()))?;
103            Err(Error::Api { code: err.code, message: err.message })
104        }
105    }
106
107    /// Dispatch a PUT request with a JSON body and decode the JSON response.
108    async fn put<T, B>(&self, path: &str, body: &B) -> Result<T, Error>
109    where
110        T: serde::de::DeserializeOwned,
111        B: serde::Serialize + ?Sized,
112    {
113        let url = format!("{}{}", self.base_url, path);
114        let resp = self
115            .http
116            .put(&url)
117            .header("Authorization", format!("Bearer {}", self.api_key))
118            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
119            .json(body)
120            .send()
121            .await
122            .map_err(|e| Error::Http(e.to_string()))?;
123
124        if resp.status().is_success() {
125            resp.json::<T>().await.map_err(|e| Error::Http(e.to_string()))
126        } else {
127            let err: ApiErrorBody = resp.json().await.map_err(|e| Error::Http(e.to_string()))?;
128            Err(Error::Api { code: err.code, message: err.message })
129        }
130    }
131
132    // ── Wallet Sets ────────────────────────────────────────────────────────
133
134    /// Create a new developer-controlled wallet set.
135    pub async fn create_wallet_set(
136        &self,
137        req: &CreateWalletSetRequest,
138    ) -> Result<WalletSetResponse, Error> {
139        self.post("/v1/w3s/developer/walletSets", req).await
140    }
141
142    /// Get a wallet set by its UUID.
143    pub async fn get_wallet_set(&self, id: &str) -> Result<WalletSetResponse, Error> {
144        let path = format!("/v1/w3s/developer/walletSets/{}", id);
145        self.get(&path, &[("", "")][..0]).await
146    }
147
148    /// Update the name of a wallet set.
149    pub async fn update_wallet_set(
150        &self,
151        id: &str,
152        req: &UpdateWalletSetRequest,
153    ) -> Result<WalletSetResponse, Error> {
154        let path = format!("/v1/w3s/developer/walletSets/{}", id);
155        self.put(&path, req).await
156    }
157
158    /// List all wallet sets belonging to the entity.
159    pub async fn list_wallet_sets(
160        &self,
161        params: &ListWalletSetsParams,
162    ) -> Result<WalletSets, Error> {
163        self.get("/v1/w3s/walletSets", params).await
164    }
165
166    // ── Wallets ────────────────────────────────────────────────────────────
167
168    /// Create one or more developer-controlled wallets.
169    pub async fn create_wallets(&self, req: &CreateWalletsRequest) -> Result<Wallets, Error> {
170        self.post("/v1/w3s/developer/wallets", req).await
171    }
172
173    /// List wallets matching the given filters.
174    pub async fn list_wallets(&self, params: &ListWalletsParams) -> Result<Wallets, Error> {
175        self.get("/v1/w3s/wallets", params).await
176    }
177
178    /// Get a wallet by its UUID.
179    pub async fn get_wallet(&self, id: &str) -> Result<WalletResponse, Error> {
180        let path = format!("/v1/w3s/wallets/{}", id);
181        self.get(&path, &[("", "")][..0]).await
182    }
183
184    /// Update the name or reference ID of a wallet.
185    pub async fn update_wallet(
186        &self,
187        id: &str,
188        req: &UpdateWalletRequest,
189    ) -> Result<WalletResponse, Error> {
190        let path = format!("/v1/w3s/wallets/{}", id);
191        self.put(&path, req).await
192    }
193
194    /// List developer wallets with their token balances.
195    pub async fn list_wallet_balances(
196        &self,
197        params: &ListWalletBalancesParams,
198    ) -> Result<WalletsWithBalances, Error> {
199        self.get("/v1/w3s/developer/wallets/balances", params).await
200    }
201
202    /// Retrieve token balances for a single wallet by its UUID.
203    pub async fn list_wallet_token_balances(
204        &self,
205        wallet_id: &str,
206        params: &WalletNftsParams,
207    ) -> Result<Balances, Error> {
208        let path = format!("/v1/w3s/wallets/{}/balances", wallet_id);
209        self.get(&path, params).await
210    }
211
212    /// Retrieve NFTs held by a wallet by its UUID.
213    pub async fn list_wallet_nfts(
214        &self,
215        wallet_id: &str,
216        params: &ListWalletNftsParams,
217    ) -> Result<Nfts, Error> {
218        let path = format!("/v1/w3s/wallets/{}/nfts", wallet_id);
219        self.get(&path, params).await
220    }
221
222    // ── Signing ────────────────────────────────────────────────────────────
223
224    /// Sign a plain or hex-encoded message.
225    pub async fn sign_message(&self, req: &SignMessageRequest) -> Result<SignatureResponse, Error> {
226        self.post("/v1/w3s/developer/sign/message", req).await
227    }
228
229    /// Sign an EIP-712 typed data payload.
230    pub async fn sign_typed_data(
231        &self,
232        req: &SignTypedDataRequest,
233    ) -> Result<SignatureResponse, Error> {
234        self.post("/v1/w3s/developer/sign/typedData", req).await
235    }
236
237    /// Sign a raw transaction.
238    pub async fn sign_transaction(
239        &self,
240        req: &SignTransactionRequest,
241    ) -> Result<SignTransactionResponse, Error> {
242        self.post("/v1/w3s/developer/sign/transaction", req).await
243    }
244
245    // ── Transactions ───────────────────────────────────────────────────────
246
247    /// List transactions matching the given filters.
248    pub async fn list_transactions(
249        &self,
250        params: &ListTransactionsParams,
251    ) -> Result<Transactions, Error> {
252        self.get("/v1/w3s/transactions", params).await
253    }
254
255    /// Get a transaction by its UUID.
256    pub async fn get_transaction(&self, id: &str) -> Result<TransactionResponse, Error> {
257        let path = format!("/v1/w3s/transactions/{}", id);
258        self.get(&path, &[("", "")][..0]).await
259    }
260
261    /// Create a developer-controlled transfer transaction.
262    pub async fn create_transfer_transaction(
263        &self,
264        req: &CreateTransferTxRequest,
265    ) -> Result<TransactionResponse, Error> {
266        self.post("/v1/w3s/developer/transactions/transfer", req).await
267    }
268
269    /// Get fee parameters for a transfer.
270    pub async fn get_fee_parameters(
271        &self,
272        req: &CreateTransferTxRequest,
273    ) -> Result<EstimateFeeResponse, Error> {
274        self.post("/v1/w3s/developer/transactions/feeParameters", req).await
275    }
276
277    /// Create a developer-controlled contract execution transaction.
278    pub async fn create_contract_execution_transaction(
279        &self,
280        req: &CreateContractExecutionTxRequest,
281    ) -> Result<TransactionResponse, Error> {
282        self.post("/v1/w3s/developer/transactions/contractExecution", req).await
283    }
284
285    /// Cancel a stuck or queued transaction.
286    pub async fn cancel_transaction(
287        &self,
288        id: &str,
289        req: &CancelTxRequest,
290    ) -> Result<TransactionResponse, Error> {
291        let path = format!("/v1/w3s/developer/transactions/{}/cancel", id);
292        self.post(&path, req).await
293    }
294
295    /// Accelerate a stuck transaction by resubmitting with higher fees.
296    pub async fn accelerate_transaction(
297        &self,
298        id: &str,
299        req: &AccelerateTxRequest,
300    ) -> Result<TransactionResponse, Error> {
301        let path = format!("/v1/w3s/developer/transactions/{}/accelerate", id);
302        self.post(&path, req).await
303    }
304
305    // ── Tokens ─────────────────────────────────────────────────────────────
306
307    /// Get a token by its UUID.
308    pub async fn get_token(&self, id: &str) -> Result<TokenResponse, Error> {
309        let path = format!("/v1/w3s/tokens/{}", id);
310        self.get(&path, &[("", "")][..0]).await
311    }
312
313    // ── Utilities ──────────────────────────────────────────────────────────
314
315    /// Estimate fees for a transfer transaction.
316    pub async fn estimate_transfer_fee(
317        &self,
318        req: &EstimateTransferFeeRequest,
319    ) -> Result<EstimateFeeResponse, Error> {
320        self.post("/v1/w3s/transactions/transfer/estimateFee", req).await
321    }
322
323    /// Validate a blockchain address.
324    pub async fn validate_address(
325        &self,
326        req: &ValidateAddressRequest,
327    ) -> Result<ValidateAddressResponse, Error> {
328        self.post("/v1/w3s/transactions/validateAddress", req).await
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use crate::models::transaction::TransactionState;
335
336    /// Verify we can construct the types without panic (static / non-network tests).
337    #[test]
338    fn transaction_state_serde_roundtrip() -> Result<(), Box<dyn std::error::Error>> {
339        let s = serde_json::to_string(&TransactionState::Complete)?;
340        assert_eq!(s, "\"COMPLETE\"");
341        Ok(())
342    }
343
344    #[test]
345    fn cancel_tx_request_serializes() -> Result<(), Box<dyn std::error::Error>> {
346        let req = crate::models::transaction::CancelTxRequest {
347            entity_secret_ciphertext: "cipher".to_string(),
348        };
349        let json = serde_json::to_string(&req)?;
350        assert!(json.contains("entitySecretCiphertext"));
351        Ok(())
352    }
353
354    #[test]
355    fn accelerate_tx_request_serializes() -> Result<(), Box<dyn std::error::Error>> {
356        let req = crate::models::transaction::AccelerateTxRequest {
357            entity_secret_ciphertext: "cipher".to_string(),
358        };
359        let json = serde_json::to_string(&req)?;
360        assert!(json.contains("entitySecretCiphertext"));
361        Ok(())
362    }
363}