diff --git a/.sqlx/query-c789dfddf9513432df0390ce450337437006d47d3551fc7fd8fa3bb33b305b9b.json b/.sqlx/query-579cf65be1ff023aca72dfcd60ebd3883e75bb092b4ac965411f24fa4a3b96d2.json similarity index 55% rename from .sqlx/query-c789dfddf9513432df0390ce450337437006d47d3551fc7fd8fa3bb33b305b9b.json rename to .sqlx/query-579cf65be1ff023aca72dfcd60ebd3883e75bb092b4ac965411f24fa4a3b96d2.json index 1129fcf5..80f49f3a 100644 --- a/.sqlx/query-c789dfddf9513432df0390ce450337437006d47d3551fc7fd8fa3bb33b305b9b.json +++ b/.sqlx/query-579cf65be1ff023aca72dfcd60ebd3883e75bb092b4ac965411f24fa4a3b96d2.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "\n INSERT INTO `transactions` (\n `transaction_id`,\n `aggregated_signature`,\n `fee`,\n `expiration_height`\n )\n VALUES (?, ?, ?, ?)\n ", + "query": "\n INSERT INTO `transactions` (\n `transaction_id`,\n `aggregated_signature`,\n `fee`\n )\n VALUES (?, ?, ?)\n ", "describe": { "columns": [], "parameters": { - "Right": 4 + "Right": 3 }, "nullable": [] }, - "hash": "c789dfddf9513432df0390ce450337437006d47d3551fc7fd8fa3bb33b305b9b" + "hash": "579cf65be1ff023aca72dfcd60ebd3883e75bb092b4ac965411f24fa4a3b96d2" } diff --git a/.sqlx/query-dffc6fddfbb3c31e82fd7b57bb8e08a4cf273e2f933dd6490e506be9cfb0d1cc.json b/.sqlx/query-8a728911b3877476b9649f890645c5b658d377a98d7c6fe8952fd6c9f7da6d84.json similarity index 58% rename from .sqlx/query-dffc6fddfbb3c31e82fd7b57bb8e08a4cf273e2f933dd6490e506be9cfb0d1cc.json rename to .sqlx/query-8a728911b3877476b9649f890645c5b658d377a98d7c6fe8952fd6c9f7da6d84.json index 95ad0b0f..c4c96d86 100644 --- a/.sqlx/query-dffc6fddfbb3c31e82fd7b57bb8e08a4cf273e2f933dd6490e506be9cfb0d1cc.json +++ b/.sqlx/query-8a728911b3877476b9649f890645c5b658d377a98d7c6fe8952fd6c9f7da6d84.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n `transaction_id`,\n `fee`,\n `submitted_at`,\n `expiration_height`\n FROM `transactions`\n ORDER BY `submitted_at` DESC, `transaction_id` ASC\n ", + "query": "\n SELECT\n `transaction_id`,\n `fee`,\n `submitted_at`\n FROM `transactions`\n ORDER BY `submitted_at` DESC, `transaction_id` ASC\n ", "describe": { "columns": [ { @@ -17,11 +17,6 @@ "name": "submitted_at", "ordinal": 2, "type_info": "Integer" - }, - { - "name": "expiration_height", - "ordinal": 3, - "type_info": "Integer" } ], "parameters": { @@ -30,9 +25,8 @@ "nullable": [ false, false, - true, true ] }, - "hash": "dffc6fddfbb3c31e82fd7b57bb8e08a4cf273e2f933dd6490e506be9cfb0d1cc" + "hash": "8a728911b3877476b9649f890645c5b658d377a98d7c6fe8952fd6c9f7da6d84" } diff --git a/.sqlx/query-ecdabf3ddfb2b9e7ddaee36c57cee4818b113faad1fa6169e486a7a1bc1be4da.json b/.sqlx/query-ecdabf3ddfb2b9e7ddaee36c57cee4818b113faad1fa6169e486a7a1bc1be4da.json new file mode 100644 index 00000000..1ff9f8b7 --- /dev/null +++ b/.sqlx/query-ecdabf3ddfb2b9e7ddaee36c57cee4818b113faad1fa6169e486a7a1bc1be4da.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT `name`\n FROM `dids`\n WHERE `launcher_id` = ?\n ", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "ecdabf3ddfb2b9e7ddaee36c57cee4818b113faad1fa6169e486a7a1bc1be4da" +} diff --git a/.sqlx/query-2ddb1f5a998f3fc7e3661dac3921edfcda74006bc0bebb59642e0553d7adbe9d.json b/.sqlx/query-f10c69e5a78d6923fa033904ba375bf7ce408896041754aaf851360a9f0f20d1.json similarity index 58% rename from .sqlx/query-2ddb1f5a998f3fc7e3661dac3921edfcda74006bc0bebb59642e0553d7adbe9d.json rename to .sqlx/query-f10c69e5a78d6923fa033904ba375bf7ce408896041754aaf851360a9f0f20d1.json index 5f721f30..7ebd556f 100644 --- a/.sqlx/query-2ddb1f5a998f3fc7e3661dac3921edfcda74006bc0bebb59642e0553d7adbe9d.json +++ b/.sqlx/query-f10c69e5a78d6923fa033904ba375bf7ce408896041754aaf851360a9f0f20d1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n REPLACE INTO `coin_states` (\n `coin_id`,\n `parent_coin_id`,\n `puzzle_hash`,\n `amount`,\n `created_height`,\n `spent_height`,\n `synced`,\n `transaction_id`\n )\n VALUES (\n ?,\n ?,\n ?,\n ?,\n ?,\n ?,\n ?,\n ?\n )\n ", + "query": "\n REPLACE INTO `coin_states` (\n `coin_id`,\n `parent_coin_id`,\n `puzzle_hash`,\n `amount`,\n `created_height`,\n `spent_height`,\n `synced`,\n `transaction_id`\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)\n ", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "2ddb1f5a998f3fc7e3661dac3921edfcda74006bc0bebb59642e0553d7adbe9d" + "hash": "f10c69e5a78d6923fa033904ba375bf7ce408896041754aaf851360a9f0f20d1" } diff --git a/crates/sage-api/src/records.rs b/crates/sage-api/src/records.rs index 01e93508..eb990303 100644 --- a/crates/sage-api/src/records.rs +++ b/crates/sage-api/src/records.rs @@ -5,6 +5,7 @@ mod nft; mod peer; mod pending_transaction; mod sync_status; +mod transaction_summary; pub use cat::*; pub use coin::*; @@ -13,3 +14,4 @@ pub use nft::*; pub use peer::*; pub use pending_transaction::*; pub use sync_status::*; +pub use transaction_summary::*; diff --git a/crates/sage-api/src/records/pending_transaction.rs b/crates/sage-api/src/records/pending_transaction.rs index d7c09d7f..915626cc 100644 --- a/crates/sage-api/src/records/pending_transaction.rs +++ b/crates/sage-api/src/records/pending_transaction.rs @@ -8,5 +8,4 @@ pub struct PendingTransactionRecord { pub transaction_id: String, pub fee: Amount, pub submitted_at: Option, - pub expiration_height: Option, } diff --git a/crates/sage-api/src/records/transaction_summary.rs b/crates/sage-api/src/records/transaction_summary.rs new file mode 100644 index 00000000..46b0af54 --- /dev/null +++ b/crates/sage-api/src/records/transaction_summary.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::Amount; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct TransactionSummary { + pub fee: Amount, + pub inputs: Vec, + pub data: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct Input { + pub coin_id: String, + pub amount: Amount, + pub address: String, + #[serde(flatten)] + pub kind: InputKind, + pub outputs: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct Output { + pub coin_id: String, + pub amount: Amount, + pub address: String, + pub receiving: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum InputKind { + Unknown, + Xch, + Launcher, + Cat { + asset_id: String, + name: Option, + ticker: Option, + icon_url: Option, + }, + Did { + launcher_id: String, + name: Option, + }, + Nft { + launcher_id: String, + image_data: Option, + image_mime_type: Option, + name: Option, + }, +} diff --git a/crates/sage-api/src/requests/bulk_mint_nfts.rs b/crates/sage-api/src/requests/bulk_mint_nfts.rs index f1f4113f..b1f101d0 100644 --- a/crates/sage-api/src/requests/bulk_mint_nfts.rs +++ b/crates/sage-api/src/requests/bulk_mint_nfts.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use specta::Type; -use crate::Amount; +use crate::{Amount, TransactionSummary}; #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct BulkMintNfts { @@ -24,4 +24,5 @@ pub struct NftMint { #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct BulkMintNftsResponse { pub nft_ids: Vec, + pub summary: TransactionSummary, } diff --git a/crates/sage-database/src/coin_states.rs b/crates/sage-database/src/coin_states.rs index 83a05886..e60aa336 100644 --- a/crates/sage-database/src/coin_states.rs +++ b/crates/sage-database/src/coin_states.rs @@ -88,16 +88,7 @@ async fn insert_coin_state( `synced`, `transaction_id` ) - VALUES ( - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ? - ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ", coin_id_ref, parent_coin_id, diff --git a/crates/sage-database/src/derivations.rs b/crates/sage-database/src/derivations.rs index 68cdf6fd..cd7e997d 100644 --- a/crates/sage-database/src/derivations.rs +++ b/crates/sage-database/src/derivations.rs @@ -15,6 +15,10 @@ impl Database { pub async fn synthetic_key_index(&self, synthetic_key: PublicKey) -> Result> { synthetic_key_index(&self.pool, synthetic_key).await } + + pub async fn is_p2_puzzle_hash(&self, p2_puzzle_hash: Bytes32) -> Result { + is_p2_puzzle_hash(&self.pool, p2_puzzle_hash).await + } } impl<'a> DatabaseTx<'a> { diff --git a/crates/sage-database/src/primitives/dids.rs b/crates/sage-database/src/primitives/dids.rs index ba3cd41a..5282942e 100644 --- a/crates/sage-database/src/primitives/dids.rs +++ b/crates/sage-database/src/primitives/dids.rs @@ -42,6 +42,10 @@ impl Database { pub async fn did(&self, did_id: Bytes32) -> Result>> { did(&self.pool, did_id).await } + + pub async fn did_name(&self, launcher_id: Bytes32) -> Result> { + did_name(&self.pool, launcher_id).await + } } impl<'a> DatabaseTx<'a> { @@ -271,3 +275,23 @@ async fn did(conn: impl SqliteExecutor<'_>, did_id: Bytes32) -> Result, launcher_id: Bytes32) -> Result> { + let launcher_id = launcher_id.as_ref(); + + let Some(row) = sqlx::query!( + " + SELECT `name` + FROM `dids` + WHERE `launcher_id` = ? + ", + launcher_id + ) + .fetch_optional(conn) + .await? + else { + return Ok(None); + }; + + Ok(row.name) +} diff --git a/crates/sage-database/src/primitives/nfts.rs b/crates/sage-database/src/primitives/nfts.rs index c9ef89ed..a8a391f2 100644 --- a/crates/sage-database/src/primitives/nfts.rs +++ b/crates/sage-database/src/primitives/nfts.rs @@ -43,6 +43,10 @@ impl Database { pub async fn nft(&self, launcher_id: Bytes32) -> Result>> { nft(&self.pool, launcher_id).await } + + pub async fn fetch_nft_data(&self, hash: Bytes32) -> Result> { + fetch_nft_data(&self.pool, hash).await + } } impl<'a> DatabaseTx<'a> { diff --git a/crates/sage-database/src/transactions.rs b/crates/sage-database/src/transactions.rs index 210809bd..8189cfea 100644 --- a/crates/sage-database/src/transactions.rs +++ b/crates/sage-database/src/transactions.rs @@ -11,7 +11,6 @@ pub struct TransactionRow { pub transaction_id: Bytes32, pub fee: u64, pub submitted_at: Option, - pub expiration_height: Option, } impl Database { @@ -34,16 +33,8 @@ impl<'a> DatabaseTx<'a> { transaction_id: Bytes32, aggregated_signature: Signature, fee: u64, - expiration_height: Option, ) -> Result<()> { - insert_transaction( - &mut *self.tx, - transaction_id, - aggregated_signature, - fee, - expiration_height, - ) - .await + insert_transaction(&mut *self.tx, transaction_id, aggregated_signature, fee).await } pub async fn insert_transaction_spend( @@ -89,7 +80,6 @@ async fn insert_transaction( transaction_id: Bytes32, aggregated_signature: Signature, fee: u64, - expiration_height: Option, ) -> Result<()> { let transaction_id = transaction_id.as_ref(); let aggregated_signature = aggregated_signature.to_bytes(); @@ -102,15 +92,13 @@ async fn insert_transaction( INSERT INTO `transactions` ( `transaction_id`, `aggregated_signature`, - `fee`, - `expiration_height` + `fee` ) - VALUES (?, ?, ?, ?) + VALUES (?, ?, ?) ", transaction_id, aggregated_signature, - fee, - expiration_height + fee ) .execute(conn) .await?; @@ -190,8 +178,7 @@ async fn transactions(conn: impl SqliteExecutor<'_>) -> Result) -> Result, + }, + Launcher, Cat { asset_id: Bytes32, - lineage_proof: LineageProof, p2_puzzle_hash: Bytes32, + lineage_proof: LineageProof, }, - /// A non-eve coin which follows the DID1 standard. Did { - lineage_proof: LineageProof, info: DidInfo, + lineage_proof: LineageProof, }, - /// A non-eve coin which follows the NFT1 standard. Nft { - lineage_proof: LineageProof, info: NftInfo, - data_hash: Option, - metadata_hash: Option, - license_hash: Option, - data_uris: Vec, - metadata_uris: Vec, - license_uris: Vec, + lineage_proof: LineageProof, + metadata: Option, }, - /// The coin could not be parsed due to an error or it was a kind of puzzle we don't know about. - Unknown { hint: Option }, } -impl PuzzleInfo { - pub fn parse( +impl ChildKind { + pub fn from_parent( parent_coin: Coin, parent_puzzle: &Program, parent_solution: &Program, coin: Coin, ) -> Result { let parse_span = debug_span!( - "parse", + "parse from parent", parent_coin = %parent_coin.coin_id(), coin = %coin.coin_id() ); let _span = parse_span.enter(); + if coin.puzzle_hash == SINGLETON_LAUNCHER_PUZZLE_HASH.into() { + return Ok(Self::Launcher); + } + let mut allocator = Allocator::new(); let parent_puzzle_ptr = parent_puzzle @@ -143,29 +140,15 @@ impl PuzzleInfo { return Ok(unknown); }; - let metadata = NftMetadata::from_clvm(&allocator, nft.info.metadata.ptr()).ok(); - let metadata_program = Program::from_clvm(&allocator, nft.info.metadata.ptr()) .map_err(|_| ParseError::Serialize)?; + let metadata = NftMetadata::from_clvm(&allocator, nft.info.metadata.ptr()).ok(); + return Ok(Self::Nft { lineage_proof, info: nft.info.with_metadata(metadata_program), - data_hash: metadata.as_ref().and_then(|m| m.data_hash), - metadata_hash: metadata.as_ref().and_then(|m| m.metadata_hash), - license_hash: metadata.as_ref().and_then(|m| m.license_hash), - data_uris: metadata - .as_ref() - .map(|m| m.data_uris.clone()) - .unwrap_or_default(), - metadata_uris: metadata - .as_ref() - .map(|m| m.metadata_uris.clone()) - .unwrap_or_default(), - license_uris: metadata - .as_ref() - .map(|m| m.license_uris.clone()) - .unwrap_or_default(), + metadata, }); } diff --git a/crates/sage-wallet/src/coin_kind.rs b/crates/sage-wallet/src/coin_kind.rs new file mode 100644 index 00000000..b516b1b4 --- /dev/null +++ b/crates/sage-wallet/src/coin_kind.rs @@ -0,0 +1,113 @@ +use chia::{ + clvm_traits::{FromClvm, ToClvm}, + protocol::{Bytes32, Program}, + puzzles::{nft::NftMetadata, singleton::SINGLETON_LAUNCHER_PUZZLE_HASH}, +}; +use chia_wallet_sdk::{CatLayer, DidInfo, HashedPtr, Layer, NftInfo, Puzzle}; +use clvmr::Allocator; +use tracing::{debug_span, warn}; + +use crate::ParseError; + +#[derive(Debug, Clone)] +#[allow(clippy::large_enum_variant)] +pub enum CoinKind { + Unknown, + Launcher, + Cat { + asset_id: Bytes32, + p2_puzzle_hash: Bytes32, + }, + Did { + info: DidInfo, + }, + Nft { + info: NftInfo, + metadata: Option, + }, +} + +impl CoinKind { + pub fn from_puzzle(puzzle: &Program) -> Result { + let parse_span = debug_span!("parse puzzle"); + let _span = parse_span.enter(); + + let mut allocator = Allocator::new(); + + let puzzle_ptr = puzzle + .to_clvm(&mut allocator) + .map_err(|_| ParseError::AllocatePuzzle)?; + + let puzzle = Puzzle::parse(&allocator, puzzle_ptr); + + if puzzle.curried_puzzle_hash() == SINGLETON_LAUNCHER_PUZZLE_HASH { + return Ok(Self::Launcher); + } + + match CatLayer::::parse_puzzle(&allocator, puzzle) { + // If there was an error parsing the CAT, we can exit early. + Err(error) => { + warn!("Invalid CAT: {}", error); + return Ok(Self::Unknown); + } + + // If the coin is a CAT coin, return the relevant information. + Ok(Some(cat)) => { + return Ok(Self::Cat { + asset_id: cat.asset_id, + p2_puzzle_hash: cat.inner_puzzle.tree_hash().into(), + }); + } + + // If the coin is not a CAT coin, continue parsing. + Ok(None) => {} + } + + match NftInfo::::parse(&allocator, puzzle) { + // If there was an error parsing the NFT, we can exit early. + Err(error) => { + warn!("Invalid NFT: {}", error); + return Ok(Self::Unknown); + } + + // If the coin is a NFT coin, return the relevant information. + Ok(Some((nft, _inner_puzzle))) => { + let metadata_program = Program::from_clvm(&allocator, nft.metadata.ptr()) + .map_err(|_| ParseError::Serialize)?; + + let metadata = NftMetadata::from_clvm(&allocator, nft.metadata.ptr()).ok(); + + return Ok(Self::Nft { + info: nft.with_metadata(metadata_program), + metadata, + }); + } + + // If the coin is not a NFT coin, continue parsing. + Ok(None) => {} + } + + match DidInfo::::parse(&allocator, puzzle) { + // If there was an error parsing the DID, we can exit early. + Err(error) => { + warn!("Invalid DID: {}", error); + return Ok(Self::Unknown); + } + + // If the coin is a DID coin, return the relevant information. + Ok(Some((did, _inner_puzzle))) => { + let metadata = Program::from_clvm(&allocator, did.metadata.ptr()) + .map_err(|_| ParseError::Serialize)?; + + return Ok(Self::Did { + info: did.with_metadata(metadata), + }); + } + + // If the coin is not a DID coin, continue parsing. + Ok(None) => {} + } + + Ok(Self::Unknown) + } +} diff --git a/crates/sage-wallet/src/lib.rs b/crates/sage-wallet/src/lib.rs index 02a44e2a..ff3cb1d8 100644 --- a/crates/sage-wallet/src/lib.rs +++ b/crates/sage-wallet/src/lib.rs @@ -1,13 +1,17 @@ +mod child_kind; +mod coin_kind; mod data; mod error; -mod puzzle_info; mod queues; mod sync_manager; +mod transaction; mod wallet; +pub use child_kind::*; +pub use coin_kind::*; pub use data::*; pub use error::*; -pub use puzzle_info::*; pub use queues::*; pub use sync_manager::*; +pub use transaction::*; pub use wallet::*; diff --git a/crates/sage-wallet/src/queues/puzzle_queue.rs b/crates/sage-wallet/src/queues/puzzle_queue.rs index 2a33f9be..db50fd34 100644 --- a/crates/sage-wallet/src/queues/puzzle_queue.rs +++ b/crates/sage-wallet/src/queues/puzzle_queue.rs @@ -11,7 +11,7 @@ use tokio::{ }; use tracing::{debug, instrument}; -use crate::{PeerState, PuzzleInfo, SyncCommand, SyncError, SyncEvent, WalletError}; +use crate::{ChildKind, PeerState, SyncCommand, SyncError, SyncEvent, WalletError}; #[derive(Debug)] pub struct PuzzleQueue { @@ -150,7 +150,7 @@ async fn fetch_puzzle( .map_err(|_| SyncError::MissingPuzzleAndSolution(parent_id))?; let info = spawn_blocking(move || { - PuzzleInfo::parse( + ChildKind::from_parent( parent_coin_state.coin, &response.puzzle, &response.solution, @@ -164,7 +164,8 @@ async fn fetch_puzzle( let mut tx = db.tx().await?; match info { - PuzzleInfo::Cat { + ChildKind::Launcher => {} + ChildKind::Cat { asset_id, lineage_proof, p2_puzzle_hash, @@ -178,7 +179,7 @@ async fn fetch_puzzle( .await .ok(); } - PuzzleInfo::Did { + ChildKind::Did { lineage_proof, info, } => { @@ -191,16 +192,15 @@ async fn fetch_puzzle( .await .ok(); } - PuzzleInfo::Nft { + ChildKind::Nft { lineage_proof, info, - data_hash, - metadata_hash, - license_hash, - data_uris, - metadata_uris, - license_uris, + metadata, } => { + let data_hash = metadata.as_ref().and_then(|m| m.data_hash); + let metadata_hash = metadata.as_ref().and_then(|m| m.metadata_hash); + let license_hash = metadata.as_ref().and_then(|m| m.license_hash); + tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; tx.insert_new_nft(info.launcher_id, true).await?; tx.insert_nft_coin( @@ -213,21 +213,23 @@ async fn fetch_puzzle( ) .await?; - if let Some(hash) = data_hash { - for uri in data_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(metadata) = metadata { + if let Some(hash) = data_hash { + for uri in metadata.data_uris { + tx.insert_nft_uri(uri, hash).await?; + } } - } - if let Some(hash) = metadata_hash { - for uri in metadata_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(hash) = metadata_hash { + for uri in metadata.metadata_uris { + tx.insert_nft_uri(uri, hash).await?; + } } - } - if let Some(hash) = license_hash { - for uri in license_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(hash) = license_hash { + for uri in metadata.license_uris { + tx.insert_nft_uri(uri, hash).await?; + } } } @@ -236,7 +238,7 @@ async fn fetch_puzzle( .await .ok(); } - PuzzleInfo::Unknown { hint } => { + ChildKind::Unknown { hint } => { tx.sync_coin(coin_id, hint).await?; tx.insert_unknown_coin(coin_id).await?; } diff --git a/crates/sage-wallet/src/transaction.rs b/crates/sage-wallet/src/transaction.rs new file mode 100644 index 00000000..d6aed23f --- /dev/null +++ b/crates/sage-wallet/src/transaction.rs @@ -0,0 +1,95 @@ +use chia::{ + clvm_traits::{FromClvm, ToClvm}, + protocol::{Coin, CoinSpend, Program}, +}; +use chia_wallet_sdk::{run_puzzle, Condition, Conditions}; +use clvmr::{Allocator, NodePtr}; + +use crate::{ChildKind, CoinKind, ParseError}; + +#[derive(Debug, Clone)] +pub struct Transaction { + pub fee: u64, + pub inputs: Vec, +} + +#[derive(Debug, Clone)] +pub struct TransactionInput { + pub coin_spend: CoinSpend, + pub kind: CoinKind, + pub outputs: Vec, +} + +#[derive(Debug, Clone)] +pub struct TransactionOutput { + pub coin: Coin, + pub kind: ChildKind, +} + +impl Transaction { + pub fn from_coin_spends(coin_spends: Vec) -> Result { + // TODO: Handle height and timestamp conditions. + + let mut inputs = Vec::new(); + let mut fee = 0; + + for coin_spend in coin_spends { + let mut outputs = Vec::new(); + + for condition in run_conditions(&coin_spend.puzzle_reveal, &coin_spend.solution)? { + match condition { + Condition::CreateCoin(create_coin) => { + let child_coin = Coin::new( + coin_spend.coin.coin_id(), + create_coin.puzzle_hash, + create_coin.amount, + ); + + outputs.push(TransactionOutput { + coin: child_coin, + kind: ChildKind::from_parent( + coin_spend.coin, + &coin_spend.puzzle_reveal, + &coin_spend.solution, + child_coin, + )?, + }); + } + Condition::ReserveFee(cond) => { + fee += cond.amount; + } + _ => {} + } + } + + let kind = CoinKind::from_puzzle(&coin_spend.puzzle_reveal)?; + + inputs.push(TransactionInput { + coin_spend, + kind, + outputs, + }); + } + + Ok(Self { fee, inputs }) + } +} + +fn run_conditions(puzzle_reveal: &Program, solution: &Program) -> Result { + let mut allocator = Allocator::new(); + + let puzzle = puzzle_reveal + .to_clvm(&mut allocator) + .map_err(|_| ParseError::AllocatePuzzle)?; + + let solution = solution + .to_clvm(&mut allocator) + .map_err(|_| ParseError::AllocateSolution)?; + + let output = run_puzzle(&mut allocator, puzzle, solution).map_err(|_| ParseError::Eval)?; + + let conditions = Conditions::::from_clvm(&allocator, output) + .map_err(|_| ParseError::InvalidConditions)?; + + Ok(conditions) +} diff --git a/crates/sage-wallet/src/wallet.rs b/crates/sage-wallet/src/wallet.rs index c3b738cf..6ef86df3 100644 --- a/crates/sage-wallet/src/wallet.rs +++ b/crates/sage-wallet/src/wallet.rs @@ -9,7 +9,6 @@ use chia::{ master_to_wallet_unhardened_intermediate, sign, DerivableKey, PublicKey, SecretKey, Signature, }, - clvm_traits::{FromClvm, ToClvm}, protocol::{Bytes, Bytes32, Coin, CoinSpend, CoinState, Program, SpendBundle}, puzzles::{ nft::{NftMetadata, NFT_METADATA_UPDATER_PUZZLE_HASH}, @@ -18,15 +17,13 @@ use chia::{ }, }; use chia_wallet_sdk::{ - run_puzzle, select_coins, AggSigConstants, Cat, CatSpend, Condition, Conditions, Did, DidOwner, - HashedPtr, Launcher, Nft, NftMint, RequiredSignature, SpendContext, SpendWithConditions, - StandardLayer, + select_coins, AggSigConstants, Cat, CatSpend, Conditions, Did, DidOwner, HashedPtr, Launcher, + Nft, NftMint, RequiredSignature, SpendContext, SpendWithConditions, StandardLayer, }; -use clvmr::{Allocator, NodePtr}; -use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use clvmr::Allocator; use sage_database::{Database, DatabaseTx}; -use crate::{ParseError, PuzzleInfo, WalletError}; +use crate::{ChildKind, Transaction, WalletError}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WalletNftMint { @@ -902,105 +899,31 @@ impl Wallet { Ok(SpendBundle::new(coin_spends, aggregated_signature)) } - pub async fn insert_transaction( - &self, - spend_bundle: &SpendBundle, - peak: u32, - ) -> Result<(), WalletError> { - let mut input_amount = 0; - let mut output_amount = 0; - let mut expiration_height = None::; - let mut spend_outputs = Vec::>::new(); - - for coin_spend in &spend_bundle.coin_spends { - input_amount += coin_spend.coin.amount; - - let mut allocator = Allocator::new(); - - let puzzle = coin_spend - .puzzle_reveal - .to_clvm(&mut allocator) - .map_err(|_| ParseError::AllocatePuzzle)?; - - let solution = coin_spend - .solution - .to_clvm(&mut allocator) - .map_err(|_| ParseError::AllocateSolution)?; - - let output = - run_puzzle(&mut allocator, puzzle, solution).map_err(|_| ParseError::Eval)?; - - let conditions = Conditions::::from_clvm(&allocator, output) - .map_err(|_| ParseError::InvalidConditions)?; - - let mut outputs = Vec::new(); - - for condition in conditions { - match condition { - Condition::CreateCoin(create_coin) => { - output_amount += create_coin.amount; - outputs.push(Coin::new( - coin_spend.coin.coin_id(), - create_coin.puzzle_hash, - create_coin.amount, - )); - } - Condition::AssertBeforeHeightRelative(cond) => { - expiration_height = expiration_height - .map_or(Some(peak + cond.height), |current| { - Some(current.min(peak + cond.height)) - }); - } - Condition::AssertBeforeHeightAbsolute(cond) => { - expiration_height = expiration_height - .map_or(Some(cond.height), |current| Some(current.min(cond.height))); - } - _ => {} - } - } - - spend_outputs.push( - outputs - .into_par_iter() - .map(|coin| { - Ok(( - coin, - PuzzleInfo::parse( - coin_spend.coin, - &coin_spend.puzzle_reveal, - &coin_spend.solution, - coin, - )?, - )) - }) - .collect::>()?, - ); - } - + pub async fn insert_transaction(&self, spend_bundle: SpendBundle) -> Result<(), WalletError> { let transaction_id = spend_bundle.name(); + let transaction = Transaction::from_coin_spends(spend_bundle.coin_spends)?; let mut tx = self.db.tx().await?; tx.insert_transaction( transaction_id, spend_bundle.aggregated_signature.clone(), - input_amount - output_amount, - expiration_height, + transaction.fee, ) .await?; - for (i, coin_spend) in spend_bundle.coin_spends.iter().enumerate() { + for input in transaction.inputs { tx.insert_transaction_spend( - coin_spend.coin, + input.coin_spend.coin, transaction_id, - coin_spend.puzzle_reveal.clone(), - coin_spend.solution.clone(), + input.coin_spend.puzzle_reveal, + input.coin_spend.solution, ) .await?; - for (coin, puzzle) in spend_outputs[i].clone() { - let coin_state = CoinState::new(coin, None, None); - let coin_id = coin.coin_id(); + for output in input.outputs { + let coin_state = CoinState::new(output.coin, None, None); + let coin_id = output.coin.coin_id(); macro_rules! insert_coin { () => { @@ -1009,14 +932,15 @@ impl Wallet { }; } - if tx.is_p2_puzzle_hash(coin.puzzle_hash).await? { + if tx.is_p2_puzzle_hash(output.coin.puzzle_hash).await? { insert_coin!(); tx.insert_p2_coin(coin_id).await?; continue; } - match puzzle { - PuzzleInfo::Cat { + match output.kind { + ChildKind::Launcher => {} + ChildKind::Cat { asset_id, lineage_proof, p2_puzzle_hash, @@ -1028,7 +952,7 @@ impl Wallet { .await?; } } - PuzzleInfo::Did { + ChildKind::Did { lineage_proof, info, } => { @@ -1039,16 +963,15 @@ impl Wallet { tx.insert_did_coin(coin_id, lineage_proof, info).await?; } } - PuzzleInfo::Nft { + ChildKind::Nft { lineage_proof, info, - data_hash, - metadata_hash, - license_hash, - data_uris, - metadata_uris, - license_uris, + metadata, } => { + let data_hash = metadata.as_ref().and_then(|m| m.data_hash); + let metadata_hash = metadata.as_ref().and_then(|m| m.metadata_hash); + let license_hash = metadata.as_ref().and_then(|m| m.license_hash); + if tx.is_p2_puzzle_hash(info.p2_puzzle_hash).await? { insert_coin!(); @@ -1064,26 +987,28 @@ impl Wallet { ) .await?; - if let Some(hash) = data_hash { - for uri in data_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(metadata) = metadata { + if let Some(hash) = data_hash { + for uri in metadata.data_uris { + tx.insert_nft_uri(uri, hash).await?; + } } - } - if let Some(hash) = metadata_hash { - for uri in metadata_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(hash) = metadata_hash { + for uri in metadata.metadata_uris { + tx.insert_nft_uri(uri, hash).await?; + } } - } - if let Some(hash) = license_hash { - for uri in license_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(hash) = license_hash { + for uri in metadata.license_uris { + tx.insert_nft_uri(uri, hash).await?; + } } } } } - PuzzleInfo::Unknown { hint } => { + ChildKind::Unknown { hint } => { let Some(p2_puzzle_hash) = hint else { continue; }; diff --git a/migrations/0001_setup.sql b/migrations/0001_setup.sql index 90bcd799..21f7078a 100644 --- a/migrations/0001_setup.sql +++ b/migrations/0001_setup.sql @@ -135,8 +135,7 @@ CREATE TABLE `transactions` ( `transaction_id` BLOB NOT NULL PRIMARY KEY, `aggregated_signature` BLOB NOT NULL, `fee` BLOB NOT NULL, - `submitted_at` INTEGER, - `expiration_height` INTEGER + `submitted_at` INTEGER ); CREATE TABLE `transaction_spends` ( diff --git a/package.json b/package.json index f8874087..8fa7e561 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", + "@radix-ui/react-scroll-area": "^1.2.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e9bfc94..e5140dba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^1.2.0 + version: 1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-select': specifier: ^2.1.1 version: 2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -794,6 +797,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.0': + resolution: {integrity: sha512-q2jMBdsJ9zB7QG6ngQNzNwlvxLQqONyL58QbEGwuyRZZb/ARQwk3uQVbCF7GvQVOtV6EU/pDxAw3zRzJZI3rpQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.1': resolution: {integrity: sha512-8iRDfyLtzxlprOo9IicnzvpsO1wNCkuwzzCM+Z5Rb5tNOpCdMvcc2AkzX0Fz+Tz9v6NJ5B/7EEgyZveo4FBRfQ==} peerDependencies: @@ -2909,6 +2925,23 @@ snapshots: '@types/react': 18.3.3 '@types/react-dom': 18.3.0 + '@radix-ui/react-scroll-area@1.2.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.0 + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.3)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.3)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + '@types/react-dom': 18.3.0 + '@radix-ui/react-select@2.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index 84fc8f1f..65a7312c 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -263,7 +263,6 @@ pub async fn get_pending_transactions( fee: Amount::from_mojos(tx.fee as u128, state.unit.decimals), // TODO: Date format? submitted_at: tx.submitted_at.map(|ts| ts.to_string()), - expiration_height: tx.expiration_height, }) }) .collect() diff --git a/src-tauri/src/commands/transactions.rs b/src-tauri/src/commands/transactions.rs index c2088a1d..6ae867c4 100644 --- a/src-tauri/src/commands/transactions.rs +++ b/src-tauri/src/commands/transactions.rs @@ -1,15 +1,22 @@ -use std::time::Duration; +use std::{collections::HashMap, time::Duration}; +use base64::prelude::*; use chia::{ protocol::{Bytes32, CoinSpend}, puzzles::nft::NftMetadata, + traits::Streamable, }; use chia_wallet_sdk::{ decode_address, encode_address, AggSigConstants, MAINNET_CONSTANTS, TESTNET11_CONSTANTS, }; -use sage_api::{Amount, BulkMintNfts, BulkMintNftsResponse}; -use sage_database::CatRow; -use sage_wallet::{fetch_uris, Wallet, WalletNftMint}; +use sage_api::{ + Amount, BulkMintNfts, BulkMintNftsResponse, Input, InputKind, Output, TransactionSummary, +}; +use sage_database::{CatRow, Database}; +use sage_wallet::{ + fetch_uris, ChildKind, CoinKind, Data, Transaction, Wallet, WalletError, WalletNftMint, +}; +use serde::Deserialize; use specta::specta; use tauri::{command, State}; use tokio::sync::MutexGuard; @@ -36,7 +43,7 @@ pub async fn send( address: String, amount: Amount, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -61,14 +68,16 @@ pub async fn send( .send_xch(puzzle_hash.into(), amount, fee, Vec::new(), false, true) .await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] #[specta] -pub async fn combine(state: State<'_, AppState>, coin_ids: Vec, fee: Amount) -> Result<()> { +pub async fn combine( + state: State<'_, AppState>, + coin_ids: Vec, + fee: Amount, +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -105,9 +114,7 @@ pub async fn combine(state: State<'_, AppState>, coin_ids: Vec, fee: Amo let coin_spends = wallet.combine_xch(coins, fee, false, true).await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] @@ -117,7 +124,7 @@ pub async fn split( coin_ids: Vec, output_count: u32, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -156,9 +163,7 @@ pub async fn split( .split_xch(&coins, output_count as usize, fee, false, true) .await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] @@ -167,7 +172,7 @@ pub async fn combine_cat( state: State<'_, AppState>, coin_ids: Vec, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -195,9 +200,7 @@ pub async fn combine_cat( let coin_spends = wallet.combine_cat(cats, fee, false, true).await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] @@ -207,7 +210,7 @@ pub async fn split_cat( coin_ids: Vec, output_count: u32, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -237,9 +240,7 @@ pub async fn split_cat( .split_cat(cats, output_count as usize, fee, false, true) .await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] @@ -250,7 +251,7 @@ pub async fn issue_cat( ticker: String, amount: Amount, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -268,7 +269,7 @@ pub async fn issue_cat( let (coin_spends, asset_id) = wallet.issue_cat(amount, fee, None, false, true).await?; - transact(&state, &wallet, coin_spends).await?; + let summary = summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await?; wallet .db @@ -282,7 +283,7 @@ pub async fn issue_cat( }) .await?; - Ok(()) + Ok(summary) } #[command] @@ -293,7 +294,7 @@ pub async fn send_cat( address: String, amount: Amount, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -320,14 +321,16 @@ pub async fn send_cat( .send_cat(asset_id, puzzle_hash.into(), amount, fee, false, true) .await?; - transact(&state, &wallet, coin_spends).await?; - - Ok(()) + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await } #[command] #[specta] -pub async fn create_did(state: State<'_, AppState>, name: String, fee: Amount) -> Result<()> { +pub async fn create_did( + state: State<'_, AppState>, + name: String, + fee: Amount, +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -343,12 +346,13 @@ pub async fn create_did(state: State<'_, AppState>, name: String, fee: Amount) - wallet .db - .insert_new_did(did.info.launcher_id, Some(name), true) + .insert_new_did(did.info.launcher_id, Some(name.clone()), true) .await?; - transact(&state, &wallet, coin_spends).await?; + let mut confirm_info = ConfirmationInfo::default(); + confirm_info.did_names.insert(did.info.launcher_id, name); - Ok(()) + summarize(&state, &wallet, coin_spends, confirm_info).await } #[command] @@ -375,6 +379,7 @@ pub async fn bulk_mint_nfts( } let mut mints = Vec::with_capacity(request.nft_mints.len()); + let mut confirm_info = ConfirmationInfo::default(); for item in request.nft_mints { let royalty_puzzle_hash = item @@ -395,43 +400,49 @@ pub async fn bulk_mint_nfts( let data_hash = if item.data_uris.is_empty() { None } else { - Some( - fetch_uris( - item.data_uris.clone(), - Duration::from_secs(15), - Duration::from_secs(5), - ) - .await? - .hash, + let data = fetch_uris( + item.data_uris.clone(), + Duration::from_secs(15), + Duration::from_secs(5), ) + .await?; + + let hash = data.hash; + confirm_info.nft_data.insert(data.hash, data); + + Some(hash) }; let metadata_hash = if item.metadata_uris.is_empty() { None } else { - Some( - fetch_uris( - item.metadata_uris.clone(), - Duration::from_secs(15), - Duration::from_secs(15), - ) - .await? - .hash, + let metadata = fetch_uris( + item.metadata_uris.clone(), + Duration::from_secs(15), + Duration::from_secs(15), ) + .await?; + + let hash = metadata.hash; + confirm_info.nft_data.insert(metadata.hash, metadata); + + Some(hash) }; let license_hash = if item.license_uris.is_empty() { None } else { - Some( - fetch_uris( - item.license_uris.clone(), - Duration::from_secs(15), - Duration::from_secs(15), - ) - .await? - .hash, + let data = fetch_uris( + item.license_uris.clone(), + Duration::from_secs(15), + Duration::from_secs(15), ) + .await?; + + let hash = data.hash; + confirm_info.nft_data.insert(data.hash, data); + + Some(hash) }; mints.push(WalletNftMint { @@ -462,13 +473,14 @@ pub async fn bulk_mint_nfts( tx.commit().await?; - transact(&state, &wallet, coin_spends).await?; + let summary = summarize(&state, &wallet, coin_spends, confirm_info).await?; Ok(BulkMintNftsResponse { nft_ids: nfts .into_iter() .map(|nft| Result::Ok(encode_address(nft.info.launcher_id.into(), "nft")?)) .collect::>()?, + summary, }) } @@ -479,7 +491,7 @@ pub async fn transfer_nft( nft_id: String, address: String, fee: Amount, -) -> Result<()> { +) -> Result { let state = state.lock().await; let wallet = state.wallet()?; @@ -506,12 +518,26 @@ pub async fn transfer_nft( .transfer_nft(launcher_id.into(), puzzle_hash.into(), fee, false, true) .await?; - transact(&state, &wallet, coin_spends).await?; + summarize(&state, &wallet, coin_spends, ConfirmationInfo::default()).await +} - Ok(()) +#[command] +#[specta] +pub async fn submit_transaction(state: State<'_, AppState>, data: String) -> Result<()> { + let state = state.lock().await; + let wallet = state.wallet()?; + let data = hex::decode(data)?; + let coin_spends = Vec::::from_bytes_unchecked(&data)?; + submit(&state, &wallet, coin_spends).await +} + +#[derive(Default)] +struct ConfirmationInfo { + did_names: HashMap, + nft_data: HashMap, } -async fn transact( +async fn submit( state: &MutexGuard<'_, AppStateInner>, wallet: &Wallet, coin_spends: Vec, @@ -533,11 +559,168 @@ async fn transact( ) .await?; - let Some(peak) = wallet.db.latest_peak().await? else { - return Err(Error::no_peak()); + wallet.insert_transaction(spend_bundle).await?; + + Ok(()) +} + +async fn summarize( + state: &MutexGuard<'_, AppStateInner>, + wallet: &Wallet, + coin_spends: Vec, + cache: ConfirmationInfo, +) -> Result { + let data = coin_spends.to_bytes()?; + let transaction = Transaction::from_coin_spends(coin_spends).map_err(WalletError::Parse)?; + + let mut inputs = Vec::with_capacity(transaction.inputs.len()); + + for input in transaction.inputs { + let coin = input.coin_spend.coin; + let mut amount = Amount::from_mojos(coin.amount as u128, state.unit.decimals); + + let (kind, p2_puzzle_hash) = match input.kind { + CoinKind::Unknown => { + let kind = if wallet.db.is_p2_puzzle_hash(coin.puzzle_hash).await? { + InputKind::Xch + } else { + InputKind::Unknown + }; + (kind, coin.puzzle_hash) + } + CoinKind::Launcher => (InputKind::Launcher, coin.puzzle_hash), + CoinKind::Cat { + asset_id, + p2_puzzle_hash, + } => { + let cat = wallet.db.cat(asset_id).await?; + let kind = InputKind::Cat { + asset_id: hex::encode(asset_id), + name: cat.as_ref().and_then(|cat| cat.name.clone()), + ticker: cat.as_ref().and_then(|cat| cat.ticker.clone()), + icon_url: cat.as_ref().and_then(|cat| cat.icon_url.clone()), + }; + amount = Amount::from_mojos(coin.amount as u128, 3); + (kind, p2_puzzle_hash) + } + CoinKind::Did { info } => { + let name = if let Some(name) = cache.did_names.get(&info.launcher_id).cloned() { + Some(name) + } else { + wallet.db.did_name(info.launcher_id).await? + }; + + let kind = InputKind::Did { + launcher_id: encode_address(info.launcher_id.into(), "did:chia:")?, + name, + }; + + (kind, info.p2_puzzle_hash) + } + CoinKind::Nft { info, metadata } => { + let extracted = extract_nft_data(Some(&wallet.db), metadata, &cache).await?; + + let kind = InputKind::Nft { + launcher_id: encode_address(info.launcher_id.into(), "nft")?, + image_data: extracted.image_data, + image_mime_type: extracted.image_mime_type, + name: extracted.name, + }; + (kind, info.p2_puzzle_hash) + } + }; + + let address = encode_address(p2_puzzle_hash.into(), &state.network().address_prefix)?; + + let mut outputs = Vec::new(); + + for output in input.outputs { + let amount = match output.kind { + ChildKind::Cat { .. } => Amount::from_mojos(output.coin.amount as u128, 3), + _ => Amount::from_mojos(output.coin.amount as u128, state.unit.decimals), + }; + + let p2_puzzle_hash = match output.kind { + ChildKind::Unknown { hint } => hint.unwrap_or(output.coin.puzzle_hash), + ChildKind::Launcher => output.coin.puzzle_hash, + ChildKind::Cat { p2_puzzle_hash, .. } => p2_puzzle_hash, + ChildKind::Did { info, .. } => info.p2_puzzle_hash, + ChildKind::Nft { info, .. } => info.p2_puzzle_hash, + }; + + let address = encode_address(p2_puzzle_hash.into(), &state.network().address_prefix)?; + + outputs.push(Output { + coin_id: hex::encode(output.coin.coin_id()), + amount, + address, + receiving: wallet.db.is_p2_puzzle_hash(p2_puzzle_hash).await?, + }); + } + + inputs.push(Input { + coin_id: hex::encode(coin.coin_id()), + amount, + address, + kind, + outputs, + }); + } + + Ok(TransactionSummary { + fee: Amount::from_mojos(transaction.fee as u128, state.unit.decimals), + inputs, + data: hex::encode(data), + }) +} + +#[derive(Debug, Default)] +struct ExtractedNftData { + image_data: Option, + image_mime_type: Option, + name: Option, +} + +#[derive(Deserialize)] +struct OffchainMetadata { + #[serde(default)] + name: Option, +} + +async fn extract_nft_data( + db: Option<&Database>, + onchain_metadata: Option, + cache: &ConfirmationInfo, +) -> Result { + let mut result = ExtractedNftData::default(); + + let Some(onchain_metadata) = onchain_metadata else { + return Ok(result); }; - wallet.insert_transaction(&spend_bundle, peak.0).await?; + if let Some(data_hash) = onchain_metadata.data_hash { + if let Some(data) = cache.nft_data.get(&data_hash) { + result.image_data = Some(BASE64_STANDARD.encode(&data.blob)); + result.image_mime_type = Some(data.mime_type.clone()); + } else if let Some(db) = &db { + if let Some(data) = db.fetch_nft_data(data_hash).await? { + result.image_data = Some(BASE64_STANDARD.encode(&data.blob)); + result.image_mime_type = Some(data.mime_type); + } + } + } - Ok(()) + if let Some(metadata_hash) = onchain_metadata.metadata_hash { + if let Some(metadata) = cache.nft_data.get(&metadata_hash) { + let metadata: OffchainMetadata = serde_json::from_slice(&metadata.blob)?; + result.name = metadata.name; + } else if let Some(db) = &db { + if let Some(metadata) = db.fetch_nft_data(metadata_hash).await? { + let metadata: OffchainMetadata = serde_json::from_slice(&metadata.blob)?; + result.name = metadata.name; + } + } + } + + Ok(result) } diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 739475a2..b9c5b1cb 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -426,3 +426,21 @@ impl From for Error { } } } + +impl From for Error { + fn from(value: serde_json::Error) -> Self { + Self { + kind: ErrorKind::Serialization, + reason: value.to_string(), + } + } +} + +impl From for Error { + fn from(value: chia::traits::Error) -> Self { + Self { + kind: ErrorKind::Serialization, + reason: value.to_string(), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0c19bbb..3fcbf61b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,6 +70,7 @@ pub fn run() { commands::create_did, commands::bulk_mint_nfts, commands::transfer_nft, + commands::submit_transaction, // Peers commands::get_peers, commands::add_peer, diff --git a/src/bindings.ts b/src/bindings.ts index ca600abb..7b670293 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -285,7 +285,7 @@ async updateNft(nftId: string, visible: boolean) : Promise> else return { status: "error", error: e as any }; } }, -async send(address: string, amount: Amount, fee: Amount) : Promise> { +async send(address: string, amount: Amount, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("send", { address, amount, fee }) }; } catch (e) { @@ -293,7 +293,7 @@ async send(address: string, amount: Amount, fee: Amount) : Promise> { +async combine(coinIds: string[], fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("combine", { coinIds, fee }) }; } catch (e) { @@ -301,7 +301,7 @@ async combine(coinIds: string[], fee: Amount) : Promise> { else return { status: "error", error: e as any }; } }, -async split(coinIds: string[], outputCount: number, fee: Amount) : Promise> { +async split(coinIds: string[], outputCount: number, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("split", { coinIds, outputCount, fee }) }; } catch (e) { @@ -309,7 +309,7 @@ async split(coinIds: string[], outputCount: number, fee: Amount) : Promise> { +async combineCat(coinIds: string[], fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("combine_cat", { coinIds, fee }) }; } catch (e) { @@ -317,7 +317,7 @@ async combineCat(coinIds: string[], fee: Amount) : Promise> else return { status: "error", error: e as any }; } }, -async splitCat(coinIds: string[], outputCount: number, fee: Amount) : Promise> { +async splitCat(coinIds: string[], outputCount: number, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("split_cat", { coinIds, outputCount, fee }) }; } catch (e) { @@ -325,7 +325,7 @@ async splitCat(coinIds: string[], outputCount: number, fee: Amount) : Promise> { +async issueCat(name: string, ticker: string, amount: Amount, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("issue_cat", { name, ticker, amount, fee }) }; } catch (e) { @@ -333,7 +333,7 @@ async issueCat(name: string, ticker: string, amount: Amount, fee: Amount) : Prom else return { status: "error", error: e as any }; } }, -async sendCat(assetId: string, address: string, amount: Amount, fee: Amount) : Promise> { +async sendCat(assetId: string, address: string, amount: Amount, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("send_cat", { assetId, address, amount, fee }) }; } catch (e) { @@ -341,7 +341,7 @@ async sendCat(assetId: string, address: string, amount: Amount, fee: Amount) : P else return { status: "error", error: e as any }; } }, -async createDid(name: string, fee: Amount) : Promise> { +async createDid(name: string, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("create_did", { name, fee }) }; } catch (e) { @@ -357,7 +357,7 @@ async bulkMintNfts(request: BulkMintNfts) : Promise> { +async transferNft(nftId: string, address: string, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("transfer_nft", { nftId, address, fee }) }; } catch (e) { @@ -365,6 +365,14 @@ async transferNft(nftId: string, address: string, fee: Amount) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("submit_transaction", { data }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async getPeers() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_peers") }; @@ -408,7 +416,7 @@ syncEvent: "sync-event" export type Amount = string export type BulkMintNfts = { nft_mints: NftMint[]; did_id: string; fee: Amount } -export type BulkMintNftsResponse = { nft_ids: string[] } +export type BulkMintNftsResponse = { nft_ids: string[]; summary: TransactionSummary } export type CatRecord = { asset_id: string; name: string | null; ticker: string | null; description: string | null; icon_url: string | null; visible: boolean; balance: Amount } export type CoinRecord = { coin_id: string; address: string; amount: Amount; created_height: number | null; spent_height: number | null; create_transaction_id: string | null; spend_transaction_id: string | null } export type DidRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; created_height: number | null; create_transaction_id: string | null } @@ -416,14 +424,17 @@ export type Error = { kind: ErrorKind; reason: string } export type ErrorKind = "Io" | "Database" | "Client" | "Keychain" | "Logging" | "Serialization" | "InvalidAddress" | "InvalidMnemonic" | "InvalidKey" | "InvalidAmount" | "InvalidRoyalty" | "InvalidAssetId" | "InvalidLauncherId" | "InsufficientFunds" | "TransactionFailed" | "UnknownNetwork" | "UnknownFingerprint" | "NotLoggedIn" | "Sync" | "Wallet" export type GetNfts = { offset: number; limit: number } export type GetNftsResponse = { items: NftRecord[]; total: number } +export type Input = ({ 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: Output[] } export type Network = { default_port: number; ticker: string; address_prefix: string; precision: number; genesis_challenge: string; agg_sig_me: string; dns_introducers: string[] } export type NetworkConfig = { network_id?: string; target_peers?: number; discover_peers?: boolean } export type NftMint = { edition_number: number | null; edition_total: number | null; data_uris: string[]; metadata_uris: string[]; license_uris: string[]; royalty_address: string | null; royalty_percent: Amount } export type NftRecord = { launcher_id: string; launcher_id_hex: string; owner_did: string | null; coin_id: string; address: string; royalty_address: string; royalty_percent: string; data_uris: string[]; data_hash: string | null; metadata_uris: string[]; metadata_hash: string | null; license_uris: string[]; license_hash: string | null; edition_number: number | null; edition_total: number | null; data_mime_type: string | null; data: string | null; metadata: string | null; created_height: number | null; create_transaction_id: string | null; visible: boolean } +export type Output = { coin_id: string; amount: Amount; address: string; receiving: boolean } export type PeerRecord = { ip_addr: string; port: number; trusted: boolean; peak_height: number } -export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: string | null; expiration_height: number | null } +export type PendingTransactionRecord = { transaction_id: string; fee: Amount; submitted_at: string | null } 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 SyncStatus = { balance: Amount; unit: Unit; synced_coins: number; total_coins: number; receive_address: string } +export type TransactionSummary = { fee: Amount; inputs: Input[]; data: string } export type Unit = { ticker: string; decimals: number } export type WalletConfig = { name?: string; derive_automatically?: boolean; derivation_batch_size?: number } export type WalletInfo = { name: string; fingerprint: number; public_key: string; kind: WalletKind } diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx new file mode 100644 index 00000000..9be380ad --- /dev/null +++ b/src/components/ConfirmationDialog.tsx @@ -0,0 +1,306 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useWalletState } from '@/state'; +import BigNumber from 'bignumber.js'; +import { + BadgeMinus, + BadgePlus, + ForwardIcon, + LoaderCircleIcon, +} from 'lucide-react'; +import { PropsWithChildren, useState } from 'react'; +import { commands, Error, TransactionSummary } from '../bindings'; +import { Badge } from './ui/badge'; + +export interface ConfirmationDialogProps { + summary: TransactionSummary | null; + close: () => void; + onConfirm?: () => void; + onError?: (error: Error) => void; +} + +interface SpentCoin { + sort: number; + badge: string; + label: string; + coinId: string; +} + +interface CreatedCoin { + sort: number; + badge: string; + label: string; + address: string; +} + +export default function ConfirmationDialog({ + summary, + close, + onConfirm, + onError, +}: ConfirmationDialogProps) { + const walletState = useWalletState(); + + const [pending, setPending] = useState(false); + + const spent: Array = []; + const created: Array = []; + + if (summary) { + for (const input of summary.inputs || []) { + if (input.type === 'xch') { + const ticker = walletState.sync.unit.ticker; + + spent.push({ + badge: 'Chia', + label: `${input.amount} ${ticker}`, + coinId: input.coin_id, + sort: 1, + }); + + for (const output of input.outputs) { + if (summary.inputs.find((i) => i.coin_id === output.coin_id)) { + continue; + } + + created.push({ + badge: 'Chia', + label: `${output.amount} ${ticker}`, + address: output.receiving ? 'Change' : output.address, + sort: 1, + }); + } + } + + if (input.type === 'cat') { + const ticker = input.ticker || 'CAT'; + + spent.push({ + badge: `CAT ${input.name || input.asset_id}`, + label: `${input.amount} ${ticker}`, + coinId: input.coin_id, + sort: 2, + }); + + for (const output of input.outputs) { + if (summary.inputs.find((i) => i.coin_id === output.coin_id)) { + continue; + } + + created.push({ + badge: `CAT ${input.name || input.asset_id}`, + label: `${output.amount} ${ticker}`, + address: output.receiving ? 'Change' : output.address, + sort: 2, + }); + } + } + + if (input.type === 'did') { + if ( + !summary.inputs + .map((i) => i.outputs) + .flat() + .find((o) => o.coin_id === input.coin_id) + ) { + spent.push({ + badge: 'Profile', + label: input.name || 'Unnamed', + coinId: input.coin_id, + sort: 3, + }); + } + + for (const output of input.outputs) { + if (summary.inputs.find((i) => i.coin_id === output.coin_id)) { + continue; + } + + if ( + BigNumber(output.amount) + .multipliedBy(BigNumber(10).pow(walletState.sync.unit.decimals)) + .mod(2) + .isEqualTo(1) + ) { + created.push({ + badge: 'Profile', + label: input.name || 'Unnamed', + address: output.receiving ? 'You' : output.address, + sort: 3, + }); + } + } + } + + if (input.type === 'nft') { + if ( + !summary.inputs + .map((i) => i.outputs) + .flat() + .find((o) => o.coin_id === input.coin_id) + ) { + spent.push({ + badge: 'NFT', + label: input.name || 'Unknown', + coinId: input.coin_id, + sort: 4, + }); + } + + for (const output of input.outputs) { + if (summary.inputs.find((i) => i.coin_id === output.coin_id)) { + continue; + } + + if ( + BigNumber(output.amount) + .multipliedBy(BigNumber(10).pow(walletState.sync.unit.decimals)) + .mod(2) + .isEqualTo(1) + ) { + created.push({ + badge: 'NFT', + label: input.name || 'Unknown', + address: output.receiving ? 'You' : output.address, + sort: 4, + }); + } + } + } + } + } + + return ( + { + close(); + setPending(false); + }} + > + + + Confirm transaction? + +
+
+ +
+ {spent + .sort((a, b) => a.sort - b.sort) + .map((spent, i) => ( +
+
+ + {spent.badge} + + {spent.label} +
+
+ +
+ {spent.coinId} +
+
+
+ ))} +
+
+ +
+ {!BigNumber(summary?.fee || 0).isZero() && ( +
+
+ Fee + + {summary?.fee} {walletState.sync.unit.ticker} + +
+
+ )} + {created + .sort((a, b) => a.sort - b.sort) + .map((created, i) => ( +
+
+ + {created.badge} + + {created.label} +
+
+ +
+ {created.address} +
+
+
+ ))} +
+
+
+
+
+
+ + + + +
+
+ ); +} + +interface GroupProps { + label: string; + icon: typeof BadgeMinus; +} + +function Group({ label, icon: Icon, children }: PropsWithChildren) { + return ( +
+
+ + {label} +
+
{children}
+
+ ); +} diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx new file mode 100644 index 00000000..4d897eeb --- /dev/null +++ b/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from '@/lib/utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/src/pages/CreateProfile.tsx b/src/pages/CreateProfile.tsx index 54bf3299..e03f5419 100644 --- a/src/pages/CreateProfile.tsx +++ b/src/pages/CreateProfile.tsx @@ -1,3 +1,4 @@ +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Header from '@/components/Header'; import { Button } from '@/components/ui/button'; import { @@ -11,12 +12,11 @@ import { import { Input } from '@/components/ui/input'; import { amount } from '@/lib/formTypes'; import { zodResolver } from '@hookform/resolvers/zod'; -import { LoaderCircleIcon } from 'lucide-react'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import * as z from 'zod'; -import { commands, Error } from '../bindings'; +import { commands, Error, TransactionSummary } from '../bindings'; import Container from '../components/Container'; import ErrorDialog from '../components/ErrorDialog'; import { useWalletState } from '../state'; @@ -26,7 +26,7 @@ export default function CreateProfile() { const walletState = useWalletState(); const [error, setError] = useState(null); - const [pending, setPending] = useState(false); + const [summary, setSummary] = useState(null); const formSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -38,7 +38,6 @@ export default function CreateProfile() { }); const onSubmit = (values: z.infer) => { - setPending(true); commands .createDid(values.name, values.fee?.toString() || '0') .then((result) => { @@ -46,10 +45,10 @@ export default function CreateProfile() { console.error(result.error); setError(result.error); return; + } else { + setSummary(result.data); } - navigate('/dids'); - }) - .finally(() => setPending(false)); + }); }; return ( @@ -101,17 +100,17 @@ export default function CreateProfile() { /> - + + setSummary(null)} + onConfirm={() => navigate('/dids')} + /> ); } diff --git a/src/pages/IssueToken.tsx b/src/pages/IssueToken.tsx index e4d9ad4b..eecee4a4 100644 --- a/src/pages/IssueToken.tsx +++ b/src/pages/IssueToken.tsx @@ -1,3 +1,4 @@ +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Header from '@/components/Header'; import { Button } from '@/components/ui/button'; import { @@ -11,12 +12,11 @@ import { import { Input } from '@/components/ui/input'; import { amount, positiveAmount } from '@/lib/formTypes'; import { zodResolver } from '@hookform/resolvers/zod'; -import { LoaderCircleIcon } from 'lucide-react'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import * as z from 'zod'; -import { commands, Error } from '../bindings'; +import { commands, Error, TransactionSummary } from '../bindings'; import Container from '../components/Container'; import ErrorDialog from '../components/ErrorDialog'; import { useWalletState } from '../state'; @@ -26,7 +26,7 @@ export default function IssueToken() { const walletState = useWalletState(); const [error, setError] = useState(null); - const [pending, setPending] = useState(false); + const [summary, setSummary] = useState(null); const formSchema = z.object({ name: z.string().min(1, 'Name is required'), @@ -40,7 +40,6 @@ export default function IssueToken() { }); const onSubmit = (values: z.infer) => { - setPending(true); commands .issueCat( values.name, @@ -52,11 +51,10 @@ export default function IssueToken() { if (result.status === 'error') { console.error(result.error); setError(result.error); - return; + } else { + setSummary(result.data); } - navigate('/wallet'); - }) - .finally(() => setPending(false)); + }); }; return ( @@ -148,17 +146,17 @@ export default function IssueToken() { /> - + + setSummary(null)} + onConfirm={() => navigate('/wallet')} + /> ); } diff --git a/src/pages/MintNft.tsx b/src/pages/MintNft.tsx index 6cdd9ab4..5f5dcfbc 100644 --- a/src/pages/MintNft.tsx +++ b/src/pages/MintNft.tsx @@ -1,3 +1,4 @@ +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Header from '@/components/Header'; import { Button } from '@/components/ui/button'; import { @@ -24,7 +25,7 @@ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import * as z from 'zod'; -import { commands, Error } from '../bindings'; +import { commands, Error, TransactionSummary } from '../bindings'; import Container from '../components/Container'; import ErrorDialog from '../components/ErrorDialog'; import { useWalletState } from '../state'; @@ -37,6 +38,7 @@ export default function MintNft() { const [error, setError] = useState(null); const [pending, setPending] = useState(false); + const [summary, setSummary] = useState(null); const formSchema = z.object({ profile: z.string().min(1, 'Profile is required'), @@ -52,10 +54,9 @@ export default function MintNft() { resolver: zodResolver(formSchema), }); - console.log(form.getValues()); - const onSubmit = (values: z.infer) => { setPending(true); + commands .bulkMintNfts({ fee: values.fee?.toString() || '0', @@ -85,11 +86,13 @@ export default function MintNft() { if (result.status === 'error') { console.error(result.error); setError(result.error); - return; + } else { + setSummary(result.data.summary); } - navigate('/nfts'); }) - .finally(() => setPending(false)); + .finally(() => { + setPending(false); + }); }; return ( @@ -269,6 +272,11 @@ export default function MintNft() { + setSummary(null)} + onConfirm={() => navigate('/nfts')} + /> ); } diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx index 50047e80..781c1f9f 100644 --- a/src/pages/Send.tsx +++ b/src/pages/Send.tsx @@ -1,13 +1,6 @@ +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Header from '@/components/Header'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Form, FormControl, @@ -20,12 +13,18 @@ import { Input } from '@/components/ui/input'; import { amount, positiveAmount } from '@/lib/formTypes'; import { useWalletState } from '@/state'; import { zodResolver } from '@hookform/resolvers/zod'; -import { LoaderCircleIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate, useParams } from 'react-router-dom'; import * as z from 'zod'; -import { CatRecord, commands, Error, events } from '../bindings'; +import { + Amount, + CatRecord, + commands, + Error, + events, + TransactionSummary, +} from '../bindings'; import Container from '../components/Container'; import ErrorDialog from '../components/ErrorDialog'; @@ -39,9 +38,8 @@ export default function Send() { const [asset, setAsset] = useState<(CatRecord & { decimals: number }) | null>( null, ); - const [isConfirmOpen, setConfirmOpen] = useState(false); - const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [summary, setSummary] = useState(null); const updateCat = useCallback(() => { commands.getCat(assetId!).then((result) => { @@ -111,36 +109,29 @@ export default function Send() { resolver: zodResolver(formSchema), }); - const onSubmit = () => { - setConfirmOpen(true); - }; - const values = form.getValues(); - const submit = () => { - setPending(true); - (isXch - ? commands.send( - values.address, - values.amount.toString(), - values.fee?.toString() || '0', - ) - : commands.sendCat( - assetId!, - values.address, - values.amount.toString(), - values.fee?.toString() || '0', - ) - ) - .then((result) => { - if (result.status === 'ok') { - navigate(-1); - } else { - console.error(result.error); - setError(result.error); - } - }) - .finally(() => setPending(false)); + const onSubmit = () => { + const command = isXch + ? commands.send + : (address: string, amount: Amount, fee: Amount) => { + return commands.sendCat(assetId!, address, amount, fee); + }; + + command( + values.address, + values.amount.toString(), + values.fee?.toString() || '0', + ).then((confirmation) => { + if (confirmation.status === 'error') { + console.error(confirmation.error); + return; + } else { + console.log(confirmation.data); + } + + setSummary(confirmation.data); + }); }; return ( @@ -232,56 +223,21 @@ export default function Send() { /> - + - - - - - - Are you sure you want to send {asset?.ticker}? - - - This transaction cannot be reversed once it has been initiated. - - -
-
-
Amount
-

