Skip to main content

circles_sdk/avatar/
base_group.rs

1use crate::avatar::common::CommonAvatar;
2use crate::cid_v0_to_digest::cid_v0_to_digest;
3use crate::{
4    ContractRunner, Core, PreparedTransaction, Profile, SdkError, SubmittedTx, call_to_tx,
5};
6use alloy_primitives::{Address, Bytes, U256, aliases::U96};
7use circles_abis::BaseGroup;
8use circles_profiles::Profiles;
9#[cfg(feature = "ws")]
10use circles_rpc::events::subscription::CirclesSubscription;
11use circles_rpc::{CirclesRpc, PagedQuery};
12#[cfg(feature = "ws")]
13use circles_types::CirclesEvent;
14use circles_types::{
15    AdvancedTransferOptions, AggregatedTrustRelation, AvatarInfo, Balance, PathfindingResult,
16    SortOrder, TokenBalanceResponse, TransactionHistoryRow, TrustRelation,
17};
18use std::sync::Arc;
19
20/// Top-level avatar enum variant: base group.
21pub struct BaseGroupAvatar {
22    /// Avatar address on-chain.
23    pub address: Address,
24    /// RPC-derived avatar metadata.
25    pub info: AvatarInfo,
26    /// Shared contract bundle and configuration.
27    pub core: Arc<Core>,
28    /// Optional runner used for write-capable flows.
29    pub runner: Option<Arc<dyn ContractRunner>>,
30    /// Shared read/write helper implementation.
31    pub common: CommonAvatar,
32}
33
34impl BaseGroupAvatar {
35    /// Get detailed token balances (v1/v2 selectable).
36    pub async fn balances(
37        &self,
38        as_time_circles: bool,
39        use_v2: bool,
40    ) -> Result<Vec<TokenBalanceResponse>, SdkError> {
41        self.common.balances(as_time_circles, use_v2).await
42    }
43
44    /// Get aggregate balance (v1/v2 selectable).
45    pub async fn total_balance(
46        &self,
47        as_time_circles: bool,
48        use_v2: bool,
49    ) -> Result<Balance, SdkError> {
50        self.common.total_balance(as_time_circles, use_v2).await
51    }
52
53    /// Get the total supply of this group's token.
54    pub async fn total_supply(&self) -> Result<U256, SdkError> {
55        let token_id = self
56            .core
57            .hub_v2()
58            .toTokenId(self.address)
59            .call()
60            .await
61            .map_err(|e| SdkError::Contract(e.to_string()))?;
62        self.core
63            .hub_v2()
64            .totalSupply(token_id)
65            .call()
66            .await
67            .map_err(|e| SdkError::Contract(e.to_string()))
68    }
69
70    /// Get trust relations.
71    pub async fn trust_relations(&self) -> Result<Vec<TrustRelation>, SdkError> {
72        self.common.trust_relations().await
73    }
74
75    /// Get aggregated trust relations.
76    pub async fn aggregated_trust_relations(
77        &self,
78    ) -> Result<Vec<AggregatedTrustRelation>, SdkError> {
79        self.common.aggregated_trust_relations().await
80    }
81
82    /// Get outgoing trust relations only.
83    pub async fn trusts(&self) -> Result<Vec<AggregatedTrustRelation>, SdkError> {
84        self.common.trusts().await
85    }
86
87    /// Get incoming trust relations only.
88    pub async fn trusted_by(&self) -> Result<Vec<AggregatedTrustRelation>, SdkError> {
89        self.common.trusted_by().await
90    }
91
92    /// Get mutual trust relations only.
93    pub async fn mutual_trusts(&self) -> Result<Vec<AggregatedTrustRelation>, SdkError> {
94        self.common.mutual_trusts().await
95    }
96
97    /// Check whether this avatar trusts `other_avatar`.
98    pub async fn is_trusting(&self, other_avatar: Address) -> Result<bool, SdkError> {
99        self.common.is_trusting(other_avatar).await
100    }
101
102    /// Check whether `other_avatar` trusts this avatar.
103    pub async fn is_trusted_by(&self, other_avatar: Address) -> Result<bool, SdkError> {
104        self.common.is_trusted_by(other_avatar).await
105    }
106
107    /// Get the group owner address.
108    pub async fn owner(&self) -> Result<Address, SdkError> {
109        self.core
110            .base_group(self.address)
111            .owner()
112            .call()
113            .await
114            .map_err(|e| SdkError::Contract(e.to_string()))
115    }
116
117    /// Get the mint handler address.
118    pub async fn mint_handler(&self) -> Result<Address, SdkError> {
119        self.core
120            .base_group(self.address)
121            .BASE_MINT_HANDLER()
122            .call()
123            .await
124            .map_err(|e| SdkError::Contract(e.to_string()))
125    }
126
127    /// Get the service address.
128    pub async fn service(&self) -> Result<Address, SdkError> {
129        self.core
130            .base_group(self.address)
131            .service()
132            .call()
133            .await
134            .map_err(|e| SdkError::Contract(e.to_string()))
135    }
136
137    /// Get the fee collection address.
138    pub async fn fee_collection(&self) -> Result<Address, SdkError> {
139        self.core
140            .base_group(self.address)
141            .feeCollection()
142            .call()
143            .await
144            .map_err(|e| SdkError::Contract(e.to_string()))
145    }
146
147    /// Get all membership conditions.
148    pub async fn membership_conditions(&self) -> Result<Vec<Address>, SdkError> {
149        self.core
150            .base_group(self.address)
151            .getMembershipConditions()
152            .call()
153            .await
154            .map_err(|e| SdkError::Contract(e.to_string()))
155    }
156
157    /// Fetch profile (cached by CID in memory).
158    pub async fn profile(&self) -> Result<Option<Profile>, SdkError> {
159        self.common.profile(self.info.cid_v0.as_deref()).await
160    }
161
162    /// Get transaction history for this avatar using cursor-based pagination.
163    pub fn transaction_history(
164        &self,
165        limit: u32,
166        sort_order: SortOrder,
167    ) -> PagedQuery<TransactionHistoryRow> {
168        self.common.transaction_history(limit, sort_order)
169    }
170
171    /// Update profile metadata digest on the base group (requires runner).
172    pub async fn update_profile(&self, profile: &Profile) -> Result<Vec<SubmittedTx>, SdkError> {
173        let cid = self.common.pin_profile(profile).await?;
174        self.update_profile_metadata(&cid).await
175    }
176
177    /// Update the on-chain profile CID pointer through the BaseGroup contract (requires runner).
178    pub async fn update_profile_metadata(&self, cid: &str) -> Result<Vec<SubmittedTx>, SdkError> {
179        let digest = cid_v0_to_digest(cid)?;
180        let call = circles_abis::BaseGroup::updateMetadataDigestCall {
181            _metadataDigest: digest,
182        };
183        let tx = call_to_tx(self.address, call, None);
184        self.common.send(vec![tx]).await
185    }
186
187    /// Register a short name using a specific nonce (requires runner).
188    pub async fn register_short_name(&self, nonce: u64) -> Result<Vec<SubmittedTx>, SdkError> {
189        let call = circles_abis::BaseGroup::registerShortNameWithNonceCall {
190            _nonce: U256::from(nonce),
191        };
192        let tx = call_to_tx(self.address, call, None);
193        self.common.send(vec![tx]).await
194    }
195
196    /// Trust one or more avatars via BaseGroup::trust (requires runner).
197    pub async fn trust_add(
198        &self,
199        avatars: &[Address],
200        expiry: u128,
201    ) -> Result<Vec<SubmittedTx>, SdkError> {
202        let runner = self.runner.clone().ok_or(SdkError::MissingRunner)?;
203        let txs = avatars
204            .iter()
205            .map(|addr| BaseGroup::trustCall {
206                _trustReceiver: *addr,
207                _expiry: U96::from(expiry),
208            })
209            .map(|call| call_to_tx(self.address, call, None))
210            .collect();
211        Ok(runner.send_transactions(txs).await?)
212    }
213
214    /// Remove trust (sets expiry to 0). Requires runner.
215    pub async fn trust_remove(&self, avatars: &[Address]) -> Result<Vec<SubmittedTx>, SdkError> {
216        self.trust_add(avatars, 0).await
217    }
218
219    /// Trust a batch of members with membership condition checks (requires runner).
220    pub async fn trust_add_batch_with_conditions(
221        &self,
222        avatars: &[Address],
223        expiry: u128,
224    ) -> Result<Vec<SubmittedTx>, SdkError> {
225        let call = BaseGroup::trustBatchWithConditionsCall {
226            _members: avatars.to_vec(),
227            _expiry: U96::from(expiry),
228        };
229        let tx = call_to_tx(self.address, call, None);
230        self.common.send(vec![tx]).await
231    }
232
233    /// Set a new owner for the group (requires runner).
234    pub async fn set_owner(&self, owner: Address) -> Result<Vec<SubmittedTx>, SdkError> {
235        let call = BaseGroup::setOwnerCall { _owner: owner };
236        let tx = call_to_tx(self.address, call, None);
237        self.common.send(vec![tx]).await
238    }
239
240    /// Set a new service address for the group (requires runner).
241    pub async fn set_service(&self, service: Address) -> Result<Vec<SubmittedTx>, SdkError> {
242        let call = BaseGroup::setServiceCall { _service: service };
243        let tx = call_to_tx(self.address, call, None);
244        self.common.send(vec![tx]).await
245    }
246
247    /// Set a new fee collection address for the group (requires runner).
248    pub async fn set_fee_collection(
249        &self,
250        fee_collection: Address,
251    ) -> Result<Vec<SubmittedTx>, SdkError> {
252        let call = BaseGroup::setFeeCollectionCall {
253            _feeCollection: fee_collection,
254        };
255        let tx = call_to_tx(self.address, call, None);
256        self.common.send(vec![tx]).await
257    }
258
259    /// Enable or disable a membership condition (requires runner).
260    pub async fn set_membership_condition(
261        &self,
262        condition: Address,
263        enabled: bool,
264    ) -> Result<Vec<SubmittedTx>, SdkError> {
265        let call = BaseGroup::setMembershipConditionCall {
266            _condition: condition,
267            _enabled: enabled,
268        };
269        let tx = call_to_tx(self.address, call, None);
270        self.common.send(vec![tx]).await
271    }
272
273    #[cfg(feature = "ws")]
274    pub async fn subscribe_events_ws(
275        &self,
276        ws_url: &str,
277        filter: Option<serde_json::Value>,
278    ) -> Result<CirclesSubscription<CirclesEvent>, SdkError> {
279        self.common.subscribe_events_ws(ws_url, filter).await
280    }
281
282    #[cfg(feature = "ws")]
283    pub async fn subscribe_events_ws_with_retries(
284        &self,
285        ws_url: &str,
286        filter: serde_json::Value,
287        max_attempts: Option<usize>,
288    ) -> Result<CirclesSubscription<CirclesEvent>, SdkError> {
289        self.common
290            .subscribe_events_ws_with_retries(ws_url, filter, max_attempts)
291            .await
292    }
293
294    /// Plan a transfer without submitting.
295    pub async fn plan_transfer(
296        &self,
297        to: Address,
298        amount: U256,
299        options: Option<AdvancedTransferOptions>,
300    ) -> Result<Vec<PreparedTransaction>, SdkError> {
301        self.common.plan_transfer(to, amount, options).await
302    }
303
304    /// Execute a transfer using the runner (requires runner).
305    pub async fn transfer(
306        &self,
307        to: Address,
308        amount: U256,
309        options: Option<AdvancedTransferOptions>,
310    ) -> Result<Vec<SubmittedTx>, SdkError> {
311        self.common.transfer(to, amount, options).await
312    }
313
314    /// Plan a direct transfer without pathfinding.
315    pub async fn plan_direct_transfer(
316        &self,
317        to: Address,
318        amount: U256,
319        token_address: Option<Address>,
320        tx_data: Option<Bytes>,
321    ) -> Result<Vec<PreparedTransaction>, SdkError> {
322        self.common
323            .plan_direct_transfer(to, amount, token_address, tx_data)
324            .await
325    }
326
327    /// Execute a direct transfer using the runner (requires runner).
328    pub async fn direct_transfer(
329        &self,
330        to: Address,
331        amount: U256,
332        token_address: Option<Address>,
333        tx_data: Option<Bytes>,
334    ) -> Result<Vec<SubmittedTx>, SdkError> {
335        self.common
336            .direct_transfer(to, amount, token_address, tx_data)
337            .await
338    }
339
340    /// Plan a replenish flow without submitting.
341    pub async fn plan_replenish(
342        &self,
343        token_id: Address,
344        amount: U256,
345        receiver: Option<Address>,
346    ) -> Result<Vec<PreparedTransaction>, SdkError> {
347        self.common.plan_replenish(token_id, amount, receiver).await
348    }
349
350    /// Execute a replenish flow using the runner (requires runner).
351    pub async fn replenish(
352        &self,
353        token_id: Address,
354        amount: U256,
355        receiver: Option<Address>,
356    ) -> Result<Vec<SubmittedTx>, SdkError> {
357        self.common.replenish(token_id, amount, receiver).await
358    }
359
360    /// Find a path between this avatar and `to` with a target flow.
361    pub async fn find_path(
362        &self,
363        to: Address,
364        target_flow: U256,
365        options: Option<AdvancedTransferOptions>,
366    ) -> Result<PathfindingResult, SdkError> {
367        self.common.find_path(to, target_flow, options).await
368    }
369
370    /// Max-flow helper: sets target_flow to U256::MAX.
371    pub async fn max_flow_to(
372        &self,
373        to: Address,
374        options: Option<AdvancedTransferOptions>,
375    ) -> Result<PathfindingResult, SdkError> {
376        self.common.max_flow_to(to, options).await
377    }
378
379    /// Build a typed base-group avatar wrapper from already-fetched components.
380    pub fn new(
381        address: Address,
382        info: AvatarInfo,
383        core: Arc<Core>,
384        profiles: Profiles,
385        rpc: Arc<CirclesRpc>,
386        runner: Option<Arc<dyn ContractRunner>>,
387    ) -> Self {
388        let common = CommonAvatar::new(address, core.clone(), profiles, rpc, runner.clone());
389        Self {
390            address,
391            info,
392            core,
393            runner,
394            common,
395        }
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use alloy_primitives::{Bytes, TxHash};
403    use alloy_sol_types::SolCall;
404    use async_trait::async_trait;
405    use circles_profiles::Profiles;
406    use circles_types::{AvatarType, CirclesConfig};
407    use std::sync::Mutex;
408
409    const TEST_CID: &str = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG";
410
411    #[derive(Default)]
412    struct RecordingRunner {
413        sender: Address,
414        sent: Mutex<Vec<Vec<PreparedTransaction>>>,
415    }
416
417    #[async_trait]
418    impl ContractRunner for RecordingRunner {
419        fn sender_address(&self) -> Address {
420            self.sender
421        }
422
423        async fn send_transactions(
424            &self,
425            txs: Vec<PreparedTransaction>,
426        ) -> Result<Vec<crate::SubmittedTx>, crate::RunnerError> {
427            self.sent.lock().expect("lock").push(txs.clone());
428            Ok(txs
429                .into_iter()
430                .map(|_| crate::SubmittedTx {
431                    tx_hash: Bytes::from(TxHash::ZERO.as_slice().to_vec()),
432                    success: true,
433                    index: None,
434                })
435                .collect())
436        }
437    }
438
439    fn dummy_config() -> CirclesConfig {
440        CirclesConfig {
441            circles_rpc_url: "https://rpc.example.com".into(),
442            pathfinder_url: "https://pathfinder.example.com".into(),
443            profile_service_url: "https://profiles.example.com".into(),
444            referrals_service_url: None,
445            v1_hub_address: Address::repeat_byte(0x01),
446            v2_hub_address: Address::repeat_byte(0x02),
447            name_registry_address: Address::repeat_byte(0x03),
448            base_group_mint_policy: Address::repeat_byte(0x04),
449            standard_treasury: Address::repeat_byte(0x05),
450            core_members_group_deployer: Address::repeat_byte(0x06),
451            base_group_factory_address: Address::repeat_byte(0x07),
452            lift_erc20_address: Address::repeat_byte(0x08),
453            invitation_escrow_address: Address::repeat_byte(0x09),
454            invitation_farm_address: Address::repeat_byte(0x0a),
455            referrals_module_address: Address::repeat_byte(0x0b),
456            invitation_module_address: Address::repeat_byte(0x0c),
457        }
458    }
459
460    fn dummy_avatar(address: Address) -> AvatarInfo {
461        AvatarInfo {
462            block_number: 0,
463            timestamp: None,
464            transaction_index: 0,
465            log_index: 0,
466            transaction_hash: TxHash::ZERO,
467            version: 2,
468            avatar_type: AvatarType::CrcV2RegisterGroup,
469            avatar: address,
470            token_id: None,
471            has_v1: false,
472            v1_token: None,
473            cid_v0_digest: None,
474            cid_v0: None,
475            v1_stopped: None,
476            is_human: false,
477            name: None,
478            symbol: None,
479        }
480    }
481
482    fn test_avatar() -> (BaseGroupAvatar, Arc<RecordingRunner>) {
483        let config = dummy_config();
484        let runner = Arc::new(RecordingRunner {
485            sender: Address::repeat_byte(0xcc),
486            sent: Mutex::new(Vec::new()),
487        });
488        let avatar = BaseGroupAvatar::new(
489            Address::repeat_byte(0xcc),
490            dummy_avatar(Address::repeat_byte(0xcc)),
491            Arc::new(Core::new(config.clone())),
492            Profiles::new(config.profile_service_url.clone()).expect("profiles"),
493            Arc::new(CirclesRpc::try_from_http(&config.circles_rpc_url).expect("rpc")),
494            Some(runner.clone()),
495        );
496        (avatar, runner)
497    }
498
499    #[tokio::test]
500    async fn base_group_write_helpers_encode_expected_calls() {
501        let (avatar, runner) = test_avatar();
502        let new_owner = Address::repeat_byte(0xdd);
503        let new_service = Address::repeat_byte(0xee);
504        let new_fee = Address::repeat_byte(0xff);
505        let condition = Address::repeat_byte(0x11);
506
507        avatar
508            .update_profile_metadata(TEST_CID)
509            .await
510            .expect("update metadata");
511        avatar
512            .register_short_name(5)
513            .await
514            .expect("register short name");
515        avatar
516            .trust_add_batch_with_conditions(&[new_owner, new_service], 42)
517            .await
518            .expect("trust batch");
519        avatar.set_owner(new_owner).await.expect("set owner");
520        avatar.set_service(new_service).await.expect("set service");
521        avatar
522            .set_fee_collection(new_fee)
523            .await
524            .expect("set fee collection");
525        avatar
526            .set_membership_condition(condition, true)
527            .await
528            .expect("set membership condition");
529
530        let sent = runner.sent.lock().expect("lock");
531        assert_eq!(sent.len(), 7);
532
533        assert_eq!(
534            &sent[0][0].data[..4],
535            &BaseGroup::updateMetadataDigestCall {
536                _metadataDigest: cid_v0_to_digest(TEST_CID).expect("cid"),
537            }
538            .abi_encode()[..4]
539        );
540        assert_eq!(
541            &sent[1][0].data[..4],
542            &BaseGroup::registerShortNameWithNonceCall {
543                _nonce: U256::from(5u64),
544            }
545            .abi_encode()[..4]
546        );
547        assert_eq!(
548            &sent[2][0].data[..4],
549            &BaseGroup::trustBatchWithConditionsCall {
550                _members: vec![new_owner, new_service],
551                _expiry: U96::from(42u128),
552            }
553            .abi_encode()[..4]
554        );
555        assert_eq!(
556            &sent[3][0].data[..4],
557            &BaseGroup::setOwnerCall { _owner: new_owner }.abi_encode()[..4]
558        );
559        assert_eq!(
560            &sent[4][0].data[..4],
561            &BaseGroup::setServiceCall {
562                _service: new_service,
563            }
564            .abi_encode()[..4]
565        );
566        assert_eq!(
567            &sent[5][0].data[..4],
568            &BaseGroup::setFeeCollectionCall {
569                _feeCollection: new_fee,
570            }
571            .abi_encode()[..4]
572        );
573        assert_eq!(
574            &sent[6][0].data[..4],
575            &BaseGroup::setMembershipConditionCall {
576                _condition: condition,
577                _enabled: true,
578            }
579            .abi_encode()[..4]
580        );
581    }
582}