diff --git a/crates/sage-api/src/records.rs b/crates/sage-api/src/records.rs index 32487ddf..b60e9a47 100644 --- a/crates/sage-api/src/records.rs +++ b/crates/sage-api/src/records.rs @@ -8,6 +8,7 @@ mod offer; mod offer_summary; mod peer; mod pending_transaction; +mod transaction; mod transaction_summary; pub use cat::*; @@ -20,4 +21,5 @@ pub use offer::*; pub use offer_summary::*; pub use peer::*; pub use pending_transaction::*; +pub use transaction::*; pub use transaction_summary::*; diff --git a/crates/sage-api/src/records/transaction.rs b/crates/sage-api/src/records/transaction.rs new file mode 100644 index 00000000..d97a8efe --- /dev/null +++ b/crates/sage-api/src/records/transaction.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::{Amount, AssetKind}; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct TransactionRecord { + pub height: u32, + pub spent: Vec, + pub created: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct TransactionCoin { + pub coin_id: String, + pub amount: Amount, + pub address: Option, + #[serde(flatten)] + pub kind: AssetKind, +} diff --git a/crates/sage-api/src/requests/data.rs b/crates/sage-api/src/requests/data.rs index b2f67320..4c913c0b 100644 --- a/crates/sage-api/src/requests/data.rs +++ b/crates/sage-api/src/requests/data.rs @@ -3,7 +3,7 @@ use specta::Type; use crate::{ Amount, CatRecord, CoinRecord, DerivationRecord, DidRecord, NftCollectionRecord, NftData, - NftRecord, PendingTransactionRecord, Unit, + NftRecord, PendingTransactionRecord, TransactionRecord, Unit, }; #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] @@ -82,6 +82,18 @@ pub struct GetPendingTransactionsResponse { pub transactions: Vec, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] +pub struct GetTransactions { + pub offset: u32, + pub limit: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct GetTransactionsResponse { + pub transactions: Vec, + pub total: u32, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] pub struct GetNftStatus {} diff --git a/crates/sage-cli/src/router.rs b/crates/sage-cli/src/router.rs index 9948b605..9d804976 100644 --- a/crates/sage-cli/src/router.rs +++ b/crates/sage-cli/src/router.rs @@ -79,6 +79,7 @@ routes!( get_cat await: GetCat = "/get_cat", get_dids await: GetDids = "/get_dids", get_pending_transactions await: GetPendingTransactions = "/get_pending_transactions", + get_transactions await: GetTransactions = "/get_transactions", get_nft_status await: GetNftStatus = "/get_nft_status", get_nft_collections await: GetNftCollections = "/get_nft_collections", get_nft_collection await: GetNftCollection = "/get_nft_collection", diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index d86f46cc..1bfc37d8 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -1,7 +1,12 @@ +use std::cmp::Reverse; + use chia::protocol::{Bytes32, CoinState}; use sqlx::SqliteExecutor; -use crate::{to_bytes32, CoinStateSql, Database, DatabaseTx, IntoRow, Result}; +use crate::{ + into_row, to_bytes32, CoinKind, CoinStateRow, CoinStateSql, Database, DatabaseTx, IntoRow, + Result, +}; impl Database { pub async fn unsynced_coin_states(&self, limit: usize) -> Result> { @@ -36,13 +41,33 @@ impl Database { synced_coin_count(&self.pool).await } - pub async fn sync_coin(&self, coin_id: Bytes32, hint: Option) -> Result<()> { - sync_coin(&self.pool, coin_id, hint).await + pub async fn sync_coin( + &self, + coin_id: Bytes32, + hint: Option, + kind: CoinKind, + ) -> Result<()> { + sync_coin(&self.pool, coin_id, hint, kind).await } pub async fn is_coin_locked(&self, coin_id: Bytes32) -> Result { is_coin_locked(&self.pool, coin_id).await } + + pub async fn get_block_heights(&self) -> Result> { + get_block_heights(&self.pool).await + } + + pub async fn get_coin_states_by_created_height( + &self, + height: u32, + ) -> Result> { + get_coin_states_by_created_height(&self.pool, height).await + } + + pub async fn get_coin_states_by_spent_height(&self, height: u32) -> Result> { + get_coin_states_by_spent_height(&self.pool, height).await + } } impl<'a> DatabaseTx<'a> { @@ -72,8 +97,13 @@ impl<'a> DatabaseTx<'a> { .await } - pub async fn sync_coin(&mut self, coin_id: Bytes32, hint: Option) -> Result<()> { - sync_coin(&mut *self.tx, coin_id, hint).await + pub async fn sync_coin( + &mut self, + coin_id: Bytes32, + hint: Option, + kind: CoinKind, + ) -> Result<()> { + sync_coin(&mut *self.tx, coin_id, hint, kind).await } pub async fn unsync_coin(&mut self, coin_id: Bytes32) -> Result<()> { @@ -179,7 +209,7 @@ async fn unsynced_coin_states( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `created_height`, `spent_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `created_height`, `spent_height`, `transaction_id`, `kind` FROM `coin_states` WHERE `synced` = 0 AND `created_height` IS NOT NULL ORDER BY `spent_height` ASC @@ -198,15 +228,18 @@ async fn sync_coin( conn: impl SqliteExecutor<'_>, coin_id: Bytes32, hint: Option, + kind: CoinKind, ) -> Result<()> { let coin_id = coin_id.as_ref(); + let kind = kind as u32; let hint = hint.as_deref(); sqlx::query!( " - UPDATE `coin_states` SET `synced` = 1, `hint` = ? WHERE `coin_id` = ? + UPDATE `coin_states` SET `synced` = 1, `hint` = ?, `kind` = ? WHERE `coin_id` = ? ", hint, + kind, coin_id ) .execute(conn) @@ -274,7 +307,7 @@ async fn coin_state(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Result, coin_id: Bytes32) -> Result, coin_id: Bytes32) -> Result 0) + Ok(row.kind == 1) } async fn is_coin_locked(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Result { @@ -378,3 +411,70 @@ async fn is_coin_locked(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Resu Ok(row.count > 0) } + +async fn get_block_heights(conn: impl SqliteExecutor<'_>) -> Result> { + let rows = sqlx::query!( + " + SELECT DISTINCT height FROM ( + SELECT created_height as height FROM coin_states INDEXED BY `coin_created` + WHERE created_height IS NOT NULL + UNION ALL + SELECT spent_height as height FROM coin_states INDEXED BY `coin_spent` + WHERE spent_height IS NOT NULL + ) + GROUP BY height + " + ) + .fetch_all(conn) + .await?; + + let mut heights = Vec::with_capacity(rows.len()); + + for row in rows { + if let Some(height) = row.height { + heights.push(height.try_into()?); + } + } + + heights.sort_by_key(|height| Reverse(*height)); + + Ok(heights) +} + +async fn get_coin_states_by_created_height( + conn: impl SqliteExecutor<'_>, + height: u32, +) -> Result> { + let rows = sqlx::query_as!( + CoinStateSql, + " + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `created_height`, `spent_height`, `transaction_id`, `kind` + FROM `coin_states` INDEXED BY `coin_created` + WHERE `created_height` = ? + ", + height + ) + .fetch_all(conn) + .await?; + + rows.into_iter().map(into_row).collect() +} + +async fn get_coin_states_by_spent_height( + conn: impl SqliteExecutor<'_>, + height: u32, +) -> Result> { + let rows = sqlx::query_as!( + CoinStateSql, + " + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `created_height`, `spent_height`, `transaction_id`, `kind` + FROM `coin_states` INDEXED BY `coin_spent` + WHERE `spent_height` = ? + ", + height + ) + .fetch_all(conn) + .await?; + + rows.into_iter().map(into_row).collect() +} diff --git a/crates/sage-database/src/primitives/cats.rs b/crates/sage-database/src/primitives/cats.rs index d668b929..235fd104 100644 --- a/crates/sage-database/src/primitives/cats.rs +++ b/crates/sage-database/src/primitives/cats.rs @@ -284,7 +284,7 @@ async fn cat_coin_states( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `cat_coins` INDEXED BY `cat_asset_id` INNER JOIN `coin_states` ON `coin_states`.coin_id = `cat_coins`.coin_id WHERE `asset_id` = ? @@ -308,7 +308,7 @@ async fn created_unspent_cat_coin_states( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `coin_states` INNER JOIN `cat_coins` ON `coin_states`.coin_id = `cat_coins`.coin_id WHERE `asset_id` = ? diff --git a/crates/sage-database/src/primitives/dids.rs b/crates/sage-database/src/primitives/dids.rs index 5bc46302..f473082e 100644 --- a/crates/sage-database/src/primitives/dids.rs +++ b/crates/sage-database/src/primitives/dids.rs @@ -409,7 +409,7 @@ async fn created_unspent_did_coin_states( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `coin_states` INNER JOIN `did_coins` ON `coin_states`.coin_id = `did_coins`.coin_id WHERE `spent_height` IS NULL @@ -434,7 +434,7 @@ async fn created_unspent_did_coin_state( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `did_coins` INNER JOIN `coin_states` ON `coin_states`.coin_id = `did_coins`.coin_id WHERE `launcher_id` = ? diff --git a/crates/sage-database/src/primitives/nfts.rs b/crates/sage-database/src/primitives/nfts.rs index 4a5931e8..ccbfe8f3 100644 --- a/crates/sage-database/src/primitives/nfts.rs +++ b/crates/sage-database/src/primitives/nfts.rs @@ -1207,7 +1207,7 @@ async fn created_unspent_nft_coin_states( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `coin_states` INNER JOIN `nft_coins` ON `coin_states`.coin_id = `nft_coins`.coin_id WHERE `spent_height` IS NULL @@ -1232,7 +1232,7 @@ async fn created_unspent_nft_coin_state( let rows = sqlx::query_as!( CoinStateSql, " - SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id` + SELECT `parent_coin_id`, `puzzle_hash`, `amount`, `spent_height`, `created_height`, `transaction_id`, `kind` FROM `coin_states` INNER JOIN `nft_coins` ON `coin_states`.coin_id = `nft_coins`.coin_id WHERE `launcher_id` = ? diff --git a/crates/sage-database/src/primitives/xch.rs b/crates/sage-database/src/primitives/xch.rs index fa5f5561..921c62f2 100644 --- a/crates/sage-database/src/primitives/xch.rs +++ b/crates/sage-database/src/primitives/xch.rs @@ -38,7 +38,7 @@ async fn insert_p2_coin(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Resu sqlx::query!( " - REPLACE INTO `p2_coins` (`coin_id`) VALUES (?) + UPDATE `coin_states` SET `kind` = 1 WHERE `coin_id` = ? ", coin_id ) @@ -51,11 +51,11 @@ async fn insert_p2_coin(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Resu async fn balance(conn: impl SqliteExecutor<'_>) -> Result { let row = sqlx::query!( " - SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent` - INNER JOIN `p2_coins` ON `coin_states`.`coin_id` = `p2_coins`.`coin_id` + SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_kind_spent` LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id` WHERE `coin_states`.`spent_height` IS NULL AND `transaction_spends`.`coin_id` IS NULL + AND `kind` = 1 " ) .fetch_all(conn) @@ -71,7 +71,6 @@ async fn spendable_coins(conn: impl SqliteExecutor<'_>) -> Result> { CoinSql, " SELECT `coin_states`.`parent_coin_id`, `coin_states`.`puzzle_hash`, `coin_states`.`amount` FROM `coin_states` - INNER JOIN `p2_coins` ON `coin_states`.`coin_id` = `p2_coins`.`coin_id` LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id` LEFT JOIN `offered_coins` ON `coin_states`.`coin_id` = `offered_coins`.`coin_id` LEFT JOIN `offers` ON `offered_coins`.`offer_id` = `offers`.`offer_id` @@ -79,6 +78,7 @@ async fn spendable_coins(conn: impl SqliteExecutor<'_>) -> Result> { AND `transaction_spends`.`coin_id` IS NULL AND (`offered_coins`.`coin_id` IS NULL OR `offers`.`status` > 0) AND `coin_states`.`transaction_id` IS NULL + AND `kind` = 1 " ) .fetch_all(conn) @@ -92,9 +92,8 @@ async fn p2_coin_states(conn: impl SqliteExecutor<'_>) -> Result, pub created_height: Option, pub transaction_id: Option>, + pub kind: i64, } pub(crate) struct CoinSql { @@ -19,10 +20,32 @@ pub(crate) struct CoinSql { pub amount: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CoinKind { + Unknown, + Xch, + Cat, + Nft, + Did, +} + +impl CoinKind { + pub fn from_i64(value: i64) -> Self { + match value { + 1 => Self::Xch, + 2 => Self::Cat, + 3 => Self::Nft, + 4 => Self::Did, + _ => Self::Unknown, + } + } +} + #[derive(Debug, Clone, Copy)] pub struct CoinStateRow { pub coin_state: CoinState, pub transaction_id: Option, + pub kind: CoinKind, } impl IntoRow for CoinStateSql { @@ -40,6 +63,7 @@ impl IntoRow for CoinStateSql { created_height: self.created_height.map(TryInto::try_into).transpose()?, }, transaction_id: self.transaction_id.as_deref().map(to_bytes32).transpose()?, + kind: CoinKind::from_i64(self.kind), }) } } diff --git a/crates/sage-wallet/src/database.rs b/crates/sage-wallet/src/database.rs index 5bc41220..b4ed8441 100644 --- a/crates/sage-wallet/src/database.rs +++ b/crates/sage-wallet/src/database.rs @@ -7,7 +7,7 @@ use chia::{ bls::Signature, protocol::{Bytes32, CoinState}, }; -use sage_database::{CatRow, Database, DatabaseTx, DidRow, NftRow}; +use sage_database::{CatRow, CoinKind, Database, DatabaseTx, DidRow, NftRow}; use crate::{compute_nft_info, fetch_nft_did, ChildKind, Transaction, WalletError, WalletPeer}; @@ -80,7 +80,8 @@ pub async fn insert_puzzle( lineage_proof, p2_puzzle_hash, } => { - tx.sync_coin(coin_id, Some(p2_puzzle_hash)).await?; + tx.sync_coin(coin_id, Some(p2_puzzle_hash), CoinKind::Cat) + .await?; tx.insert_cat(CatRow { asset_id, name: None, @@ -100,7 +101,8 @@ pub async fn insert_puzzle( } => { let launcher_id = info.launcher_id; - tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; + tx.sync_coin(coin_id, Some(info.p2_puzzle_hash), CoinKind::Did) + .await?; tx.insert_did_coin(coin_id, lineage_proof, info).await?; if coin_state.spent_height.is_some() { @@ -142,7 +144,8 @@ pub async fn insert_puzzle( let launcher_id = info.launcher_id; let owner_did = info.current_owner; - tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; + tx.sync_coin(coin_id, Some(info.p2_puzzle_hash), CoinKind::Nft) + .await?; tx.insert_nft_coin( coin_id, @@ -319,7 +322,17 @@ pub async fn insert_transaction( tx.insert_coin_state(coin_state, true, Some(transaction_id)) .await?; - tx.sync_coin(coin_id, Some(p2_puzzle_hash)).await?; + tx.sync_coin( + coin_id, + Some(p2_puzzle_hash), + match output.kind { + ChildKind::Unknown { .. } | ChildKind::Launcher => CoinKind::Unknown, + ChildKind::Cat { .. } => CoinKind::Cat, + ChildKind::Did { .. } => CoinKind::Did, + ChildKind::Nft { .. } => CoinKind::Nft, + }, + ) + .await?; if output.kind.subscribe() { subscriptions.push(coin_id); diff --git a/crates/sage-wallet/src/queues/puzzle_queue.rs b/crates/sage-wallet/src/queues/puzzle_queue.rs index 9e7a6a9e..c33cdd24 100644 --- a/crates/sage-wallet/src/queues/puzzle_queue.rs +++ b/crates/sage-wallet/src/queues/puzzle_queue.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use chia::protocol::{Bytes32, CoinState}; use futures_util::{stream::FuturesUnordered, StreamExt}; -use sage_database::Database; +use sage_database::{CoinKind, Database}; use tokio::{ sync::{mpsc, Mutex}, task::spawn_blocking, @@ -141,7 +141,8 @@ async fn fetch_puzzle( coin_state: CoinState, ) -> Result { if db.is_p2_puzzle_hash(coin_state.coin.puzzle_hash).await? { - db.sync_coin(coin_state.coin.coin_id(), None).await?; + db.sync_coin(coin_state.coin.coin_id(), None, CoinKind::Xch) + .await?; warn!( "Could {} should already be synced, but isn't", coin_state.coin.coin_id() diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index 35e492f0..20d303db 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -8,16 +8,17 @@ use chia_wallet_sdk::{encode_address, Nft}; use clvmr::Allocator; use hex_literal::hex; use sage_api::{ - Amount, CatRecord, CoinRecord, DerivationRecord, DidRecord, GetCat, GetCatCoins, + Amount, AssetKind, CatRecord, CoinRecord, DerivationRecord, DidRecord, GetCat, GetCatCoins, GetCatCoinsResponse, GetCatResponse, GetCats, GetCatsResponse, GetDerivations, GetDerivationsResponse, GetDids, GetDidsResponse, GetNft, GetNftCollection, GetNftCollectionResponse, GetNftCollections, GetNftCollectionsResponse, GetNftData, GetNftDataResponse, GetNftResponse, GetNftStatus, GetNftStatusResponse, GetNfts, GetNftsResponse, GetPendingTransactions, GetPendingTransactionsResponse, GetSyncStatus, - GetSyncStatusResponse, GetXchCoins, GetXchCoinsResponse, NftCollectionRecord, NftData, - NftRecord, NftSortMode, PendingTransactionRecord, + GetSyncStatusResponse, GetTransactions, GetTransactionsResponse, GetXchCoins, + GetXchCoinsResponse, NftCollectionRecord, NftData, NftRecord, NftSortMode, + PendingTransactionRecord, TransactionCoin, TransactionRecord, }; -use sage_database::NftRow; +use sage_database::{CoinKind, CoinStateRow, Database, NftRow}; use sage_wallet::WalletError; use crate::{parse_asset_id, parse_collection_id, parse_nft_id, Result, Sage}; @@ -258,6 +259,45 @@ impl Sage { Ok(GetPendingTransactionsResponse { transactions }) } + pub async fn get_transactions(&self, req: GetTransactions) -> Result { + let wallet = self.wallet()?; + + let mut transactions = Vec::new(); + + let heights = wallet.db.get_block_heights().await?; + + for &height in heights + .iter() + .skip(req.offset.try_into()?) + .take(req.limit.try_into()?) + { + let spent_rows = wallet.db.get_coin_states_by_spent_height(height).await?; + let created_rows = wallet.db.get_coin_states_by_created_height(height).await?; + + let mut spent = Vec::new(); + let mut created = Vec::new(); + + for row in spent_rows { + spent.push(self.transaction_coin(&wallet.db, row).await?); + } + + for row in created_rows { + created.push(self.transaction_coin(&wallet.db, row).await?); + } + + transactions.push(TransactionRecord { + height, + spent, + created, + }); + } + + Ok(GetTransactionsResponse { + transactions, + total: heights.len().try_into()?, + }) + } + pub async fn get_nft_status(&self, _req: GetNftStatus) -> Result { let wallet = self.wallet()?; @@ -610,4 +650,99 @@ impl Sage { created_height: nft_row.created_height, }) } + + async fn transaction_coin(&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 data = if let Some(hash) = data_hash { + db.fetch_nft_data(hash).await? + } else { + None + }; + + ( + AssetKind::Nft { + launcher_id: encode_address(nft.info.launcher_id.to_bytes(), "nft")?, + name: row.as_ref().and_then(|row| row.name.clone()), + image_data: data + .as_ref() + .map(|data| BASE64_STANDARD.encode(&data.blob)), + image_mime_type: data.map(|data| data.mime_type), + }, + 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: encode_address( + did.info.launcher_id.to_bytes(), + "did:chia:", + )?, + name: row.and_then(|row| row.name), + }, + Some(did.info.p2_puzzle_hash), + ) + } else { + (AssetKind::Unknown, None) + } + } + }; + + Ok(TransactionCoin { + coin_id: hex::encode(coin_id), + address: p2_puzzle_hash + .map(|p2_puzzle_hash| { + encode_address(p2_puzzle_hash.to_bytes(), &self.network().address_prefix) + }) + .transpose()?, + amount: Amount::u64(coin.coin_state.coin.amount), + kind, + }) + } } diff --git a/migrations/0004_transactions.sql b/migrations/0004_transactions.sql new file mode 100644 index 00000000..94c2b028 --- /dev/null +++ b/migrations/0004_transactions.sql @@ -0,0 +1,10 @@ +ALTER TABLE `coin_states` ADD COLUMN `kind` INTEGER NOT NULL DEFAULT 0; +CREATE INDEX `coin_kind` ON `coin_states` (`kind`); +CREATE INDEX `coin_kind_spent` ON `coin_states` (`kind`, `spent_height` ASC); + +UPDATE `coin_states` SET `kind` = 1 WHERE `coin_id` IN (SELECT `coin_id` FROM `p2_coins`); +DROP TABLE `p2_coins`; + +UPDATE `coin_states` SET `kind` = 2 WHERE `coin_id` IN (SELECT `coin_id` FROM `cat_coins`); +UPDATE `coin_states` SET `kind` = 3 WHERE `coin_id` IN (SELECT `coin_id` FROM `nft_coins`); +UPDATE `coin_states` SET `kind` = 4 WHERE `coin_id` IN (SELECT `coin_id` FROM `did_coins`); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5b962f83..bf8d83e0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -349,6 +349,15 @@ pub async fn get_pending_transactions( Ok(state.lock().await.get_pending_transactions(req).await?) } +#[command] +#[specta] +pub async fn get_transactions( + state: State<'_, AppState>, + req: GetTransactions, +) -> Result { + Ok(state.lock().await.get_transactions(req).await?) +} + #[command] #[specta] pub async fn get_nft_status( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fa9f96fe..9472bec8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,6 +58,7 @@ pub fn run() { commands::get_nft, commands::get_nft_data, commands::get_pending_transactions, + commands::get_transactions, commands::validate_address, commands::make_offer, commands::take_offer, diff --git a/src/bindings.ts b/src/bindings.ts index b4f712b4..0885e62c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -128,6 +128,9 @@ async getNftData(req: GetNftData) : Promise { async getPendingTransactions(req: GetPendingTransactions) : Promise { return await TAURI_INVOKE("get_pending_transactions", { req }); }, +async getTransactions(req: GetTransactions) : Promise { + return await TAURI_INVOKE("get_transactions", { req }); +}, async validateAddress(address: string) : Promise { return await TAURI_INVOKE("validate_address", { address }); }, @@ -297,6 +300,8 @@ 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 } +export type GetTransactions = { offset: number; limit: number } +export type GetTransactionsResponse = { transactions: TransactionRecord[]; total: number } export type GetXchCoins = Record export type GetXchCoinsResponse = { coins: CoinRecord[] } export type ImportKey = { name: string; key: string; save_secrets?: boolean; login?: boolean } @@ -367,8 +372,10 @@ export type SubmitTransactionResponse = Record export type SyncEvent = { type: "start"; ip: string } | { type: "stop" } | { type: "subscribed" } | { type: "derivation" } | { type: "coin_state" } | { type: "puzzle_batch_synced" } | { type: "cat_info" } | { type: "did_info" } | { type: "nft_data" } export type TakeOffer = { offer: string; fee: Amount; auto_submit?: boolean } export type TakeOfferResponse = { summary: TransactionSummary; spend_bundle: SpendBundleJson; transaction_id: string } +export type TransactionCoin = ({ type: "unknown" } | { type: "xch" } | { type: "launcher" } | { type: "cat"; asset_id: string; name: string | null; ticker: string | null; icon_url: string | null } | { type: "did"; launcher_id: string; name: string | null } | { type: "nft"; launcher_id: string; image_data: string | null; image_mime_type: string | null; name: string | null }) & { coin_id: string; amount: Amount; address: string | null } export type TransactionInput = ({ type: "unknown" } | { type: "xch" } | { type: "launcher" } | { type: "cat"; asset_id: string; name: string | null; ticker: string | null; icon_url: string | null } | { type: "did"; launcher_id: string; name: string | null } | { type: "nft"; launcher_id: string; image_data: string | null; image_mime_type: string | null; name: string | null }) & { coin_id: string; amount: Amount; address: string; outputs: TransactionOutput[] } export type TransactionOutput = { coin_id: string; amount: Amount; address: string; receiving: boolean; burning: boolean } +export type TransactionRecord = { height: number; spent: TransactionCoin[]; created: TransactionCoin[] } export type TransactionResponse = { summary: TransactionSummary; coin_spends: CoinSpendJson[] } export type TransactionSummary = { fee: Amount; inputs: TransactionInput[] } export type TransferDids = { did_ids: string[]; address: string; fee: Amount; auto_submit?: boolean } diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 7d444834..e033f2d3 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -2,6 +2,7 @@ import { ArrowLeftRight, BookUser, Images, + RouteIcon, SquareUserRound, WalletIcon, } from 'lucide-react'; @@ -10,7 +11,7 @@ import { Link } from 'react-router-dom'; export function Nav() { return ( -