hoprd_api/
channels.rs

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