hoprd_api/
tickets.rs

1use std::sync::Arc;
2
3use axum::{
4    extract::{Json, Path, State},
5    http::status::StatusCode,
6    response::IntoResponse,
7};
8use hopr_crypto_types::types::Hash;
9use hopr_lib::{
10    HoprBalance, HoprTransportError, ProtocolError, Ticket, TicketStatistics, ToHex,
11    errors::{HoprLibError, HoprStatusError},
12};
13use serde::Deserialize;
14use serde_with::{DisplayFromStr, serde_as};
15
16use crate::{ApiError, ApiErrorStatus, BASE_PATH, InternalState};
17
18#[serde_as]
19#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
20#[schema(example = json!({
21        "amount": "100",
22        "channelEpoch": 1,
23        "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
24        "index": 0,
25        "indexOffset": 1,
26        "signature": "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891",
27        "winProb": "1"
28    }))]
29#[serde(rename_all = "camelCase")]
30/// Represents a ticket in a channel.
31pub(crate) struct ChannelTicket {
32    #[serde_as(as = "DisplayFromStr")]
33    #[schema(value_type = String, example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")]
34    channel_id: Hash,
35    #[serde_as(as = "DisplayFromStr")]
36    #[schema(value_type = String, example = "1.0 wxHOPR")]
37    amount: HoprBalance,
38    #[schema(example = 0)]
39    index: u64,
40    #[schema(example = 1)]
41    index_offset: u32,
42    #[schema(example = "1")]
43    win_prob: String,
44    #[schema(example = 1)]
45    channel_epoch: u32,
46    #[schema(
47        example = "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891"
48    )]
49    signature: String,
50}
51
52impl From<Ticket> for ChannelTicket {
53    fn from(value: Ticket) -> Self {
54        Self {
55            channel_id: value.channel_id,
56            amount: value.amount,
57            index: value.index,
58            index_offset: value.index_offset,
59            win_prob: value.win_prob().to_string(),
60            channel_epoch: value.channel_epoch,
61            signature: value.signature.expect("impossible to have an unsigned ticket").to_hex(),
62        }
63    }
64}
65
66#[derive(Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub(crate) struct ChannelIdParams {
69    channel_id: String,
70}
71
72/// Lists all tickets for the given channel  ID.
73#[utoipa::path(
74        get,
75        path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/tickets"),
76        description = "Lists all tickets for the given channel ID.",
77        params(
78            ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
79        ),
80        responses(
81            (status = 200, description = "Fetched all tickets for the given channel ID", body = [ChannelTicket], example = json!([
82                {
83                    "amount": "10 wxHOPR",
84                    "channelEpoch": 1,
85                    "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
86                    "index": 0,
87                    "indexOffset": 1,
88                    "signature": "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891",
89                    "winProb": "1"
90                }
91            ])),
92            (status = 400, description = "Invalid channel id.", body = ApiError),
93            (status = 401, description = "Invalid authorization token.", body = ApiError),
94            (status = 404, description = "Channel not found.", body = ApiError),
95            (status = 422, description = "Unknown failure", body = ApiError)
96        ),
97        security(
98            ("api_token" = []),
99            ("bearer_token" = [])
100        ),
101        tag = "Channels"
102    )]
103pub(super) async fn show_channel_tickets(
104    Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
105    State(state): State<Arc<InternalState>>,
106) -> impl IntoResponse {
107    let hopr = state.hopr.clone();
108
109    match Hash::from_hex(channel_id.as_str()) {
110        Ok(channel_id) => match hopr.tickets_in_channel(&channel_id).await {
111            Ok(Some(_tickets)) => {
112                let tickets: Vec<ChannelTicket> = vec![];
113                (StatusCode::OK, Json(tickets)).into_response()
114            }
115            Ok(None) => (StatusCode::NOT_FOUND, ApiErrorStatus::TicketsNotFound).into_response(),
116            Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
117        },
118        Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
119    }
120}
121
122/// Endpoint is deprecated and will be removed in the future. Returns an empty array.
123#[utoipa::path(
124    get,
125    path = const_format::formatcp!("{BASE_PATH}/tickets"),
126    description = "(deprecated) Returns an empty array.",
127    responses(
128        (status = 200, description = "Fetched all tickets in all the channels", body = [ChannelTicket], example = json!([
129        {
130            "amount": "10 wxHOPR",
131            "channelEpoch": 1,
132            "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
133            "index": 0,
134            "indexOffset": 1,
135            "signature": "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891",
136            "winProb": "1"
137        }
138        ])),
139        (status = 401, description = "Invalid authorization token.", body = ApiError),
140        (status = 422, description = "Unknown failure", body = ApiError)
141    ),
142    security(
143        ("api_token" = []),
144        ("bearer_token" = [])
145    ),
146    tag = "Tickets"
147    )]
148pub(super) async fn show_all_tickets() -> impl IntoResponse {
149    let tickets: Vec<ChannelTicket> = vec![];
150    (StatusCode::OK, Json(tickets)).into_response()
151}
152
153#[serde_as]
154#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
155#[schema(example = json!({
156        "winningCount": 0,
157        "neglectedValue": "0 wxHOPR",
158        "redeemedValue": "1000 wxHOPR",
159        "rejectedValue": "0 wxHOPR",
160        "unredeemedValue": "2000 wxHOPR",
161    }))]
162#[serde(rename_all = "camelCase")]
163/// Received tickets statistics.
164pub(crate) struct NodeTicketStatisticsResponse {
165    #[schema(example = 0)]
166    winning_count: u64,
167    #[serde_as(as = "DisplayFromStr")]
168    #[schema(value_type = String, example = "20 wxHOPR")]
169    unredeemed_value: HoprBalance,
170    #[serde_as(as = "DisplayFromStr")]
171    #[schema(value_type = String,example = "100 wxHOPR")]
172    redeemed_value: HoprBalance,
173    #[serde_as(as = "DisplayFromStr")]
174    #[schema(value_type = String,example = "0 wxHOPR")]
175    neglected_value: HoprBalance,
176    #[serde_as(as = "DisplayFromStr")]
177    #[schema(value_type = String, example = "0 wHOPR")]
178    rejected_value: HoprBalance,
179}
180
181impl From<TicketStatistics> for NodeTicketStatisticsResponse {
182    fn from(value: TicketStatistics) -> Self {
183        Self {
184            winning_count: value.winning_count as u64,
185            unredeemed_value: value.unredeemed_value,
186            redeemed_value: value.redeemed_value,
187            neglected_value: value.neglected_value,
188            rejected_value: value.rejected_value,
189        }
190    }
191}
192
193/// Returns current complete statistics on tickets.
194#[utoipa::path(
195        get,
196        path = const_format::formatcp!("{BASE_PATH}/tickets/statistics"),
197        description = "Returns current complete statistics on tickets.",
198        responses(
199            (status = 200, description = "Tickets statistics fetched successfully. Check schema for description of every field in the statistics.", body = NodeTicketStatisticsResponse),
200            (status = 401, description = "Invalid authorization token.", body = ApiError),
201            (status = 422, description = "Unknown failure", body = ApiError)
202        ),
203        security(
204            ("api_token" = []),
205            ("bearer_token" = [])
206        ),
207        tag = "Tickets"
208    )]
209pub(super) async fn show_ticket_statistics(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
210    let hopr = state.hopr.clone();
211    match hopr.ticket_statistics().await.map(NodeTicketStatisticsResponse::from) {
212        Ok(stats) => (StatusCode::OK, Json(stats)).into_response(),
213        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
214    }
215}
216
217/// Resets the ticket metrics.
218#[utoipa::path(
219        delete,
220        path = const_format::formatcp!("{BASE_PATH}/tickets/statistics"),
221        description = "Resets the ticket metrics.",
222        responses(
223            (status = 204, description = "Ticket statistics reset successfully."),
224            (status = 401, description = "Invalid authorization token.", body = ApiError),
225            (status = 422, description = "Unknown failure", body = ApiError)
226        ),
227        security(
228            ("api_token" = []),
229            ("bearer_token" = [])
230        ),
231        tag = "Tickets"
232    )]
233pub(super) async fn reset_ticket_statistics(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
234    let hopr = state.hopr.clone();
235    match hopr.reset_ticket_statistics().await {
236        Ok(()) => (StatusCode::NO_CONTENT, "").into_response(),
237        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
238    }
239}
240
241/// Starts redeeming of all tickets in all channels.
242///
243/// **WARNING:** this should almost **never** be used as it can issue a large
244/// number of on-chain transactions. The tickets should almost always be aggregated first.
245#[utoipa::path(
246        post,
247        path = const_format::formatcp!("{BASE_PATH}/tickets/redeem"),
248        description = "Starts redeeming of all tickets in all channels.",
249        responses(
250            (status = 204, description = "Tickets redeemed successfully."),
251            (status = 401, description = "Invalid authorization token.", body = ApiError),
252            (status = 412, description = "The node is not ready."),
253            (status = 422, description = "Unknown failure", body = ApiError)
254        ),
255        security(
256            ("api_token" = []),
257            ("bearer_token" = [])
258        ),
259        tag = "Tickets"
260    )]
261pub(super) async fn redeem_all_tickets(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
262    let hopr = state.hopr.clone();
263    match hopr.redeem_all_tickets(false).await {
264        Ok(()) => (StatusCode::NO_CONTENT, "").into_response(),
265        Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
266            (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
267        }
268        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
269    }
270}
271
272/// Starts redeeming all tickets in the given channel.
273///
274/// **WARNING:** this should almost **never** be used as it can issue a large
275/// number of on-chain transactions. The tickets should almost always be aggregated first.
276#[utoipa::path(
277        post,
278        path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/tickets/redeem"),
279        description = "Starts redeeming all tickets in the given channel.",
280        params(
281            ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
282        ),
283        responses(
284            (status = 204, description = "Tickets redeemed successfully."),
285            (status = 400, description = "Invalid channel id.", body = ApiError),
286            (status = 401, description = "Invalid authorization token.", body = ApiError),
287            (status = 404, description = "Tickets were not found for that channel. That means that no messages were sent inside this channel yet.", body = ApiError),
288            (status = 412, description = "The node is not ready."),
289            (status = 422, description = "Unknown failure", body = ApiError)
290        ),
291        security(
292            ("api_token" = []),
293            ("bearer_token" = [])
294        ),
295        tag = "Channels"
296    )]
297pub(super) async fn redeem_tickets_in_channel(
298    Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
299    State(state): State<Arc<InternalState>>,
300) -> impl IntoResponse {
301    let hopr = state.hopr.clone();
302
303    match Hash::from_hex(channel_id.as_str()) {
304        Ok(channel_id) => match hopr.redeem_tickets_in_channel(&channel_id, false).await {
305            Ok(count) if count > 0 => (StatusCode::NO_CONTENT, "").into_response(),
306            Ok(_) => (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response(),
307            Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
308                (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
309            }
310            Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
311        },
312        Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
313    }
314}
315
316/// Starts aggregation of tickets in the given channel.
317#[utoipa::path(
318        post,
319        path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/tickets/aggregate"),
320        description = "Starts aggregation of tickets in the given channel.",
321        params(
322            ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
323        ),
324        responses(
325            (status = 204, description = "Tickets successfully aggregated"),
326            (status = 400, description = "Invalid channel id.", body = ApiError),
327            (status = 401, description = "Invalid authorization token.", body = ApiError),
328            (status = 404, description = "Tickets were not found for that channel. That means that no messages were sent inside this channel yet.", body = ApiError),
329            (status = 422, description = "Unknown failure", body = ApiError)
330        ),
331        security(
332            ("api_token" = []),
333            ("bearer_token" = [])
334        ),
335        tag = "Channels"
336    )]
337pub(super) async fn aggregate_tickets_in_channel(
338    Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
339    State(state): State<Arc<InternalState>>,
340) -> impl IntoResponse {
341    let hopr = state.hopr.clone();
342
343    match Hash::from_hex(channel_id.as_str()) {
344        Ok(channel_id) => match hopr.aggregate_tickets(&channel_id).await {
345            Ok(_) => (StatusCode::NO_CONTENT, "").into_response(),
346            Err(HoprLibError::TransportError(HoprTransportError::Protocol(ProtocolError::ChannelNotFound))) => {
347                (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response()
348            }
349            Err(HoprLibError::TransportError(HoprTransportError::Protocol(ProtocolError::ChannelClosed))) => {
350                (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::ChannelNotOpen).into_response()
351            }
352            Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
353        },
354        Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
355    }
356}