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")]
30pub(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#[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#[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")]
163pub(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#[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#[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#[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#[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#[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}