hopr_chain_actions/
node.rs

1//! This module contains the [NodeActions] trait defining action which relate to HOPR node itself.
2//!
3//! An implementation of this trait is added to [ChainActions] which realizes the redemption
4//! operations via [ActionQueue](crate::action_queue::ActionQueue).
5//!
6//! There are 3 functions that can be used to redeem tickets in the [NodeActions] trait:
7//! - [withdraw](NodeActions::withdraw)
8//! - [announce](NodeActions::announce)
9//! - [register_safe_by_node](NodeActions::register_safe_by_node)
10//!
11//! All necessary pre-requisites are checked by the implementation before the respective [Action] is submitted
12//! to the [ActionQueue](crate::action_queue::ActionQueue).
13
14use async_trait::async_trait;
15use hopr_chain_types::actions::Action;
16use hopr_crypto_types::{keypairs::OffchainKeypair, prelude::Keypair};
17use hopr_db_sql::accounts::HoprDbAccountOperations;
18use hopr_internal_types::prelude::*;
19use hopr_primitive_types::prelude::*;
20use multiaddr::Multiaddr;
21use tracing::info;
22
23use crate::{
24    ChainActions,
25    action_queue::PendingAction,
26    errors::{
27        ChainActionsError::{AlreadyAnnounced, InvalidArguments},
28        Result,
29    },
30};
31
32/// Contains all on-chain calls specific to the HOPR node itself.
33#[async_trait]
34pub trait NodeActions {
35    /// Withdraws the specified `amount` of tokens to the given `recipient`.
36    async fn withdraw(&self, recipient: Address, amount: HoprBalance) -> Result<PendingAction>;
37
38    /// Withdraws the specified `amount` of native coins to the given `recipient`.
39    async fn withdraw_native(&self, recipient: Address, amount: XDaiBalance) -> Result<PendingAction>;
40
41    /// Announces node on-chain with key binding.
42    /// The operation should also check if such an announcement has not been already made on-chain.
43    async fn announce(&self, multiaddrs: &[Multiaddr], offchain_key: &OffchainKeypair) -> Result<PendingAction>;
44
45    /// Registers the safe address with the node
46    async fn register_safe_by_node(&self, safe_address: Address) -> Result<PendingAction>;
47}
48
49#[async_trait]
50impl<Db> NodeActions for ChainActions<Db>
51where
52    Db: HoprDbAccountOperations + Clone + Send + Sync + std::fmt::Debug,
53{
54    #[tracing::instrument(level = "debug", skip(self))]
55    async fn withdraw(&self, recipient: Address, amount: HoprBalance) -> Result<PendingAction> {
56        if !amount.is_zero() {
57            info!(%amount, %recipient, "initiating withdrawal");
58            self.tx_sender.send(Action::Withdraw(recipient, amount)).await
59        } else {
60            Err(InvalidArguments("cannot withdraw zero amount".into()))
61        }
62    }
63
64    #[tracing::instrument(level = "debug", skip(self))]
65    async fn withdraw_native(&self, recipient: Address, amount: XDaiBalance) -> Result<PendingAction> {
66        if !amount.is_zero() {
67            info!(%amount, %recipient, "initiating native withdrawal");
68            self.tx_sender.send(Action::WithdrawNative(recipient, amount)).await
69        } else {
70            Err(InvalidArguments("cannot withdraw zero amount".into()))
71        }
72    }
73
74    #[tracing::instrument(level = "debug", skip(self))]
75    async fn announce(&self, multiaddrs: &[Multiaddr], offchain_key: &OffchainKeypair) -> Result<PendingAction> {
76        // TODO: allow announcing all addresses once that option is supported
77        let announcement_data = AnnouncementData::new(
78            multiaddrs[0].clone(),
79            Some(KeyBinding::new(self.self_address(), offchain_key)),
80        )?;
81
82        if !self.db.get_accounts(None, true).await?.into_iter().any(|account| {
83            account.public_key.eq(offchain_key.public())
84                && account
85                    .get_multiaddr()
86                    .is_some_and(|ma| decapsulate_multiaddress(ma).eq(announcement_data.multiaddress()))
87        }) {
88            info!(%announcement_data, "initiating announcement");
89            self.tx_sender.send(Action::Announce(announcement_data)).await
90        } else {
91            Err(AlreadyAnnounced)
92        }
93    }
94
95    #[tracing::instrument(level = "debug", skip(self))]
96    async fn register_safe_by_node(&self, safe_address: Address) -> Result<PendingAction> {
97        info!(%safe_address, "initiating safe address registration");
98        self.tx_sender.send(Action::RegisterSafe(safe_address)).await
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::str::FromStr;
105
106    use futures::FutureExt;
107    use hex_literal::hex;
108    use hopr_chain_types::{
109        actions::Action,
110        chain_events::{ChainEventType, SignificantChainEvent},
111    };
112    use hopr_crypto_random::random_bytes;
113    use hopr_crypto_types::prelude::*;
114    use hopr_db_sql::{
115        accounts::HoprDbAccountOperations, api::info::DomainSeparator, db::HoprDb, info::HoprDbInfoOperations,
116    };
117    use hopr_internal_types::prelude::*;
118    use hopr_primitive_types::prelude::*;
119    use multiaddr::Multiaddr;
120
121    use crate::{
122        ChainActions,
123        action_queue::{ActionQueue, MockTransactionExecutor},
124        action_state::MockActionState,
125        errors::ChainActionsError,
126        node::NodeActions,
127    };
128
129    lazy_static::lazy_static! {
130        static ref ALICE_KP: ChainKeypair = ChainKeypair::from_secret(&hex!("492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775")).expect("lazy static keypair should be constructible");
131        static ref BOB_KP: ChainKeypair = ChainKeypair::from_secret(&hex!("48680484c6fc31bc881a0083e6e32b6dc789f9eaba0f8b981429fd346c697f8c")).expect("lazy static keypair should be constructible");
132        static ref ALICE: Address = ALICE_KP.public().to_address();
133        static ref BOB: Address = BOB_KP.public().to_address();
134        static ref ALICE_OFFCHAIN: OffchainKeypair = OffchainKeypair::from_secret(&hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e")).expect("lazy static keypair should be constructible");
135    }
136
137    #[tokio::test]
138    async fn test_announce() -> anyhow::Result<()> {
139        let random_hash = Hash::from(random_bytes::<{ Hash::SIZE }>());
140        let announce_multiaddr = Multiaddr::from_str("/ip4/1.2.3.4/tcp/9009")?;
141
142        let db = HoprDb::new_in_memory(ALICE_KP.clone()).await?;
143        db.set_domain_separator(None, DomainSeparator::Channel, Default::default())
144            .await?;
145
146        let ma = announce_multiaddr.clone();
147        let pubkey_clone = *ALICE_OFFCHAIN.public();
148        let mut tx_exec = MockTransactionExecutor::new();
149        tx_exec
150            .expect_announce()
151            .once()
152            .withf(move |ad| {
153                let kb = ad.key_binding.clone().expect("key binding must be present");
154                ma.eq(ad.multiaddress()) && kb.packet_key == pubkey_clone && kb.chain_key == *ALICE
155            })
156            .returning(move |_| Ok(random_hash));
157
158        let ma = announce_multiaddr.clone();
159        let pk = *ALICE_OFFCHAIN.public();
160        let mut indexer_action_tracker = MockActionState::new();
161        indexer_action_tracker
162            .expect_register_expectation()
163            .once()
164            .returning(move |_| {
165                Ok(futures::future::ok(SignificantChainEvent {
166                    tx_hash: random_hash,
167                    event_type: ChainEventType::Announcement {
168                        peer: pk.into(),
169                        multiaddresses: vec![ma.clone()],
170                        address: *ALICE,
171                    },
172                })
173                .boxed())
174            });
175
176        let tx_queue = ActionQueue::new(db.clone(), indexer_action_tracker, tx_exec, Default::default());
177        let tx_sender = tx_queue.new_sender();
178        tokio::task::spawn(async move {
179            tx_queue.start().await;
180        });
181
182        let actions = ChainActions::new(&ALICE_KP, db.clone(), tx_sender.clone());
183        let tx_res = actions.announce(&[announce_multiaddr], &ALICE_OFFCHAIN).await?.await?;
184
185        assert_eq!(tx_res.tx_hash, random_hash, "tx hashes must be equal");
186        assert!(matches!(tx_res.action, Action::Announce(_)), "must be announce action");
187        assert!(
188            matches!(tx_res.event, Some(ChainEventType::Announcement { .. })),
189            "must correspond to announcement chain event"
190        );
191
192        Ok(())
193    }
194
195    #[tokio::test]
196    async fn test_announce_should_not_allow_reannouncing_with_same_multiaddress() -> anyhow::Result<()> {
197        let announce_multiaddr = Multiaddr::from_str("/ip4/1.2.3.4/tcp/9009")?;
198
199        let db = HoprDb::new_in_memory(ALICE_KP.clone()).await?;
200        db.set_domain_separator(None, DomainSeparator::Channel, Default::default())
201            .await?;
202
203        db.insert_account(
204            None,
205            AccountEntry {
206                public_key: *ALICE_OFFCHAIN.public(),
207                chain_addr: *ALICE,
208                entry_type: AccountType::Announced {
209                    multiaddr: announce_multiaddr.clone(),
210                    updated_block: 0,
211                },
212                published_at: 1,
213            },
214        )
215        .await?;
216
217        let tx_queue = ActionQueue::new(
218            db.clone(),
219            MockActionState::new(),
220            MockTransactionExecutor::new(),
221            Default::default(),
222        );
223        let tx_sender = tx_queue.new_sender();
224
225        let actions = ChainActions::new(&ALICE_KP, db.clone(), tx_sender.clone());
226
227        let res = actions.announce(&[announce_multiaddr], &ALICE_OFFCHAIN).await;
228        assert!(
229            matches!(res, Err(ChainActionsError::AlreadyAnnounced)),
230            "must not be able to re-announce with same address"
231        );
232
233        Ok(())
234    }
235
236    #[tokio::test]
237    async fn test_withdraw() -> anyhow::Result<()> {
238        let stake = HoprBalance::from(10_u32);
239        let random_hash = Hash::from(random_bytes::<{ Hash::SIZE }>());
240
241        let db = HoprDb::new_in_memory(ALICE_KP.clone()).await?;
242        db.set_domain_separator(None, DomainSeparator::Channel, Default::default())
243            .await?;
244
245        let mut tx_exec = MockTransactionExecutor::new();
246        tx_exec
247            .expect_withdraw()
248            .times(1)
249            .withf(move |dst, balance| *BOB == *dst && stake.eq(balance))
250            .returning(move |_, _| Ok(random_hash));
251
252        let mut indexer_action_tracker = MockActionState::new();
253        indexer_action_tracker.expect_register_expectation().never();
254
255        let tx_queue = ActionQueue::new(db.clone(), indexer_action_tracker, tx_exec, Default::default());
256        let tx_sender = tx_queue.new_sender();
257        tokio::task::spawn(async move {
258            tx_queue.start().await;
259        });
260
261        let actions = ChainActions::new(&ALICE_KP, db.clone(), tx_sender.clone());
262
263        let tx_res = actions.withdraw(*BOB, stake).await?.await?;
264
265        assert_eq!(tx_res.tx_hash, random_hash, "tx hashes must be equal");
266        assert!(
267            matches!(tx_res.action, Action::Withdraw(_, _)),
268            "must be withdraw action"
269        );
270        assert!(
271            tx_res.event.is_none(),
272            "withdraw tx must not connect to any chain event"
273        );
274
275        Ok(())
276    }
277
278    #[tokio::test]
279    async fn test_should_not_withdraw_zero_amount() -> anyhow::Result<()> {
280        let db = HoprDb::new_in_memory(ALICE_KP.clone()).await?;
281        db.set_domain_separator(None, DomainSeparator::Channel, Default::default())
282            .await?;
283
284        let tx_queue = ActionQueue::new(
285            db.clone(),
286            MockActionState::new(),
287            MockTransactionExecutor::new(),
288            Default::default(),
289        );
290        let actions = ChainActions::new(&ALICE_KP, db.clone(), tx_queue.new_sender());
291
292        assert!(
293            matches!(
294                actions
295                    .withdraw(*BOB, HoprBalance::zero())
296                    .await
297                    .err()
298                    .expect("must be error"),
299                ChainActionsError::InvalidArguments(_)
300            ),
301            "should not allow to withdraw 0"
302        );
303
304        Ok(())
305    }
306}