hoprd_api/
tickets.rs

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