Skip to main content

circle_user_controlled_wallets/
client.rs

1//! HTTP client for the User-Controlled Wallets API.
2
3use crate::{
4    error::Error,
5    models::{
6        auth::{
7            DeviceTokenEmailRequest, DeviceTokenEmailResponse, DeviceTokenSocialRequest,
8            DeviceTokenSocialResponse, RefreshUserTokenRequest, RefreshUserTokenResponse,
9            ResendOtpRequest, ResendOtpResponse,
10        },
11        challenge::{
12            ChallengeIdResponse, ChallengeResponse, Challenges, SetPinAndInitWalletRequest,
13            SetPinRequest,
14        },
15        common::ApiErrorBody,
16        signing::{SignMessageRequest, SignTransactionRequest, SignTypedDataRequest},
17        transaction::{
18            AccelerateTxRequest, CancelTxRequest, CreateContractExecutionTxRequest,
19            CreateTransferTxRequest, CreateWalletUpgradeTxRequest, EstimateContractExecFeeRequest,
20            EstimateTransactionFee, EstimateTransferFeeRequest, GetLowestNonceTransactionResponse,
21            GetLowestNonceTxParams, ListTransactionsParams, TransactionResponse, Transactions,
22            ValidateAddressRequest, ValidateAddressResponse,
23        },
24        user::{
25            CreateUserRequest, GetUserByIdResponse, GetUserTokenRequest, ListUsersParams,
26            UserResponse, UserTokenResponse, Users,
27        },
28        wallet::{
29            Balances, CreateEndUserWalletRequest, ListWalletBalancesParams, ListWalletNftsParams,
30            ListWalletsParams, Nfts, TokenResponse, UpdateWalletRequest, WalletResponse, Wallets,
31        },
32    },
33};
34
35/// Async HTTP client for the Circle W3S User-Controlled Wallets API.
36pub struct UserWalletsClient {
37    /// Base URL for the Circle API (defaults to `https://api.circle.com`).
38    base_url: String,
39    /// API key used in `Authorization: Bearer` header.
40    api_key: String,
41    /// Underlying HTTP client.
42    http: hpx::Client,
43}
44
45impl std::fmt::Debug for UserWalletsClient {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.debug_struct("UserWalletsClient")
48            .field("base_url", &self.base_url)
49            .field("api_key", &"<redacted>")
50            .finish_non_exhaustive()
51    }
52}
53
54impl UserWalletsClient {
55    /// Creates a new client using the Circle production base URL.
56    pub fn new(api_key: impl Into<String>) -> Self {
57        Self::with_base_url(api_key, "https://api.circle.com")
58    }
59
60    /// Creates a new client with a custom base URL (useful for Prism mock servers).
61    pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
62        Self { base_url: base_url.into(), api_key: api_key.into(), http: hpx::Client::new() }
63    }
64
65    // ── Private HTTP helpers ──────────────────────────────────────────────
66
67    /// Authenticated GET request, no user token.
68    async fn get<T, P>(&self, path: &str, params: &P) -> Result<T, Error>
69    where
70        T: serde::de::DeserializeOwned,
71        P: serde::Serialize + ?Sized,
72    {
73        let url = format!("{}{}", self.base_url, path);
74        let resp = self
75            .http
76            .get(&url)
77            .header("Authorization", format!("Bearer {}", self.api_key))
78            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
79            .query(params)
80            .send()
81            .await
82            .map_err(|e| Error::Http(e.to_string()))?;
83
84        Self::decode(resp).await
85    }
86
87    /// Authenticated POST request, no user token.
88    async fn post<T, B>(&self, path: &str, body: &B) -> Result<T, Error>
89    where
90        T: serde::de::DeserializeOwned,
91        B: serde::Serialize + ?Sized,
92    {
93        let url = format!("{}{}", self.base_url, path);
94        let resp = self
95            .http
96            .post(&url)
97            .header("Authorization", format!("Bearer {}", self.api_key))
98            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
99            .json(body)
100            .send()
101            .await
102            .map_err(|e| Error::Http(e.to_string()))?;
103
104        Self::decode(resp).await
105    }
106
107    /// Authenticated PUT request, no user token.
108    #[expect(dead_code)]
109    async fn put<T, B>(&self, path: &str, body: &B) -> Result<T, Error>
110    where
111        T: serde::de::DeserializeOwned,
112        B: serde::Serialize + ?Sized,
113    {
114        let url = format!("{}{}", self.base_url, path);
115        let resp = self
116            .http
117            .put(&url)
118            .header("Authorization", format!("Bearer {}", self.api_key))
119            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
120            .json(body)
121            .send()
122            .await
123            .map_err(|e| Error::Http(e.to_string()))?;
124
125        Self::decode(resp).await
126    }
127
128    /// Authenticated GET request with an additional `X-User-Token` header.
129    async fn get_with_user_token<T, P>(
130        &self,
131        path: &str,
132        params: &P,
133        user_token: &str,
134    ) -> Result<T, Error>
135    where
136        T: serde::de::DeserializeOwned,
137        P: serde::Serialize + ?Sized,
138    {
139        let url = format!("{}{}", self.base_url, path);
140        let resp = self
141            .http
142            .get(&url)
143            .header("Authorization", format!("Bearer {}", self.api_key))
144            .header("X-User-Token", user_token)
145            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
146            .query(params)
147            .send()
148            .await
149            .map_err(|e| Error::Http(e.to_string()))?;
150
151        Self::decode(resp).await
152    }
153
154    /// Authenticated POST request with an additional `X-User-Token` header.
155    async fn post_with_user_token<T, B>(
156        &self,
157        path: &str,
158        body: &B,
159        user_token: &str,
160    ) -> Result<T, Error>
161    where
162        T: serde::de::DeserializeOwned,
163        B: serde::Serialize + ?Sized,
164    {
165        let url = format!("{}{}", self.base_url, path);
166        let resp = self
167            .http
168            .post(&url)
169            .header("Authorization", format!("Bearer {}", self.api_key))
170            .header("X-User-Token", user_token)
171            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
172            .json(body)
173            .send()
174            .await
175            .map_err(|e| Error::Http(e.to_string()))?;
176
177        Self::decode(resp).await
178    }
179
180    /// Authenticated PUT request with an additional `X-User-Token` header.
181    async fn put_with_user_token<T, B>(
182        &self,
183        path: &str,
184        body: &B,
185        user_token: &str,
186    ) -> Result<T, Error>
187    where
188        T: serde::de::DeserializeOwned,
189        B: serde::Serialize + ?Sized,
190    {
191        let url = format!("{}{}", self.base_url, path);
192        let resp = self
193            .http
194            .put(&url)
195            .header("Authorization", format!("Bearer {}", self.api_key))
196            .header("X-User-Token", user_token)
197            .header("X-Request-Id", uuid::Uuid::new_v4().to_string())
198            .json(body)
199            .send()
200            .await
201            .map_err(|e| Error::Http(e.to_string()))?;
202
203        Self::decode(resp).await
204    }
205
206    /// Decode a response: if 2xx parse as `T`, otherwise parse as [`ApiErrorBody`].
207    async fn decode<T: serde::de::DeserializeOwned>(resp: hpx::Response) -> Result<T, Error> {
208        if resp.status().is_success() {
209            resp.json::<T>().await.map_err(|e| Error::Http(e.to_string()))
210        } else {
211            let err: ApiErrorBody = resp.json().await.map_err(|e| Error::Http(e.to_string()))?;
212            Err(Error::Api { code: err.code, message: err.message })
213        }
214    }
215
216    // ── User Management ───────────────────────────────────────────────────
217
218    /// Register a new end-user.
219    ///
220    /// `POST /v1/w3s/users`
221    pub async fn create_user(&self, req: &CreateUserRequest) -> Result<UserResponse, Error> {
222        self.post("/v1/w3s/users", req).await
223    }
224
225    /// List all end-users with optional filtering and pagination.
226    ///
227    /// `GET /v1/w3s/users`
228    pub async fn list_users(&self, params: &ListUsersParams) -> Result<Users, Error> {
229        self.get("/v1/w3s/users", params).await
230    }
231
232    /// Retrieve an end-user by their Circle user ID.
233    ///
234    /// `GET /v1/w3s/users/{id}`
235    pub async fn get_user(&self, id: &str) -> Result<GetUserByIdResponse, Error> {
236        let path = format!("/v1/w3s/users/{id}");
237        self.get(&path, &[("", "")][..0]).await
238    }
239
240    /// Obtain a short-lived user token for the given user ID.
241    ///
242    /// `POST /v1/w3s/users/token`
243    pub async fn get_user_token(
244        &self,
245        req: &GetUserTokenRequest,
246    ) -> Result<UserTokenResponse, Error> {
247        self.post("/v1/w3s/users/token", req).await
248    }
249
250    // ── Session Auth ──────────────────────────────────────────────────────
251
252    /// Obtain a device token for social sign-in flows.
253    ///
254    /// `POST /v1/w3s/users/social/token`
255    pub async fn get_device_token_social(
256        &self,
257        req: &DeviceTokenSocialRequest,
258    ) -> Result<DeviceTokenSocialResponse, Error> {
259        self.post("/v1/w3s/users/social/token", req).await
260    }
261
262    /// Obtain a device token and OTP for email sign-in flows.
263    ///
264    /// `POST /v1/w3s/users/email/token`
265    pub async fn get_device_token_email(
266        &self,
267        req: &DeviceTokenEmailRequest,
268    ) -> Result<DeviceTokenEmailResponse, Error> {
269        self.post("/v1/w3s/users/email/token", req).await
270    }
271
272    /// Refresh a user token using a refresh token.
273    ///
274    /// `POST /v1/w3s/users/token/refresh`
275    pub async fn refresh_user_token(
276        &self,
277        user_token: &str,
278        req: &RefreshUserTokenRequest,
279    ) -> Result<RefreshUserTokenResponse, Error> {
280        self.post_with_user_token("/v1/w3s/users/token/refresh", req, user_token).await
281    }
282
283    /// Resend a one-time password to the user's email.
284    ///
285    /// `POST /v1/w3s/users/email/resendOTP`
286    pub async fn resend_otp(
287        &self,
288        user_token: &str,
289        req: &ResendOtpRequest,
290    ) -> Result<ResendOtpResponse, Error> {
291        self.post_with_user_token("/v1/w3s/users/email/resendOTP", req, user_token).await
292    }
293
294    // ── User-token endpoint ───────────────────────────────────────────────
295
296    /// Retrieve the end-user record associated with the supplied user token.
297    ///
298    /// `GET /v1/w3s/user`
299    pub async fn get_user_by_token(&self, user_token: &str) -> Result<UserResponse, Error> {
300        self.get_with_user_token("/v1/w3s/user", &[("", "")][..0], user_token).await
301    }
302
303    // ── PIN Challenges ────────────────────────────────────────────────────
304
305    /// Initialize a user's PIN and optionally create wallets in a single challenge.
306    ///
307    /// `POST /v1/w3s/user/initialize`
308    pub async fn initialize_user(
309        &self,
310        user_token: &str,
311        req: &SetPinAndInitWalletRequest,
312    ) -> Result<ChallengeIdResponse, Error> {
313        self.post_with_user_token("/v1/w3s/user/initialize", req, user_token).await
314    }
315
316    /// Create a challenge for the user to set their PIN.
317    ///
318    /// `POST /v1/w3s/user/pin`
319    pub async fn create_pin_challenge(
320        &self,
321        user_token: &str,
322        req: &SetPinRequest,
323    ) -> Result<ChallengeIdResponse, Error> {
324        self.post_with_user_token("/v1/w3s/user/pin", req, user_token).await
325    }
326
327    /// Create a challenge for the user to update their PIN.
328    ///
329    /// `PUT /v1/w3s/user/pin`
330    pub async fn update_pin_challenge(
331        &self,
332        user_token: &str,
333        req: &SetPinRequest,
334    ) -> Result<ChallengeIdResponse, Error> {
335        self.put_with_user_token("/v1/w3s/user/pin", req, user_token).await
336    }
337
338    /// Create a challenge for the user to restore a locked PIN.
339    ///
340    /// `POST /v1/w3s/user/pin/restore`
341    pub async fn restore_pin_challenge(
342        &self,
343        user_token: &str,
344        req: &SetPinRequest,
345    ) -> Result<ChallengeIdResponse, Error> {
346        self.post_with_user_token("/v1/w3s/user/pin/restore", req, user_token).await
347    }
348
349    /// List challenges for the authenticated user.
350    ///
351    /// `GET /v1/w3s/user/challenges`
352    pub async fn list_challenges(&self, user_token: &str) -> Result<Challenges, Error> {
353        self.get_with_user_token("/v1/w3s/user/challenges", &[("", "")][..0], user_token).await
354    }
355
356    /// Retrieve a single challenge by its ID.
357    ///
358    /// `GET /v1/w3s/user/challenges/{id}`
359    pub async fn get_challenge(
360        &self,
361        user_token: &str,
362        id: &str,
363    ) -> Result<ChallengeResponse, Error> {
364        let path = format!("/v1/w3s/user/challenges/{id}");
365        self.get_with_user_token(&path, &[("", "")][..0], user_token).await
366    }
367
368    // ── Wallets ───────────────────────────────────────────────────────────
369
370    /// Create new wallet(s) for the authenticated user.
371    ///
372    /// Returns a `challengeId` — the user must complete the challenge in the
373    /// mobile SDK to finalise wallet creation.
374    ///
375    /// `POST /v1/w3s/user/wallets`
376    pub async fn create_wallet(
377        &self,
378        user_token: &str,
379        req: &CreateEndUserWalletRequest,
380    ) -> Result<ChallengeIdResponse, Error> {
381        self.post_with_user_token("/v1/w3s/user/wallets", req, user_token).await
382    }
383
384    /// List wallets accessible to the authenticated user.
385    ///
386    /// `GET /v1/w3s/wallets`
387    pub async fn list_wallets(
388        &self,
389        user_token: &str,
390        params: &ListWalletsParams,
391    ) -> Result<Wallets, Error> {
392        self.get_with_user_token("/v1/w3s/wallets", params, user_token).await
393    }
394
395    /// Retrieve a single wallet by its ID.
396    ///
397    /// `GET /v1/w3s/wallets/{id}`
398    pub async fn get_wallet(&self, user_token: &str, id: &str) -> Result<WalletResponse, Error> {
399        let path = format!("/v1/w3s/wallets/{id}");
400        self.get_with_user_token(&path, &[("", "")][..0], user_token).await
401    }
402
403    /// Update the name or reference ID of a wallet.
404    ///
405    /// `PUT /v1/w3s/wallets/{id}`
406    pub async fn update_wallet(
407        &self,
408        user_token: &str,
409        id: &str,
410        req: &UpdateWalletRequest,
411    ) -> Result<WalletResponse, Error> {
412        let path = format!("/v1/w3s/wallets/{id}");
413        self.put_with_user_token(&path, req, user_token).await
414    }
415
416    /// List token balances for a wallet.
417    ///
418    /// `GET /v1/w3s/wallets/{id}/balances`
419    pub async fn list_wallet_balances(
420        &self,
421        user_token: &str,
422        wallet_id: &str,
423        params: &ListWalletBalancesParams,
424    ) -> Result<Balances, Error> {
425        let path = format!("/v1/w3s/wallets/{wallet_id}/balances");
426        self.get_with_user_token(&path, params, user_token).await
427    }
428
429    /// List NFTs held by a wallet.
430    ///
431    /// `GET /v1/w3s/wallets/{id}/nfts`
432    pub async fn list_wallet_nfts(
433        &self,
434        user_token: &str,
435        wallet_id: &str,
436        params: &ListWalletNftsParams,
437    ) -> Result<Nfts, Error> {
438        let path = format!("/v1/w3s/wallets/{wallet_id}/nfts");
439        self.get_with_user_token(&path, params, user_token).await
440    }
441
442    // ── Transactions ──────────────────────────────────────────────────────
443
444    /// Initiate a token transfer transaction (returns a challengeId).
445    ///
446    /// `POST /v1/w3s/user/transactions/transfer`
447    pub async fn create_transfer_transaction(
448        &self,
449        user_token: &str,
450        req: &CreateTransferTxRequest,
451    ) -> Result<ChallengeIdResponse, Error> {
452        self.post_with_user_token("/v1/w3s/user/transactions/transfer", req, user_token).await
453    }
454
455    /// Accelerate a stuck transaction (returns a challengeId).
456    ///
457    /// `POST /v1/w3s/user/transactions/{id}/accelerate`
458    pub async fn accelerate_transaction(
459        &self,
460        user_token: &str,
461        id: &str,
462        req: &AccelerateTxRequest,
463    ) -> Result<ChallengeIdResponse, Error> {
464        let path = format!("/v1/w3s/user/transactions/{id}/accelerate");
465        self.post_with_user_token(&path, req, user_token).await
466    }
467
468    /// Cancel a pending transaction (returns a challengeId).
469    ///
470    /// `POST /v1/w3s/user/transactions/{id}/cancel`
471    pub async fn cancel_transaction(
472        &self,
473        user_token: &str,
474        id: &str,
475        req: &CancelTxRequest,
476    ) -> Result<ChallengeIdResponse, Error> {
477        let path = format!("/v1/w3s/user/transactions/{id}/cancel");
478        self.post_with_user_token(&path, req, user_token).await
479    }
480
481    /// Initiate a smart-contract execution transaction (returns a challengeId).
482    ///
483    /// `POST /v1/w3s/user/transactions/contractExecution`
484    pub async fn create_contract_execution_transaction(
485        &self,
486        user_token: &str,
487        req: &CreateContractExecutionTxRequest,
488    ) -> Result<ChallengeIdResponse, Error> {
489        self.post_with_user_token("/v1/w3s/user/transactions/contractExecution", req, user_token)
490            .await
491    }
492
493    /// Initiate a wallet-upgrade transaction (returns a challengeId).
494    ///
495    /// `POST /v1/w3s/user/transactions/walletUpgrade`
496    pub async fn create_wallet_upgrade_transaction(
497        &self,
498        user_token: &str,
499        req: &CreateWalletUpgradeTxRequest,
500    ) -> Result<ChallengeIdResponse, Error> {
501        self.post_with_user_token("/v1/w3s/user/transactions/walletUpgrade", req, user_token).await
502    }
503
504    /// List transactions visible to the authenticated user.
505    ///
506    /// `GET /v1/w3s/transactions`
507    pub async fn list_transactions(
508        &self,
509        user_token: &str,
510        params: &ListTransactionsParams,
511    ) -> Result<Transactions, Error> {
512        self.get_with_user_token("/v1/w3s/transactions", params, user_token).await
513    }
514
515    /// Retrieve a single transaction by its ID.
516    ///
517    /// `GET /v1/w3s/transactions/{id}`
518    pub async fn get_transaction(
519        &self,
520        user_token: &str,
521        id: &str,
522    ) -> Result<TransactionResponse, Error> {
523        let path = format!("/v1/w3s/transactions/{id}");
524        self.get_with_user_token(&path, &[("", "")][..0], user_token).await
525    }
526
527    /// Retrieve the transaction with the lowest pending nonce for an address.
528    ///
529    /// `GET /v1/w3s/transactions/lowestNonceTransaction`
530    pub async fn get_lowest_nonce_transaction(
531        &self,
532        params: &GetLowestNonceTxParams,
533    ) -> Result<GetLowestNonceTransactionResponse, Error> {
534        self.get("/v1/w3s/transactions/lowestNonceTransaction", params).await
535    }
536
537    /// Estimate transfer transaction fees.
538    ///
539    /// `POST /v1/w3s/transactions/transfer/estimateFee`
540    pub async fn estimate_transfer_fee(
541        &self,
542        user_token: &str,
543        req: &EstimateTransferFeeRequest,
544    ) -> Result<EstimateTransactionFee, Error> {
545        self.post_with_user_token("/v1/w3s/transactions/transfer/estimateFee", req, user_token)
546            .await
547    }
548
549    /// Estimate contract execution transaction fees.
550    ///
551    /// `POST /v1/w3s/transactions/contractExecution/estimateFee`
552    pub async fn estimate_contract_execution_fee(
553        &self,
554        user_token: &str,
555        req: &EstimateContractExecFeeRequest,
556    ) -> Result<EstimateTransactionFee, Error> {
557        self.post_with_user_token(
558            "/v1/w3s/transactions/contractExecution/estimateFee",
559            req,
560            user_token,
561        )
562        .await
563    }
564
565    /// Validate that an address is valid for a given blockchain.
566    ///
567    /// `POST /v1/w3s/transactions/validateAddress`
568    pub async fn validate_address(
569        &self,
570        req: &ValidateAddressRequest,
571    ) -> Result<ValidateAddressResponse, Error> {
572        self.post("/v1/w3s/transactions/validateAddress", req).await
573    }
574
575    // ── Token ─────────────────────────────────────────────────────────────
576
577    /// Retrieve token metadata by its Circle token ID.
578    ///
579    /// `GET /v1/w3s/tokens/{id}`
580    pub async fn get_token(&self, id: &str) -> Result<TokenResponse, Error> {
581        let path = format!("/v1/w3s/tokens/{id}");
582        self.get(&path, &[("", "")][..0]).await
583    }
584
585    // ── Signing ───────────────────────────────────────────────────────────
586
587    /// Request a message signing challenge (returns a challengeId).
588    ///
589    /// `POST /v1/w3s/user/sign/message`
590    pub async fn sign_message(
591        &self,
592        user_token: &str,
593        req: &SignMessageRequest,
594    ) -> Result<ChallengeIdResponse, Error> {
595        self.post_with_user_token("/v1/w3s/user/sign/message", req, user_token).await
596    }
597
598    /// Request an EIP-712 typed data signing challenge (returns a challengeId).
599    ///
600    /// `POST /v1/w3s/user/sign/typedData`
601    pub async fn sign_typed_data(
602        &self,
603        user_token: &str,
604        req: &SignTypedDataRequest,
605    ) -> Result<ChallengeIdResponse, Error> {
606        self.post_with_user_token("/v1/w3s/user/sign/typedData", req, user_token).await
607    }
608
609    /// Request a raw transaction signing challenge (returns a challengeId).
610    ///
611    /// `POST /v1/w3s/user/sign/transaction`
612    pub async fn sign_transaction(
613        &self,
614        user_token: &str,
615        req: &SignTransactionRequest,
616    ) -> Result<ChallengeIdResponse, Error> {
617        self.post_with_user_token("/v1/w3s/user/sign/transaction", req, user_token).await
618    }
619}