From 109cfd75d0dde0aed9e14d4eea7100c28634c69b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 6 Apr 2025 11:57:12 -0500 Subject: [PATCH 01/10] starting --- crates/sage-database/src/coin_states.rs | 148 ++++++++++++++++++++++- crates/sage/src/endpoints/data.rs | 149 ++++++++++++++++++++++-- 2 files changed, 285 insertions(+), 12 deletions(-) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 576c416eb..3431fd72e 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -1,6 +1,6 @@ use chia::protocol::{Bytes32, CoinState}; -use sqlx::Row; use sqlx::SqliteExecutor; +use sqlx::{sqlite::SqliteRow, Row}; use crate::{ into_row, to_bytes32, CoinKind, CoinStateRow, CoinStateSql, Database, DatabaseTx, IntoRow, @@ -57,6 +57,16 @@ impl Database { is_coin_locked(&self.pool, coin_id).await } + pub async fn get_transaction_coins( + &self, + offset: u32, + limit: u32, + asc: bool, + find_value: Option, + ) -> Result<(Vec, u32)> { + get_transaction_coins(&self.pool, offset, limit, asc, find_value).await + } + pub async fn get_block_heights( &self, offset: u32, @@ -432,6 +442,142 @@ async fn is_coin_locked(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Resu Ok(row.count > 0) } +async fn get_transaction_coins( + conn: impl SqliteExecutor<'_>, + offset: u32, + limit: u32, + asc: bool, + find_value: Option, +) -> Result<(Vec, u32)> { + let mut query = sqlx::QueryBuilder::new( + " + WITH coin_states_with_heights AS ( + SELECT + cs.coin_id, + cs.created_height as height + FROM coin_states cs + WHERE cs.created_height IS NOT NULL + + UNION ALL + + SELECT + cs.coin_id, + cs.spent_height as height + FROM coin_states cs + WHERE cs.spent_height IS NOT NULL + ), + joined_coin_states AS ( + SELECT + DISTINCT h.height + FROM coin_states_with_heights h + LEFT JOIN cat_coins ON h.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON h.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON h.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + WHERE 1=1 + ", + ); + + if let Some(value) = &find_value { + // Check if searching for XCH (matches "x", "xc", or "xch") + let should_filter_xch = if value.len() <= 3 { + let value_lower = value.to_lowercase(); + value_lower == "x" || value_lower == "xc" || value_lower == "xch" + } else { + false + }; + + query.push(" AND ("); + + if should_filter_xch { + // XCH coins have kind = 1 (standard P2 coins) + query.push("kind = 1 OR "); + } + + query + .push("ticker LIKE ") + .push_bind(format!("%{value}%")) + .push(" OR cats.name LIKE ") + .push_bind(format!("%{value}%")) + .push(" OR dids.name LIKE ") + .push_bind(format!("%{value}%")) + .push(" OR nfts.name LIKE ") + .push_bind(format!("%{value}%")) + .push(")"); + } + + query.push( + " + ), + paged_heights AS ( + SELECT + height, + COUNT(*) OVER() as total_count + FROM joined_coin_states + ORDER BY height ", + ); + query.push(if asc { "ASC" } else { "DESC" }); + query.push(" LIMIT ? OFFSET ? )"); + + query.push(" + SELECT + paged_heights.total_count as total_tx_count, + cs.coin_id, + cs.kind, + cs.created_height, + cs.spent_height, + parent_coin_id, + puzzle_hash, + amount, + transaction_id, + kind, + created_unixtime, + spent_unixtime, + COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS address, + COALESCE (cat_coins.parent_parent_coin_id, did_coins.parent_parent_coin_id, nft_coins.parent_parent_coin_id, NULL) AS parent_parent_coin_id, + COALESCE (cat_coins.parent_inner_puzzle_hash, did_coins.parent_inner_puzzle_hash, nft_coins.parent_inner_puzzle_hash, NULL) AS parent_inner_puzzle_hash, + COALESCE (cat_coins.parent_amount, did_coins.parent_amount, nft_coins.parent_amount, NULL) AS parent_amount, + COALESCE (cats.visible, nfts.visible, dids.visible, NULL) AS visible, + COALESCE (cats.name, nfts.name, dids.name, NULL) AS name, + HEX(COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL)) AS item_id, + COALESCE (nft_coins.metadata, did_coins.metadata, NULL) AS metadata, + COALESCE (nfts.is_owned, dids.is_owned, NULL) AS is_owned, + cats.ticker, + cats.description, + cats.icon, + cats.fetched, + nft_coins.metadata_updater_puzzle_hash, + nft_coins.current_owner, + nft_coins.royalty_puzzle_hash, + nft_coins.royalty_ten_thousandths, + nfts.collection_id, + nfts.minter_did, + nfts.owner_did, + nfts.sensitive_content, + nfts.is_named, + nfts.is_pending, + nfts.metadata_hash, + did_coins.recovery_list_hash, + did_coins.num_verifications_required + FROM coin_states cs + LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + INNER JOIN paged_heights ON (cs.created_height = paged_heights.height OR cs.spent_height = paged_heights.height) + "); + let built_query = query.build(); + let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; + + let total: u32 = rows.first().unwrap().try_get("total_count")?; + + Ok((rows, total)) +} + async fn get_block_heights( conn: impl SqliteExecutor<'_>, offset: u32, diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index b427add01..870c753c4 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -25,7 +25,7 @@ use sage_database::{ CoinKind, CoinSortMode, CoinStateRow, Database, NftGroup, NftRow, NftSearchParams, NftSortMode, }; use sage_wallet::WalletError; - +use sqlx::{Row, sqlite::SqliteRow}; use crate::{ parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, Error, Result, Sage, BURN_PUZZLE_HASH, @@ -322,7 +322,7 @@ impl Sage { Ok(GetPendingTransactionsResponse { transactions }) } - pub async fn get_transactions(&self, req: GetTransactions) -> Result { + pub async fn get_transactions2(&self, req: GetTransactions) -> Result { let wallet = self.wallet()?; let mut transactions = Vec::new(); @@ -332,12 +332,29 @@ impl Sage { .get_block_heights(req.offset, req.limit, req.ascending, req.find_value) .await?; for height in heights { - let transaction = self.transaction_record(&wallet.db, height).await?; + let transaction = self.transaction_record2(&wallet.db, height).await?; transactions.push(transaction); } - // Note: The actual summarization logic will be implemented later - // For now, we're just passing the parameter through + Ok(GetTransactionsResponse { + transactions, + total, + }) + } + + pub async fn get_transactions(&self, req: GetTransactions) -> Result { + let wallet = self.wallet()?; + + let mut transactions = Vec::new(); + + let (transaction_coins, total) = wallet + .db + .get_transaction_coins(req.offset, req.limit, req.ascending, req.find_value) + .await?; + for coin in transaction_coins { + let transaction = self.transaction_record(coin).await?; + transactions.push(transaction); + } Ok(GetTransactionsResponse { transactions, @@ -358,7 +375,7 @@ impl Sage { .get_block_heights_by_item_id(req.offset, req.limit, req.ascending, req.id) .await?; for height in heights { - let transaction = self.transaction_record(&wallet.db, height).await?; + let transaction = self.transaction_record2(&wallet.db, height).await?; transactions.push(transaction); } @@ -373,7 +390,7 @@ impl Sage { pub async fn get_transaction(&self, req: GetTransaction) -> Result { let wallet = self.wallet()?; - let transaction = self.transaction_record(&wallet.db, req.height).await?; + let transaction = self.transaction_record2(&wallet.db, req.height).await?; Ok(GetTransactionResponse { transaction }) } @@ -711,7 +728,7 @@ impl Sage { }) } - async fn transaction_coin(&self, db: &Database, coin: CoinStateRow) -> Result { + async fn transaction_coin2(&self, db: &Database, coin: CoinStateRow) -> Result { let coin_id = coin.coin_state.coin.coin_id(); let (kind, p2_puzzle_hash) = match coin.kind { @@ -812,7 +829,117 @@ impl Sage { }) } - async fn transaction_record(&self, db: &Database, height: u32) -> Result { + + async fn transaction_coin(&self, transaction_coin: SqliteRow) -> Result { + let coin_id = coin.coin_state.coin.coin_id(); + + let (kind, p2_puzzle_hash) = match coin.kind { + CoinKind::Unknown => (AssetKind::Unknown, None), + CoinKind::Xch => (AssetKind::Xch, Some(coin.coin_state.coin.puzzle_hash)), + CoinKind::Cat => { + if let Some(cat) = db.cat_coin(coin_id).await? { + if let Some(row) = db.cat(cat.asset_id).await? { + ( + AssetKind::Cat { + asset_id: hex::encode(cat.asset_id), + name: row.name, + ticker: row.ticker, + icon_url: row.icon, + }, + Some(cat.p2_puzzle_hash), + ) + } else { + ( + AssetKind::Cat { + asset_id: hex::encode(cat.asset_id), + name: None, + ticker: None, + icon_url: None, + }, + Some(cat.p2_puzzle_hash), + ) + } + } else { + (AssetKind::Unknown, None) + } + } + CoinKind::Nft => { + if let Some(nft) = db.nft_by_coin_id(coin_id).await? { + let row = db.nft_row(nft.info.launcher_id).await?; + + let mut allocator = Allocator::new(); + let metadata_ptr = nft.info.metadata.to_clvm(&mut allocator)?; + let metadata = NftMetadata::from_clvm(&allocator, metadata_ptr).ok(); + + let data_hash = metadata.as_ref().and_then(|m| m.data_hash); + + let icon = if let Some(hash) = data_hash { + db.nft_icon(hash).await? + } else { + None + }; + + ( + AssetKind::Nft { + launcher_id: Address::new(nft.info.launcher_id, "nft".to_string()) + .encode()?, + name: row.as_ref().and_then(|row| row.name.clone()), + icon: icon.map(|icon| BASE64_STANDARD.encode(icon)), + }, + Some(nft.info.p2_puzzle_hash), + ) + } else { + (AssetKind::Unknown, None) + } + } + CoinKind::Did => { + if let Some(did) = db.did_by_coin_id(coin_id).await? { + let row = db.did_row(did.info.launcher_id).await?; + ( + AssetKind::Did { + launcher_id: Address::new( + did.info.launcher_id, + "did:chia:".to_string(), + ) + .encode()?, + name: row.and_then(|row| row.name), + }, + Some(did.info.p2_puzzle_hash), + ) + } else { + (AssetKind::Unknown, None) + } + } + }; + + let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { + self.address_kind(db, p2_puzzle_hash).await? + } else { + AddressKind::Unknown + }; + + Ok(TransactionCoin { + coin_id: hex::encode(coin_id), + address: p2_puzzle_hash + .map(|p2_puzzle_hash| { + Address::new(p2_puzzle_hash, self.network().prefix()).encode() + }) + .transpose()?, + address_kind, + amount: Amount::u64(coin.coin_state.coin.amount), + kind, + }) + } + + async fn transaction_record(&self, transaction_coin: SqliteRow) -> Result { + let coin_id = coin.coin_state.coin.coin_id(); + + let (kind, p2_puzzle_hash) = match coin.kind { + CoinKind::Unknown => (AssetKind::Unknown, None), + } + } + + async fn transaction_record2(&self, db: &Database, height: u32) -> Result { let spent_rows = db.get_coin_states_by_spent_height(height).await?; let created_rows = db.get_coin_states_by_created_height(height).await?; let timestamp = db.check_blockinfo(height).await?; @@ -821,11 +948,11 @@ impl Sage { let mut created = Vec::new(); for row in spent_rows { - spent.push(self.transaction_coin(db, row).await?); + spent.push(self.transaction_coin2(db, row).await?); } for row in created_rows { - created.push(self.transaction_coin(db, row).await?); + created.push(self.transaction_coin2(db, row).await?); } Ok(TransactionRecord { From 26b6201b3f801641543b76f0aecd850b3b2a0e24 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 6 Apr 2025 19:13:18 -0500 Subject: [PATCH 02/10] it compiles! --- crates/sage-database/src/coin_states.rs | 64 +++++++- crates/sage/src/endpoints/data.rs | 191 ++++++++++++++---------- 2 files changed, 165 insertions(+), 90 deletions(-) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 3431fd72e..ee1f77530 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -523,25 +523,24 @@ async fn get_transaction_coins( query.push(" SELECT - paged_heights.total_count as total_tx_count, + paged_heights.total_count AS total_count, + 'created' AS action_type, cs.coin_id, cs.kind, - cs.created_height, - cs.spent_height, + cs.created_height AS height, parent_coin_id, puzzle_hash, amount, transaction_id, kind, - created_unixtime, - spent_unixtime, - COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS address, + created_unixtime AS unixtime, + COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS p2_puzzle_hash, COALESCE (cat_coins.parent_parent_coin_id, did_coins.parent_parent_coin_id, nft_coins.parent_parent_coin_id, NULL) AS parent_parent_coin_id, COALESCE (cat_coins.parent_inner_puzzle_hash, did_coins.parent_inner_puzzle_hash, nft_coins.parent_inner_puzzle_hash, NULL) AS parent_inner_puzzle_hash, COALESCE (cat_coins.parent_amount, did_coins.parent_amount, nft_coins.parent_amount, NULL) AS parent_amount, COALESCE (cats.visible, nfts.visible, dids.visible, NULL) AS visible, COALESCE (cats.name, nfts.name, dids.name, NULL) AS name, - HEX(COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL)) AS item_id, + COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, COALESCE (nft_coins.metadata, did_coins.metadata, NULL) AS metadata, COALESCE (nfts.is_owned, dids.is_owned, NULL) AS is_owned, cats.ticker, @@ -562,13 +561,62 @@ async fn get_transaction_coins( did_coins.recovery_list_hash, did_coins.num_verifications_required FROM coin_states cs + INNER JOIN paged_heights ON cs.created_height = paged_heights.height + LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + + UNION ALL + + SELECT + paged_heights.total_count AS total_count, + 'spent' AS action_type, + cs.coin_id, + cs.kind, + cs.spent_height AS height, + parent_coin_id, + puzzle_hash, + amount, + transaction_id, + kind, + spent_unixtime AS unixtime, + COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS p2_puzzle_hash, + COALESCE (cat_coins.parent_parent_coin_id, did_coins.parent_parent_coin_id, nft_coins.parent_parent_coin_id, NULL) AS parent_parent_coin_id, + COALESCE (cat_coins.parent_inner_puzzle_hash, did_coins.parent_inner_puzzle_hash, nft_coins.parent_inner_puzzle_hash, NULL) AS parent_inner_puzzle_hash, + COALESCE (cat_coins.parent_amount, did_coins.parent_amount, nft_coins.parent_amount, NULL) AS parent_amount, + COALESCE (cats.visible, nfts.visible, dids.visible, NULL) AS visible, + COALESCE (cats.name, nfts.name, dids.name, NULL) AS name, + COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, + COALESCE (nft_coins.metadata, did_coins.metadata, NULL) AS metadata, + COALESCE (nfts.is_owned, dids.is_owned, NULL) AS is_owned, + cats.ticker, + cats.description, + cats.icon, + cats.fetched, + nft_coins.metadata_updater_puzzle_hash, + nft_coins.current_owner, + nft_coins.royalty_puzzle_hash, + nft_coins.royalty_ten_thousandths, + nfts.collection_id, + nfts.minter_did, + nfts.owner_did, + nfts.sensitive_content, + nfts.is_named, + nfts.is_pending, + nfts.metadata_hash, + did_coins.recovery_list_hash, + did_coins.num_verifications_required + FROM coin_states cs + INNER JOIN paged_heights ON cs.spent_height = paged_heights.height LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id LEFT JOIN dids ON did_coins.coin_id = dids.coin_id LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - INNER JOIN paged_heights ON (cs.created_height = paged_heights.height OR cs.spent_height = paged_heights.height) "); let built_query = query.build(); let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 870c753c4..ee862a7ab 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -1,3 +1,7 @@ +use crate::{ + parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, Error, Result, Sage, + BURN_PUZZLE_HASH, +}; use base64::{prelude::BASE64_STANDARD, Engine}; use chia::{ clvm_traits::{FromClvm, ToClvm}, @@ -7,6 +11,7 @@ use chia::{ use chia_puzzles::{SETTLEMENT_PAYMENT_HASH, SINGLETON_LAUNCHER_HASH}; use chia_wallet_sdk::{driver::Nft, utils::Address}; use clvmr::Allocator; +use itertools::Itertools; use sage_api::{ AddressKind, Amount, AssetKind, CatRecord, CheckAddress, CheckAddressResponse, CoinRecord, CoinSortMode as ApiCoinSortMode, DerivationRecord, DidRecord, GetCat, GetCatCoins, @@ -22,14 +27,11 @@ use sage_api::{ PendingTransactionRecord, TransactionCoin, TransactionRecord, }; use sage_database::{ - CoinKind, CoinSortMode, CoinStateRow, Database, NftGroup, NftRow, NftSearchParams, NftSortMode, + CoinKind, CoinSortMode, CoinStateRow, Database, DatabaseError, NftGroup, NftRow, + NftSearchParams, NftSortMode, }; use sage_wallet::WalletError; -use sqlx::{Row, sqlite::SqliteRow}; -use crate::{ - parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, Error, Result, Sage, - BURN_PUZZLE_HASH, -}; +use sqlx::{sqlite::SqliteRow, Row}; impl Sage { pub async fn get_sync_status(&self, _req: GetSyncStatus) -> Result { @@ -351,9 +353,23 @@ impl Sage { .db .get_transaction_coins(req.offset, req.limit, req.ascending, req.find_value) .await?; - for coin in transaction_coins { - let transaction = self.transaction_record(coin).await?; - transactions.push(transaction); + + // Group transaction coins by height + let grouped_coins = transaction_coins + .into_iter() + .chunk_by(|row: &SqliteRow| row.get::("height")) + .into_iter() + .map(|(height, group)| (height, group.collect::>())) + .collect::>(); + + for (height, coins) in grouped_coins { + // Process each group by height + let height_u32: u32 = height.try_into().unwrap_or_default(); + let transaction_record = self + .transaction_record(&wallet.db, height_u32, coins) + .await?; + + transactions.push(transaction_record); } Ok(GetTransactionsResponse { @@ -728,7 +744,11 @@ impl Sage { }) } - async fn transaction_coin2(&self, db: &Database, coin: CoinStateRow) -> Result { + async fn transaction_coin2( + &self, + db: &Database, + coin: CoinStateRow, + ) -> Result { let coin_id = coin.coin_state.coin.coin_id(); let (kind, p2_puzzle_hash) = match coin.kind { @@ -829,85 +849,71 @@ impl Sage { }) } + fn to_bytes32_opt(bytes: Option>) -> Option { + bytes.map(|b| { + let array: [u8; 32] = b.try_into().unwrap_or_default(); + array.into() + }) + } - async fn transaction_coin(&self, transaction_coin: SqliteRow) -> Result { - let coin_id = coin.coin_state.coin.coin_id(); + fn to_u64(slice: &[u8]) -> Result { + Ok(u64::from_be_bytes(Self::to_bytes::<8>(slice)?)) + } - let (kind, p2_puzzle_hash) = match coin.kind { - CoinKind::Unknown => (AssetKind::Unknown, None), - CoinKind::Xch => (AssetKind::Xch, Some(coin.coin_state.coin.puzzle_hash)), + pub fn to_bytes(slice: &[u8]) -> Result<[u8; N]> { + slice + .try_into() + .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) + } + + async fn transaction_coin( + &self, + db: &Database, + transaction_coin: SqliteRow, + ) -> Result { + let coin_id: Option = Self::to_bytes32_opt(transaction_coin.get("coin_id")); + let kind_int: i64 = transaction_coin.get("kind"); + let coin_kind = CoinKind::from_i64(kind_int); + let p2_puzzle_hash: Option = + Self::to_bytes32_opt(transaction_coin.get("p2_puzzle_hash")); + let name: Option = transaction_coin.get("name"); + let item_id: Option = Self::to_bytes32_opt(transaction_coin.get("item_id")); + let amount: Vec = transaction_coin.get("amount"); + + let kind = match coin_kind { + CoinKind::Unknown => AssetKind::Unknown, + CoinKind::Xch => AssetKind::Xch, CoinKind::Cat => { - if let Some(cat) = db.cat_coin(coin_id).await? { - if let Some(row) = db.cat(cat.asset_id).await? { - ( - AssetKind::Cat { - asset_id: hex::encode(cat.asset_id), - name: row.name, - ticker: row.ticker, - icon_url: row.icon, - }, - Some(cat.p2_puzzle_hash), - ) - } else { - ( - AssetKind::Cat { - asset_id: hex::encode(cat.asset_id), - name: None, - ticker: None, - icon_url: None, - }, - Some(cat.p2_puzzle_hash), - ) + if let Some(item_id) = item_id { + AssetKind::Cat { + asset_id: hex::encode(item_id), + name, + ticker: transaction_coin.get("ticker"), + icon_url: transaction_coin.get("icon"), } } else { - (AssetKind::Unknown, None) + AssetKind::Unknown } } CoinKind::Nft => { - if let Some(nft) = db.nft_by_coin_id(coin_id).await? { - let row = db.nft_row(nft.info.launcher_id).await?; - - let mut allocator = Allocator::new(); - let metadata_ptr = nft.info.metadata.to_clvm(&mut allocator)?; - let metadata = NftMetadata::from_clvm(&allocator, metadata_ptr).ok(); - - let data_hash = metadata.as_ref().and_then(|m| m.data_hash); - - let icon = if let Some(hash) = data_hash { - db.nft_icon(hash).await? - } else { - None - }; - - ( - AssetKind::Nft { - launcher_id: Address::new(nft.info.launcher_id, "nft".to_string()) - .encode()?, - name: row.as_ref().and_then(|row| row.name.clone()), - icon: icon.map(|icon| BASE64_STANDARD.encode(icon)), - }, - Some(nft.info.p2_puzzle_hash), - ) + if let Some(item_id) = item_id { + AssetKind::Nft { + launcher_id: Address::new(item_id, "nft".to_string()).encode()?, + name, + icon: None, // icon.map(|icon| BASE64_STANDARD.encode(icon)), + } } else { - (AssetKind::Unknown, None) + AssetKind::Unknown } } CoinKind::Did => { - if let Some(did) = db.did_by_coin_id(coin_id).await? { - let row = db.did_row(did.info.launcher_id).await?; - ( - AssetKind::Did { - launcher_id: Address::new( - did.info.launcher_id, - "did:chia:".to_string(), - ) - .encode()?, - name: row.and_then(|row| row.name), - }, - Some(did.info.p2_puzzle_hash), - ) + if let Some(item_id) = item_id { + AssetKind::Did { + launcher_id: Address::new(item_id, "did:chia:".to_string()).encode()?, + name, + } } else { - (AssetKind::Unknown, None) + AssetKind::Unknown } } }; @@ -919,24 +925,45 @@ impl Sage { }; Ok(TransactionCoin { - coin_id: hex::encode(coin_id), + coin_id: coin_id.map_or_else(String::new, |id| hex::encode(id)), address: p2_puzzle_hash .map(|p2_puzzle_hash| { Address::new(p2_puzzle_hash, self.network().prefix()).encode() }) .transpose()?, address_kind, - amount: Amount::u64(coin.coin_state.coin.amount), + amount: Amount::u64(Self::to_u64(&amount)?), kind, }) } - async fn transaction_record(&self, transaction_coin: SqliteRow) -> Result { - let coin_id = coin.coin_state.coin.coin_id(); + async fn transaction_record( + &self, + db: &Database, + height: u32, + coins: Vec, + ) -> Result { + let mut spent = Vec::new(); + let mut created = Vec::new(); - let (kind, p2_puzzle_hash) = match coin.kind { - CoinKind::Unknown => (AssetKind::Unknown, None), + for coin in coins { + let action: String = coin.get("action_type"); + let transaction_coin = self.transaction_coin(&db, coin).await?; + + if action == "spent" { + spent.push(transaction_coin); + } else { + created.push(transaction_coin); + } } + let timestamp = db.check_blockinfo(height).await?; + + Ok(TransactionRecord { + height, + timestamp: timestamp.map(TryInto::try_into).transpose()?, + spent, + created, + }) } async fn transaction_record2(&self, db: &Database, height: u32) -> Result { From a19ab0b1c6510dfda86930d4309ad8bf9bce141b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 6 Apr 2025 20:01:05 -0500 Subject: [PATCH 03/10] - add larger page sizes - ensure the transactions are ordered after grouping --- crates/sage/src/endpoints/data.rs | 29 +++++++++++++++++++---------- src/pages/Transactions.tsx | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index ee862a7ab..bfd182c8c 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -355,18 +355,27 @@ impl Sage { .await?; // Group transaction coins by height - let grouped_coins = transaction_coins + let mut grouped_coins = transaction_coins .into_iter() .chunk_by(|row: &SqliteRow| row.get::("height")) .into_iter() .map(|(height, group)| (height, group.collect::>())) .collect::>(); + // Sort grouped_coins by height + if req.ascending { + grouped_coins.sort_by_key(|(height, _)| *height); + } else { + grouped_coins.sort_by_key(|(height, _)| std::cmp::Reverse(*height)); + } + for (height, coins) in grouped_coins { // Process each group by height let height_u32: u32 = height.try_into().unwrap_or_default(); + let timestamp: Option = coins.first().unwrap().try_get("unixtime")?; + let transaction_record = self - .transaction_record(&wallet.db, height_u32, coins) + .transaction_record(&wallet.db, height_u32, timestamp, coins) .await?; transactions.push(transaction_record); @@ -918,11 +927,11 @@ impl Sage { } }; - let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { - self.address_kind(db, p2_puzzle_hash).await? - } else { - AddressKind::Unknown - }; + // let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { + // self.address_kind(db, p2_puzzle_hash).await? + // } else { + // AddressKind::Unknown + // }; Ok(TransactionCoin { coin_id: coin_id.map_or_else(String::new, |id| hex::encode(id)), @@ -931,7 +940,7 @@ impl Sage { Address::new(p2_puzzle_hash, self.network().prefix()).encode() }) .transpose()?, - address_kind, + address_kind: AddressKind::Unknown, amount: Amount::u64(Self::to_u64(&amount)?), kind, }) @@ -941,6 +950,7 @@ impl Sage { &self, db: &Database, height: u32, + timestamp: Option, coins: Vec, ) -> Result { let mut spent = Vec::new(); @@ -956,11 +966,10 @@ impl Sage { created.push(transaction_coin); } } - let timestamp = db.check_blockinfo(height).await?; Ok(TransactionRecord { height, - timestamp: timestamp.map(TryInto::try_into).transpose()?, + timestamp, spent, created, }) diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index 4b09b21da..39f1d3ac4 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -175,7 +175,7 @@ export function Transactions() { pageSize={pageSize} onPageChange={(newPage) => handlePageChange(newPage, compact)} onPageSizeChange={(newSize) => handlePageSizeChange(newSize, compact)} - pageSizeOptions={[10, 25, 50]} + pageSizeOptions={[10, 25, 50, 100, 250, 500]} compact={compact} isLoading={isLoading || isPaginationLoading} /> From 88ed9c8f8ca74894dc7ce68f90f6c7edc6d6d84b Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 6 Apr 2025 21:04:04 -0500 Subject: [PATCH 04/10] reduce query to only needed fields --- crates/sage-database/src/coin_states.rs | 123 +++++++----------------- crates/sage/src/endpoints/data.rs | 12 +-- 2 files changed, 40 insertions(+), 95 deletions(-) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index ee1f77530..3b069b4dd 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -451,32 +451,32 @@ async fn get_transaction_coins( ) -> Result<(Vec, u32)> { let mut query = sqlx::QueryBuilder::new( " - WITH coin_states_with_heights AS ( - SELECT - cs.coin_id, - cs.created_height as height - FROM coin_states cs - WHERE cs.created_height IS NOT NULL - - UNION ALL - - SELECT - cs.coin_id, - cs.spent_height as height - FROM coin_states cs - WHERE cs.spent_height IS NOT NULL - ), - joined_coin_states AS ( - SELECT - DISTINCT h.height - FROM coin_states_with_heights h - LEFT JOIN cat_coins ON h.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - LEFT JOIN did_coins ON h.coin_id = did_coins.coin_id - LEFT JOIN dids ON did_coins.coin_id = dids.coin_id - LEFT JOIN nft_coins ON h.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - WHERE 1=1 + WITH coin_states_with_heights AS ( + SELECT + cs.coin_id, + cs.created_height as height + FROM coin_states cs + WHERE cs.created_height IS NOT NULL + + UNION ALL + + SELECT + cs.coin_id, + cs.spent_height as height + FROM coin_states cs + WHERE cs.spent_height IS NOT NULL + ), + joined_coin_states AS ( + SELECT + DISTINCT h.height + FROM coin_states_with_heights h + LEFT JOIN cat_coins ON h.coin_id = cat_coins.coin_id + LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id + LEFT JOIN did_coins ON h.coin_id = did_coins.coin_id + LEFT JOIN dids ON did_coins.coin_id = dids.coin_id + LEFT JOIN nft_coins ON h.coin_id = nft_coins.coin_id + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + WHERE 1=1 ", ); @@ -523,43 +523,19 @@ async fn get_transaction_coins( query.push(" SELECT - paged_heights.total_count AS total_count, + paged_heights.total_count, 'created' AS action_type, + cs.created_height AS height, + created_unixtime AS unixtime, cs.coin_id, cs.kind, - cs.created_height AS height, - parent_coin_id, - puzzle_hash, amount, - transaction_id, - kind, - created_unixtime AS unixtime, COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS p2_puzzle_hash, - COALESCE (cat_coins.parent_parent_coin_id, did_coins.parent_parent_coin_id, nft_coins.parent_parent_coin_id, NULL) AS parent_parent_coin_id, - COALESCE (cat_coins.parent_inner_puzzle_hash, did_coins.parent_inner_puzzle_hash, nft_coins.parent_inner_puzzle_hash, NULL) AS parent_inner_puzzle_hash, - COALESCE (cat_coins.parent_amount, did_coins.parent_amount, nft_coins.parent_amount, NULL) AS parent_amount, - COALESCE (cats.visible, nfts.visible, dids.visible, NULL) AS visible, COALESCE (cats.name, nfts.name, dids.name, NULL) AS name, COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, - COALESCE (nft_coins.metadata, did_coins.metadata, NULL) AS metadata, - COALESCE (nfts.is_owned, dids.is_owned, NULL) AS is_owned, + nft_coins.metadata AS nft_metadata, cats.ticker, - cats.description, - cats.icon, - cats.fetched, - nft_coins.metadata_updater_puzzle_hash, - nft_coins.current_owner, - nft_coins.royalty_puzzle_hash, - nft_coins.royalty_ten_thousandths, - nfts.collection_id, - nfts.minter_did, - nfts.owner_did, - nfts.sensitive_content, - nfts.is_named, - nfts.is_pending, - nfts.metadata_hash, - did_coins.recovery_list_hash, - did_coins.num_verifications_required + cats.icon FROM coin_states cs INNER JOIN paged_heights ON cs.created_height = paged_heights.height LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id @@ -572,43 +548,19 @@ async fn get_transaction_coins( UNION ALL SELECT - paged_heights.total_count AS total_count, + paged_heights.total_count, 'spent' AS action_type, + cs.spent_height AS height, + spent_unixtime AS unixtime, cs.coin_id, cs.kind, - cs.spent_height AS height, - parent_coin_id, - puzzle_hash, amount, - transaction_id, - kind, - spent_unixtime AS unixtime, COALESCE (cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, puzzle_hash) AS p2_puzzle_hash, - COALESCE (cat_coins.parent_parent_coin_id, did_coins.parent_parent_coin_id, nft_coins.parent_parent_coin_id, NULL) AS parent_parent_coin_id, - COALESCE (cat_coins.parent_inner_puzzle_hash, did_coins.parent_inner_puzzle_hash, nft_coins.parent_inner_puzzle_hash, NULL) AS parent_inner_puzzle_hash, - COALESCE (cat_coins.parent_amount, did_coins.parent_amount, nft_coins.parent_amount, NULL) AS parent_amount, - COALESCE (cats.visible, nfts.visible, dids.visible, NULL) AS visible, COALESCE (cats.name, nfts.name, dids.name, NULL) AS name, COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, - COALESCE (nft_coins.metadata, did_coins.metadata, NULL) AS metadata, - COALESCE (nfts.is_owned, dids.is_owned, NULL) AS is_owned, + nft_coins.metadata AS nft_metadata, cats.ticker, - cats.description, - cats.icon, - cats.fetched, - nft_coins.metadata_updater_puzzle_hash, - nft_coins.current_owner, - nft_coins.royalty_puzzle_hash, - nft_coins.royalty_ten_thousandths, - nfts.collection_id, - nfts.minter_did, - nfts.owner_did, - nfts.sensitive_content, - nfts.is_named, - nfts.is_pending, - nfts.metadata_hash, - did_coins.recovery_list_hash, - did_coins.num_verifications_required + cats.icon FROM coin_states cs INNER JOIN paged_heights ON cs.spent_height = paged_heights.height LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id @@ -616,8 +568,7 @@ async fn get_transaction_coins( LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id LEFT JOIN dids ON did_coins.coin_id = dids.coin_id LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - "); + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id"); let built_query = query.build(); let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index bfd182c8c..e493d711f 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -373,9 +373,8 @@ impl Sage { // Process each group by height let height_u32: u32 = height.try_into().unwrap_or_default(); let timestamp: Option = coins.first().unwrap().try_get("unixtime")?; - let transaction_record = self - .transaction_record(&wallet.db, height_u32, timestamp, coins) + .transaction_record(height_u32, timestamp, coins) .await?; transactions.push(transaction_record); @@ -875,11 +874,7 @@ impl Sage { .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) } - async fn transaction_coin( - &self, - db: &Database, - transaction_coin: SqliteRow, - ) -> Result { + async fn transaction_coin(&self, transaction_coin: SqliteRow) -> Result { let coin_id: Option = Self::to_bytes32_opt(transaction_coin.get("coin_id")); let kind_int: i64 = transaction_coin.get("kind"); let coin_kind = CoinKind::from_i64(kind_int); @@ -948,7 +943,6 @@ impl Sage { async fn transaction_record( &self, - db: &Database, height: u32, timestamp: Option, coins: Vec, @@ -958,7 +952,7 @@ impl Sage { for coin in coins { let action: String = coin.get("action_type"); - let transaction_coin = self.transaction_coin(&db, coin).await?; + let transaction_coin = self.transaction_coin(coin).await?; if action == "spent" { spent.push(transaction_coin); From 64cd2350121a384299cdc925942812f270e208d3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 6 Apr 2025 21:47:54 -0500 Subject: [PATCH 05/10] remove old code --- crates/sage-api/endpoints.json | 2 - crates/sage-api/src/requests/data.rs | 28 --- crates/sage-database/src/coin_states.rs | 294 ++++-------------------- crates/sage-database/src/lib.rs | 3 + crates/sage/src/endpoints/data.rs | 213 ++--------------- src-tauri/src/lib.rs | 2 - src/bindings.ts | 10 - src/pages/Transactions.tsx | 47 +--- 8 files changed, 69 insertions(+), 530 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index b16d89d12..11e8de646 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -20,8 +20,6 @@ "get_minter_did_ids": true, "get_pending_transactions": true, "get_transactions": true, - "get_transactions_by_item_id": true, - "get_transaction": true, "get_nft_collections": true, "get_nft_collection": true, "get_nfts": true, diff --git a/crates/sage-api/src/requests/data.rs b/crates/sage-api/src/requests/data.rs index b4a628417..b079b8afa 100644 --- a/crates/sage-api/src/requests/data.rs +++ b/crates/sage-api/src/requests/data.rs @@ -179,34 +179,6 @@ pub struct GetTransactionsResponse { pub total: u32, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -pub struct GetTransactionsByItemId { - pub offset: u32, - pub limit: u32, - pub ascending: bool, - pub id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -pub struct GetTransactionsByItemIdResponse { - pub transactions: Vec, - pub total: u32, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -pub struct GetTransaction { - pub height: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "tauri", derive(specta::Type))] -pub struct GetTransactionResponse { - pub transaction: TransactionRecord, -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] pub struct GetNftCollections { diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 3b069b4dd..9f0ea78ed 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -67,26 +67,6 @@ impl Database { get_transaction_coins(&self.pool, offset, limit, asc, find_value).await } - pub async fn get_block_heights( - &self, - offset: u32, - limit: u32, - asc: bool, - find_value: Option, - ) -> Result<(Vec, u32)> { - get_block_heights(&self.pool, offset, limit, asc, find_value).await - } - - pub async fn get_block_heights_by_item_id( - &self, - offset: u32, - limit: u32, - asc: bool, - id: Option, - ) -> Result<(Vec, u32)> { - get_block_heights_by_item_id(&self.pool, offset, limit, asc, id).await - } - pub async fn get_coin_states_by_created_height( &self, height: u32, @@ -496,6 +476,28 @@ async fn get_transaction_coins( query.push("kind = 1 OR "); } + if is_valid_asset_id(value) { + query.push("cats.asset_id = X'").push(value).push("' OR "); + } else if is_valid_address(value, "nft") { + if let Some(puzzle_hash) = puzzle_hash_from_address(value) { + query + .push("nfts.launcher_id = X'") + .push(puzzle_hash) + .push("' OR "); + } + } else if is_valid_address(value, "did:chia:") { + if let Some(puzzle_hash) = puzzle_hash_from_address(value) { + query + .push("dids.launcher_id = X'") + .push(puzzle_hash) + .push("' OR "); + } + } + + if is_valid_height(value) { + query.push("height = ").push(value).push(" OR "); + } + query .push("ticker LIKE ") .push_bind(format!("%{value}%")) @@ -507,7 +509,6 @@ async fn get_transaction_coins( .push_bind(format!("%{value}%")) .push(")"); } - query.push( " ), @@ -577,230 +578,6 @@ async fn get_transaction_coins( Ok((rows, total)) } -async fn get_block_heights( - conn: impl SqliteExecutor<'_>, - offset: u32, - limit: u32, - asc: bool, - find_value: Option, -) -> Result<(Vec, u32)> { - let mut query = sqlx::QueryBuilder::new( - " - WITH filtered_coins AS ( - SELECT cs.coin_id, - cs.kind, - cats.ticker, - cats.name as cat_name, - dids.name as did_name, - nfts.name as nft_name, - cs.created_height as height - FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id - LEFT JOIN dids ON did_coins.coin_id = dids.coin_id - LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - WHERE cs.created_height IS NOT NULL - UNION ALL - SELECT cs.coin_id, - cs.kind, - cats.ticker, - cats.name as cat_name, - dids.name as did_name, - nfts.name as nft_name, - cs.spent_height as height - FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id - LEFT JOIN dids ON did_coins.coin_id = dids.coin_id - LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - WHERE cs.spent_height IS NOT NULL - ), - filtered_heights AS ( - SELECT DISTINCT height - FROM filtered_coins - WHERE 1=1 - ", - ); - - if let Some(value) = &find_value { - // Check if searching for XCH (matches "x", "xc", or "xch") - let should_filter_xch = if value.len() <= 3 { - let value_lower = value.to_lowercase(); - value_lower == "x" || value_lower == "xc" || value_lower == "xch" - } else { - false - }; - - query.push(" AND ("); - - if should_filter_xch { - // XCH coins have kind = 1 (standard P2 coins) - query.push("kind = 1 OR "); - } - - query - .push("ticker LIKE ") - .push_bind(format!("%{value}%")) - .push(" OR cat_name LIKE ") - .push_bind(format!("%{value}%")) - .push(" OR did_name LIKE ") - .push_bind(format!("%{value}%")) - .push(" OR nft_name LIKE ") - .push_bind(format!("%{value}%")) - .push(")"); - } - - query.push(")"); - - // Select both the paginated results and the total count - query.push( - " - SELECT height, COUNT(*) OVER() as total_count - FROM filtered_heights - ORDER BY height ", - ); - - query.push(if asc { "ASC" } else { "DESC" }); - query.push(" LIMIT ? OFFSET ?"); - - let built_query = query.build(); - let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; - - let mut heights = Vec::with_capacity(rows.len()); - let mut total_count = 0; - - for row in rows { - if let Some(height) = row.get::, _>(0) { - heights.push(height.try_into()?); - } - // Get the total count from the first row (it will be the same in all rows) - if total_count == 0 { - total_count = row.get::(1).try_into()?; - } - } - - Ok((heights, total_count)) -} - -async fn get_block_heights_by_item_id( - conn: impl SqliteExecutor<'_>, - offset: u32, - limit: u32, - asc: bool, - id: Option, -) -> Result<(Vec, u32)> { - // Early return with empty results if ID is provided but not valid - let id_bytes = if let Some(value) = &id { - // First try to decode as a bech32m address (NFT ID, DID ID, etc.) - if value.starts_with("nft") || value.starts_with("did:chia:") { - match chia_wallet_sdk::utils::Address::decode(value) { - Ok(address) => Some(address.puzzle_hash.to_vec()), - Err(_) => return Ok((Vec::new(), 0)), // Invalid bech32m address - } - } else { - // If not a bech32m address, try to decode as a hex string - // Strip 0x prefix if present - let stripped = value.strip_prefix("0x").unwrap_or(value); - - // Try to decode the hex string to bytes - match hex::decode(stripped) { - Ok(bytes) if bytes.len() == 32 => Some(bytes), - _ => return Ok((Vec::new(), 0)), // Return empty results for invalid ID - } - } - } else { - None - }; - - // if all of these joins are too slow we can use the id type to execute 3 different queries - // (cat, nft, and did) for now performs well so am preferring simplicity over performance - let mut query = sqlx::QueryBuilder::new( - " - WITH filtered_coins AS ( - SELECT cs.coin_id, - cs.kind, - cats.asset_id, - dids.launcher_id as did_launcher_id, - nfts.launcher_id as nft_launcher_id, - cs.created_height as height - FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id - LEFT JOIN dids ON did_coins.coin_id = dids.coin_id - LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - WHERE cs.created_height IS NOT NULL - UNION ALL - SELECT cs.coin_id, - cs.kind, - cats.asset_id, - dids.launcher_id as did_launcher_id, - nfts.launcher_id as nft_launcher_id, - cs.spent_height as height - FROM coin_states cs - LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id - LEFT JOIN cats ON cat_coins.asset_id = cats.asset_id - LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id - LEFT JOIN dids ON did_coins.coin_id = dids.coin_id - LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id - WHERE cs.spent_height IS NOT NULL - ), - filtered_heights AS ( - SELECT DISTINCT height - FROM filtered_coins - WHERE 1=1 - ", - ); - - if let Some(bytes) = id_bytes { - query - .push(" AND (asset_id = ") - .push_bind(bytes.clone()) - .push(" OR did_launcher_id = ") - .push_bind(bytes.clone()) - .push(" OR nft_launcher_id = ") - .push_bind(bytes) - .push(")"); - } - - query.push(")"); - - // Select both the paginated results and the total count - query.push( - " - SELECT height, COUNT(*) OVER() as total_count - FROM filtered_heights - ORDER BY height ", - ); - - query.push(if asc { "ASC" } else { "DESC" }); - query.push(" LIMIT ? OFFSET ?"); - - let built_query = query.build(); - let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; - - let mut heights = Vec::with_capacity(rows.len()); - let mut total_count = 0; - - for row in rows { - if let Some(height) = row.get::, _>(0) { - heights.push(height.try_into()?); - } - // Get the total count from the first row (it will be the same in all rows) - if total_count == 0 { - total_count = row.get::(1).try_into()?; - } - } - - Ok((heights, total_count)) -} - async fn get_coin_states_by_created_height( conn: impl SqliteExecutor<'_>, height: u32, @@ -861,3 +638,30 @@ async fn full_coin_state( Ok(Some(sql.into_row()?)) } + +/// Checks if the provided string is a valid asset ID (64 character hex string) +pub fn is_valid_asset_id(asset_id: &str) -> bool { + asset_id.len() == 64 && asset_id.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Checks if a given address is valid for the specified prefix +pub fn is_valid_address(address: &str, prefix: &str) -> bool { + if let Ok(decoded) = chia_wallet_sdk::utils::Address::decode(address) { + // Check if the prefix matches and the puzzle hash is valid (32 bytes) + decoded.prefix == prefix && decoded.puzzle_hash.as_ref().len() == 32 + } else { + false + } +} + +/// Extracts puzzle hash from an address +fn puzzle_hash_from_address(address: &str) -> Option { + chia_wallet_sdk::utils::Address::decode(address) + .map(|decoded| hex::encode(decoded.puzzle_hash.as_ref())) + .ok() +} + +/// Checks if a string is a valid block height (non-negative integer) +pub fn is_valid_height(height_str: &str) -> bool { + height_str.parse::().is_ok() +} diff --git a/crates/sage-database/src/lib.rs b/crates/sage-database/src/lib.rs index 933f22678..7ba161257 100644 --- a/crates/sage-database/src/lib.rs +++ b/crates/sage-database/src/lib.rs @@ -145,6 +145,9 @@ pub enum DatabaseError { #[error("Invalid offer status {0}")] InvalidOfferStatus(i64), + + #[error("Invalid address")] + InvalidAddress, } pub(crate) type Result = std::result::Result; diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index e493d711f..f33785411 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -21,14 +21,12 @@ use sage_api::{ GetNftCollectionsResponse, GetNftData, GetNftDataResponse, GetNftIcon, GetNftIconResponse, GetNftResponse, GetNftThumbnail, GetNftThumbnailResponse, GetNfts, GetNftsResponse, GetPendingTransactions, GetPendingTransactionsResponse, GetSyncStatus, GetSyncStatusResponse, - GetTransaction, GetTransactionResponse, GetTransactions, GetTransactionsByItemId, - GetTransactionsByItemIdResponse, GetTransactionsResponse, GetXchCoins, GetXchCoinsResponse, + GetTransactions, GetTransactionsResponse, GetXchCoins, GetXchCoinsResponse, NftCollectionRecord, NftData, NftRecord, NftSortMode as ApiNftSortMode, PendingTransactionRecord, TransactionCoin, TransactionRecord, }; use sage_database::{ - CoinKind, CoinSortMode, CoinStateRow, Database, DatabaseError, NftGroup, NftRow, - NftSearchParams, NftSortMode, + CoinKind, CoinSortMode, Database, DatabaseError, NftGroup, NftRow, NftSearchParams, NftSortMode, }; use sage_wallet::WalletError; use sqlx::{sqlite::SqliteRow, Row}; @@ -324,26 +322,6 @@ impl Sage { Ok(GetPendingTransactionsResponse { transactions }) } - pub async fn get_transactions2(&self, req: GetTransactions) -> Result { - let wallet = self.wallet()?; - - let mut transactions = Vec::new(); - - let (heights, total) = wallet - .db - .get_block_heights(req.offset, req.limit, req.ascending, req.find_value) - .await?; - for height in heights { - let transaction = self.transaction_record2(&wallet.db, height).await?; - transactions.push(transaction); - } - - Ok(GetTransactionsResponse { - transactions, - total, - }) - } - pub async fn get_transactions(&self, req: GetTransactions) -> Result { let wallet = self.wallet()?; @@ -386,38 +364,6 @@ impl Sage { }) } - pub async fn get_transactions_by_item_id( - &self, - req: GetTransactionsByItemId, - ) -> Result { - let wallet = self.wallet()?; - - let mut transactions = Vec::new(); - - let (heights, total) = wallet - .db - .get_block_heights_by_item_id(req.offset, req.limit, req.ascending, req.id) - .await?; - for height in heights { - let transaction = self.transaction_record2(&wallet.db, height).await?; - transactions.push(transaction); - } - - // Note: The actual summarization logic will be implemented later - // For now, we're just passing the parameter through - - Ok(GetTransactionsByItemIdResponse { - transactions, - total, - }) - } - - pub async fn get_transaction(&self, req: GetTransaction) -> Result { - let wallet = self.wallet()?; - let transaction = self.transaction_record2(&wallet.db, req.height).await?; - Ok(GetTransactionResponse { transaction }) - } - pub async fn get_nft_collections( &self, req: GetNftCollections, @@ -752,128 +698,6 @@ impl Sage { }) } - async fn transaction_coin2( - &self, - db: &Database, - coin: CoinStateRow, - ) -> Result { - let coin_id = coin.coin_state.coin.coin_id(); - - let (kind, p2_puzzle_hash) = match coin.kind { - CoinKind::Unknown => (AssetKind::Unknown, None), - CoinKind::Xch => (AssetKind::Xch, Some(coin.coin_state.coin.puzzle_hash)), - CoinKind::Cat => { - if let Some(cat) = db.cat_coin(coin_id).await? { - if let Some(row) = db.cat(cat.asset_id).await? { - ( - AssetKind::Cat { - asset_id: hex::encode(cat.asset_id), - name: row.name, - ticker: row.ticker, - icon_url: row.icon, - }, - Some(cat.p2_puzzle_hash), - ) - } else { - ( - AssetKind::Cat { - asset_id: hex::encode(cat.asset_id), - name: None, - ticker: None, - icon_url: None, - }, - Some(cat.p2_puzzle_hash), - ) - } - } else { - (AssetKind::Unknown, None) - } - } - CoinKind::Nft => { - if let Some(nft) = db.nft_by_coin_id(coin_id).await? { - let row = db.nft_row(nft.info.launcher_id).await?; - - let mut allocator = Allocator::new(); - let metadata_ptr = nft.info.metadata.to_clvm(&mut allocator)?; - let metadata = NftMetadata::from_clvm(&allocator, metadata_ptr).ok(); - - let data_hash = metadata.as_ref().and_then(|m| m.data_hash); - - let icon = if let Some(hash) = data_hash { - db.nft_icon(hash).await? - } else { - None - }; - - ( - AssetKind::Nft { - launcher_id: Address::new(nft.info.launcher_id, "nft".to_string()) - .encode()?, - name: row.as_ref().and_then(|row| row.name.clone()), - icon: icon.map(|icon| BASE64_STANDARD.encode(icon)), - }, - Some(nft.info.p2_puzzle_hash), - ) - } else { - (AssetKind::Unknown, None) - } - } - CoinKind::Did => { - if let Some(did) = db.did_by_coin_id(coin_id).await? { - let row = db.did_row(did.info.launcher_id).await?; - ( - AssetKind::Did { - launcher_id: Address::new( - did.info.launcher_id, - "did:chia:".to_string(), - ) - .encode()?, - name: row.and_then(|row| row.name), - }, - Some(did.info.p2_puzzle_hash), - ) - } else { - (AssetKind::Unknown, None) - } - } - }; - - let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { - self.address_kind(db, p2_puzzle_hash).await? - } else { - AddressKind::Unknown - }; - - Ok(TransactionCoin { - coin_id: hex::encode(coin_id), - address: p2_puzzle_hash - .map(|p2_puzzle_hash| { - Address::new(p2_puzzle_hash, self.network().prefix()).encode() - }) - .transpose()?, - address_kind, - amount: Amount::u64(coin.coin_state.coin.amount), - kind, - }) - } - - fn to_bytes32_opt(bytes: Option>) -> Option { - bytes.map(|b| { - let array: [u8; 32] = b.try_into().unwrap_or_default(); - array.into() - }) - } - - fn to_u64(slice: &[u8]) -> Result { - Ok(u64::from_be_bytes(Self::to_bytes::<8>(slice)?)) - } - - pub fn to_bytes(slice: &[u8]) -> Result<[u8; N]> { - slice - .try_into() - .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) - } - async fn transaction_coin(&self, transaction_coin: SqliteRow) -> Result { let coin_id: Option = Self::to_bytes32_opt(transaction_coin.get("coin_id")); let kind_int: i64 = transaction_coin.get("kind"); @@ -969,28 +793,21 @@ impl Sage { }) } - async fn transaction_record2(&self, db: &Database, height: u32) -> Result { - let spent_rows = db.get_coin_states_by_spent_height(height).await?; - let created_rows = db.get_coin_states_by_created_height(height).await?; - let timestamp = db.check_blockinfo(height).await?; - - let mut spent = Vec::new(); - let mut created = Vec::new(); - - for row in spent_rows { - spent.push(self.transaction_coin2(db, row).await?); - } + fn to_bytes32_opt(bytes: Option>) -> Option { + bytes.map(|b| { + let array: [u8; 32] = b.try_into().unwrap_or_default(); + array.into() + }) + } - for row in created_rows { - created.push(self.transaction_coin2(db, row).await?); - } + fn to_u64(slice: &[u8]) -> Result { + Ok(u64::from_be_bytes(Self::to_bytes::<8>(slice)?)) + } - Ok(TransactionRecord { - height, - timestamp: timestamp.map(TryInto::try_into).transpose()?, - spent, - created, - }) + pub fn to_bytes(slice: &[u8]) -> Result<[u8; N]> { + slice + .try_into() + .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) } async fn address_kind(&self, db: &Database, p2_puzzle_hash: Bytes32) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c3081c75a..6ba99c4d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,8 +73,6 @@ pub fn run() { commands::get_nft_thumbnail, commands::get_pending_transactions, commands::get_transactions, - commands::get_transactions_by_item_id, - commands::get_transaction, commands::validate_address, commands::make_offer, commands::take_offer, diff --git a/src/bindings.ts b/src/bindings.ts index 951ff87c4..2869799fd 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -155,12 +155,6 @@ async getPendingTransactions(req: GetPendingTransactions) : Promise { return await TAURI_INVOKE("get_transactions", { req }); }, -async getTransactionsByItemId(req: GetTransactionsByItemId) : Promise { - return await TAURI_INVOKE("get_transactions_by_item_id", { req }); -}, -async getTransaction(req: GetTransaction) : Promise { - return await TAURI_INVOKE("get_transaction", { req }); -}, async validateAddress(address: string) : Promise { return await TAURI_INVOKE("validate_address", { address }); }, @@ -408,11 +402,7 @@ export type GetSecretKey = { fingerprint: number } export type GetSecretKeyResponse = { secrets: SecretKeyInfo | null } export type GetSyncStatus = Record export type GetSyncStatusResponse = { balance: Amount; unit: Unit; synced_coins: number; total_coins: number; receive_address: string; burn_address: string; unhardened_derivation_index: number; hardened_derivation_index: number } -export type GetTransaction = { height: number } -export type GetTransactionResponse = { transaction: TransactionRecord } export type GetTransactions = { offset: number; limit: number; ascending: boolean; find_value: string | null } -export type GetTransactionsByItemId = { offset: number; limit: number; ascending: boolean; id: string | null } -export type GetTransactionsByItemIdResponse = { transactions: TransactionRecord[]; total: number } export type GetTransactionsResponse = { transactions: TransactionRecord[]; total: number } export type GetXchCoins = { offset: number; limit: number; sort_mode?: CoinSortMode; ascending?: boolean; include_spent_coins?: boolean } export type GetXchCoinsResponse = { coins: CoinRecord[]; total: number } diff --git a/src/pages/Transactions.tsx b/src/pages/Transactions.tsx index 39f1d3ac4..cd0e367c5 100644 --- a/src/pages/Transactions.tsx +++ b/src/pages/Transactions.tsx @@ -47,58 +47,15 @@ export function Transactions() { const pendingResult = await commands.getPendingTransactions({}); setPending(pendingResult.transactions); - // if the search term might be a block height, try to get the block - // and add it to the list of transactions - const searchHeight = search ? parseInt(search, 10) : null; - const isValidHeight = - searchHeight !== null && !isNaN(searchHeight) && searchHeight >= 0; - - let specificBlock: TransactionRecord[] = []; - if (isValidHeight) { - const block = await commands.getTransaction({ height: searchHeight }); - specificBlock = [block.transaction]; - } - - // Check if the search term is an asset_id, NFT ID, or DID ID - let itemIdTransactions: TransactionRecord[] = []; - let itemIdTotal = 0; - - if (search) { - if ( - isValidAssetId(search) || - isValidAddress(search, 'nft') || - isValidAddress(search, 'did:chia:') - ) { - const itemIdResult = await commands.getTransactionsByItemId({ - offset: (page - 1) * pageSize, - limit: pageSize, - ascending, - id: search, - }); - itemIdTransactions = itemIdResult.transactions; - itemIdTotal = itemIdResult.total; - } - } - - let regularTransactions: TransactionRecord[] = []; - let regularTotal = 0; - const result = await commands.getTransactions({ offset: (page - 1) * pageSize, limit: pageSize, ascending, find_value: search || null, }); - regularTransactions = result.transactions; - regularTotal = result.total; - const combinedTransactions = [ - ...specificBlock, - ...itemIdTransactions, - ...regularTransactions, - ]; - setTransactions(combinedTransactions); - setTotalTransactions(regularTotal + specificBlock.length + itemIdTotal); + setTransactions(result.transactions); + setTotalTransactions(result.total); } catch (error) { addError(error as any); } finally { From f4554f3a935c9c41c09e33924b580c620fd62871 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 7 Apr 2025 08:48:57 -0500 Subject: [PATCH 06/10] compute AddressKind without a separate database call --- crates/sage-database/src/coin_states.rs | 10 ++++++++-- crates/sage/src/endpoints/data.rs | 21 +++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 9f0ea78ed..53e9daca5 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -536,7 +536,10 @@ async fn get_transaction_coins( COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, nft_coins.metadata AS nft_metadata, cats.ticker, - cats.icon + cats.icon, + (SELECT COUNT(*) + FROM derivations d + WHERE d.p2_puzzle_hash = COALESCE(cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, cs.puzzle_hash)) AS derivation_count FROM coin_states cs INNER JOIN paged_heights ON cs.created_height = paged_heights.height LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id @@ -561,7 +564,10 @@ async fn get_transaction_coins( COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, nft_coins.metadata AS nft_metadata, cats.ticker, - cats.icon + cats.icon, + (SELECT COUNT(*) + FROM derivations d + WHERE d.p2_puzzle_hash = COALESCE(cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, cs.puzzle_hash)) AS derivation_count FROM coin_states cs INNER JOIN paged_heights ON cs.spent_height = paged_heights.height LEFT JOIN cat_coins ON cs.coin_id = cat_coins.coin_id diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index f33785411..977b3a67e 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -746,11 +746,11 @@ impl Sage { } }; - // let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { - // self.address_kind(db, p2_puzzle_hash).await? - // } else { - // AddressKind::Unknown - // }; + let address_kind = if let Some(p2_puzzle_hash) = p2_puzzle_hash { + self.address_kind(transaction_coin, p2_puzzle_hash).await? + } else { + AddressKind::Unknown + }; Ok(TransactionCoin { coin_id: coin_id.map_or_else(String::new, |id| hex::encode(id)), @@ -759,7 +759,7 @@ impl Sage { Address::new(p2_puzzle_hash, self.network().prefix()).encode() }) .transpose()?, - address_kind: AddressKind::Unknown, + address_kind, amount: Amount::u64(Self::to_u64(&amount)?), kind, }) @@ -810,7 +810,11 @@ impl Sage { .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) } - async fn address_kind(&self, db: &Database, p2_puzzle_hash: Bytes32) -> Result { + async fn address_kind( + &self, + transaction_coin: SqliteRow, + p2_puzzle_hash: Bytes32, + ) -> Result { if p2_puzzle_hash == BURN_PUZZLE_HASH.into() { return Ok(AddressKind::Burn); } else if p2_puzzle_hash == SINGLETON_LAUNCHER_HASH.into() { @@ -819,7 +823,8 @@ impl Sage { return Ok(AddressKind::Offer); } - Ok(if db.is_p2_puzzle_hash(p2_puzzle_hash).await? { + let derivation_count: Option = transaction_coin.get("derivation_count"); + Ok(if derivation_count.is_some_and(|count| count > 0) { AddressKind::Own } else { AddressKind::External From 5ff90a9b28b687adfb7c5c0d839f1042fafb7189 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 7 Apr 2025 09:00:59 -0500 Subject: [PATCH 07/10] move some helpers to utils --- crates/sage/src/endpoints/data.rs | 34 ++++++++----------------------- crates/sage/src/utils.rs | 21 +++++++++++++++++++ 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 977b3a67e..141e8a1c5 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -1,6 +1,7 @@ use crate::{ - parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, Error, Result, Sage, - BURN_PUZZLE_HASH, + parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, + utils::{to_bytes32_opt, to_u64}, + Error, Result, Sage, BURN_PUZZLE_HASH, }; use base64::{prelude::BASE64_STANDARD, Engine}; use chia::{ @@ -25,9 +26,7 @@ use sage_api::{ NftCollectionRecord, NftData, NftRecord, NftSortMode as ApiNftSortMode, PendingTransactionRecord, TransactionCoin, TransactionRecord, }; -use sage_database::{ - CoinKind, CoinSortMode, Database, DatabaseError, NftGroup, NftRow, NftSearchParams, NftSortMode, -}; +use sage_database::{CoinKind, CoinSortMode, NftGroup, NftRow, NftSearchParams, NftSortMode}; use sage_wallet::WalletError; use sqlx::{sqlite::SqliteRow, Row}; @@ -699,13 +698,13 @@ impl Sage { } async fn transaction_coin(&self, transaction_coin: SqliteRow) -> Result { - let coin_id: Option = Self::to_bytes32_opt(transaction_coin.get("coin_id")); + let coin_id: Option = to_bytes32_opt(transaction_coin.get("coin_id")); let kind_int: i64 = transaction_coin.get("kind"); let coin_kind = CoinKind::from_i64(kind_int); let p2_puzzle_hash: Option = - Self::to_bytes32_opt(transaction_coin.get("p2_puzzle_hash")); + to_bytes32_opt(transaction_coin.get("p2_puzzle_hash")); let name: Option = transaction_coin.get("name"); - let item_id: Option = Self::to_bytes32_opt(transaction_coin.get("item_id")); + let item_id: Option = to_bytes32_opt(transaction_coin.get("item_id")); let amount: Vec = transaction_coin.get("amount"); let kind = match coin_kind { @@ -760,7 +759,7 @@ impl Sage { }) .transpose()?, address_kind, - amount: Amount::u64(Self::to_u64(&amount)?), + amount: Amount::u64(to_u64(&amount)?), kind, }) } @@ -793,23 +792,6 @@ impl Sage { }) } - fn to_bytes32_opt(bytes: Option>) -> Option { - bytes.map(|b| { - let array: [u8; 32] = b.try_into().unwrap_or_default(); - array.into() - }) - } - - fn to_u64(slice: &[u8]) -> Result { - Ok(u64::from_be_bytes(Self::to_bytes::<8>(slice)?)) - } - - pub fn to_bytes(slice: &[u8]) -> Result<[u8; N]> { - slice - .try_into() - .map_err(|_| Error::Database(DatabaseError::InvalidLength(slice.len(), N))) - } - async fn address_kind( &self, transaction_coin: SqliteRow, diff --git a/crates/sage/src/utils.rs b/crates/sage/src/utils.rs index 50eb77fc6..cc0c5e15b 100644 --- a/crates/sage/src/utils.rs +++ b/crates/sage/src/utils.rs @@ -9,3 +9,24 @@ pub use coins::*; pub use confirmation::*; pub use offer_status::*; pub use parse::*; + +use crate::Error; +use chia::protocol::Bytes32; + +pub fn to_bytes(slice: &[u8]) -> Result<[u8; N], Error> { + slice + .try_into() + .map_err(|_| Error::Database(sage_database::DatabaseError::InvalidLength(slice.len(), N))) +} + +pub fn to_bytes32(slice: &[u8]) -> Result { + to_bytes(slice).map(Bytes32::new) +} + +pub fn to_u64(slice: &[u8]) -> Result { + Ok(u64::from_be_bytes(to_bytes::<8>(slice)?)) +} + +pub fn to_bytes32_opt(bytes: Option>) -> Option { + bytes.map(|b| to_bytes32(&b).unwrap_or_default()) +} From e915d7bebb416bbd3d43a30caef218010492208f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 8 Apr 2025 08:25:34 -0500 Subject: [PATCH 08/10] early exit when no results --- crates/sage-database/src/coin_states.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 53e9daca5..3475c7665 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -579,6 +579,10 @@ async fn get_transaction_coins( let built_query = query.build(); let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; + if rows.is_empty() { + return Ok((vec![], 0)); + } + let total: u32 = rows.first().unwrap().try_get("total_count")?; Ok((rows, total)) From 584b45aea23fd2b64aeaed2ea2bc5ad4e6da6d7a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 8 Apr 2025 09:00:20 -0500 Subject: [PATCH 09/10] replace getTransaction with getTransactions with height as the find_value --- src/pages/Transaction.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/pages/Transaction.tsx b/src/pages/Transaction.tsx index 32a87746b..70106cb71 100644 --- a/src/pages/Transaction.tsx +++ b/src/pages/Transaction.tsx @@ -27,9 +27,20 @@ export default function Transaction() { ); const updateTransaction = useCallback(() => { - commands.getTransaction({ height: Number(height) }).then((data) => { - setTransaction(data.transaction); - }); + commands + .getTransactions({ + offset: 0, + limit: 1, + ascending: true, + find_value: height ?? '', + }) + .then((data) => { + if (data.transactions.length > 0) { + setTransaction(data.transactions[0]); + } else { + setTransaction(null); + } + }); }, [height]); useEffect(() => { From 046115a7c4535b5c1c8124215affeb412b3e8ebf Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 8 Apr 2025 09:13:58 -0500 Subject: [PATCH 10/10] get nft icon --- crates/sage-database/src/coin_states.rs | 10 +++++++--- crates/sage/src/endpoints/data.rs | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 3475c7665..0c8fa120a 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -536,7 +536,8 @@ async fn get_transaction_coins( COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, nft_coins.metadata AS nft_metadata, cats.ticker, - cats.icon, + cats.icon AS cat_icon_url, + nft_thumbnails.icon AS nft_icon, (SELECT COUNT(*) FROM derivations d WHERE d.p2_puzzle_hash = COALESCE(cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, cs.puzzle_hash)) AS derivation_count @@ -548,6 +549,7 @@ async fn get_transaction_coins( LEFT JOIN dids ON did_coins.coin_id = dids.coin_id LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + LEFT JOIN nft_thumbnails ON nft_coins.data_hash = nft_thumbnails.hash UNION ALL @@ -564,7 +566,8 @@ async fn get_transaction_coins( COALESCE (cats.asset_id, nfts.launcher_id, dids.launcher_id, NULL) AS item_id, nft_coins.metadata AS nft_metadata, cats.ticker, - cats.icon, + cats.icon AS cat_icon_url, + nft_thumbnails.icon AS nft_icon, (SELECT COUNT(*) FROM derivations d WHERE d.p2_puzzle_hash = COALESCE(cat_coins.p2_puzzle_hash, did_coins.p2_puzzle_hash, nft_coins.p2_puzzle_hash, cs.puzzle_hash)) AS derivation_count @@ -575,7 +578,8 @@ async fn get_transaction_coins( LEFT JOIN did_coins ON cs.coin_id = did_coins.coin_id LEFT JOIN dids ON did_coins.coin_id = dids.coin_id LEFT JOIN nft_coins ON cs.coin_id = nft_coins.coin_id - LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id"); + LEFT JOIN nfts ON nft_coins.coin_id = nfts.coin_id + LEFT JOIN nft_thumbnails ON nft_coins.data_hash = nft_thumbnails.hash"); let built_query = query.build(); let rows = built_query.bind(limit).bind(offset).fetch_all(conn).await?; diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 141e8a1c5..50f677da6 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -716,7 +716,7 @@ impl Sage { asset_id: hex::encode(item_id), name, ticker: transaction_coin.get("ticker"), - icon_url: transaction_coin.get("icon"), + icon_url: transaction_coin.get("cat_icon_url"), } } else { AssetKind::Unknown @@ -724,10 +724,12 @@ impl Sage { } CoinKind::Nft => { if let Some(item_id) = item_id { + let icon: Option> = transaction_coin.get("nft_icon"); + AssetKind::Nft { launcher_id: Address::new(item_id, "nft".to_string()).encode()?, name, - icon: None, // icon.map(|icon| BASE64_STANDARD.encode(icon)), + icon: icon.map(|icon| BASE64_STANDARD.encode(icon)), } } else { AssetKind::Unknown