hoprd_api/
channels.rs

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}))]
28/// Channel information as seen by the node.
29pub(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")]
57/// General information about a channel state.
58pub(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/// Listing of channels.
83#[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    /// Channels incoming to this node.
106    incoming: Vec<NodeChannel>,
107    /// Channels outgoing from this node.
108    outgoing: Vec<NodeChannel>,
109    /// Complete channel topology as seen by this node.
110    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    }))]
136/// Parameters for enumerating channels.
137pub(crate) struct ChannelsQueryRequest {
138    /// Should be the closed channels included?
139    #[schema(required = false)]
140    #[serde(default)]
141    including_closed: bool,
142    /// Should all channels (not only the ones concerning this node) be enumerated?
143    #[schema(required = false)]
144    #[serde(default)]
145    full_topology: bool,
146}
147
148/// Lists channels opened to/from this node. Alternatively, it can print all
149/// the channels in the network as this node sees them.
150#[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    }))]
241/// Request body for opening a channel.
242pub(crate) struct OpenChannelBodyRequest {
243    /// On-chain address of the counterparty.
244    #[serde_as(as = "DisplayFromStr")]
245    #[schema(value_type = String, example = "0xa8194d36e322592d4c707b70dbe96121f5c74c64")]
246    destination: Address,
247    /// Initial amount of stake in HOPR tokens.
248    #[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")]
260/// Response body for opening a channel.
261pub(crate) struct OpenChannelResponse {
262    /// ID of the new channel.
263    #[serde_as(as = "DisplayFromStr")]
264    #[schema(value_type = String, example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")]
265    channel_id: Hash,
266    /// Receipt of the channel open transaction.
267    #[serde_as(as = "DisplayFromStr")]
268    #[schema(value_type = String, example = "0x5181ac24759b8e01b3c932e4636c3852f386d17517a8dfc640a5ba6f2258f29c")]
269    transaction_receipt: Hash,
270}
271
272/// Opens a channel to the given on-chain address with the given initial stake of HOPR tokens.
273#[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/// Returns information about the given channel.
337#[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")]
386/// Status of the channel after a close operation.
387pub(crate) struct CloseChannelResponse {
388    /// Receipt for the channel close transaction.
389    #[serde_as(as = "DisplayFromStr")]
390    #[schema(value_type = String, example = "0xd77da7c1821249e663dead1464d185c03223d9663a06bc1d46ed0ad449a07118")]
391    receipt: Hash,
392    /// New status of the channel. Will be one of `Closed` or `PendingToClose`.
393    #[serde_as(as = "DisplayFromStr")]
394    #[schema(value_type = String, example = "PendingToClose")]
395    channel_status: ChannelStatus,
396}
397
398/// Closes the given channel.
399///
400/// If the channel is currently `Open`, it will transition it to `PendingToClose`.
401/// If the channels are in `PendingToClose` and the channel closure period has elapsed,
402/// it will transition it to `Closed`.
403#[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")]
461/// Response body for funding a channel.
462pub(crate) struct FundChannelResponse {
463    #[serde_as(as = "DisplayFromStr")]
464    #[schema(value_type = String)]
465    hash: Hash,
466}
467
468/// Specifies the amount of HOPR tokens to fund a channel with.
469#[serde_as]
470#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
471#[schema(example = json!({
472        "amount": "10 wxHOPR"
473    }))]
474pub(crate) struct FundBodyRequest {
475    /// Amount of HOPR tokens to fund the channel with.
476    #[serde_as(as = "DisplayFromStr")]
477    #[schema(value_type = String, example = "10 wxHOPR")]
478    amount: HoprBalance,
479}
480
481/// Funds the given channel with the given amount of HOPR tokens.
482#[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}