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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116pub enum ReferralSyncStatus {
117 Synced,
118 Cached,
119}
120
121#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct ApiError {
136 pub error: String,
137}
138
139#[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#[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#[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#[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#[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#[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}