- {values.amount} {asset?.ticker} (with a fee of{' '} - {values.fee || 0} {walletState.sync.unit.ticker}) -

-
-
-
Address
-

{values.address}

-
-
- - - - -
-
+ setSummary(null)} + onConfirm={() => navigate(-1)} + onError={(error) => { + console.error(error); + setError(error); + }} + /> ); } diff --git a/src/pages/Token.tsx b/src/pages/Token.tsx index fbc7b7a7..b7a35f99 100644 --- a/src/pages/Token.tsx +++ b/src/pages/Token.tsx @@ -1,4 +1,5 @@ import CoinList from '@/components/CoinList'; +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Container from '@/components/Container'; import Header from '@/components/Header'; import { ReceiveAddress } from '@/components/ReceiveAddress'; @@ -44,7 +45,13 @@ import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as z from 'zod'; -import { CatRecord, CoinRecord, commands, events } from '../bindings'; +import { + CatRecord, + CoinRecord, + commands, + events, + TransactionSummary, +} from '../bindings'; export default function Token() { const navigate = useNavigate(); @@ -54,6 +61,8 @@ export default function Token() { const [asset, setAsset] = useState(null); const [coins, setCoins] = useState([]); + const [summary, setSummary] = useState(null); + const [selectedCoins, setSelectedCoins] = useState({}); const updateCoins = () => { const getCoins = @@ -229,6 +238,9 @@ export default function Token() { combineHandler={ asset?.asset_id === 'xch' ? commands.combine : commands.combineCat } + setSummary={setSummary} + selectedCoins={selectedCoins} + setSelectedCoins={setSelectedCoins} /> @@ -296,6 +308,12 @@ export default function Token() { + + setSummary(null)} + onConfirm={() => setSelectedCoins({})} + /> ); } @@ -305,6 +323,9 @@ interface CoinCardProps { asset: CatRecord | null; splitHandler: typeof commands.split | null; combineHandler: typeof commands.combine | null; + setSummary: (summary: TransactionSummary) => void; + selectedCoins: RowSelectionState; + setSelectedCoins: React.Dispatch>; } function CoinCard({ @@ -312,11 +333,12 @@ function CoinCard({ asset, splitHandler, combineHandler, + setSummary, + selectedCoins, + setSelectedCoins, }: CoinCardProps) { const walletState = useWalletState(); - const [selectedCoins, setSelectedCoins] = useState({}); - const selectedCoinIds = useMemo(() => { return Object.keys(selectedCoins).filter((key) => selectedCoins[key]); }, [selectedCoins]); @@ -373,7 +395,7 @@ function CoinCard({ setCombineOpen(false); if (result.status === 'ok') { - setSelectedCoins({}); + setSummary(result.data); } }) .catch((error) => console.log('Failed to combine coins', error)); @@ -401,7 +423,7 @@ function CoinCard({ setSplitOpen(false); if (result.status === 'ok') { - setSelectedCoins({}); + setSummary(result.data); } }) .catch((error) => console.log('Failed to split coins', error)); diff --git a/src/pages/WalletNfts.tsx b/src/pages/WalletNfts.tsx index e7ef5d6c..1dd90d3b 100644 --- a/src/pages/WalletNfts.tsx +++ b/src/pages/WalletNfts.tsx @@ -1,3 +1,4 @@ +import ConfirmationDialog from '@/components/ConfirmationDialog'; import Container from '@/components/Container'; import Header from '@/components/Header'; import { ReceiveAddress } from '@/components/ReceiveAddress'; @@ -44,13 +45,11 @@ import { } from 'lucide-react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import { z } from 'zod'; -import { commands, events, NftRecord } from '../bindings'; +import { commands, events, NftRecord, TransactionSummary } from '../bindings'; export function WalletNfts() { - const navigate = useNavigate(); - const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(1); const [showHidden, setShowHidden] = useState(false); @@ -185,6 +184,7 @@ function Nft({ nft, updateNfts }: NftProps) { const walletState = useWalletState(); const [isTransferOpen, setTransferOpen] = useState(false); + const [summary, setSummary] = useState(null); let json: any = {}; @@ -227,12 +227,12 @@ function Nft({ nft, updateNfts }: NftProps) { .transferNft(nft.launcher_id, values.address, values.fee) .then((result) => { setTransferOpen(false); - updateNfts(); if (result.status === 'error') { console.error('Failed to transfer NFT', result.error); + } else { + setSummary(result.data); } - }) - .catch((error) => console.log('Failed to combine coins', error)); + }); }; return ( @@ -354,6 +354,12 @@ function Nft({ nft, updateNfts }: NftProps) { + + setSummary(null)} + onConfirm={() => updateNfts()} + /> ); }