1use std::sync::Arc;
2
3use axum::{
4 extract::{Json, Path, Query, State},
5 http::status::StatusCode,
6 response::IntoResponse,
7};
8use futures::TryFutureExt;
9use hopr_crypto_types::types::Hash;
10use hopr_lib::{
11 Address, AsUnixTimestamp, ChainActionsError, ChannelEntry, ChannelStatus, HoprBalance, ToHex,
12 errors::{HoprLibError, HoprStatusError},
13};
14use serde::{Deserialize, Serialize};
15use serde_with::{DisplayFromStr, serde_as};
16
17use crate::{ApiError, ApiErrorStatus, BASE_PATH, InternalState, checksum_address_serializer};
18
19#[serde_as]
20#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
21#[serde(rename_all = "camelCase")]
22#[schema(example = json!({
23 "id": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
24 "address": "0x188c4462b75e46f0c7262d7f48d182447b93a93c",
25 "status": "Open",
26 "balance": "10 wxHOPR"
27}))]
28pub(crate) struct NodeChannel {
30 #[serde_as(as = "DisplayFromStr")]
31 #[schema(value_type = String, example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")]
32 id: Hash,
33 #[serde(serialize_with = "checksum_address_serializer")]
34 #[schema(value_type = String, example = "0x188c4462b75e46f0c7262d7f48d182447b93a93c")]
35 peer_address: Address,
36 #[serde_as(as = "DisplayFromStr")]
37 #[schema(value_type = String, example = "Open")]
38 status: ChannelStatus,
39 #[serde_as(as = "DisplayFromStr")]
40 #[schema(value_type = String, example = "10 wxHOPR")]
41 balance: HoprBalance,
42}
43
44#[serde_as]
45#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
46#[schema(example = json!({
47 "balance": "10 wxHOPR",
48 "channelEpoch": 1,
49 "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
50 "closureTime": 0,
51 "destination": "0x188c4462b75e46f0c7262d7f48d182447b93a93c",
52 "source": "0x07eaf07d6624f741e04f4092a755a9027aaab7f6",
53 "status": "Open",
54 "ticketIndex": 0
55 }))]
56#[serde(rename_all = "camelCase")]
57pub(crate) struct ChannelInfoResponse {
59 #[serde_as(as = "DisplayFromStr")]
60 #[schema(value_type = String, example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")]
61 channel_id: Hash,
62 #[serde(serialize_with = "checksum_address_serializer")]
63 #[schema(value_type = String, example = "0x07eaf07d6624f741e04f4092a755a9027aaab7f6")]
64 source: Address,
65 #[serde(serialize_with = "checksum_address_serializer")]
66 #[schema(value_type = String, example = "0x188c4462b75e46f0c7262d7f48d182447b93a93c")]
67 destination: Address,
68 #[serde_as(as = "DisplayFromStr")]
69 #[schema(value_type = String, example = "10 wxHOPR")]
70 balance: HoprBalance,
71 #[serde_as(as = "DisplayFromStr")]
72 #[schema(value_type = String, example = "Open")]
73 status: ChannelStatus,
74 #[schema(example = 0)]
75 ticket_index: u32,
76 #[schema(example = 1)]
77 channel_epoch: u32,
78 #[schema(example = 0)]
79 closure_time: u64,
80}
81
82#[serde_as]
84#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
85#[schema(example = json!({
86 "all": [{
87 "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
88 "source": "0x07eaf07d6624f741e04f4092a755a9027aaab7f6",
89 "destination": "0x188c4462b75e46f0c7262d7f48d182447b93a93c",
90 "balance": "10 wxHOPR",
91 "status": "Open",
92 "ticketIndex": 0,
93 "channelEpoch": 1,
94 "closureTime": 0
95 }],
96 "incoming": [],
97 "outgoing": [{
98 "balance": "10 wxHOPR",
99 "id": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
100 "peerAddress": "0x188c4462b75e46f0c7262d7f48d182447b93a93c",
101 "status": "Open"
102 }]
103 }))]
104pub(crate) struct NodeChannelsResponse {
105 incoming: Vec<NodeChannel>,
107 outgoing: Vec<NodeChannel>,
109 all: Vec<ChannelInfoResponse>,
111}
112
113async fn query_topology_info(channel: &ChannelEntry) -> Result<ChannelInfoResponse, HoprLibError> {
114 Ok(ChannelInfoResponse {
115 channel_id: channel.get_id(),
116 source: channel.source,
117 destination: channel.destination,
118 balance: channel.balance,
119 status: channel.status,
120 ticket_index: channel.ticket_index.as_u32(),
121 channel_epoch: channel.channel_epoch.as_u32(),
122 closure_time: channel
123 .closure_time_at()
124 .map(|ct| ct.as_unix_timestamp().as_secs())
125 .unwrap_or_default(),
126 })
127}
128
129#[derive(Debug, Default, Copy, Clone, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
130#[into_params(parameter_in = Query)]
131#[serde(default, rename_all = "camelCase")]
132#[schema(example = json!({
133 "includingClosed": true,
134 "fullTopology": false
135 }))]
136pub(crate) struct ChannelsQueryRequest {
138 #[schema(required = false)]
140 #[serde(default)]
141 including_closed: bool,
142 #[schema(required = false)]
144 #[serde(default)]
145 full_topology: bool,
146}
147
148#[utoipa::path(
151 get,
152 path = const_format::formatcp!("{BASE_PATH}/channels"),
153 description = "List channels opened to/from this node. Alternatively, it can print all the channels in the network as this node sees them.",
154 params(ChannelsQueryRequest),
155 responses(
156 (status = 200, description = "Channels fetched successfully", body = NodeChannelsResponse),
157 (status = 401, description = "Invalid authorization token.", body = ApiError),
158 (status = 422, description = "Unknown failure", body = ApiError)
159 ),
160 security(
161 ("api_token" = []),
162 ("bearer_token" = [])
163 ),
164 tag = "Channels",
165 )]
166pub(super) async fn list_channels(
167 Query(query): Query<ChannelsQueryRequest>,
168 State(state): State<Arc<InternalState>>,
169) -> impl IntoResponse {
170 let hopr = state.hopr.clone();
171
172 if query.full_topology {
173 let topology = hopr
174 .all_channels()
175 .and_then(|channels| async move {
176 futures::future::try_join_all(channels.iter().map(query_topology_info)).await
177 })
178 .await;
179
180 match topology {
181 Ok(all) => (
182 StatusCode::OK,
183 Json(NodeChannelsResponse {
184 incoming: vec![],
185 outgoing: vec![],
186 all,
187 }),
188 )
189 .into_response(),
190 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
191 }
192 } else {
193 let channels = hopr
194 .channels_to(&hopr.me_onchain())
195 .and_then(|incoming| async {
196 let outgoing = hopr.channels_from(&hopr.me_onchain()).await?;
197 Ok((incoming, outgoing))
198 })
199 .await;
200
201 match channels {
202 Ok((incoming, outgoing)) => {
203 let channel_info = NodeChannelsResponse {
204 incoming: incoming
205 .into_iter()
206 .filter(|c| query.including_closed || c.status != ChannelStatus::Closed)
207 .map(|c| NodeChannel {
208 id: c.get_id(),
209 peer_address: c.source,
210 status: c.status,
211 balance: c.balance,
212 })
213 .collect(),
214 outgoing: outgoing
215 .into_iter()
216 .filter(|c| query.including_closed || c.status != ChannelStatus::Closed)
217 .map(|c| NodeChannel {
218 id: c.get_id(),
219 peer_address: c.destination,
220 status: c.status,
221 balance: c.balance,
222 })
223 .collect(),
224 all: vec![],
225 };
226
227 (StatusCode::OK, Json(channel_info)).into_response()
228 }
229 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
230 }
231 }
232}
233
234#[serde_as]
235#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
236#[serde(rename_all = "camelCase")]
237#[schema(example = json!({
238 "amount": "10 wxHOPR",
239 "destination": "0xa8194d36e322592d4c707b70dbe96121f5c74c64"
240 }))]
241pub(crate) struct OpenChannelBodyRequest {
243 #[serde_as(as = "DisplayFromStr")]
245 #[schema(value_type = String, example = "0xa8194d36e322592d4c707b70dbe96121f5c74c64")]
246 destination: Address,
247 #[serde_as(as = "DisplayFromStr")]
249 #[schema(value_type = String, example = "10 wxHOPR")]
250 amount: HoprBalance,
251}
252
253#[serde_as]
254#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
255#[schema(example = json!({
256 "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
257 "transactionReceipt": "0x5181ac24759b8e01b3c932e4636c3852f386d17517a8dfc640a5ba6f2258f29c"
258 }))]
259#[serde(rename_all = "camelCase")]
260pub(crate) struct OpenChannelResponse {
262 #[serde_as(as = "DisplayFromStr")]
264 #[schema(value_type = String, example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")]
265 channel_id: Hash,
266 #[serde_as(as = "DisplayFromStr")]
268 #[schema(value_type = String, example = "0x5181ac24759b8e01b3c932e4636c3852f386d17517a8dfc640a5ba6f2258f29c")]
269 transaction_receipt: Hash,
270}
271
272#[utoipa::path(
274 post,
275 path = const_format::formatcp!("{BASE_PATH}/channels"),
276 description = "Opens a channel to the given on-chain address with the given initial stake of HOPR tokens.",
277 request_body(
278 content = OpenChannelBodyRequest,
279 description = "Open channel request specification: on-chain address of the counterparty and the initial HOPR token stake.",
280 content_type = "application/json"),
281 responses(
282 (status = 201, description = "Channel successfully opened.", body = OpenChannelResponse),
283 (status = 400, description = "Invalid counterparty address or stake amount.", body = ApiError),
284 (status = 401, description = "Invalid authorization token.", body = ApiError),
285 (status = 403, description = "Failed to open the channel because of insufficient HOPR balance or allowance.", body = ApiError),
286 (status = 409, description = "Failed to open the channel because the channel between these nodes already exists.", body = ApiError),
287 (status = 412, description = "The node is not ready."),
288 (status = 422, description = "Unknown failure", body = ApiError)
289 ),
290 security(
291 ("api_token" = []),
292 ("bearer_token" = [])
293 ),
294 tag = "Channels",
295 )]
296pub(super) async fn open_channel(
297 State(state): State<Arc<InternalState>>,
298 Json(open_req): Json<OpenChannelBodyRequest>,
299) -> impl IntoResponse {
300 let hopr = state.hopr.clone();
301
302 match hopr.open_channel(&open_req.destination, open_req.amount).await {
303 Ok(channel_details) => (
304 StatusCode::CREATED,
305 Json(OpenChannelResponse {
306 channel_id: channel_details.channel_id,
307 transaction_receipt: channel_details.tx_hash,
308 }),
309 )
310 .into_response(),
311 Err(HoprLibError::ChainError(ChainActionsError::BalanceTooLow)) => {
312 (StatusCode::FORBIDDEN, ApiErrorStatus::NotEnoughBalance).into_response()
313 }
314 Err(HoprLibError::ChainError(ChainActionsError::NotEnoughAllowance)) => {
315 (StatusCode::FORBIDDEN, ApiErrorStatus::NotEnoughAllowance).into_response()
316 }
317 Err(HoprLibError::ChainError(ChainActionsError::ChannelAlreadyExists)) => {
318 (StatusCode::CONFLICT, ApiErrorStatus::ChannelAlreadyOpen).into_response()
319 }
320 Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
321 (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
322 }
323 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
324 }
325}
326
327#[derive(Deserialize, utoipa::ToSchema)]
328#[schema(example = json!({
329 "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f"
330}))]
331#[serde(rename_all = "camelCase")]
332pub(crate) struct ChannelIdParams {
333 channel_id: String,
334}
335
336#[utoipa::path(
338 get,
339 path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}"),
340 description = "Returns information about the given channel.",
341 params(
342 ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
343 ),
344 responses(
345 (status = 200, description = "Channel fetched successfully", body = ChannelInfoResponse),
346 (status = 400, description = "Invalid channel id.", body = ApiError),
347 (status = 401, description = "Invalid authorization token.", body = ApiError),
348 (status = 404, description = "Channel not found.", body = ApiError),
349 (status = 422, description = "Unknown failure", body = ApiError)
350 ),
351 security(
352 ("api_token" = []),
353 ("bearer_token" = [])
354 ),
355 tag = "Channels",
356 )]
357pub(super) async fn show_channel(
358 Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
359 State(state): State<Arc<InternalState>>,
360) -> impl IntoResponse {
361 let hopr = state.hopr.clone();
362
363 match Hash::from_hex(channel_id.as_str()) {
364 Ok(channel_id) => match hopr.channel_from_hash(&channel_id).await {
365 Ok(Some(channel)) => {
366 let info = query_topology_info(&channel).await;
367 match info {
368 Ok(info) => (StatusCode::OK, Json(info)).into_response(),
369 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
370 }
371 }
372 Ok(None) => (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response(),
373 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
374 },
375 Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
376 }
377}
378
379#[serde_as]
380#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
381#[schema(example = json!({
382 "receipt": "0xd77da7c1821249e663dead1464d185c03223d9663a06bc1d46ed0ad449a07118",
383 "channelStatus": "PendingToClose"
384 }))]
385#[serde(rename_all = "camelCase")]
386pub(crate) struct CloseChannelResponse {
388 #[serde_as(as = "DisplayFromStr")]
390 #[schema(value_type = String, example = "0xd77da7c1821249e663dead1464d185c03223d9663a06bc1d46ed0ad449a07118")]
391 receipt: Hash,
392 #[serde_as(as = "DisplayFromStr")]
394 #[schema(value_type = String, example = "PendingToClose")]
395 channel_status: ChannelStatus,
396}
397
398#[utoipa::path(
404 delete,
405 path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}"),
406 description = "Closes the given channel.",
407 params(
408 ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
409 ),
410 responses(
411 (status = 200, description = "Channel closed successfully", body = CloseChannelResponse),
412 (status = 400, description = "Invalid channel id.", body = ApiError),
413 (status = 401, description = "Invalid authorization token.", body = ApiError),
414 (status = 404, description = "Channel not found.", body = ApiError),
415 (status = 412, description = "The node is not ready."),
416 (status = 422, description = "Unknown failure", body = ApiError)
417 ),
418 security(
419 ("api_token" = []),
420 ("bearer_token" = [])
421 ),
422 tag = "Channels",
423 )]
424pub(super) async fn close_channel(
425 Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
426 State(state): State<Arc<InternalState>>,
427) -> impl IntoResponse {
428 let hopr = state.hopr.clone();
429
430 match Hash::from_hex(channel_id.as_str()) {
431 Ok(channel_id) => match hopr.close_channel_by_id(channel_id, false).await {
432 Ok(receipt) => (
433 StatusCode::OK,
434 Json(CloseChannelResponse {
435 channel_status: receipt.status,
436 receipt: receipt.tx_hash,
437 }),
438 )
439 .into_response(),
440 Err(HoprLibError::ChainError(ChainActionsError::ChannelDoesNotExist)) => {
441 (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response()
442 }
443 Err(HoprLibError::ChainError(ChainActionsError::InvalidArguments(_))) => {
444 (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::UnsupportedFeature).into_response()
445 }
446 Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
447 (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
448 }
449 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
450 },
451 Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
452 }
453}
454
455#[serde_as]
456#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
457#[schema(example = json!({
458 "hash": "0x188c4462b75e46f0c7262d7f48d182447b93a93c",
459}))]
460#[serde(rename_all = "camelCase")]
461pub(crate) struct FundChannelResponse {
463 #[serde_as(as = "DisplayFromStr")]
464 #[schema(value_type = String)]
465 hash: Hash,
466}
467
468#[serde_as]
470#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
471#[schema(example = json!({
472 "amount": "10 wxHOPR"
473 }))]
474pub(crate) struct FundBodyRequest {
475 #[serde_as(as = "DisplayFromStr")]
477 #[schema(value_type = String, example = "10 wxHOPR")]
478 amount: HoprBalance,
479}
480
481#[utoipa::path(
483 post,
484 path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/fund"),
485 description = "Funds the given channel with the given amount of HOPR tokens.",
486 params(
487 ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
488 ),
489 request_body(
490 content = FundBodyRequest,
491 description = "Specifies the amount of HOPR tokens to fund a channel with.",
492 content_type = "application/json",
493 ),
494 responses(
495 (status = 200, description = "Channel funded successfully", body = FundChannelResponse),
496 (status = 400, description = "Invalid channel id.", body = ApiError),
497 (status = 401, description = "Invalid authorization token.", body = ApiError),
498 (status = 403, description = "Failed to fund the channel because of insufficient HOPR balance or allowance.", body = ApiError),
499 (status = 404, description = "Channel not found.", body = ApiError),
500 (status = 412, description = "The node is not ready."),
501 (status = 422, description = "Unknown failure", body = ApiError)
502 ),
503 security(
504 ("api_token" = []),
505 ("bearer_token" = [])
506 ),
507 tag = "Channels",
508 )]
509pub(super) async fn fund_channel(
510 Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
511 State(state): State<Arc<InternalState>>,
512 Json(fund_req): Json<FundBodyRequest>,
513) -> impl IntoResponse {
514 let hopr = state.hopr.clone();
515
516 match Hash::from_hex(channel_id.as_str()) {
517 Ok(channel_id) => match hopr.fund_channel(&channel_id, fund_req.amount).await {
518 Ok(hash) => (StatusCode::OK, Json(FundChannelResponse { hash })).into_response(),
519 Err(HoprLibError::ChainError(ChainActionsError::ChannelDoesNotExist)) => {
520 (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response()
521 }
522 Err(HoprLibError::ChainError(ChainActionsError::NotEnoughAllowance)) => {
523 (StatusCode::FORBIDDEN, ApiErrorStatus::NotEnoughAllowance).into_response()
524 }
525 Err(HoprLibError::ChainError(ChainActionsError::BalanceTooLow)) => {
526 (StatusCode::FORBIDDEN, ApiErrorStatus::NotEnoughBalance).into_response()
527 }
528 Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
529 (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
530 }
531 Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
532 },
533 Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
534 }
535}