hoprd_api/
account.rs

1use axum::{
2    extract::{Json, State},
3    http::status::StatusCode,
4    response::IntoResponse,
5};
6use serde::{Deserialize, Serialize};
7use serde_with::{serde_as, DisplayFromStr};
8use std::sync::Arc;
9
10use hopr_lib::{
11    errors::{HoprLibError, HoprStatusError},
12    Address, Balance, BalanceType, U256,
13};
14
15use crate::{ApiError, ApiErrorStatus, InternalState, BASE_PATH};
16
17#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
18#[schema(example = json!({
19        "hopr": "12D3KooWJmLm8FnBfvYQ5BAZ5qcYBxQFFBzAAEYUBUNJNE8cRsYS",
20        "native": "0x07eaf07d6624f741e04f4092a755a9027aaab7f6"
21    }))]
22#[serde(rename_all = "camelCase")]
23pub(crate) struct AccountAddressesResponse {
24    native: String,
25    hopr: String,
26}
27
28/// Get node's HOPR and native addresses.
29///
30/// HOPR address is represented by the P2P PeerId and can be used by other node owner to interact with this node.
31#[utoipa::path(
32        get,
33        path = const_format::formatcp!("{BASE_PATH}/account/addresses"),
34        responses(
35            (status = 200, description = "The node's public addresses", body = AccountAddressesResponse),
36            (status = 401, description = "Invalid authorization token.", body = ApiError),
37            (status = 422, description = "Unknown failure", body = ApiError)
38        ),
39        security(
40            ("api_token" = []),
41            ("bearer_token" = [])
42        ),
43        tag = "Account",
44    )]
45pub(super) async fn addresses(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
46    let addresses = AccountAddressesResponse {
47        native: state.hopr.me_onchain().to_checksum(),
48        hopr: state.hopr.me_peer_id().to_string(),
49    };
50
51    (StatusCode::OK, Json(addresses)).into_response()
52}
53
54#[derive(Debug, Default, Clone, Serialize, utoipa::ToSchema)]
55#[schema(example = json!({
56        "hopr": "2000000000000000000000",
57        "native": "9999563581204904000",
58        "safeHopr": "2000000000000000000000",
59        "safeHoprAllowance": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
60        "safeNative": "10000000000000000000"
61    }))]
62#[serde(rename_all = "camelCase")]
63pub(crate) struct AccountBalancesResponse {
64    safe_native: String,
65    native: String,
66    safe_hopr: String,
67    hopr: String,
68    safe_hopr_allowance: String,
69}
70
71/// Get node's and associated Safe's HOPR and native balances as the allowance for HOPR
72/// tokens to be drawn by HoprChannels from Safe.
73///
74/// HOPR tokens from the Safe balance are used to fund the payment channels between this
75/// node and other nodes on the network.
76/// NATIVE balance of the Node is used to pay for the gas fees for the blockchain.
77#[utoipa::path(
78        get,
79        path = const_format::formatcp!("{BASE_PATH}/account/balances"),
80        responses(
81            (status = 200, description = "The node's HOPR and Safe balances", body = AccountBalancesResponse),
82            (status = 401, description = "Invalid authorization token.", body = ApiError),
83            (status = 422, description = "Unknown failure", body = ApiError)
84        ),
85        security(
86            ("api_token" = []),
87            ("bearer_token" = [])
88        ),
89        tag = "Account",
90    )]
91pub(super) async fn balances(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
92    let hopr = state.hopr.clone();
93
94    let mut account_balances = AccountBalancesResponse::default();
95
96    match hopr.get_balance(BalanceType::Native).await {
97        Ok(v) => account_balances.native = v.to_value_string(),
98        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
99    }
100
101    match hopr.get_balance(BalanceType::HOPR).await {
102        Ok(v) => account_balances.hopr = v.to_value_string(),
103        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
104    }
105
106    match hopr.get_safe_balance(BalanceType::Native).await {
107        Ok(v) => account_balances.safe_native = v.to_value_string(),
108        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
109    }
110
111    match hopr.get_safe_balance(BalanceType::HOPR).await {
112        Ok(v) => account_balances.safe_hopr = v.to_value_string(),
113        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
114    }
115
116    match hopr.safe_allowance().await {
117        Ok(v) => account_balances.safe_hopr_allowance = v.to_value_string(),
118        Err(e) => return (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
119    }
120
121    (StatusCode::OK, Json(account_balances)).into_response()
122}
123
124fn deserialize_u256_value_from_str<'de, D>(deserializer: D) -> Result<U256, D::Error>
125where
126    D: serde::de::Deserializer<'de>,
127{
128    let s: &str = serde::de::Deserialize::deserialize(deserializer)?;
129    let v: u128 = s.parse().map_err(serde::de::Error::custom)?;
130    Ok(U256::from(v))
131}
132
133// #[deprecated(
134//     since = "3.2.0",
135//     note = "The `BalanceType` enum deserialization using all capitals is deprecated and will be removed in hoprd v3.0 REST API"
136// )]
137fn deserialize_balance_type<'de, D>(deserializer: D) -> Result<BalanceType, D::Error>
138where
139    D: serde::Deserializer<'de>,
140{
141    let buf = <String as serde::Deserialize>::deserialize(deserializer)?;
142
143    match buf.as_str() {
144        "Native" | "NATIVE" => Ok(BalanceType::Native),
145        "HOPR" => Ok(BalanceType::HOPR),
146        _ => Err(serde::de::Error::custom("Unsupported balance type")),
147    }
148}
149
150#[serde_as]
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
152#[schema(example = json!({
153        "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
154        "amount": "20000",
155        "currency": "HOPR"
156    }))]
157#[serde(rename_all = "camelCase")]
158pub(crate) struct WithdrawBodyRequest {
159    // #[serde_as(as = "DisplayFromStr")]
160    #[serde(deserialize_with = "deserialize_balance_type")]
161    #[schema(value_type = String)]
162    currency: BalanceType,
163    #[serde(deserialize_with = "deserialize_u256_value_from_str")]
164    #[schema(value_type = String)]
165    amount: U256,
166    #[serde_as(as = "DisplayFromStr")]
167    #[schema(value_type = String)]
168    address: Address,
169}
170
171#[derive(Debug, Default, Clone, Serialize, utoipa::ToSchema)]
172#[schema(example = json!({
173        "receipt": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe",
174    }))]
175#[serde(rename_all = "camelCase")]
176pub(crate) struct WithdrawResponse {
177    receipt: String,
178}
179
180/// Withdraw funds from this node to the ethereum wallet address.
181///
182/// Both Native or HOPR can be withdrawn using this method.
183#[utoipa::path(
184        post,
185        path = const_format::formatcp!("{BASE_PATH}/account/withdraw"),
186        request_body(
187            content = WithdrawBodyRequest,
188            content_type = "application/json"),
189        responses(
190            (status = 200, description = "The node's funds have been withdrawn", body = WithdrawResponse),
191            (status = 401, description = "Invalid authorization token.", body = ApiError),
192            (status = 412, description = "The node is not ready."),
193            (status = 422, description = "Unknown failure", body = ApiError)
194        ),
195        security(
196            ("api_token" = []),
197            ("bearer_token" = [])
198        ),
199        tag = "Account",
200    )]
201pub(super) async fn withdraw(
202    State(state): State<Arc<InternalState>>,
203    Json(req_data): Json<WithdrawBodyRequest>,
204) -> impl IntoResponse {
205    match state
206        .hopr
207        .withdraw(req_data.address, Balance::new(req_data.amount, req_data.currency))
208        .await
209    {
210        Ok(receipt) => (
211            StatusCode::OK,
212            Json(WithdrawResponse {
213                receipt: receipt.to_string(),
214            }),
215        )
216            .into_response(),
217        Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(_, _))) => {
218            (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
219        }
220
221        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use std::str::FromStr;
228
229    use hopr_lib::Address;
230
231    use crate::account::WithdrawBodyRequest;
232
233    #[test]
234    fn withdraw_input_data_should_be_convertible_from_string() -> anyhow::Result<()> {
235        let expected = WithdrawBodyRequest {
236            currency: "HOPR".parse().unwrap(),
237            amount: 20000.into(),
238            address: Address::from_str("0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe")?,
239        };
240
241        let actual: WithdrawBodyRequest = serde_json::from_str(
242            r#"{
243            "currency": "HOPR",
244            "amount": "20000",
245            "address": "0xb4ce7e6e36ac8b01a974725d5ba730af2b156fbe"}"#,
246        )?;
247
248        assert_eq!(actual, expected);
249
250        Ok(())
251    }
252}