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_lib::{
9    ChannelTicketStatistics, HoprBalance, ToHex,
10    errors::{HoprLibError, HoprStatusError},
11    prelude::Hash,
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    win_prob: String,
42    #[schema(example = 1)]
43    channel_epoch: u32,
44    #[schema(
45        example = "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891"
46    )]
47    signature: String,
48}
49
50#[derive(Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub(crate) struct ChannelIdParams {
53    channel_id: String,
54}
55
56/// Lists all tickets for the given channel  ID.
57#[utoipa::path(
58        get,
59        path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/tickets"),
60        description = "Lists all tickets for the given channel ID.",
61        params(
62            ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
63        ),
64        responses(
65            (status = 200, description = "Fetched all tickets for the given channel ID", body = [ChannelTicket], example = json!([
66                {
67                    "amount": "10 wxHOPR",
68                    "channelEpoch": 1,
69                    "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
70                    "index": 0,
71                    "indexOffset": 1,
72                    "signature": "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891",
73                    "winProb": "1"
74                }
75            ])),
76            (status = 400, description = "Invalid channel id.", body = ApiError),
77            (status = 401, description = "Invalid authorization token.", body = ApiError),
78            (status = 404, description = "Channel not found.", body = ApiError),
79            (status = 422, description = "Unknown failure", body = ApiError)
80        ),
81        security(
82            ("api_token" = []),
83            ("bearer_token" = [])
84        ),
85        tag = "Channels"
86    )]
87pub(super) async fn show_channel_tickets(
88    Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
89    State(state): State<Arc<InternalState>>,
90) -> impl IntoResponse {
91    let hopr = state.hopr.clone();
92
93    match Hash::from_hex(channel_id.as_str()) {
94        Ok(channel_id) => match hopr.tickets_in_channel(&channel_id).await {
95            Ok(Some(_tickets)) => {
96                let tickets: Vec<ChannelTicket> = vec![];
97                (StatusCode::OK, Json(tickets)).into_response()
98            }
99            Ok(None) => (StatusCode::NOT_FOUND, ApiErrorStatus::TicketsNotFound).into_response(),
100            Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
101        },
102        Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
103    }
104}
105
106/// Endpoint is deprecated and will be removed in the future. Returns an empty array.
107#[utoipa::path(
108    get,
109    path = const_format::formatcp!("{BASE_PATH}/tickets"),
110    description = "(deprecated) Returns an empty array.",
111    responses(
112        (status = 200, description = "Fetched all tickets in all the channels", body = [ChannelTicket], example = json!([
113        {
114            "amount": "10 wxHOPR",
115            "channelEpoch": 1,
116            "channelId": "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f",
117            "index": 0,
118            "indexOffset": 1,
119            "signature": "0xe445fcf4e90d25fe3c9199ccfaff85e23ecce8773304d85e7120f1f38787f2329822470487a37f1b5408c8c0b73e874ee9f7594a632713b6096e616857999891",
120            "winProb": "1"
121        }
122        ])),
123        (status = 401, description = "Invalid authorization token.", body = ApiError),
124        (status = 422, description = "Unknown failure", body = ApiError)
125    ),
126    security(
127        ("api_token" = []),
128        ("bearer_token" = [])
129    ),
130    tag = "Tickets"
131    )]
132pub(super) async fn show_all_tickets() -> impl IntoResponse {
133    let tickets: Vec<ChannelTicket> = vec![];
134    (StatusCode::OK, Json(tickets)).into_response()
135}
136
137#[serde_as]
138#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
139#[schema(example = json!({
140        "winningCount": 0,
141        "neglectedValue": "0 wxHOPR",
142        "redeemedValue": "1000 wxHOPR",
143        "rejectedValue": "0 wxHOPR",
144        "unredeemedValue": "2000 wxHOPR",
145    }))]
146#[serde(rename_all = "camelCase")]
147/// Received tickets statistics.
148pub(crate) struct NodeTicketStatisticsResponse {
149    #[schema(example = 0)]
150    winning_count: u64,
151    #[serde_as(as = "DisplayFromStr")]
152    #[schema(value_type = String, example = "20 wxHOPR")]
153    unredeemed_value: HoprBalance,
154    #[serde_as(as = "DisplayFromStr")]
155    #[schema(value_type = String,example = "100 wxHOPR")]
156    redeemed_value: HoprBalance,
157    #[serde_as(as = "DisplayFromStr")]
158    #[schema(value_type = String,example = "0 wxHOPR")]
159    neglected_value: HoprBalance,
160    #[serde_as(as = "DisplayFromStr")]
161    #[schema(value_type = String, example = "0 wHOPR")]
162    rejected_value: HoprBalance,
163}
164
165impl From<ChannelTicketStatistics> for NodeTicketStatisticsResponse {
166    fn from(value: ChannelTicketStatistics) -> Self {
167        Self {
168            winning_count: value.winning_tickets as u64,
169            unredeemed_value: value.unredeemed_value,
170            redeemed_value: value.redeemed_value(),
171            neglected_value: value.neglected_value(),
172            rejected_value: value.rejected_value(),
173        }
174    }
175}
176
177/// Returns current complete statistics on tickets.
178#[utoipa::path(
179        get,
180        path = const_format::formatcp!("{BASE_PATH}/tickets/statistics"),
181        description = "Returns current complete statistics on tickets.",
182        responses(
183            (status = 200, description = "Tickets statistics fetched successfully. Check schema for description of every field in the statistics.", body = NodeTicketStatisticsResponse),
184            (status = 401, description = "Invalid authorization token.", body = ApiError),
185            (status = 422, description = "Unknown failure", body = ApiError)
186        ),
187        security(
188            ("api_token" = []),
189            ("bearer_token" = [])
190        ),
191        tag = "Tickets"
192    )]
193pub(super) async fn show_ticket_statistics(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
194    let hopr = state.hopr.clone();
195    match hopr.ticket_statistics().await.map(NodeTicketStatisticsResponse::from) {
196        Ok(stats) => (StatusCode::OK, Json(stats)).into_response(),
197        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
198    }
199}
200
201/// Resets the ticket metrics.
202#[utoipa::path(
203        delete,
204        path = const_format::formatcp!("{BASE_PATH}/tickets/statistics"),
205        description = "Resets the ticket metrics.",
206        responses(
207            (status = 204, description = "Ticket statistics reset successfully."),
208            (status = 401, description = "Invalid authorization token.", body = ApiError),
209            (status = 422, description = "Unknown failure", body = ApiError)
210        ),
211        security(
212            ("api_token" = []),
213            ("bearer_token" = [])
214        ),
215        tag = "Tickets"
216    )]
217pub(super) async fn reset_ticket_statistics(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
218    let hopr = state.hopr.clone();
219    match hopr.reset_ticket_statistics().await {
220        Ok(()) => (StatusCode::NO_CONTENT, "").into_response(),
221        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
222    }
223}
224
225/// Starts redeeming of all tickets in all channels.
226///
227/// **WARNING:** this should almost **never** be used as it can issue a large
228/// number of on-chain transactions. The tickets should almost always be aggregated first.
229#[utoipa::path(
230        post,
231        path = const_format::formatcp!("{BASE_PATH}/tickets/redeem"),
232        description = "Starts redeeming of all tickets in all channels.",
233        responses(
234            (status = 204, description = "Tickets redeemed successfully."),
235            (status = 401, description = "Invalid authorization token.", body = ApiError),
236            (status = 412, description = "The node is not ready."),
237            (status = 422, description = "Unknown failure", body = ApiError)
238        ),
239        security(
240            ("api_token" = []),
241            ("bearer_token" = [])
242        ),
243        tag = "Tickets"
244    )]
245pub(super) async fn redeem_all_tickets(State(state): State<Arc<InternalState>>) -> impl IntoResponse {
246    let hopr = state.hopr.clone();
247    match hopr.redeem_all_tickets(0).await {
248        Ok(()) => (StatusCode::NO_CONTENT, "").into_response(),
249        Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
250            (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
251        }
252        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
253    }
254}
255
256/// Starts redeeming all tickets in the given channel.
257///
258/// **WARNING:** this should almost **never** be used as it can issue a large
259/// number of on-chain transactions. The tickets should almost always be aggregated first.
260#[utoipa::path(
261        post,
262        path = const_format::formatcp!("{BASE_PATH}/channels/{{channelId}}/tickets/redeem"),
263        description = "Starts redeeming all tickets in the given channel.",
264        params(
265            ("channelId" = String, Path, description = "ID of the channel.", example = "0x04efc1481d3f106b88527b3844ba40042b823218a9cd29d1aa11c2c2ef8f538f")
266        ),
267        responses(
268            (status = 204, description = "Tickets redeemed successfully."),
269            (status = 400, description = "Invalid channel id.", body = ApiError),
270            (status = 401, description = "Invalid authorization token.", body = ApiError),
271            (status = 404, description = "Tickets were not found for that channel. That means that no messages were sent inside this channel yet.", body = ApiError),
272            (status = 412, description = "The node is not ready."),
273            (status = 422, description = "Unknown failure", body = ApiError)
274        ),
275        security(
276            ("api_token" = []),
277            ("bearer_token" = [])
278        ),
279        tag = "Channels"
280    )]
281pub(super) async fn redeem_tickets_in_channel(
282    Path(ChannelIdParams { channel_id }): Path<ChannelIdParams>,
283    State(state): State<Arc<InternalState>>,
284) -> impl IntoResponse {
285    let hopr = state.hopr.clone();
286
287    match Hash::from_hex(channel_id.as_str()) {
288        Ok(channel_id) => match hopr.redeem_tickets_in_channel(&channel_id, 0).await {
289            Ok(_) => (StatusCode::NO_CONTENT, "").into_response(),
290            // Ok(_) => (StatusCode::NOT_FOUND, ApiErrorStatus::ChannelNotFound).into_response(),
291            Err(HoprLibError::StatusError(HoprStatusError::NotThereYet(..))) => {
292                (StatusCode::PRECONDITION_FAILED, ApiErrorStatus::NotReady).into_response()
293            }
294            Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, ApiErrorStatus::from(e)).into_response(),
295        },
296        Err(_) => (StatusCode::BAD_REQUEST, ApiErrorStatus::InvalidChannelId).into_response(),
297    }
298}