1use std::{collections::VecDeque, num::NonZeroUsize};
16
17use async_trait::async_trait;
18use committable::{Commitment, Committable};
19use futures::stream::{self, StreamExt, TryStreamExt};
20use hotshot_types::traits::{block_contents::BlockHeader, node_implementation::NodeType};
21use itertools::Itertools;
22use sqlx::{FromRow, Row};
23use tagged_base64::{Tagged, TaggedBase64};
24
25use super::{
26 super::transaction::{query, Transaction, TransactionMode},
27 Database, Db, DecodeError, BLOCK_COLUMNS,
28};
29use crate::{
30 availability::{BlockQueryData, QueryableHeader, QueryablePayload},
31 data_source::storage::{ExplorerStorage, NodeStorage},
32 explorer::{
33 self,
34 errors::{self, NotFound},
35 query_data::TransactionDetailResponse,
36 traits::ExplorerHeader,
37 BalanceAmount, BlockDetail, BlockIdentifier, BlockRange, BlockSummary, ExplorerHistograms,
38 ExplorerSummary, GenesisOverview, GetBlockDetailError, GetBlockSummariesError,
39 GetBlockSummariesRequest, GetExplorerSummaryError, GetSearchResultsError,
40 GetTransactionDetailError, GetTransactionSummariesError, GetTransactionSummariesRequest,
41 MonetaryValue, SearchResult, TransactionIdentifier, TransactionRange, TransactionSummary,
42 TransactionSummaryFilter,
43 },
44 types::HeightIndexed,
45 Header, Payload, QueryError, QueryResult, Transaction as HotshotTransaction,
46};
47
48impl From<sqlx::Error> for GetExplorerSummaryError {
49 fn from(err: sqlx::Error) -> Self {
50 Self::from(QueryError::from(err))
51 }
52}
53
54impl From<sqlx::Error> for GetTransactionDetailError {
55 fn from(err: sqlx::Error) -> Self {
56 Self::from(QueryError::from(err))
57 }
58}
59
60impl From<sqlx::Error> for GetTransactionSummariesError {
61 fn from(err: sqlx::Error) -> Self {
62 Self::from(QueryError::from(err))
63 }
64}
65
66impl From<sqlx::Error> for GetBlockDetailError {
67 fn from(err: sqlx::Error) -> Self {
68 Self::from(QueryError::from(err))
69 }
70}
71
72impl From<sqlx::Error> for GetBlockSummariesError {
73 fn from(err: sqlx::Error) -> Self {
74 Self::from(QueryError::from(err))
75 }
76}
77
78impl From<sqlx::Error> for GetSearchResultsError {
79 fn from(err: sqlx::Error) -> Self {
80 Self::from(QueryError::from(err))
81 }
82}
83
84impl<'r, Types> FromRow<'r, <Db as Database>::Row> for BlockSummary<Types>
85where
86 Types: NodeType,
87 Header<Types>: BlockHeader<Types> + ExplorerHeader<Types>,
88 Payload<Types>: QueryablePayload<Types>,
89{
90 fn from_row(row: &'r <Db as Database>::Row) -> sqlx::Result<Self> {
91 BlockQueryData::<Types>::from_row(row)?
92 .try_into()
93 .decode_error("malformed block summary")
94 }
95}
96
97impl<'r, Types> FromRow<'r, <Db as Database>::Row> for BlockDetail<Types>
98where
99 Types: NodeType,
100 Header<Types>: BlockHeader<Types> + ExplorerHeader<Types>,
101 Payload<Types>: QueryablePayload<Types>,
102 BalanceAmount<Types>: Into<MonetaryValue>,
103{
104 fn from_row(row: &'r <Db as Database>::Row) -> sqlx::Result<Self> {
105 BlockQueryData::<Types>::from_row(row)?
106 .try_into()
107 .decode_error("malformed block detail")
108 }
109}
110
111lazy_static::lazy_static! {
112 static ref GET_BLOCK_SUMMARIES_QUERY_FOR_LATEST: String = {
113 format!(
114 "SELECT {BLOCK_COLUMNS}
115 FROM header AS h
116 JOIN payload AS p ON h.height = p.height
117 ORDER BY h.height DESC
118 LIMIT $1"
119 )
120 };
121
122 static ref GET_BLOCK_SUMMARIES_QUERY_FOR_HEIGHT: String = {
123 format!(
124 "SELECT {BLOCK_COLUMNS}
125 FROM header AS h
126 JOIN payload AS p ON h.height = p.height
127 WHERE h.height <= $1
128 ORDER BY h.height DESC
129 LIMIT $2"
130 )
131 };
132
133 static ref GET_BLOCK_SUMMARIES_QUERY_FOR_HASH: String = {
139 format!(
140 "SELECT {BLOCK_COLUMNS}
141 FROM header AS h
142 JOIN payload AS p ON h.height = p.height
143 WHERE h.height <= (SELECT h1.height FROM header AS h1 WHERE h1.hash = $1)
144 ORDER BY h.height DESC
145 LIMIT $2",
146 )
147 };
148
149 static ref GET_BLOCK_DETAIL_QUERY_FOR_LATEST: String = {
150 format!(
151 "SELECT {BLOCK_COLUMNS}
152 FROM header AS h
153 JOIN payload AS p ON h.height = p.height
154 ORDER BY h.height DESC
155 LIMIT 1"
156 )
157 };
158
159 static ref GET_BLOCK_DETAIL_QUERY_FOR_HEIGHT: String = {
160 format!(
161 "SELECT {BLOCK_COLUMNS}
162 FROM header AS h
163 JOIN payload AS p ON h.height = p.height
164 WHERE h.height = $1
165 ORDER BY h.height DESC
166 LIMIT 1"
167 )
168 };
169
170 static ref GET_BLOCK_DETAIL_QUERY_FOR_HASH: String = {
171 format!(
172 "SELECT {BLOCK_COLUMNS}
173 FROM header AS h
174 JOIN payload AS p ON h.height = p.height
175 WHERE h.hash = $1
176 ORDER BY h.height DESC
177 LIMIT 1"
178 )
179 };
180
181
182 static ref GET_BLOCKS_CONTAINING_TRANSACTIONS_NO_FILTER_QUERY: String = {
183 format!(
184 "SELECT {BLOCK_COLUMNS}
185 FROM header AS h
186 JOIN payload AS p ON h.height = p.height
187 WHERE h.height IN (
188 SELECT t.block_height
189 FROM transactions AS t
190 WHERE (t.block_height, t.ns_id, t.position) <= ($1, $2, $3)
191 ORDER BY t.block_height DESC, t.ns_id DESC, t.position DESC
192 LIMIT $4
193 )
194 ORDER BY h.height DESC"
195 )
196 };
197
198 static ref GET_BLOCKS_CONTAINING_TRANSACTIONS_IN_NAMESPACE_QUERY: String = {
199 format!(
200 "SELECT {BLOCK_COLUMNS}
201 FROM header AS h
202 JOIN payload AS p ON h.height = p.height
203 WHERE h.height IN (
204 SELECT t.block_height
205 FROM transactions AS t
206 WHERE (t.block_height, t.ns_id, t.position) <= ($1, $2, $3)
207 AND t.ns_id = $5
208 ORDER BY t.block_height DESC, t.ns_id DESC, t.position DESC
209 LIMIT $4
210 )
211 ORDER BY h.height DESC"
212 )
213 };
214
215 static ref GET_TRANSACTION_SUMMARIES_QUERY_FOR_BLOCK: String = {
216 format!(
217 "SELECT {BLOCK_COLUMNS}
218 FROM header AS h
219 JOIN payload AS p ON h.height = p.height
220 WHERE h.height = $1
221 ORDER BY h.height DESC"
222 )
223 };
224
225 static ref GET_TRANSACTION_DETAIL_QUERY_FOR_LATEST: String = {
226 format!(
227 "SELECT {BLOCK_COLUMNS}
228 FROM header AS h
229 JOIN payload AS p ON h.height = p.height
230 WHERE h.height = (
231 SELECT MAX(t1.block_height)
232 FROM transactions AS t1
233 )
234 ORDER BY h.height DESC"
235 )
236 };
237
238 static ref GET_TRANSACTION_DETAIL_QUERY_FOR_HEIGHT_AND_OFFSET: String = {
239 format!(
240 "SELECT {BLOCK_COLUMNS}
241 FROM header AS h
242 JOIN payload AS p ON h.height = p.height
243 WHERE h.height = (
244 SELECT t1.block_height
245 FROM transactions AS t1
246 WHERE t1.block_height = $1
247 ORDER BY t1.block_height, t1.ns_id, t1.position
248 LIMIT 1
249 OFFSET $2
250
251 )
252 ORDER BY h.height DESC",
253 )
254 };
255
256 static ref GET_TRANSACTION_DETAIL_QUERY_FOR_HASH: String = {
257 format!(
258 "SELECT {BLOCK_COLUMNS}
259 FROM header AS h
260 JOIN payload AS p ON h.height = p.height
261 WHERE h.height = (
262 SELECT t1.block_height
263 FROM transactions AS t1
264 WHERE t1.hash = $1
265 ORDER BY t1.block_height DESC, t1.ns_id DESC, t1.position DESC
266 LIMIT 1
267 )
268 ORDER BY h.height DESC"
269 )
270 };
271}
272
273const EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES: usize = 50;
276
277const EXPLORER_SUMMARY_NUM_BLOCKS: usize = 10;
280
281const EXPLORER_SUMMARY_NUM_TRANSACTIONS: usize = 10;
284
285#[async_trait]
286impl<Mode, Types> ExplorerStorage<Types> for Transaction<Mode>
287where
288 Mode: TransactionMode,
289 Types: NodeType,
290 Payload<Types>: QueryablePayload<Types>,
291 Header<Types>: QueryableHeader<Types> + ExplorerHeader<Types>,
292 crate::Transaction<Types>: explorer::traits::ExplorerTransaction<Types>,
293 BalanceAmount<Types>: Into<explorer::monetary_value::MonetaryValue>,
294{
295 async fn get_block_summaries(
296 &mut self,
297 request: GetBlockSummariesRequest<Types>,
298 ) -> Result<Vec<BlockSummary<Types>>, GetBlockSummariesError> {
299 let request = &request.0;
300
301 let query_stmt = match request.target {
302 BlockIdentifier::Latest => {
303 query(&GET_BLOCK_SUMMARIES_QUERY_FOR_LATEST).bind(request.num_blocks.get() as i64)
304 },
305 BlockIdentifier::Height(height) => query(&GET_BLOCK_SUMMARIES_QUERY_FOR_HEIGHT)
306 .bind(height as i64)
307 .bind(request.num_blocks.get() as i64),
308 BlockIdentifier::Hash(hash) => query(&GET_BLOCK_SUMMARIES_QUERY_FOR_HASH)
309 .bind(hash.to_string())
310 .bind(request.num_blocks.get() as i64),
311 };
312
313 let row_stream = query_stmt.fetch(self.as_mut());
314 let result = row_stream.map(|row| BlockSummary::from_row(&row?));
315
316 Ok(result.try_collect().await?)
317 }
318
319 async fn get_block_detail(
320 &mut self,
321 request: BlockIdentifier<Types>,
322 ) -> Result<BlockDetail<Types>, GetBlockDetailError> {
323 let query_stmt = match request {
324 BlockIdentifier::Latest => query(&GET_BLOCK_DETAIL_QUERY_FOR_LATEST),
325 BlockIdentifier::Height(height) => {
326 query(&GET_BLOCK_DETAIL_QUERY_FOR_HEIGHT).bind(height as i64)
327 },
328 BlockIdentifier::Hash(hash) => {
329 query(&GET_BLOCK_DETAIL_QUERY_FOR_HASH).bind(hash.to_string())
330 },
331 };
332
333 let query_result = query_stmt.fetch_one(self.as_mut()).await?;
334 let block = BlockDetail::from_row(&query_result)?;
335
336 Ok(block)
337 }
338
339 async fn get_transaction_summaries(
340 &mut self,
341 request: GetTransactionSummariesRequest<Types>,
342 ) -> Result<Vec<TransactionSummary<Types>>, GetTransactionSummariesError> {
343 let range = &request.range;
344 let target = &range.target;
345 let filter = &request.filter;
346
347 let transaction_target_query = match target {
350 TransactionIdentifier::Latest => query(
351 "SELECT block_height AS height, ns_id, position FROM transactions ORDER BY \
352 block_height DESC, ns_id DESC, position DESC LIMIT 1",
353 ),
354 TransactionIdentifier::HeightAndOffset(height, _) => query(
355 "SELECT block_height AS height, ns_id, position FROM transactions WHERE \
356 block_height = $1 ORDER BY ns_id DESC, position DESC LIMIT 1",
357 )
358 .bind(*height as i64),
359 TransactionIdentifier::Hash(hash) => query(
360 "SELECT block_height AS height, ns_id, position FROM transactions WHERE hash = $1 \
361 ORDER BY block_height DESC, ns_id DESC, position DESC LIMIT 1",
362 )
363 .bind(hash.to_string()),
364 };
365 let Some(transaction_target) = transaction_target_query
366 .fetch_optional(self.as_mut())
367 .await?
368 else {
369 return Ok(vec![]);
372 };
373
374 let block_height = transaction_target.get::<i64, _>("height") as usize;
375 let namespace = transaction_target.get::<i64, _>("ns_id");
376 let position = transaction_target.get::<i64, _>("position");
377 let offset = if let TransactionIdentifier::HeightAndOffset(_, offset) = target {
378 *offset
379 } else {
380 0
381 };
382
383 let query_stmt = match filter {
391 TransactionSummaryFilter::RollUp(ns) => {
392 query(&GET_BLOCKS_CONTAINING_TRANSACTIONS_IN_NAMESPACE_QUERY)
393 .bind(block_height as i64)
394 .bind(namespace)
395 .bind(position)
396 .bind((range.num_transactions.get() + offset) as i64)
397 .bind((*ns).into())
398 },
399 TransactionSummaryFilter::None => {
400 query(&GET_BLOCKS_CONTAINING_TRANSACTIONS_NO_FILTER_QUERY)
401 .bind(block_height as i64)
402 .bind(namespace)
403 .bind(position)
404 .bind((range.num_transactions.get() + offset) as i64)
405 },
406
407 TransactionSummaryFilter::Block(block) => {
408 query(&GET_TRANSACTION_SUMMARIES_QUERY_FOR_BLOCK).bind(*block as i64)
409 },
410 };
411
412 let block_stream = query_stmt
413 .fetch(self.as_mut())
414 .map(|row| BlockQueryData::from_row(&row?));
415
416 let transaction_summary_stream = block_stream.flat_map(|row| match row {
417 Ok(block) => {
418 tracing::info!(height = block.height(), "selected block");
419 stream::iter(
420 block
421 .enumerate()
422 .filter(|(ix, _)| {
423 if let TransactionSummaryFilter::RollUp(ns) = filter {
424 let tx_ns = QueryableHeader::<Types>::namespace_id(
425 block.header(),
426 &ix.ns_index,
427 );
428 tx_ns.as_ref() == Some(ns)
429 } else {
430 true
431 }
432 })
433 .enumerate()
434 .map(|(index, (_, txn))| {
435 TransactionSummary::try_from((&block, index, txn)).map_err(|err| {
436 QueryError::Error {
437 message: err.to_string(),
438 }
439 })
440 })
441 .collect::<Vec<QueryResult<TransactionSummary<Types>>>>()
442 .into_iter()
443 .rev()
444 .collect::<Vec<QueryResult<TransactionSummary<Types>>>>(),
445 )
446 },
447 Err(err) => stream::iter(vec![Err(err.into())]),
448 });
449
450 let transaction_summary_vec = transaction_summary_stream
451 .try_collect::<Vec<TransactionSummary<Types>>>()
452 .await?;
453
454 Ok(transaction_summary_vec
455 .into_iter()
456 .skip(offset)
457 .skip_while(|txn| {
458 if let TransactionIdentifier::Hash(hash) = target {
459 txn.hash != *hash
460 } else {
461 false
462 }
463 })
464 .take(range.num_transactions.get())
465 .collect::<Vec<TransactionSummary<Types>>>())
466 }
467
468 async fn get_transaction_detail(
469 &mut self,
470 request: TransactionIdentifier<Types>,
471 ) -> Result<TransactionDetailResponse<Types>, GetTransactionDetailError> {
472 let target = request;
473
474 let query_stmt = match target {
475 TransactionIdentifier::Latest => query(&GET_TRANSACTION_DETAIL_QUERY_FOR_LATEST),
476 TransactionIdentifier::HeightAndOffset(height, offset) => {
477 query(&GET_TRANSACTION_DETAIL_QUERY_FOR_HEIGHT_AND_OFFSET)
478 .bind(height as i64)
479 .bind(offset as i64)
480 },
481 TransactionIdentifier::Hash(hash) => {
482 query(&GET_TRANSACTION_DETAIL_QUERY_FOR_HASH).bind(hash.to_string())
483 },
484 };
485
486 let query_row = query_stmt.fetch_one(self.as_mut()).await?;
487 let block = BlockQueryData::<Types>::from_row(&query_row)?;
488
489 let txns = block.enumerate().map(|(_, txn)| txn).collect::<Vec<_>>();
490
491 let (offset, txn) = match target {
492 TransactionIdentifier::Latest => txns.into_iter().enumerate().next_back().ok_or(
493 GetTransactionDetailError::TransactionNotFound(NotFound {
494 key: "Latest".to_string(),
495 }),
496 ),
497 TransactionIdentifier::HeightAndOffset(height, offset) => {
498 txns.into_iter().enumerate().nth(offset).ok_or(
499 GetTransactionDetailError::TransactionNotFound(NotFound {
500 key: format!("at {height} and {offset}"),
501 }),
502 )
503 },
504 TransactionIdentifier::Hash(hash) => txns
505 .into_iter()
506 .enumerate()
507 .find(|(_, txn)| txn.commit() == hash)
508 .ok_or(GetTransactionDetailError::TransactionNotFound(NotFound {
509 key: format!("hash {hash}"),
510 })),
511 }?;
512
513 Ok(TransactionDetailResponse::try_from((&block, offset, txn))?)
514 }
515
516 async fn get_explorer_summary(
517 &mut self,
518 ) -> Result<ExplorerSummary<Types>, GetExplorerSummaryError> {
519 let histograms = {
520 let histogram_query_result = query(
521 "SELECT
522 h.height AS height,
523 h.timestamp AS timestamp,
524 h.timestamp - lead(timestamp) OVER (ORDER BY h.height DESC) AS time,
525 p.size AS size,
526 p.num_transactions AS transactions
527 FROM header AS h
528 JOIN payload AS p ON
529 p.height = h.height
530 WHERE
531 h.height IN (SELECT height FROM header ORDER BY height DESC LIMIT $1)
532 ORDER BY h.height
533 ",
534 )
535 .bind((EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES + 1) as i64)
536 .fetch(self.as_mut());
537
538 let mut histograms: ExplorerHistograms = histogram_query_result
539 .map(|row_stream| {
540 row_stream.map(|row| {
541 let height: i64 = row.try_get("height")?;
542 let timestamp: i64 = row.try_get("timestamp")?;
543 let time: Option<i64> = row.try_get("time")?;
544 let size: Option<i32> = row.try_get("size")?;
545 let num_transactions: i32 = row.try_get("transactions")?;
546
547 Ok((height, timestamp, time, size, num_transactions))
548 })
549 })
550 .try_fold(
551 ExplorerHistograms {
552 block_time: VecDeque::with_capacity(EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES),
553 block_size: VecDeque::with_capacity(EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES),
554 block_transactions: VecDeque::with_capacity(EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES),
555 block_heights: VecDeque::with_capacity(EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES),
556 },
557 |mut histograms: ExplorerHistograms,
558 row: sqlx::Result<(i64, i64, Option<i64>, Option<i32>, i32)>| async {
559 let (height, _timestamp, time, size, num_transactions) = row?;
560
561 histograms.block_time.push_back(time.map(|i| i as u64));
562 histograms.block_size.push_back(size.map(|i| i as u64));
563 histograms.block_transactions.push_back(num_transactions as u64);
564 histograms.block_heights.push_back(height as u64);
565 Ok(histograms)
566 },
567 )
568 .await?;
569
570 while histograms.block_time.len() > EXPLORER_SUMMARY_HISTOGRAM_NUM_ENTRIES {
571 histograms.block_time.pop_front();
572 histograms.block_size.pop_front();
573 histograms.block_transactions.pop_front();
574 histograms.block_heights.pop_front();
575 }
576
577 histograms
578 };
579
580 let genesis_overview = {
581 let blocks = NodeStorage::<Types>::block_height(self).await? as u64;
582 let transactions =
583 NodeStorage::<Types>::count_transactions_in_range(self, .., None).await? as u64;
584 GenesisOverview {
585 rollups: 0,
586 transactions,
587 blocks,
588 }
589 };
590
591 let latest_block: BlockDetail<Types> =
592 self.get_block_detail(BlockIdentifier::Latest).await?;
593
594 let latest_blocks: Vec<BlockSummary<Types>> = self
595 .get_block_summaries(GetBlockSummariesRequest(BlockRange {
596 target: BlockIdentifier::Latest,
597 num_blocks: NonZeroUsize::new(EXPLORER_SUMMARY_NUM_BLOCKS).unwrap(),
598 }))
599 .await?;
600
601 let latest_transactions: Vec<TransactionSummary<Types>> = self
602 .get_transaction_summaries(GetTransactionSummariesRequest {
603 range: TransactionRange {
604 target: TransactionIdentifier::Latest,
605 num_transactions: NonZeroUsize::new(EXPLORER_SUMMARY_NUM_TRANSACTIONS).unwrap(),
606 },
607 filter: TransactionSummaryFilter::None,
608 })
609 .await?;
610
611 Ok(ExplorerSummary {
612 genesis_overview,
613 latest_block,
614 latest_transactions,
615 latest_blocks,
616 histograms,
617 })
618 }
619
620 async fn get_search_results(
621 &mut self,
622 search_query: TaggedBase64,
623 ) -> Result<SearchResult<Types>, GetSearchResultsError> {
624 let search_tag = search_query.tag();
625 let header_tag = Commitment::<Header<Types>>::tag();
626 let tx_tag = Commitment::<HotshotTransaction<Types>>::tag();
627
628 if search_tag != header_tag && search_tag != tx_tag {
629 return Err(GetSearchResultsError::InvalidQuery(errors::BadQuery {}));
630 }
631
632 let search_query_string = search_query.to_string();
633 if search_tag == header_tag {
634 let block_query = format!(
635 "SELECT {BLOCK_COLUMNS}
636 FROM header AS h
637 JOIN payload AS p ON h.height = p.height
638 WHERE h.hash = $1
639 ORDER BY h.height DESC
640 LIMIT 1"
641 );
642 let row = query(block_query.as_str())
643 .bind(&search_query_string)
644 .fetch_one(self.as_mut())
645 .await?;
646
647 let block = BlockSummary::from_row(&row)?;
648
649 Ok(SearchResult {
650 blocks: vec![block],
651 transactions: Vec::new(),
652 })
653 } else {
654 let transactions_query = format!(
655 "SELECT {BLOCK_COLUMNS}
656 FROM header AS h
657 JOIN payload AS p ON h.height = p.height
658 JOIN transactions AS t ON h.height = t.block_height
659 WHERE t.hash = $1
660 ORDER BY h.height DESC
661 LIMIT 5"
662 );
663 let transactions_query_rows = query(transactions_query.as_str())
664 .bind(&search_query_string)
665 .fetch(self.as_mut());
666 let transactions_query_result: Vec<TransactionSummary<Types>> = transactions_query_rows
667 .map(|row| -> Result<Vec<TransactionSummary<Types>>, QueryError>{
668 let block = BlockQueryData::<Types>::from_row(&row?)?;
669 let transactions = block
670 .enumerate()
671 .enumerate()
672 .filter(|(_, (_, txn))| txn.commit().to_string() == search_query_string)
673 .map(|(offset, (_, txn))| {
674 Ok(TransactionSummary::try_from((
675 &block, offset, txn,
676 ))?)
677 })
678 .try_collect::<TransactionSummary<Types>, Vec<TransactionSummary<Types>>, QueryError>()?;
679 Ok(transactions)
680 })
681 .try_collect::<Vec<Vec<TransactionSummary<Types>>>>()
682 .await?
683 .into_iter()
684 .flatten()
685 .collect();
686
687 Ok(SearchResult {
688 blocks: Vec::new(),
689 transactions: transactions_query_result,
690 })
691 }
692 }
693}