Skip to main content

circles_sdk/
lib.rs

1//! Circles SDK orchestrating RPC, profile service access, pathfinding, transfers,
2//! and optional contract execution.
3//!
4//! This crate mirrors the high-level TypeScript SDK shape while keeping the Rust
5//! implementation read-first: most reads work with `Sdk::new(config, None)`, and
6//! write paths are gated behind a [`ContractRunner`].
7//!
8//! ## Quick Start
9//!
10//! ```rust,no_run
11//! use alloy_primitives::address;
12//! use circles_sdk::{Sdk, config};
13//!
14//! # #[tokio::main]
15//! # async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//! let sdk = Sdk::new(config::gnosis_mainnet(), None)?;
17//! let avatar = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
18//! let info = sdk.avatar_info(avatar).await?;
19//! println!("avatar type: {:?}", info.avatar_type);
20//!
21//! let typed = sdk.get_avatar(avatar).await?;
22//! match typed {
23//!     circles_sdk::Avatar::Human(human) => {
24//!         let balances = human.balances(false, true).await?;
25//!         println!("balances: {}", balances.len());
26//!     }
27//!     circles_sdk::Avatar::Organisation(_) | circles_sdk::Avatar::Group(_) => {}
28//! }
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! ## Usage Model
34//!
35//! - [`Sdk`] wires together RPC, profile lookups, pathfinding, transfers, and contract bindings.
36//! - [`Avatar`] gives you a typed wrapper after runtime avatar detection.
37//! - [`ContractRunner`] is only required for write paths such as registrations, trust changes,
38//!   and transfer submission.
39//! - [`SafeContractRunner`] and [`EoaContractRunner`] are the built-in execution backends for
40//!   existing single-owner Safe wallets and direct EOA execution, and now expose buffered batch,
41//!   gas-estimation, and read-call helper surface on the runner itself.
42//! - [`SafeExecutionBuilder`] is the browser/external-signature foundation for Safe-backed
43//!   flows: it prepares the canonical Safe payload/hash without requiring a local private key.
44//! - The optional `ws` feature enables WebSocket subscriptions with retry/backoff and HTTP catch-up helpers.
45//!
46//! ## Recommended Entry Points
47//!
48//! - [`config::gnosis_mainnet`] for the shared mainnet configuration.
49//! - [`Sdk::avatar_info`] for a fast read-only probe.
50//! - [`Sdk::get_avatar`] when you want a typed avatar wrapper.
51//! - [`EoaContractRunner::connect`] and [`SafeContractRunner::connect`] when you want built-in
52//!   execution backends for existing wallets.
53//! - [`SafeExecutionBuilder::connect`] when you need the TS-style Safe transaction-preparation
54//!   seam before an external/browser signer submits the transaction.
55//! - [`HumanAvatar::plan_transfer`], [`OrganisationAvatar::plan_transfer`], and
56//!   [`BaseGroupAvatar::plan_transfer`] for pathfinding-based transaction planning.
57//! - [`HumanAvatar::plan_direct_transfer`], [`OrganisationAvatar::plan_direct_transfer`], and
58//!   [`BaseGroupAvatar::plan_direct_transfer`] for TS-style direct-send planning.
59//! - [`HumanAvatar::plan_group_token_redeem`] and
60//!   [`OrganisationAvatar::plan_group_token_redeem`] for automatic group-token redeem planning.
61//! - [`HumanAvatar::available_invitations`], [`HumanAvatar::invitation_origin`],
62//!   [`HumanAvatar::proxy_inviters`], and [`HumanAvatar::find_farm_invite_path`] for the
63//!   current invitation/referral query surface.
64//! - [`HumanAvatar::plan_invite`] and [`HumanAvatar::invite`] for TS-style direct invite
65//!   planning/execution against existing Safe wallets.
66//! - [`Sdk::referrals`] and [`HumanAvatar::list_referrals`] for the optional referrals backend.
67//! - [`HumanAvatar::plan_referral_code`] and [`HumanAvatar::get_referral_code`] for the
68//!   TS-style single-referral planner used by `getReferralCode()`.
69//! - [`HumanAvatar::plan_generate_referrals`] and [`HumanAvatar::generate_referrals`] for
70//!   invitation-farm batch referral planning/execution.
71//!
72//! ## Validation
73//!
74//! - Unit tests: `cargo test -p circles-sdk`
75//! - WS helpers: `cargo test -p circles-sdk --features ws`
76//! - Live checks (ignored by default): `RUN_LIVE=1 LIVE_AVATAR=0x... cargo test -p circles-sdk -- --ignored`
77
78mod avatar;
79mod cid_v0_to_digest;
80pub mod config;
81mod core;
82mod runner;
83mod services;
84#[cfg(feature = "ws")]
85pub mod ws;
86pub use services::referrals::{
87    Referral, ReferralInfo, ReferralList, ReferralListMineOptions, ReferralPreview,
88    ReferralPreviewList, ReferralPublicListOptions, ReferralSession, ReferralStatus,
89    ReferralStoreInput, ReferralSyncStatus, Referrals, ReferralsError, StoreBatchError,
90    StoreBatchResult,
91};
92pub use services::registration;
93
94#[cfg(feature = "ws")]
95use alloy_json_rpc::RpcSend;
96use alloy_primitives::Address;
97pub use avatar::{BaseGroupAvatar, HumanAvatar, OrganisationAvatar};
98use circles_profiles::{Profile, Profiles};
99#[cfg(feature = "ws")]
100use circles_rpc::events::subscription::CirclesSubscription;
101use circles_rpc::{CirclesRpc, PagedQuery};
102#[cfg(feature = "ws")]
103use circles_types::CirclesEvent;
104use circles_types::{
105    AggregatedTrustRelation, AvatarInfo, AvatarType, CirclesConfig, GroupMembershipRow,
106    GroupTokenHolderRow, SortOrder, TokenBalanceResponse, TrustRelation,
107};
108use core::Core;
109pub use runner::{
110    BatchRun, ContractRunner, EoaContractRunner, PreparedSafeExecution, PreparedTransaction,
111    RunnerError, SafeContractRunner, SafeExecutionBuilder, SubmittedTx, call_to_tx,
112};
113#[cfg(feature = "ws")]
114use serde_json::to_value;
115use std::sync::Arc;
116use thiserror::Error;
117
118/// Generic registration outcome carrying submitted transactions and an optional avatar.
119///
120/// Registration helpers may return prepared txs without sending if no runner is provided.
121pub struct RegistrationResult<T> {
122    /// Best-effort typed avatar returned after registration succeeds.
123    pub avatar: Option<T>,
124    /// Submitted transactions returned by the runner.
125    pub txs: Vec<SubmittedTx>,
126}
127
128/// High-level SDK errors.
129#[derive(Debug, Error)]
130pub enum SdkError {
131    #[error("circles rpc error: {0}")]
132    Rpc(#[from] circles_rpc::CirclesRpcError),
133    #[error("profiles error: {0}")]
134    Profiles(#[from] circles_profiles::ProfilesError),
135    #[error("referrals error: {0}")]
136    Referrals(#[from] services::referrals::ReferralsError),
137    #[error("transfers error: {0}")]
138    Transfers(#[from] circles_transfers::TransferError),
139    #[error("runner error: {0}")]
140    Runner(#[from] RunnerError),
141    #[error("cid error: {0}")]
142    Cid(#[from] cid_v0_to_digest::CidError),
143    #[error("contract call error: {0}")]
144    Contract(String),
145    #[error("operation failed: {0}")]
146    OperationFailed(String),
147    #[error("contract runner is required for this operation")]
148    MissingRunner,
149    #[error("sender address is required for this operation")]
150    MissingSender,
151    #[error("avatar not found for address {0:?}")]
152    AvatarNotFound(Address),
153    #[error("invalid registration input: {0}")]
154    InvalidRegistration(String),
155    #[error("websocket subscription failed after {attempts} attempts: {reason}")]
156    WsSubscribeFailed { attempts: usize, reason: String },
157}
158
159/// Top-level SDK orchestrator.
160///
161/// Construct this once per config/runner pair and reuse it across read and write flows.
162pub struct Sdk {
163    pub(crate) config: CirclesConfig,
164    pub(crate) rpc: Arc<CirclesRpc>,
165    pub(crate) profiles: Profiles,
166    pub(crate) referrals: Option<Referrals>,
167    pub(crate) core: Arc<Core>,
168    pub(crate) runner: Option<Arc<dyn ContractRunner>>,
169    pub(crate) sender_address: Option<Address>,
170}
171
172impl Sdk {
173    /// Create a new SDK instance. Provide a runner for write operations; omit for read-only.
174    pub fn new(
175        config: CirclesConfig,
176        runner: Option<Arc<dyn ContractRunner>>,
177    ) -> Result<Self, SdkError> {
178        let sender_address = runner.as_ref().map(|r| r.sender_address());
179        let core = Arc::new(Core::new(config.clone()));
180        let rpc = Arc::new(CirclesRpc::try_from_http(&config.circles_rpc_url)?);
181        let profiles = Profiles::new(config.profile_service_url.clone())?;
182        let referrals = config
183            .referrals_service_url
184            .as_deref()
185            .map(|url| Referrals::new(url, core.clone()))
186            .transpose()?;
187        Ok(Self {
188            rpc,
189            profiles,
190            referrals,
191            config,
192            core,
193            runner,
194            sender_address,
195        })
196    }
197
198    /// Access the underlying RPC client.
199    pub fn rpc(&self) -> &CirclesRpc {
200        self.rpc.as_ref()
201    }
202
203    /// Access the loaded configuration.
204    pub fn config(&self) -> &CirclesConfig {
205        &self.config
206    }
207
208    /// Access core contract bundle.
209    pub fn core(&self) -> &Arc<Core> {
210        &self.core
211    }
212
213    /// Access the profiles client.
214    pub fn profiles(&self) -> &Profiles {
215        &self.profiles
216    }
217
218    /// Optional referrals client when `referrals_service_url` is configured.
219    pub fn referrals(&self) -> Option<&Referrals> {
220        self.referrals.as_ref()
221    }
222
223    /// Optional runner.
224    pub fn runner(&self) -> Option<&Arc<dyn ContractRunner>> {
225        self.runner.as_ref()
226    }
227
228    /// Sender address derived from the runner.
229    pub fn sender_address(&self) -> Option<Address> {
230        self.sender_address
231    }
232
233    /// Create and pin a profile via the profile service.
234    ///
235    /// This only talks to the profile service and does not submit any on-chain transaction.
236    pub async fn create_profile(&self, profile: &Profile) -> Result<String, SdkError> {
237        Ok(self.profiles.create(profile).await?)
238    }
239
240    /// Fetch a profile by CID (returns `Ok(None)` if missing or unparsable).
241    pub async fn get_profile(&self, cid: &str) -> Result<Option<Profile>, SdkError> {
242        Ok(self.profiles.get(cid).await?)
243    }
244
245    /// Read avatar metadata directly from the RPC service.
246    pub async fn data_avatar(&self, avatar: Address) -> Result<AvatarInfo, SdkError> {
247        Ok(self.rpc.avatar().get_avatar_info(avatar).await?)
248    }
249
250    /// Read trust relations for an avatar directly from the RPC service.
251    pub async fn data_trust(&self, avatar: Address) -> Result<Vec<TrustRelation>, SdkError> {
252        Ok(self.rpc.trust().get_trust_relations(avatar).await?)
253    }
254
255    /// Read aggregated trust relations for an avatar directly from the RPC service.
256    pub async fn data_trust_aggregated(
257        &self,
258        avatar: Address,
259    ) -> Result<Vec<AggregatedTrustRelation>, SdkError> {
260        Ok(self
261            .rpc
262            .trust()
263            .get_aggregated_trust_relations(avatar)
264            .await?)
265    }
266
267    /// Read token balances for an avatar directly from the RPC service.
268    ///
269    /// Set `as_time_circles` to request balances in time-Circles units and `use_v2`
270    /// to scope the query to v2 balances.
271    pub async fn data_balances(
272        &self,
273        avatar: Address,
274        as_time_circles: bool,
275        use_v2: bool,
276    ) -> Result<Vec<TokenBalanceResponse>, SdkError> {
277        Ok(self
278            .rpc
279            .token()
280            .get_token_balances(avatar, as_time_circles, use_v2)
281            .await?)
282    }
283
284    /// Get all members of a specific group via the shared paged query helper.
285    pub fn group_members(
286        &self,
287        group: Address,
288        limit: u32,
289        sort_order: SortOrder,
290    ) -> PagedQuery<GroupMembershipRow> {
291        self.rpc.group().get_group_members(group, limit, sort_order)
292    }
293
294    /// Get collateral balances held in a group's treasury.
295    pub async fn group_collateral(
296        &self,
297        group: Address,
298    ) -> Result<Vec<TokenBalanceResponse>, SdkError> {
299        let treasury = self
300            .core
301            .base_group(group)
302            .BASE_TREASURY()
303            .call()
304            .await
305            .map_err(|e| SdkError::Contract(e.to_string()))?;
306        Ok(self
307            .rpc
308            .token()
309            .get_token_balances(treasury, false, true)
310            .await?)
311    }
312
313    /// Get holders of a group token ordered like the TypeScript helper.
314    pub fn group_holders(&self, group: Address, limit: u32) -> PagedQuery<GroupTokenHolderRow> {
315        self.rpc.group().get_group_holders(group, limit)
316    }
317
318    /// Convenience accessor for avatar info (read-only).
319    pub async fn avatar_info(&self, avatar: Address) -> Result<AvatarInfo, SdkError> {
320        Ok(self.rpc.avatar().get_avatar_info(avatar).await?)
321    }
322
323    /// Subscribe to Circles events over WebSocket with a custom JSON-RPC filter payload.
324    #[cfg(feature = "ws")]
325    pub async fn subscribe_events_ws<F>(
326        &self,
327        ws_url: &str,
328        filter: F,
329    ) -> Result<CirclesSubscription<CirclesEvent>, SdkError>
330    where
331        F: RpcSend + 'static,
332    {
333        let val = to_value(&filter).map_err(|e| SdkError::WsSubscribeFailed {
334            attempts: 0,
335            reason: e.to_string(),
336        })?;
337        self.subscribe_events_ws_with_retries(ws_url, val, None)
338            .await
339    }
340
341    /// Subscribe with retry/backoff on WebSocket connection or subscription failure.
342    #[cfg(feature = "ws")]
343    pub async fn subscribe_events_ws_with_retries(
344        &self,
345        ws_url: &str,
346        filter: serde_json::Value,
347        max_attempts: Option<usize>,
348    ) -> Result<CirclesSubscription<CirclesEvent>, SdkError> {
349        ws::subscribe_with_retries(ws_url, filter, max_attempts).await
350    }
351
352    /// Subscribe with retry/backoff and optionally fetch historical events first over HTTP.
353    #[cfg(feature = "ws")]
354    pub async fn subscribe_events_ws_with_catchup(
355        &self,
356        ws_url: &str,
357        filter: serde_json::Value,
358        max_attempts: Option<usize>,
359        catch_up_from_block: Option<u64>,
360        catch_up_filter: Option<Vec<circles_types::Filter>>,
361    ) -> Result<(Vec<CirclesEvent>, CirclesSubscription<CirclesEvent>), SdkError> {
362        ws::subscribe_with_catchup(
363            self.rpc.as_ref(),
364            ws_url,
365            filter,
366            max_attempts,
367            catch_up_from_block,
368            catch_up_filter,
369            None,
370        )
371        .await
372    }
373
374    /// Fetch avatar info and return the matching typed avatar wrapper.
375    ///
376    /// Unknown or personal avatar types are treated as [`Avatar::Human`] to match the
377    /// current SDK behavior.
378    pub async fn get_avatar(&self, avatar: Address) -> Result<Avatar, SdkError> {
379        let info = self.rpc.avatar().get_avatar_info(avatar).await?;
380        Ok(match info.avatar_type {
381            AvatarType::CrcV2RegisterGroup => Avatar::Group(BaseGroupAvatar::new(
382                avatar,
383                info,
384                self.core.clone(),
385                self.profiles.clone(),
386                self.rpc.clone(),
387                self.runner.clone(),
388            )),
389            AvatarType::CrcV2RegisterOrganization => Avatar::Organisation(OrganisationAvatar::new(
390                avatar,
391                info,
392                self.core.clone(),
393                self.profiles.clone(),
394                self.rpc.clone(),
395                self.runner.clone(),
396            )),
397            _ => Avatar::Human(HumanAvatar::new(
398                avatar,
399                info,
400                self.core.clone(),
401                self.profiles.clone(),
402                self.rpc.clone(),
403                self.runner.clone(),
404            )),
405        })
406    }
407
408    /// Register a human avatar (profile is pinned before submission). Requires a runner.
409    pub async fn register_human(
410        &self,
411        inviter: Address,
412        profile: &Profile,
413    ) -> Result<RegistrationResult<HumanAvatar>, SdkError> {
414        registration::register_human(self, inviter, profile).await
415    }
416
417    /// Register an organisation avatar. Requires a runner.
418    pub async fn register_organisation(
419        &self,
420        name: &str,
421        profile: &Profile,
422    ) -> Result<RegistrationResult<OrganisationAvatar>, SdkError> {
423        registration::register_organisation(self, name, profile).await
424    }
425
426    /// Register a base group via the factory. Returns submitted txs and best-effort avatar.
427    #[allow(clippy::too_many_arguments)]
428    pub async fn register_group(
429        &self,
430        owner: Address,
431        service: Address,
432        fee_collection: Address,
433        initial_conditions: &[Address],
434        name: &str,
435        symbol: &str,
436        profile: &Profile,
437    ) -> Result<RegistrationResult<BaseGroupAvatar>, SdkError> {
438        registration::register_group(
439            self,
440            owner,
441            service,
442            fee_collection,
443            initial_conditions,
444            name,
445            symbol,
446            profile,
447        )
448        .await
449    }
450}
451
452/// Top-level avatar enum (human, organisation, group).
453pub enum Avatar {
454    /// Human or personal avatar wrapper.
455    Human(HumanAvatar),
456    /// Organisation avatar wrapper.
457    Organisation(OrganisationAvatar),
458    /// Base group avatar wrapper.
459    Group(BaseGroupAvatar),
460}