Skip to main content

circles_sdk/services/
referrals.rs

1use crate::core::Core;
2use alloy_primitives::{Address, keccak256};
3use circles_abis::ReferralsModule;
4use k256::{SecretKey, elliptic_curve::rand_core::OsRng, elliptic_curve::sec1::ToEncodedPoint};
5use reqwest::{Client, StatusCode, Url};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use thiserror::Error;
9
10/// Errors surfaced by the optional referrals backend client.
11#[derive(Debug, Error)]
12pub enum ReferralsError {
13    #[error("invalid referrals service url `{url}`: {reason}")]
14    InvalidUrl { url: String, reason: String },
15    #[error("referrals service url cannot be a base: {url}")]
16    CannotBeABase { url: String },
17    #[error("request failed: {0}")]
18    Http(#[from] reqwest::Error),
19    #[error("referrals store failed: {0}")]
20    StoreFailed(String),
21    #[error("referrals batch store failed: {0}")]
22    StoreBatchFailed(String),
23    #[error("failed to retrieve referral: {reason}")]
24    RetrieveFailed {
25        reason: String,
26        http_status: Option<StatusCode>,
27    },
28    #[error("failed to list referrals: {0}")]
29    ListFailed(String),
30    #[error("authentication required to list referrals")]
31    AuthRequired,
32    #[error("unexpected response format (status {status}): {body}")]
33    DecodeFailed { status: StatusCode, body: String },
34    #[error("invalid referral private key: {0}")]
35    InvalidPrivateKey(String),
36    #[error("referrals contract call failed: {0}")]
37    Contract(String),
38}
39
40/// Referral status lifecycle exposed by the referrals backend.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum ReferralStatus {
44    Pending,
45    Stale,
46    Confirmed,
47    Claimed,
48    Expired,
49}
50
51/// Referral info returned from the public retrieve endpoint.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ReferralInfo {
55    pub inviter: Option<String>,
56    pub status: Option<ReferralStatus>,
57    pub account_address: Option<String>,
58    pub error: Option<String>,
59}
60
61/// Distribution-session metadata attached to private referrals.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ReferralSession {
65    pub id: String,
66    pub slug: String,
67    pub label: Option<String>,
68}
69
70/// Full private referral record from the authenticated backend view.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct Referral {
74    pub id: String,
75    pub private_key: String,
76    pub status: ReferralStatus,
77    pub account_address: Option<String>,
78    pub created_at: String,
79    pub pending_at: String,
80    pub stale_at: Option<String>,
81    pub confirmed_at: Option<String>,
82    pub claimed_at: Option<String>,
83    pub sessions: Vec<ReferralSession>,
84}
85
86/// Paginated authenticated referral list.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct ReferralList {
90    pub referrals: Vec<Referral>,
91    pub count: u32,
92    pub total: u32,
93    pub limit: u32,
94    pub offset: u32,
95}
96
97/// Public masked referral preview.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct ReferralPreview {
101    pub id: String,
102    pub key_preview: String,
103    pub status: ReferralStatus,
104    pub account_address: Option<String>,
105    pub created_at: String,
106    pub pending_at: Option<String>,
107    pub stale_at: Option<String>,
108    pub confirmed_at: Option<String>,
109    pub claimed_at: Option<String>,
110    pub in_session: bool,
111}
112
113/// Cache freshness metadata for public preview queries.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum ReferralSyncStatus {
117    Synced,
118    Cached,
119}
120
121/// Paginated public referral preview list.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "camelCase")]
124pub struct ReferralPreviewList {
125    pub referrals: Vec<ReferralPreview>,
126    pub count: u32,
127    pub total: u32,
128    pub limit: u32,
129    pub offset: u32,
130    pub sync_status: ReferralSyncStatus,
131}
132
133/// Error payload returned by the referrals backend.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct ApiError {
136    pub error: String,
137}
138
139/// One item in the batch store request body.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "camelCase")]
142pub struct ReferralStoreInput {
143    pub private_key: String,
144    pub inviter: Address,
145}
146
147/// Error entry returned from the batch store endpoint.
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct StoreBatchError {
151    pub index: u32,
152    pub key_preview: String,
153    pub reason: String,
154}
155
156/// Batch store result returned by the referrals backend.
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct StoreBatchResult {
160    pub success: bool,
161    pub stored: u32,
162    pub failed: u32,
163    pub errors: Option<Vec<StoreBatchError>>,
164}
165
166/// Optional filters for authenticated `my-referrals` queries.
167#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct ReferralListMineOptions {
169    pub limit: Option<u32>,
170    pub offset: Option<u32>,
171    pub in_session: Option<bool>,
172    pub status: Option<String>,
173}
174
175/// Optional filters for public `list/{address}` queries.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct ReferralPublicListOptions {
178    pub limit: Option<u32>,
179    pub offset: Option<u32>,
180    pub in_session: Option<bool>,
181}
182
183/// Optional referrals backend client.
184#[derive(Clone)]
185pub struct Referrals {
186    base_url: Url,
187    client: Client,
188    core: Arc<Core>,
189}
190
191impl Referrals {
192    pub fn new(
193        referrals_service_url: impl AsRef<str>,
194        core: Arc<Core>,
195    ) -> Result<Self, ReferralsError> {
196        Self::with_client(referrals_service_url, core, Client::new())
197    }
198
199    pub fn with_client(
200        referrals_service_url: impl AsRef<str>,
201        core: Arc<Core>,
202        client: Client,
203    ) -> Result<Self, ReferralsError> {
204        let base_url = normalize_base_url(referrals_service_url.as_ref())?;
205        Ok(Self {
206            base_url,
207            client,
208            core,
209        })
210    }
211
212    pub async fn store(&self, private_key: &str, inviter: Address) -> Result<(), ReferralsError> {
213        let url = endpoint(&self.base_url, "store")?;
214        let response = self
215            .client
216            .post(url)
217            .json(&ReferralStoreInput {
218                private_key: private_key.to_owned(),
219                inviter,
220            })
221            .send()
222            .await?;
223        let status = response.status();
224        let body = response.text().await?;
225
226        if !status.is_success() {
227            return Err(ReferralsError::StoreFailed(api_reason(status, &body)));
228        }
229
230        Ok(())
231    }
232
233    pub async fn store_batch(
234        &self,
235        invitations: &[ReferralStoreInput],
236    ) -> Result<StoreBatchResult, ReferralsError> {
237        let url = endpoint(&self.base_url, "store-batch")?;
238        let response = self
239            .client
240            .post(url)
241            .json(&StoreBatchRequest { invitations })
242            .send()
243            .await?;
244        let status = response.status();
245        let body = response.text().await?;
246
247        if !status.is_success() {
248            return Err(ReferralsError::StoreBatchFailed(api_reason(status, &body)));
249        }
250
251        serde_json::from_str(&body).map_err(|_| ReferralsError::DecodeFailed { status, body })
252    }
253
254    pub async fn retrieve(&self, private_key: &str) -> Result<ReferralInfo, ReferralsError> {
255        let signer = private_key_to_address(private_key)?;
256        let ReferralsModule::accountsReturn { account, claimed } = self
257            .core
258            .referrals_module()
259            .accounts(signer)
260            .call()
261            .await
262            .map_err(|e| ReferralsError::Contract(e.to_string()))?;
263
264        let mut url = endpoint(&self.base_url, "retrieve")?;
265        url.query_pairs_mut().append_pair("key", private_key);
266
267        let response = self.client.get(url).send().await?;
268        let status = response.status();
269        let body = response.text().await?;
270
271        if status.is_success() || status == StatusCode::GONE || claimed {
272            let mut info: ReferralInfo =
273                serde_json::from_str(&body).map_err(|_| ReferralsError::DecodeFailed {
274                    status,
275                    body: body.clone(),
276                })?;
277            if account == Address::ZERO {
278                info = referral_not_found_info(signer, Some(info));
279            }
280            return Ok(info);
281        }
282
283        if account == Address::ZERO {
284            return Ok(referral_not_found_info(signer, None));
285        }
286
287        Err(ReferralsError::RetrieveFailed {
288            reason: api_reason(status, &body),
289            http_status: Some(status),
290        })
291    }
292
293    pub async fn list_mine(
294        &self,
295        auth_token: Option<&str>,
296        opts: Option<ReferralListMineOptions>,
297    ) -> Result<ReferralList, ReferralsError> {
298        let token = auth_token.ok_or(ReferralsError::AuthRequired)?;
299        let mut url = endpoint(&self.base_url, "my-referrals")?;
300        append_mine_query(&mut url, opts.as_ref());
301
302        let response = self.client.get(url).bearer_auth(token).send().await?;
303        let status = response.status();
304        let body = response.text().await?;
305
306        if !status.is_success() {
307            return Err(ReferralsError::ListFailed(api_reason(status, &body)));
308        }
309
310        serde_json::from_str(&body).map_err(|_| ReferralsError::DecodeFailed { status, body })
311    }
312
313    pub async fn list_public(
314        &self,
315        inviter: Address,
316        opts: Option<ReferralPublicListOptions>,
317    ) -> Result<ReferralPreviewList, ReferralsError> {
318        let url = public_list_url(&self.base_url, inviter, opts.as_ref())?;
319        let response = self.client.get(url).send().await?;
320        let status = response.status();
321        let body = response.text().await?;
322
323        if !status.is_success() {
324            return Err(ReferralsError::ListFailed(api_reason(status, &body)));
325        }
326
327        serde_json::from_str(&body).map_err(|_| ReferralsError::DecodeFailed { status, body })
328    }
329}
330
331pub fn generate_private_key() -> String {
332    let secret = SecretKey::random(&mut OsRng);
333    format!("0x{}", hex::encode(secret.to_bytes()))
334}
335
336pub fn private_key_to_address(private_key: &str) -> Result<Address, ReferralsError> {
337    let clean_key = private_key.strip_prefix("0x").unwrap_or(private_key);
338    let key_bytes =
339        hex::decode(clean_key).map_err(|err| ReferralsError::InvalidPrivateKey(err.to_string()))?;
340    let secret = SecretKey::from_slice(&key_bytes)
341        .map_err(|err| ReferralsError::InvalidPrivateKey(err.to_string()))?;
342    let public_key = secret.public_key();
343    let encoded = public_key.to_encoded_point(false);
344    let hash = keccak256(&encoded.as_bytes()[1..]);
345    Ok(Address::from_slice(&hash[12..]))
346}
347
348#[derive(Debug, Serialize)]
349struct StoreBatchRequest<'a> {
350    invitations: &'a [ReferralStoreInput],
351}
352
353fn endpoint(base: &Url, path: &str) -> Result<Url, ReferralsError> {
354    base.join(path)
355        .map_err(|source| ReferralsError::InvalidUrl {
356            url: format!("{base}{path}"),
357            reason: source.to_string(),
358        })
359}
360
361fn public_list_url(
362    base: &Url,
363    inviter: Address,
364    opts: Option<&ReferralPublicListOptions>,
365) -> Result<Url, ReferralsError> {
366    let mut url = endpoint(base, &format!("list/{inviter:#x}"))?;
367    append_public_query(&mut url, opts);
368    Ok(url)
369}
370
371fn append_mine_query(url: &mut Url, opts: Option<&ReferralListMineOptions>) {
372    let Some(opts) = opts else {
373        return;
374    };
375
376    let mut pairs = url.query_pairs_mut();
377    if let Some(limit) = opts.limit {
378        pairs.append_pair("limit", &limit.to_string());
379    }
380    if let Some(offset) = opts.offset {
381        pairs.append_pair("offset", &offset.to_string());
382    }
383    if let Some(in_session) = opts.in_session {
384        pairs.append_pair("inSession", if in_session { "true" } else { "false" });
385    }
386    if let Some(status) = opts.status.as_deref() {
387        pairs.append_pair("status", status);
388    }
389}
390
391fn append_public_query(url: &mut Url, opts: Option<&ReferralPublicListOptions>) {
392    let Some(opts) = opts else {
393        return;
394    };
395
396    let mut pairs = url.query_pairs_mut();
397    if let Some(limit) = opts.limit {
398        pairs.append_pair("limit", &limit.to_string());
399    }
400    if let Some(offset) = opts.offset {
401        pairs.append_pair("offset", &offset.to_string());
402    }
403    if let Some(in_session) = opts.in_session {
404        pairs.append_pair("inSession", if in_session { "true" } else { "false" });
405    }
406}
407
408fn api_reason(status: StatusCode, body: &str) -> String {
409    serde_json::from_str::<ApiError>(body)
410        .map(|err| err.error)
411        .unwrap_or_else(|_| {
412            if body.is_empty() {
413                status
414                    .canonical_reason()
415                    .unwrap_or("request failed")
416                    .to_string()
417            } else {
418                body.to_string()
419            }
420        })
421}
422
423fn normalize_base_url(raw: &str) -> Result<Url, ReferralsError> {
424    let mut url = Url::parse(raw).map_err(|source| ReferralsError::InvalidUrl {
425        url: raw.to_owned(),
426        reason: source.to_string(),
427    })?;
428
429    if !url.path().ends_with('/') {
430        url.path_segments_mut()
431            .map_err(|_| ReferralsError::CannotBeABase {
432                url: raw.to_owned(),
433            })?
434            .push("");
435    }
436
437    Ok(url)
438}
439
440fn referral_not_found_info(signer: Address, info: Option<ReferralInfo>) -> ReferralInfo {
441    let mut info = info.unwrap_or(ReferralInfo {
442        inviter: None,
443        status: None,
444        account_address: None,
445        error: None,
446    });
447    info.error = Some(format!(
448        "Referral not found on-chain for signer {signer:#x}"
449    ));
450    info
451}
452
453#[cfg(test)]
454mod tests {
455    use super::{
456        ReferralInfo, ReferralListMineOptions, ReferralPublicListOptions, api_reason,
457        normalize_base_url, private_key_to_address, referral_not_found_info,
458    };
459    use alloy_primitives::{Address, address};
460    use reqwest::StatusCode;
461
462    #[test]
463    fn ensures_trailing_slash() {
464        let normalized = normalize_base_url("https://example.com/api").unwrap();
465        assert_eq!(normalized.as_str(), "https://example.com/api/");
466    }
467
468    #[test]
469    fn keeps_existing_slash() {
470        let normalized = normalize_base_url("https://example.com/api/").unwrap();
471        assert_eq!(normalized.as_str(), "https://example.com/api/");
472    }
473
474    #[test]
475    fn private_key_to_address_matches_anvil_fixture() {
476        let signer = private_key_to_address(
477            "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
478        )
479        .unwrap();
480
481        assert_eq!(signer, address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"));
482    }
483
484    #[test]
485    fn referral_not_found_info_sets_ts_style_error() {
486        let signer = Address::repeat_byte(0x11);
487        let info = referral_not_found_info(
488            signer,
489            Some(ReferralInfo {
490                inviter: Some("0xabc".into()),
491                status: None,
492                account_address: None,
493                error: None,
494            }),
495        );
496
497        assert_eq!(info.inviter.as_deref(), Some("0xabc"));
498        assert_eq!(
499            info.error.as_deref(),
500            Some(
501                "Referral not found on-chain for signer 0x1111111111111111111111111111111111111111"
502            )
503        );
504    }
505
506    #[test]
507    fn api_reason_prefers_backend_error_field() {
508        assert_eq!(
509            api_reason(StatusCode::BAD_REQUEST, r#"{"error":"bad key"}"#),
510            "bad key"
511        );
512    }
513
514    #[test]
515    fn options_default_shapes_are_empty() {
516        let public = ReferralPublicListOptions::default();
517        let mine = ReferralListMineOptions::default();
518        assert!(public.limit.is_none());
519        assert!(mine.status.is_none());
520    }
521}