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#[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#[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
133fn 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(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#[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}