diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d16052e..85f7c5ca 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: run: pnpm prettier:check - name: Install GTK - run: sudo apt-get install libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev + run: sudo apt-get update && sudo apt-get install libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev - name: Clippy run: cargo clippy --workspace --all-features --all-targets @@ -102,7 +102,7 @@ jobs: - name: Ubuntu dependencies if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'linux-arm64' - run: sudo apt-get install -y libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev - name: Windows dependencies if: matrix.platform == 'windows-latest' diff --git a/.sqlx/query-9c148797c6a72ceeb8e08bf506019953007ee7b8a2338d1b35f1326c270f478b.json b/.sqlx/query-0a55a34cdc4bd0cb4c0514ab938f061de3ceb1ac6b1c0dd4de92149ed2c4494a.json similarity index 77% rename from .sqlx/query-9c148797c6a72ceeb8e08bf506019953007ee7b8a2338d1b35f1326c270f478b.json rename to .sqlx/query-0a55a34cdc4bd0cb4c0514ab938f061de3ceb1ac6b1c0dd4de92149ed2c4494a.json index 2a4844dd..d5678107 100644 --- a/.sqlx/query-9c148797c6a72ceeb8e08bf506019953007ee7b8a2338d1b35f1326c270f478b.json +++ b/.sqlx/query-0a55a34cdc4bd0cb4c0514ab938f061de3ceb1ac6b1c0dd4de92149ed2c4494a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent`\n INNER JOIN `cat_coins` ON `coin_states`.`coin_id` = `cat_coins`.`coin_id`\n LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id`\n WHERE `coin_states`.`spent_height` IS NULL\n AND `cat_coins`.`asset_id` = ?\n AND `transaction_spends`.`coin_id` IS NULL\n AND `coin_states`.`transaction_id` IS NULL\n ", + "query": "\n SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent`\n INNER JOIN `cat_coins` ON `coin_states`.`coin_id` = `cat_coins`.`coin_id`\n LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id`\n WHERE `coin_states`.`spent_height` IS NULL\n AND `cat_coins`.`asset_id` = ?\n AND `transaction_spends`.`coin_id` IS NULL\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ false ] }, - "hash": "9c148797c6a72ceeb8e08bf506019953007ee7b8a2338d1b35f1326c270f478b" + "hash": "0a55a34cdc4bd0cb4c0514ab938f061de3ceb1ac6b1c0dd4de92149ed2c4494a" } diff --git a/.sqlx/query-27815839b854f9772bac905dd26491742b08d92e9cfe9819b471ec5de02e3f99.json b/.sqlx/query-27815839b854f9772bac905dd26491742b08d92e9cfe9819b471ec5de02e3f99.json new file mode 100644 index 00000000..b778b8de --- /dev/null +++ b/.sqlx/query-27815839b854f9772bac905dd26491742b08d92e9cfe9819b471ec5de02e3f99.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "REPLACE INTO `nfts` (`launcher_id`, `visible`) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "27815839b854f9772bac905dd26491742b08d92e9cfe9819b471ec5de02e3f99" +} diff --git a/.sqlx/query-369c327b91ccc7e62dbe1950cdeb0a2d419079289e0dd9c42141f434a41e4cbb.json b/.sqlx/query-369c327b91ccc7e62dbe1950cdeb0a2d419079289e0dd9c42141f434a41e4cbb.json new file mode 100644 index 00000000..5094a4b1 --- /dev/null +++ b/.sqlx/query-369c327b91ccc7e62dbe1950cdeb0a2d419079289e0dd9c42141f434a41e4cbb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n REPLACE INTO `nft_coins` (\n `coin_id`,\n `parent_parent_coin_id`,\n `parent_inner_puzzle_hash`,\n `parent_amount`,\n `launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 14 + }, + "nullable": [] + }, + "hash": "369c327b91ccc7e62dbe1950cdeb0a2d419079289e0dd9c42141f434a41e4cbb" +} diff --git a/.sqlx/query-6dcab18967139419788a85f1414777752a7d90bd9ab08afee6a5f9596653800f.json b/.sqlx/query-40b86e546936bd5d891a3af65fe1e987028f7203e2dc01c43f5df0fd26b33419.json similarity index 54% rename from .sqlx/query-6dcab18967139419788a85f1414777752a7d90bd9ab08afee6a5f9596653800f.json rename to .sqlx/query-40b86e546936bd5d891a3af65fe1e987028f7203e2dc01c43f5df0fd26b33419.json index 2f9f3731..59603876 100644 --- a/.sqlx/query-6dcab18967139419788a85f1414777752a7d90bd9ab08afee6a5f9596653800f.json +++ b/.sqlx/query-40b86e546936bd5d891a3af65fe1e987028f7203e2dc01c43f5df0fd26b33419.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n `coin_id`,\n `launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`\n FROM `nft_coins`\n WHERE `launcher_id` = ?\n ", + "query": "\n SELECT\n cs.`coin_id`,\n `nft_coins`.`launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`,\n `transaction_id`,\n `created_height`,\n `visible`\n FROM `nft_coins`\n INNER JOIN `coin_states` AS cs ON `nft_coins`.`coin_id` = `cs`.`coin_id`\n INNER JOIN `nfts` ON `nft_coins`.`launcher_id` = `nfts`.`launcher_id`\n WHERE `nft_coins`.`launcher_id` = ?\n ", "describe": { "columns": [ { @@ -57,6 +57,21 @@ "name": "license_hash", "ordinal": 10, "type_info": "Blob" + }, + { + "name": "transaction_id", + "ordinal": 11, + "type_info": "Blob" + }, + { + "name": "created_height", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "visible", + "ordinal": 13, + "type_info": "Bool" } ], "parameters": { @@ -73,8 +88,11 @@ false, true, true, - true + true, + true, + true, + false ] }, - "hash": "6dcab18967139419788a85f1414777752a7d90bd9ab08afee6a5f9596653800f" + "hash": "40b86e546936bd5d891a3af65fe1e987028f7203e2dc01c43f5df0fd26b33419" } diff --git a/.sqlx/query-60df6496cd6dda0de85f7ed27ee557902c652995daf768f5f768e004c8c489a7.json b/.sqlx/query-60df6496cd6dda0de85f7ed27ee557902c652995daf768f5f768e004c8c489a7.json new file mode 100644 index 00000000..27827b4a --- /dev/null +++ b/.sqlx/query-60df6496cd6dda0de85f7ed27ee557902c652995daf768f5f768e004c8c489a7.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO `nfts` (`launcher_id`, `visible`) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "60df6496cd6dda0de85f7ed27ee557902c652995daf768f5f768e004c8c489a7" +} diff --git a/.sqlx/query-07e83fa88213b72ea2e048c2e7f6ef762d1471ae7dd806f53a98602d6d296a26.json b/.sqlx/query-841892053a942af83d7da7987d07e14e2b00fa4c6e5391a43f0ddce9f4884f30.json similarity index 82% rename from .sqlx/query-07e83fa88213b72ea2e048c2e7f6ef762d1471ae7dd806f53a98602d6d296a26.json rename to .sqlx/query-841892053a942af83d7da7987d07e14e2b00fa4c6e5391a43f0ddce9f4884f30.json index 329bc64b..73e97c71 100644 --- a/.sqlx/query-07e83fa88213b72ea2e048c2e7f6ef762d1471ae7dd806f53a98602d6d296a26.json +++ b/.sqlx/query-841892053a942af83d7da7987d07e14e2b00fa4c6e5391a43f0ddce9f4884f30.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent`\n INNER JOIN `p2_coins` ON `coin_states`.`coin_id` = `p2_coins`.`coin_id`\n LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id`\n WHERE `coin_states`.`spent_height` IS NULL\n AND `transaction_spends`.`coin_id` IS NULL\n AND `coin_states`.`transaction_id` IS NULL\n ", + "query": "\n SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent`\n INNER JOIN `p2_coins` ON `coin_states`.`coin_id` = `p2_coins`.`coin_id`\n LEFT JOIN `transaction_spends` ON `coin_states`.`coin_id` = `transaction_spends`.`coin_id`\n WHERE `coin_states`.`spent_height` IS NULL\n AND `transaction_spends`.`coin_id` IS NULL\n ", "describe": { "columns": [ { @@ -16,5 +16,5 @@ false ] }, - "hash": "07e83fa88213b72ea2e048c2e7f6ef762d1471ae7dd806f53a98602d6d296a26" + "hash": "841892053a942af83d7da7987d07e14e2b00fa4c6e5391a43f0ddce9f4884f30" } diff --git a/.sqlx/query-9b57663e0de9a8296a221242ff363e9a7d8e503afacc87df9e340f0042a86b86.json b/.sqlx/query-9b57663e0de9a8296a221242ff363e9a7d8e503afacc87df9e340f0042a86b86.json new file mode 100644 index 00000000..b59ae9e0 --- /dev/null +++ b/.sqlx/query-9b57663e0de9a8296a221242ff363e9a7d8e503afacc87df9e340f0042a86b86.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n `parent_coin_id`, `puzzle_hash`, `amount`,\n `parent_parent_coin_id`, `parent_inner_puzzle_hash`, `parent_amount`,\n `asset_id`, `p2_puzzle_hash`\n FROM `coin_states`\n INNER JOIN `cat_coins` ON `coin_states`.`coin_id` = `cat_coins`.`coin_id`\n WHERE `coin_states`.`coin_id` = ?\n ", + "describe": { + "columns": [ + { + "name": "parent_coin_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "puzzle_hash", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "amount", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "parent_parent_coin_id", + "ordinal": 3, + "type_info": "Blob" + }, + { + "name": "parent_inner_puzzle_hash", + "ordinal": 4, + "type_info": "Blob" + }, + { + "name": "parent_amount", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "asset_id", + "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "p2_puzzle_hash", + "ordinal": 7, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "9b57663e0de9a8296a221242ff363e9a7d8e503afacc87df9e340f0042a86b86" +} diff --git a/.sqlx/query-adf2bcfce2e4565f65d5177c1f16484d287f8fdeb0c468b58266573286b1bfb3.json b/.sqlx/query-adf2bcfce2e4565f65d5177c1f16484d287f8fdeb0c468b58266573286b1bfb3.json deleted file mode 100644 index 977829cc..00000000 --- a/.sqlx/query-adf2bcfce2e4565f65d5177c1f16484d287f8fdeb0c468b58266573286b1bfb3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n INSERT INTO `nft_coins` (\n `coin_id`,\n `parent_parent_coin_id`,\n `parent_inner_puzzle_hash`,\n `parent_amount`,\n `launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 14 - }, - "nullable": [] - }, - "hash": "adf2bcfce2e4565f65d5177c1f16484d287f8fdeb0c468b58266573286b1bfb3" -} diff --git a/.sqlx/query-d564b7fa3613ccc334298bc628afa74698a35517e5a3b96edcb980e6c14e7a4f.json b/.sqlx/query-d564b7fa3613ccc334298bc628afa74698a35517e5a3b96edcb980e6c14e7a4f.json new file mode 100644 index 00000000..5953211b --- /dev/null +++ b/.sqlx/query-d564b7fa3613ccc334298bc628afa74698a35517e5a3b96edcb980e6c14e7a4f.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT `visible` FROM `nfts` WHERE `launcher_id` = ?", + "describe": { + "columns": [ + { + "name": "visible", + "ordinal": 0, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "d564b7fa3613ccc334298bc628afa74698a35517e5a3b96edcb980e6c14e7a4f" +} diff --git a/.sqlx/query-ea313e53c48645fc7b239b69f16c2769868c9b9aa05088e13607124f99860432.json b/.sqlx/query-ea313e53c48645fc7b239b69f16c2769868c9b9aa05088e13607124f99860432.json new file mode 100644 index 00000000..9fa69c1b --- /dev/null +++ b/.sqlx/query-ea313e53c48645fc7b239b69f16c2769868c9b9aa05088e13607124f99860432.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n cs.parent_coin_id, cs.puzzle_hash, cs.amount,\n did.parent_parent_coin_id, did.parent_inner_puzzle_hash, did.parent_amount,\n did.launcher_id, did.recovery_list_hash, did.num_verifications_required,\n did.metadata, did.p2_puzzle_hash\n FROM `coin_states` AS cs\n INNER JOIN `did_coins` AS did ON cs.coin_id = did.coin_id\n WHERE did.launcher_id = ?\n AND cs.spent_height IS NULL\n AND cs.created_height IS NOT NULL\n ", + "describe": { + "columns": [ + { + "name": "parent_coin_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "puzzle_hash", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "amount", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "parent_parent_coin_id", + "ordinal": 3, + "type_info": "Blob" + }, + { + "name": "parent_inner_puzzle_hash", + "ordinal": 4, + "type_info": "Blob" + }, + { + "name": "parent_amount", + "ordinal": 5, + "type_info": "Blob" + }, + { + "name": "launcher_id", + "ordinal": 6, + "type_info": "Blob" + }, + { + "name": "recovery_list_hash", + "ordinal": 7, + "type_info": "Blob" + }, + { + "name": "num_verifications_required", + "ordinal": 8, + "type_info": "Blob" + }, + { + "name": "metadata", + "ordinal": 9, + "type_info": "Blob" + }, + { + "name": "p2_puzzle_hash", + "ordinal": 10, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "ea313e53c48645fc7b239b69f16c2769868c9b9aa05088e13607124f99860432" +} diff --git a/.sqlx/query-a59505791e1488b0f072fe16d898eb54d155e68b535c40c750003ccab6bf5b25.json b/.sqlx/query-f6f934b980349d009253854e2bba07a36f7d4a414636b1bac64c462cc3045dcc.json similarity index 50% rename from .sqlx/query-a59505791e1488b0f072fe16d898eb54d155e68b535c40c750003ccab6bf5b25.json rename to .sqlx/query-f6f934b980349d009253854e2bba07a36f7d4a414636b1bac64c462cc3045dcc.json index e5367847..babff6e6 100644 --- a/.sqlx/query-a59505791e1488b0f072fe16d898eb54d155e68b535c40c750003ccab6bf5b25.json +++ b/.sqlx/query-f6f934b980349d009253854e2bba07a36f7d4a414636b1bac64c462cc3045dcc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n `nft_coins`.`coin_id`,\n `launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`\n FROM `nft_coins`\n INNER JOIN `coin_states` AS cs INDEXED BY `coin_height` ON `nft_coins`.`coin_id` = `cs`.`coin_id`\n WHERE `cs`.`spent_height` IS NULL\n ORDER BY `created_height` DESC\n LIMIT ? OFFSET ?\n ", + "query": "\n SELECT\n `nft_coins`.`coin_id`,\n `nft_coins`.`launcher_id`,\n `metadata`,\n `metadata_updater_puzzle_hash`,\n `current_owner`,\n `royalty_puzzle_hash`,\n `royalty_ten_thousandths`,\n `p2_puzzle_hash`,\n `data_hash`,\n `metadata_hash`,\n `license_hash`,\n cs.`transaction_id`,\n `created_height`,\n `visible`\n FROM `nft_coins`\n INNER JOIN `nfts` INDEXED BY `nft_visible` ON `nft_coins`.`launcher_id` = `nfts`.`launcher_id`\n INNER JOIN `coin_states` AS cs INDEXED BY `coin_height` ON `nft_coins`.`coin_id` = `cs`.`coin_id`\n LEFT JOIN `transaction_spends` ON `cs`.`coin_id` = `transaction_spends`.`coin_id`\n WHERE `cs`.`spent_height` IS NULL\n AND `transaction_spends`.`transaction_id` IS NULL\n ORDER BY `visible` DESC, cs.`transaction_id` DESC, `created_height` DESC\n LIMIT ? OFFSET ?\n ", "describe": { "columns": [ { @@ -57,6 +57,21 @@ "name": "license_hash", "ordinal": 10, "type_info": "Blob" + }, + { + "name": "transaction_id", + "ordinal": 11, + "type_info": "Blob" + }, + { + "name": "created_height", + "ordinal": 12, + "type_info": "Integer" + }, + { + "name": "visible", + "ordinal": 13, + "type_info": "Bool" } ], "parameters": { @@ -73,8 +88,11 @@ false, true, true, - true + true, + true, + true, + false ] }, - "hash": "a59505791e1488b0f072fe16d898eb54d155e68b535c40c750003ccab6bf5b25" + "hash": "f6f934b980349d009253854e2bba07a36f7d4a414636b1bac64c462cc3045dcc" } diff --git a/crates/sage-api/src/records/nft.rs b/crates/sage-api/src/records/nft.rs index a14c88b7..227c3f8b 100644 --- a/crates/sage-api/src/records/nft.rs +++ b/crates/sage-api/src/records/nft.rs @@ -21,4 +21,7 @@ pub struct NftRecord { pub data_mime_type: Option, pub data: Option, pub metadata: Option, + pub created_height: Option, + pub create_transaction_id: Option, + pub visible: bool, } diff --git a/crates/sage-api/src/requests.rs b/crates/sage-api/src/requests.rs index 58531e0b..479f2f58 100644 --- a/crates/sage-api/src/requests.rs +++ b/crates/sage-api/src/requests.rs @@ -1,3 +1,5 @@ +mod bulk_mint_nfts; mod get_nfts; +pub use bulk_mint_nfts::*; pub use get_nfts::*; diff --git a/crates/sage-api/src/requests/bulk_mint_nfts.rs b/crates/sage-api/src/requests/bulk_mint_nfts.rs new file mode 100644 index 00000000..f1f4113f --- /dev/null +++ b/crates/sage-api/src/requests/bulk_mint_nfts.rs @@ -0,0 +1,27 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::Amount; + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct BulkMintNfts { + pub nft_mints: Vec, + pub did_id: String, + pub fee: Amount, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct NftMint { + pub edition_number: Option, + pub edition_total: Option, + pub data_uris: Vec, + pub metadata_uris: Vec, + pub license_uris: Vec, + pub royalty_address: Option, + pub royalty_percent: Amount, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +pub struct BulkMintNftsResponse { + pub nft_ids: Vec, +} diff --git a/crates/sage-api/src/types/amount.rs b/crates/sage-api/src/types/amount.rs index 59c38440..1fec7b6b 100644 --- a/crates/sage-api/src/types/amount.rs +++ b/crates/sage-api/src/types/amount.rs @@ -36,6 +36,16 @@ impl Amount { mojos.to_u64() } + + pub fn to_ten_thousandths(&self) -> Option { + let mojos = &self.0 * 100u16; + + if mojos.normalized().fractional_digit_count() > 0 { + return None; + } + + mojos.to_u16() + } } impl fmt::Display for Amount { diff --git a/crates/sage-database/src/primitives/cats.rs b/crates/sage-database/src/primitives/cats.rs index 70d2a8d8..9dd0c92a 100644 --- a/crates/sage-database/src/primitives/cats.rs +++ b/crates/sage-database/src/primitives/cats.rs @@ -2,6 +2,7 @@ use chia::{ protocol::{Bytes32, Coin}, puzzles::LineageProof, }; +use chia_wallet_sdk::Cat; use sqlx::SqliteExecutor; use crate::{ @@ -55,8 +56,12 @@ impl Database { spendable_cat_coins(&self.pool, asset_id).await } - pub async fn spendable_cat_balance(&self, asset_id: Bytes32) -> Result { - spendable_cat_balance(&self.pool, asset_id).await + pub async fn cat_balance(&self, asset_id: Bytes32) -> Result { + cat_balance(&self.pool, asset_id).await + } + + pub async fn cat_coin(&self, coin_id: Bytes32) -> Result> { + cat_coin(&self.pool, coin_id).await } } @@ -306,7 +311,7 @@ async fn spendable_cat_coins( .collect() } -async fn spendable_cat_balance(conn: impl SqliteExecutor<'_>, asset_id: Bytes32) -> Result { +async fn cat_balance(conn: impl SqliteExecutor<'_>, asset_id: Bytes32) -> Result { let asset_id = asset_id.as_ref(); let row = sqlx::query!( @@ -317,7 +322,6 @@ async fn spendable_cat_balance(conn: impl SqliteExecutor<'_>, asset_id: Bytes32) WHERE `coin_states`.`spent_height` IS NULL AND `cat_coins`.`asset_id` = ? AND `transaction_spends`.`coin_id` IS NULL - AND `coin_states`.`transaction_id` IS NULL ", asset_id ) @@ -363,3 +367,36 @@ async fn cat_coin_states( }) .collect() } + +async fn cat_coin(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Result> { + let coin_id = coin_id.as_ref(); + + let row = sqlx::query!( + " + SELECT + `parent_coin_id`, `puzzle_hash`, `amount`, + `parent_parent_coin_id`, `parent_inner_puzzle_hash`, `parent_amount`, + `asset_id`, `p2_puzzle_hash` + FROM `coin_states` + INNER JOIN `cat_coins` ON `coin_states`.`coin_id` = `cat_coins`.`coin_id` + WHERE `coin_states`.`coin_id` = ? + ", + coin_id + ) + .fetch_optional(conn) + .await?; + + row.map(|row| { + Ok(Cat { + coin: to_coin(&row.parent_coin_id, &row.puzzle_hash, &row.amount)?, + asset_id: to_bytes32(&row.asset_id)?, + p2_puzzle_hash: to_bytes32(&row.p2_puzzle_hash)?, + lineage_proof: Some(to_lineage_proof( + &row.parent_parent_coin_id, + &row.parent_inner_puzzle_hash, + &row.parent_amount, + )?), + }) + }) + .transpose() +} diff --git a/crates/sage-database/src/primitives/dids.rs b/crates/sage-database/src/primitives/dids.rs index 55206ff0..70e6b894 100644 --- a/crates/sage-database/src/primitives/dids.rs +++ b/crates/sage-database/src/primitives/dids.rs @@ -38,6 +38,10 @@ impl Database { pub async fn did_coins(&self) -> Result> { did_coins(&self.pool).await } + + pub async fn did(&self, did_id: Bytes32) -> Result>> { + did(&self.pool, did_id).await + } } impl<'a> DatabaseTx<'a> { @@ -218,3 +222,49 @@ async fn did_coins(conn: impl SqliteExecutor<'_>) -> Result> { }) .collect() } + +async fn did(conn: impl SqliteExecutor<'_>, did_id: Bytes32) -> Result>> { + let did_id = did_id.as_ref(); + + let Some(row) = sqlx::query!( + " + SELECT + cs.parent_coin_id, cs.puzzle_hash, cs.amount, + did.parent_parent_coin_id, did.parent_inner_puzzle_hash, did.parent_amount, + did.launcher_id, did.recovery_list_hash, did.num_verifications_required, + did.metadata, did.p2_puzzle_hash + FROM `coin_states` AS cs + INNER JOIN `did_coins` AS did ON cs.coin_id = did.coin_id + WHERE did.launcher_id = ? + AND cs.spent_height IS NULL + AND cs.created_height IS NOT NULL + ", + did_id + ) + .fetch_optional(conn) + .await? + else { + return Ok(None); + }; + + Ok(Some(Did { + coin: to_coin(&row.parent_coin_id, &row.puzzle_hash, &row.amount)?, + proof: Proof::Lineage(to_lineage_proof( + &row.parent_parent_coin_id, + &row.parent_inner_puzzle_hash, + &row.parent_amount, + )?), + info: DidInfo:: { + launcher_id: to_bytes32(&row.launcher_id)?, + recovery_list_hash: row + .recovery_list_hash + .map(|hash| to_bytes32(&hash)) + .transpose()?, + num_verifications_required: u64::from_be_bytes(to_bytes( + &row.num_verifications_required, + )?), + metadata: row.metadata.into(), + p2_puzzle_hash: to_bytes32(&row.p2_puzzle_hash)?, + }, + })) +} diff --git a/crates/sage-database/src/primitives/nfts.rs b/crates/sage-database/src/primitives/nfts.rs index 07605129..0e9f540f 100644 --- a/crates/sage-database/src/primitives/nfts.rs +++ b/crates/sage-database/src/primitives/nfts.rs @@ -8,12 +8,15 @@ use sqlx::SqliteExecutor; use crate::{to_bytes32, Database, DatabaseTx, Result}; #[derive(Debug, Clone)] -pub struct NftDisplayInfo { +pub struct NftRow { pub coin_id: Bytes32, pub info: NftInfo, pub data_hash: Option, pub metadata_hash: Option, pub license_hash: Option, + pub create_transaction_id: Option, + pub created_height: Option, + pub visible: bool, } #[derive(Debug, Clone)] @@ -32,6 +35,10 @@ impl Database { pub async fn unchecked_nft_uris(&self, limit: u32) -> Result> { unchecked_nft_uris(&self.pool, limit).await } + + pub async fn update_nft(&self, launcher_id: Bytes32, visible: bool) -> Result<()> { + update_nft(&self.pool, launcher_id, visible).await + } } impl<'a> DatabaseTx<'a> { @@ -56,11 +63,11 @@ impl<'a> DatabaseTx<'a> { .await } - pub async fn fetch_nfts(&mut self, limit: u32, offset: u32) -> Result> { + pub async fn fetch_nfts(&mut self, limit: u32, offset: u32) -> Result> { fetch_nfts(&mut *self.tx, limit, offset).await } - pub async fn fetch_nft(&mut self, launcher_id: Bytes32) -> Result> { + pub async fn fetch_nft(&mut self, launcher_id: Bytes32) -> Result> { fetch_nft(&mut *self.tx, launcher_id).await } @@ -83,6 +90,14 @@ impl<'a> DatabaseTx<'a> { pub async fn fetch_nft_data(&mut self, hash: Bytes32) -> Result> { fetch_nft_data(&mut *self.tx, hash).await } + + pub async fn insert_new_nft(&mut self, launcher_id: Bytes32, visible: bool) -> Result<()> { + insert_new_nft(&mut *self.tx, launcher_id, visible).await + } + + pub async fn nft_visible(&mut self, launcher_id: Bytes32) -> Result { + nft_visible(&mut *self.tx, launcher_id).await + } } async fn insert_nft_coin( @@ -112,7 +127,7 @@ async fn insert_nft_coin( sqlx::query!( " - INSERT INTO `nft_coins` ( + REPLACE INTO `nft_coins` ( `coin_id`, `parent_parent_coin_id`, `parent_inner_puzzle_hash`, @@ -151,16 +166,12 @@ async fn insert_nft_coin( Ok(()) } -async fn fetch_nfts( - conn: impl SqliteExecutor<'_>, - limit: u32, - offset: u32, -) -> Result> { +async fn fetch_nfts(conn: impl SqliteExecutor<'_>, limit: u32, offset: u32) -> Result> { let rows = sqlx::query!( " SELECT `nft_coins`.`coin_id`, - `launcher_id`, + `nft_coins`.`launcher_id`, `metadata`, `metadata_updater_puzzle_hash`, `current_owner`, @@ -169,11 +180,17 @@ async fn fetch_nfts( `p2_puzzle_hash`, `data_hash`, `metadata_hash`, - `license_hash` + `license_hash`, + cs.`transaction_id`, + `created_height`, + `visible` FROM `nft_coins` + INNER JOIN `nfts` INDEXED BY `nft_visible` ON `nft_coins`.`launcher_id` = `nfts`.`launcher_id` INNER JOIN `coin_states` AS cs INDEXED BY `coin_height` ON `nft_coins`.`coin_id` = `cs`.`coin_id` + LEFT JOIN `transaction_spends` ON `cs`.`coin_id` = `transaction_spends`.`coin_id` WHERE `cs`.`spent_height` IS NULL - ORDER BY `created_height` DESC + AND `transaction_spends`.`transaction_id` IS NULL + ORDER BY `visible` DESC, cs.`transaction_id` DESC, `created_height` DESC LIMIT ? OFFSET ? ", limit, @@ -201,29 +218,29 @@ async fn fetch_nfts( let metadata_hash = row.metadata_hash.as_deref().map(to_bytes32).transpose()?; let license_hash = row.license_hash.as_deref().map(to_bytes32).transpose()?; - nfts.push(NftDisplayInfo { + nfts.push(NftRow { coin_id, info, data_hash, metadata_hash, license_hash, + create_transaction_id: row.transaction_id.as_deref().map(to_bytes32).transpose()?, + created_height: row.created_height.map(TryInto::try_into).transpose()?, + visible: row.visible, }); } Ok(nfts) } -async fn fetch_nft( - conn: impl SqliteExecutor<'_>, - launcher_id: Bytes32, -) -> Result> { +async fn fetch_nft(conn: impl SqliteExecutor<'_>, launcher_id: Bytes32) -> Result> { let launcher_id = launcher_id.as_ref(); let row = sqlx::query!( " SELECT - `coin_id`, - `launcher_id`, + cs.`coin_id`, + `nft_coins`.`launcher_id`, `metadata`, `metadata_updater_puzzle_hash`, `current_owner`, @@ -232,9 +249,14 @@ async fn fetch_nft( `p2_puzzle_hash`, `data_hash`, `metadata_hash`, - `license_hash` + `license_hash`, + `transaction_id`, + `created_height`, + `visible` FROM `nft_coins` - WHERE `launcher_id` = ? + INNER JOIN `coin_states` AS cs ON `nft_coins`.`coin_id` = `cs`.`coin_id` + INNER JOIN `nfts` ON `nft_coins`.`launcher_id` = `nfts`.`launcher_id` + WHERE `nft_coins`.`launcher_id` = ? ", launcher_id ) @@ -258,12 +280,15 @@ async fn fetch_nft( let metadata_hash = row.metadata_hash.as_deref().map(to_bytes32).transpose()?; let license_hash = row.license_hash.as_deref().map(to_bytes32).transpose()?; - Ok(NftDisplayInfo { + Ok(NftRow { coin_id, info, data_hash, metadata_hash, license_hash, + create_transaction_id: row.transaction_id.as_deref().map(to_bytes32).transpose()?, + created_height: row.created_height.map(TryInto::try_into).transpose()?, + visible: row.visible, }) }) .transpose() @@ -372,3 +397,52 @@ async fn fetch_nft_data(conn: impl SqliteExecutor<'_>, hash: Bytes32) -> Result< mime_type: row.mime_type, })) } + +async fn insert_new_nft( + conn: impl SqliteExecutor<'_>, + launcher_id: Bytes32, + visible: bool, +) -> Result<()> { + let launcher_id = launcher_id.as_ref(); + + sqlx::query!( + "INSERT OR IGNORE INTO `nfts` (`launcher_id`, `visible`) VALUES (?, ?)", + launcher_id, + visible + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn update_nft( + conn: impl SqliteExecutor<'_>, + launcher_id: Bytes32, + visible: bool, +) -> Result<()> { + let launcher_id = launcher_id.as_ref(); + + sqlx::query!( + "REPLACE INTO `nfts` (`launcher_id`, `visible`) VALUES (?, ?)", + launcher_id, + visible + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn nft_visible(conn: impl SqliteExecutor<'_>, launcher_id: Bytes32) -> Result { + let launcher_id = launcher_id.as_ref(); + + let row = sqlx::query!( + "SELECT `visible` FROM `nfts` WHERE `launcher_id` = ?", + launcher_id + ) + .fetch_optional(conn) + .await?; + + Ok(row.is_some_and(|row| row.visible)) +} diff --git a/crates/sage-database/src/primitives/xch.rs b/crates/sage-database/src/primitives/xch.rs index b4d34ff4..7226a789 100644 --- a/crates/sage-database/src/primitives/xch.rs +++ b/crates/sage-database/src/primitives/xch.rs @@ -20,8 +20,8 @@ impl<'a> DatabaseTx<'a> { insert_p2_coin(&mut *self.tx, coin_id).await } - pub async fn spendable_balance(&mut self) -> Result { - spendable_balance(&mut *self.tx).await + pub async fn balance(&mut self) -> Result { + balance(&mut *self.tx).await } } @@ -40,7 +40,7 @@ async fn insert_p2_coin(conn: impl SqliteExecutor<'_>, coin_id: Bytes32) -> Resu Ok(()) } -async fn spendable_balance(conn: impl SqliteExecutor<'_>) -> Result { +async fn balance(conn: impl SqliteExecutor<'_>) -> Result { let row = sqlx::query!( " SELECT `coin_states`.`amount` FROM `coin_states` INDEXED BY `coin_spent` @@ -48,7 +48,6 @@ async fn spendable_balance(conn: impl SqliteExecutor<'_>) -> Result { 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 `coin_states`.`transaction_id` IS NULL " ) .fetch_all(conn) diff --git a/crates/sage-wallet/src/data.rs b/crates/sage-wallet/src/data.rs new file mode 100644 index 00000000..658aa668 --- /dev/null +++ b/crates/sage-wallet/src/data.rs @@ -0,0 +1,3 @@ +mod fetch_uri; + +pub use fetch_uri::*; diff --git a/crates/sage-wallet/src/data/fetch_uri.rs b/crates/sage-wallet/src/data/fetch_uri.rs new file mode 100644 index 00000000..31d280e7 --- /dev/null +++ b/crates/sage-wallet/src/data/fetch_uri.rs @@ -0,0 +1,142 @@ +use std::time::Duration; + +use chia::protocol::Bytes32; +use clvmr::sha2::Sha256; +use futures_lite::StreamExt; +use futures_util::stream::FuturesUnordered; +use reqwest::header::CONTENT_TYPE; +use thiserror::Error; +use tokio::time::timeout; + +#[derive(Debug, Clone)] +pub struct Data { + pub blob: Vec, + pub mime_type: String, + pub hash: Bytes32, +} + +#[derive(Debug, Error)] +pub enum UriError { + #[error("Failed to fetch data for URI {0}: {1}")] + Fetch(String, reqwest::Error), + + #[error("Timed out fetching data for URI {0}")] + FetchTimeout(String), + + #[error("Missing or invalid content type for URI {0}")] + InvalidContentType(String), + + #[error("Failed to stream response bytes for URI {0}: {1}")] + Stream(String, reqwest::Error), + + #[error("Timed out streaming response bytes for URI {0}")] + StreamTimeout(String), + + #[error("Mime type mismatch for URI {uri}, expected {expected} but found {found}")] + MimeTypeMismatch { + uri: String, + expected: String, + found: String, + }, + + #[error("Hash mismatch for URI {uri}, expected {expected} but found {found}")] + HashMismatch { + uri: String, + expected: Bytes32, + found: Bytes32, + }, + + #[error("No URIs provided")] + NoUris, +} + +pub async fn fetch_uri( + uri: &str, + request_timeout: Duration, + stream_timeout: Duration, +) -> Result { + let response = match timeout(request_timeout, reqwest::get(uri)).await { + Ok(Ok(response)) => response, + Ok(Err(error)) => { + return Err(UriError::Fetch(uri.to_string(), error)); + } + Err(_) => { + return Err(UriError::FetchTimeout(uri.to_string())); + } + }; + + let Some(mime_type) = response + .headers() + .get(CONTENT_TYPE) + .cloned() + .and_then(|value| value.to_str().map(ToString::to_string).ok()) + else { + return Err(UriError::InvalidContentType(uri.to_string())); + }; + + let blob = match timeout(stream_timeout, response.bytes()).await { + Ok(Ok(data)) => data.to_vec(), + Ok(Err(error)) => { + return Err(UriError::Stream(uri.to_string(), error)); + } + Err(_) => { + return Err(UriError::StreamTimeout(uri.to_string())); + } + }; + + let mut hasher = Sha256::new(); + hasher.update(&blob); + let hash = Bytes32::new(hasher.finalize()); + + Ok(Data { + blob, + mime_type, + hash, + }) +} + +pub async fn fetch_uris( + uris: Vec, + request_timeout: Duration, + stream_timeout: Duration, +) -> Result { + let mut futures = FuturesUnordered::new(); + + for uri in uris { + futures.push(async move { + let result = fetch_uri(&uri, request_timeout, stream_timeout).await; + (uri, result) + }); + } + + let mut data = None; + + while let Some((uri, result)) = futures.next().await { + let item = result?; + + let Some(data) = data.take() else { + data = Some(item); + continue; + }; + + if data.hash != item.hash { + return Err(UriError::HashMismatch { + uri, + expected: data.hash, + found: item.hash, + }); + } + + if data.mime_type != item.mime_type { + return Err(UriError::MimeTypeMismatch { + uri, + expected: data.mime_type, + found: item.mime_type, + }); + } + + assert_eq!(data.blob, item.blob); + } + + data.ok_or(UriError::NoUris) +} diff --git a/crates/sage-wallet/src/error.rs b/crates/sage-wallet/src/error.rs index 89e55cfe..8795c682 100644 --- a/crates/sage-wallet/src/error.rs +++ b/crates/sage-wallet/src/error.rs @@ -38,6 +38,9 @@ pub enum WalletError { #[error("Not enough keys have been derived")] InsufficientDerivations, + #[error("Spendable DID not found: {0}")] + MissingDid(Bytes32), + #[error("Unknown public key in transaction: {0:?}")] UnknownPublicKey(PublicKey), diff --git a/crates/sage-wallet/src/lib.rs b/crates/sage-wallet/src/lib.rs index 69395b32..02a44e2a 100644 --- a/crates/sage-wallet/src/lib.rs +++ b/crates/sage-wallet/src/lib.rs @@ -1,9 +1,11 @@ +mod data; mod error; mod puzzle_info; mod queues; mod sync_manager; mod wallet; +pub use data::*; pub use error::*; pub use puzzle_info::*; pub use queues::*; diff --git a/crates/sage-wallet/src/queues/nft_queue.rs b/crates/sage-wallet/src/queues/nft_queue.rs index 70bc35b0..fbdb4a63 100644 --- a/crates/sage-wallet/src/queues/nft_queue.rs +++ b/crates/sage-wallet/src/queues/nft_queue.rs @@ -2,15 +2,11 @@ use std::time::Duration; use futures_lite::StreamExt; use futures_util::stream::FuturesUnordered; -use reqwest::header::CONTENT_TYPE; use sage_database::{Database, NftData}; -use tokio::{ - sync::mpsc, - time::{sleep, timeout}, -}; +use tokio::{sync::mpsc, time::sleep}; use tracing::{debug, info}; -use crate::{SyncEvent, WalletError}; +use crate::{fetch_uri, SyncEvent, WalletError}; #[derive(Debug)] pub struct NftQueue { @@ -44,7 +40,7 @@ impl NftQueue { for item in batch { futures.push(async move { let result = - fetch_uri(&item.uri, Duration::from_secs(3), Duration::from_secs(3)).await; + fetch_uri(&item.uri, Duration::from_secs(15), Duration::from_secs(15)).await; (item, result) }); } @@ -52,9 +48,28 @@ impl NftQueue { while let Some((item, result)) = futures.next().await { let mut tx = self.db.tx().await?; - if let Some(nft_data) = result { - tx.insert_nft_data(item.hash, nft_data).await?; - } + match result { + Ok(data) => { + if data.hash == item.hash { + tx.insert_nft_data( + item.hash, + NftData { + mime_type: data.mime_type, + blob: data.blob, + }, + ) + .await?; + } else { + debug!( + "Hash mismatch for URI {} (expected {} but found {})", + item.uri, item.hash, data.hash + ); + } + } + Err(error) => { + debug!("{error}"); + } + }; tx.mark_nft_uri_checked(item.uri, item.hash).await?; @@ -66,48 +81,3 @@ impl NftQueue { Ok(()) } } - -async fn fetch_uri( - uri: &str, - request_timeout: Duration, - stream_timeout: Duration, -) -> Option { - let response = match timeout(request_timeout, reqwest::get(uri)).await { - Ok(Ok(response)) => response, - Ok(Err(error)) => { - debug!("Failed to fetch NFT data for {}: {}", uri, error); - return None; - } - Err(_) => { - debug!("Timed out fetching NFT data for {}", uri); - return None; - } - }; - - let Some(mime_type) = response - .headers() - .get(CONTENT_TYPE) - .cloned() - .and_then(|value| value.to_str().map(ToString::to_string).ok()) - else { - debug!("Invalid or missing content type for NFT URI {}", uri); - return None; - }; - - let blob = match timeout(stream_timeout, response.bytes()).await { - Ok(Ok(data)) => data.to_vec(), - Ok(Err(error)) => { - debug!( - "Failed to consume response bytes for NFT URI {}: {}", - uri, error - ); - return None; - } - Err(_) => { - debug!("Timed out consuming response bytes for NFT URI {}", uri); - return None; - } - }; - - Some(NftData { blob, mime_type }) -} diff --git a/crates/sage-wallet/src/queues/puzzle_queue.rs b/crates/sage-wallet/src/queues/puzzle_queue.rs index e2114d2c..2a33f9be 100644 --- a/crates/sage-wallet/src/queues/puzzle_queue.rs +++ b/crates/sage-wallet/src/queues/puzzle_queue.rs @@ -202,7 +202,7 @@ async fn fetch_puzzle( license_uris, } => { tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; - + tx.insert_new_nft(info.launcher_id, true).await?; tx.insert_nft_coin( coin_id, lineage_proof, diff --git a/crates/sage-wallet/src/sync_manager.rs b/crates/sage-wallet/src/sync_manager.rs index 97cad1c2..021cf4eb 100644 --- a/crates/sage-wallet/src/sync_manager.rs +++ b/crates/sage-wallet/src/sync_manager.rs @@ -290,8 +290,6 @@ impl SyncManager { unspent_count, spent_count, message.height, message.peak_hash ); - - self.event_sender.send(SyncEvent::CoinState).await.ok(); } else { debug!("Received coin state update but no database to update"); } diff --git a/crates/sage-wallet/src/sync_manager/wallet_sync.rs b/crates/sage-wallet/src/sync_manager/wallet_sync.rs index fd3cf8de..a467e76c 100644 --- a/crates/sage-wallet/src/sync_manager/wallet_sync.rs +++ b/crates/sage-wallet/src/sync_manager/wallet_sync.rs @@ -163,7 +163,6 @@ async fn sync_coin_ids( if !data.coin_states.is_empty() { incremental_sync(wallet, data.coin_states, true, &sync_sender).await?; - sync_sender.send(SyncEvent::CoinState).await.ok(); } } Err(rejection) => match rejection.reason { @@ -226,7 +225,6 @@ async fn sync_puzzle_hashes( if !data.coin_states.is_empty() { found_coins = true; incremental_sync(wallet, data.coin_states, true, &sync_sender).await?; - sync_sender.send(SyncEvent::CoinState).await.ok(); } prev_height = Some(data.height); @@ -304,6 +302,8 @@ pub async fn incremental_sync( tx.commit().await?; + sync_sender.send(SyncEvent::CoinState).await.ok(); + if derived { sync_sender.send(SyncEvent::Derivation).await.ok(); } diff --git a/crates/sage-wallet/src/wallet.rs b/crates/sage-wallet/src/wallet.rs index da90a7f6..25a70640 100644 --- a/crates/sage-wallet/src/wallet.rs +++ b/crates/sage-wallet/src/wallet.rs @@ -10,12 +10,17 @@ use chia::{ Signature, }, clvm_traits::{FromClvm, ToClvm}, - protocol::{Bytes, Bytes32, Coin, CoinSpend, CoinState, SpendBundle}, - puzzles::{standard::StandardArgs, DeriveSynthetic}, + protocol::{Bytes, Bytes32, Coin, CoinSpend, CoinState, Program, SpendBundle}, + puzzles::{ + nft::{NftMetadata, NFT_METADATA_UPDATER_PUZZLE_HASH}, + standard::StandardArgs, + DeriveSynthetic, + }, }; use chia_wallet_sdk::{ - run_puzzle, select_coins, AggSigConstants, Cat, CatSpend, Condition, Conditions, Did, Launcher, - RequiredSignature, SpendContext, SpendWithConditions, StandardLayer, + run_puzzle, select_coins, AggSigConstants, Cat, CatSpend, Condition, Conditions, Did, DidOwner, + HashedPtr, Launcher, Nft, NftMint, RequiredSignature, SpendContext, SpendWithConditions, + StandardLayer, }; use clvmr::{Allocator, NodePtr}; use rayon::iter::{IntoParallelIterator, ParallelIterator}; @@ -23,6 +28,13 @@ use sage_database::{Database, DatabaseTx}; use crate::{ParseError, PuzzleInfo, WalletError}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WalletNftMint { + pub metadata: NftMetadata, + pub royalty_puzzle_hash: Option, + pub royalty_ten_thousandths: u16, +} + #[derive(Debug)] pub struct Wallet { pub db: Database, @@ -275,7 +287,6 @@ impl Wallet { &self, coins: Vec, fee: u64, - memos: Vec, hardened: bool, reuse: bool, ) -> Result, WalletError> { @@ -298,7 +309,7 @@ impl Wallet { } if change > 0 { - conditions = conditions.create_coin(p2_puzzle_hash, change, memos.clone()); + conditions = conditions.create_coin(p2_puzzle_hash, change, Vec::new()); } let mut ctx = SpendContext::new(); @@ -306,13 +317,12 @@ impl Wallet { Ok(ctx.take()) } - /// Splits the given coins into multiple new coins, with the given fee subtracted from the output. + /// Splits the given XCH coins into multiple new coins, with the given fee subtracted from the output. pub async fn split_xch( &self, coins: &[Coin], output_count: usize, fee: u64, - memos: Vec, hardened: bool, reuse: bool, ) -> Result, WalletError> { @@ -370,7 +380,7 @@ impl Wallet { remaining_amount -= amount as u128; - conditions = conditions.create_coin(derivation, amount, memos.clone()); + conditions = conditions.create_coin(derivation, amount, Vec::new()); remaining_count -= 1; } @@ -390,7 +400,6 @@ impl Wallet { coins: Vec, puzzle_hash: Bytes32, mut fee: u64, - memos: Vec, ) -> Result, WalletError> { // Select the most optimal coins to use for the fee, to keep cost to a minimum. let fee_coins: HashSet = select_coins(coins.clone(), fee as u128)? @@ -412,14 +421,14 @@ impl Wallet { Conditions::new().create_coin( puzzle_hash, coin.amount - consumed, - memos.clone(), + Vec::new(), ) } else { Conditions::new() } } else { // Otherwise, just create a new output coin at the given puzzle hash. - Conditions::new().create_coin(puzzle_hash, coin.amount, memos.clone()) + Conditions::new().create_coin(puzzle_hash, coin.amount, Vec::new()) }; // Ensure that there is a ring of assertions for all of the coins. @@ -449,6 +458,141 @@ impl Wallet { Ok(ctx.take()) } + /// Combines multiple CAT coins into a single coin, with the given fee subtracted from the output. + pub async fn combine_cat( + &self, + cats: Vec, + fee: u64, + hardened: bool, + reuse: bool, + ) -> Result, WalletError> { + let fee_coins = self.select_p2_coins(fee as u128).await?; + let fee_total: u128 = fee_coins.iter().map(|coin| coin.amount as u128).sum(); + let cat_total: u128 = cats.iter().map(|cat| cat.coin.amount as u128).sum(); + + let p2_puzzle_hash = self.p2_puzzle_hash(hardened, reuse).await?; + + let fee_change: u64 = (fee_total - fee as u128) + .try_into() + .expect("change amount overflow"); + + let mut fee_conditions = Conditions::new().assert_concurrent_spend(cats[0].coin.coin_id()); + + if fee > 0 { + fee_conditions = fee_conditions.reserve_fee(fee); + } + + if fee_change > 0 { + fee_conditions = fee_conditions.create_coin(p2_puzzle_hash, fee_change, Vec::new()); + } + + let mut ctx = SpendContext::new(); + + self.spend_p2_coins(&mut ctx, fee_coins, fee_conditions) + .await?; + + self.spend_cat_coins( + &mut ctx, + cats.into_iter().enumerate().map(|(i, cat)| { + if i == 0 { + ( + cat, + Conditions::new().create_coin( + p2_puzzle_hash, + cat_total.try_into().expect("output amount overflow"), + vec![p2_puzzle_hash.into()], + ), + ) + } else { + (cat, Conditions::new()) + } + }), + ) + .await?; + + Ok(ctx.take()) + } + + /// Splits the given CAT coins into multiple new coins, with the given fee subtracted from the output. + pub async fn split_cat( + &self, + cats: Vec, + output_count: usize, + fee: u64, + hardened: bool, + reuse: bool, + ) -> Result, WalletError> { + let fee_coins = self.select_p2_coins(fee as u128).await?; + let fee_total: u128 = fee_coins.iter().map(|coin| coin.amount as u128).sum(); + let cat_total: u128 = cats.iter().map(|cat| cat.coin.amount as u128).sum(); + + let mut remaining_count = output_count; + let mut remaining_amount = cat_total; + + let max_individual_amount: u64 = remaining_amount + .div_ceil(output_count as u128) + .try_into() + .expect("output amount overflow"); + + let derivations_needed: u32 = output_count + .div_ceil(cats.len()) + .try_into() + .expect("derivation count overflow"); + + let derivations = self + .p2_puzzle_hashes(derivations_needed, hardened, reuse) + .await?; + + let mut ctx = SpendContext::new(); + + let fee_change: u64 = (fee_total - fee as u128) + .try_into() + .expect("change amount overflow"); + + let mut fee_conditions = Conditions::new().assert_concurrent_spend(cats[0].coin.coin_id()); + + if fee > 0 { + fee_conditions = fee_conditions.reserve_fee(fee); + } + + if fee_change > 0 { + fee_conditions = fee_conditions.create_coin(derivations[0], fee_change, Vec::new()); + } + + self.spend_p2_coins(&mut ctx, fee_coins, fee_conditions) + .await?; + + self.spend_cat_coins( + &mut ctx, + cats.into_iter().map(|cat| { + let mut conditions = Conditions::new(); + + for &derivation in &derivations { + if remaining_count == 0 { + break; + } + + let amount: u64 = (max_individual_amount as u128) + .min(remaining_amount) + .try_into() + .expect("output amount overflow"); + + remaining_amount -= amount as u128; + + conditions = + conditions.create_coin(derivation, amount, vec![derivation.into()]); + + remaining_count -= 1; + } + + (cat, conditions) + }), + ) + .await?; + + Ok(ctx.take()) + } + pub async fn issue_cat( &self, amount: u64, @@ -595,6 +739,76 @@ impl Wallet { Ok((ctx.take(), did)) } + pub async fn bulk_mint_nfts( + &self, + fee: u64, + did_id: Bytes32, + mints: Vec, + hardened: bool, + reuse: bool, + ) -> Result<(Vec, Vec>, Did), WalletError> { + let Some(did) = self.db.did(did_id).await? else { + return Err(WalletError::MissingDid(did_id)); + }; + + let total_amount = fee as u128 + 1; + let coins = self.select_p2_coins(total_amount).await?; + let selected: u128 = coins.iter().map(|coin| coin.amount as u128).sum(); + + let change: u64 = (selected - total_amount) + .try_into() + .expect("change amount overflow"); + + let p2_puzzle_hash = self.p2_puzzle_hash(hardened, reuse).await?; + + let mut ctx = SpendContext::new(); + + let did_metadata_ptr = ctx.alloc(&did.info.metadata)?; + let did = did.with_metadata(HashedPtr::from_ptr(&ctx.allocator, did_metadata_ptr)); + + let synthetic_key = self.db.synthetic_key(did.info.p2_puzzle_hash).await?; + let p2 = StandardLayer::new(synthetic_key); + + let mut did_conditions = Conditions::new(); + let mut nfts = Vec::with_capacity(mints.len()); + + for (i, mint) in mints.into_iter().enumerate() { + let mint = NftMint { + metadata: mint.metadata, + metadata_updater_puzzle_hash: NFT_METADATA_UPDATER_PUZZLE_HASH.into(), + royalty_puzzle_hash: mint.royalty_puzzle_hash.unwrap_or(p2_puzzle_hash), + royalty_ten_thousandths: mint.royalty_ten_thousandths, + p2_puzzle_hash, + owner: Some(DidOwner::from_did_info(&did.info)), + }; + + let (mint_nft, nft) = Launcher::new(did.coin.coin_id(), i as u64 * 2) + .with_singleton_amount(1) + .mint_nft(&mut ctx, mint)?; + + did_conditions = did_conditions.extend(mint_nft); + nfts.push(nft); + } + + let new_did = did.update(&mut ctx, &p2, did_conditions)?; + + let mut conditions = Conditions::new().assert_concurrent_spend(did.coin.coin_id()); + + if fee > 0 { + conditions = conditions.reserve_fee(fee); + } + + if change > 0 { + conditions = conditions.create_coin(p2_puzzle_hash, change, Vec::new()); + } + + self.spend_p2_coins(&mut ctx, coins, conditions).await?; + + let new_did = new_did.with_metadata(ctx.serialize(&new_did.info.metadata)?); + + Ok((ctx.take(), nfts, new_did)) + } + pub async fn sign_transaction( &self, mut coin_spends: Vec, @@ -739,10 +953,15 @@ impl Wallet { let coin_state = CoinState::new(coin, None, None); let coin_id = coin.coin_id(); - tx.insert_coin_state(coin_state, true, Some(transaction_id)) - .await?; + macro_rules! insert_coin { + () => { + tx.insert_coin_state(coin_state, true, Some(transaction_id)) + .await?; + }; + } if tx.is_p2_puzzle_hash(coin.puzzle_hash).await? { + insert_coin!(); tx.insert_p2_coin(coin_id).await?; continue; } @@ -753,17 +972,23 @@ impl Wallet { lineage_proof, p2_puzzle_hash, } => { - tx.sync_coin(coin_id, Some(p2_puzzle_hash)).await?; - tx.insert_cat_coin(coin_id, lineage_proof, p2_puzzle_hash, asset_id) - .await?; + if tx.is_p2_puzzle_hash(p2_puzzle_hash).await? { + insert_coin!(); + tx.sync_coin(coin_id, Some(p2_puzzle_hash)).await?; + tx.insert_cat_coin(coin_id, lineage_proof, p2_puzzle_hash, asset_id) + .await?; + } } PuzzleInfo::Did { lineage_proof, info, } => { - tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; - tx.insert_new_did(info.launcher_id, None, true).await?; - tx.insert_did_coin(coin_id, lineage_proof, info).await?; + if tx.is_p2_puzzle_hash(info.p2_puzzle_hash).await? { + insert_coin!(); + tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; + tx.insert_new_did(info.launcher_id, None, true).await?; + tx.insert_did_coin(coin_id, lineage_proof, info).await?; + } } PuzzleInfo::Nft { lineage_proof, @@ -775,38 +1000,50 @@ impl Wallet { metadata_uris, license_uris, } => { - tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; - tx.insert_nft_coin( - coin_id, - lineage_proof, - info, - data_hash, - metadata_hash, - license_hash, - ) - .await?; + if tx.is_p2_puzzle_hash(info.p2_puzzle_hash).await? { + insert_coin!(); + + tx.sync_coin(coin_id, Some(info.p2_puzzle_hash)).await?; + tx.insert_new_nft(info.launcher_id, true).await?; + tx.insert_nft_coin( + coin_id, + lineage_proof, + info, + data_hash, + metadata_hash, + license_hash, + ) + .await?; - if let Some(hash) = data_hash { - for uri in data_uris { - tx.insert_nft_uri(uri, hash).await?; + if let Some(hash) = data_hash { + for uri in 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_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 license_uris { + tx.insert_nft_uri(uri, hash).await?; + } } } } PuzzleInfo::Unknown { hint } => { - tx.sync_coin(coin_id, hint).await?; - tx.insert_unknown_coin(coin_id).await?; + let Some(p2_puzzle_hash) = hint else { + continue; + }; + + if tx.is_p2_puzzle_hash(p2_puzzle_hash).await? { + insert_coin!(); + tx.sync_coin(coin_id, hint).await?; + tx.insert_unknown_coin(coin_id).await?; + } } } } diff --git a/migrations/0001_setup.sql b/migrations/0001_setup.sql index 0a7b8f21..45a521ac 100644 --- a/migrations/0001_setup.sql +++ b/migrations/0001_setup.sql @@ -123,6 +123,13 @@ CREATE TABLE `dids` ( `visible` BOOLEAN NOT NULL ); +CREATE TABLE `nfts` ( + `launcher_id` BLOB NOT NULL PRIMARY KEY, + `visible` BOOLEAN NOT NULL +); + +CREATE INDEX `nft_visible` ON `nfts` (`visible`); + CREATE TABLE `transactions` ( `transaction_id` BLOB NOT NULL PRIMARY KEY, `aggregated_signature` BLOB NOT NULL, diff --git a/src-tauri/src/commands/actions.rs b/src-tauri/src/commands/actions.rs index 9d75318c..d42fca17 100644 --- a/src-tauri/src/commands/actions.rs +++ b/src-tauri/src/commands/actions.rs @@ -73,3 +73,20 @@ pub async fn update_did( Ok(()) } + +#[command] +#[specta] +pub async fn update_nft(state: State<'_, AppState>, nft_id: String, visible: bool) -> Result<()> { + let state = state.lock().await; + let wallet = state.wallet()?; + + let (launcher_id, prefix) = decode_address(&nft_id)?; + + if prefix != "nft" { + return Err(Error::invalid_prefix(&prefix)); + } + + wallet.db.update_nft(launcher_id.into(), visible).await?; + + Ok(()) +} diff --git a/src-tauri/src/commands/data.rs b/src-tauri/src/commands/data.rs index af9e657f..858a6cc0 100644 --- a/src-tauri/src/commands/data.rs +++ b/src-tauri/src/commands/data.rs @@ -10,7 +10,7 @@ use sage_api::{ Amount, CatRecord, CoinRecord, DidRecord, GetNfts, GetNftsResponse, NftRecord, PendingTransactionRecord, SyncStatus, }; -use sage_database::{DidRow, NftData, NftDisplayInfo}; +use sage_database::{DidRow, NftData, NftRow}; use sage_wallet::WalletError; use specta::specta; use tauri::{command, State}; @@ -47,7 +47,7 @@ pub async fn get_sync_status(state: State<'_, AppState>) -> Result { let wallet = state.wallet()?; let mut tx = wallet.db.tx().await?; - let balance = tx.spendable_balance().await?; + let balance = tx.balance().await?; let total_coins = tx.total_coin_count().await?; let synced_coins = tx.synced_coin_count().await?; tx.commit().await?; @@ -164,7 +164,7 @@ pub async fn get_cats(state: State<'_, AppState>) -> Result> { let mut records = Vec::with_capacity(cats.len()); for cat in cats { - let balance = wallet.db.spendable_cat_balance(cat.asset_id).await?; + let balance = wallet.db.cat_balance(cat.asset_id).await?; records.push(CatRecord { asset_id: hex::encode(cat.asset_id), @@ -191,7 +191,7 @@ pub async fn get_cat(state: State<'_, AppState>, asset_id: String) -> Result, launcher_id: String) -> Result< } fn nft_record( - nft: &NftDisplayInfo, + nft: &NftRow, prefix: &str, data: Option, offchain_metadata: Option, @@ -401,5 +401,8 @@ fn nft_record( None } }), + created_height: nft.created_height, + create_transaction_id: nft.create_transaction_id.map(hex::encode), + visible: nft.visible, }) } diff --git a/src-tauri/src/commands/transactions.rs b/src-tauri/src/commands/transactions.rs index 9d72c527..3a195415 100644 --- a/src-tauri/src/commands/transactions.rs +++ b/src-tauri/src/commands/transactions.rs @@ -1,8 +1,15 @@ -use chia::protocol::{Bytes32, CoinSpend}; -use chia_wallet_sdk::{decode_address, AggSigConstants, MAINNET_CONSTANTS, TESTNET11_CONSTANTS}; -use sage_api::Amount; +use std::time::Duration; + +use chia::{ + protocol::{Bytes32, CoinSpend}, + puzzles::nft::NftMetadata, +}; +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::Wallet; +use sage_wallet::{fetch_uris, Wallet, WalletNftMint}; use specta::specta; use tauri::{command, State}; use tokio::sync::MutexGuard; @@ -96,9 +103,7 @@ pub async fn combine(state: State<'_, AppState>, coin_ids: Vec, fee: Amo tx.commit().await?; - let coin_spends = wallet - .combine_xch(coins, fee, Vec::new(), false, true) - .await?; + let coin_spends = wallet.combine_xch(coins, fee, false, true).await?; transact(&state, &wallet, coin_spends).await?; @@ -148,7 +153,88 @@ pub async fn split( tx.commit().await?; let coin_spends = wallet - .split_xch(&coins, output_count as usize, fee, Vec::new(), false, true) + .split_xch(&coins, output_count as usize, fee, false, true) + .await?; + + transact(&state, &wallet, coin_spends).await?; + + Ok(()) +} + +#[command] +#[specta] +pub async fn combine_cat( + state: State<'_, AppState>, + coin_ids: Vec, + fee: Amount, +) -> Result<()> { + let state = state.lock().await; + let wallet = state.wallet()?; + + if !state.keychain.has_secret_key(wallet.fingerprint) { + return Err(Error::no_secret_key()); + } + + let Some(fee) = fee.to_mojos(state.unit.decimals) else { + return Err(Error::invalid_amount(&fee)); + }; + + let coin_ids = coin_ids + .iter() + .map(|coin_id| Ok(hex::decode(coin_id)?.try_into()?)) + .collect::>>()?; + + let mut cats = Vec::new(); + + for coin_id in coin_ids { + let Some(cat) = wallet.db.cat_coin(coin_id).await? else { + return Err(Error::unknown_coin_id()); + }; + cats.push(cat); + } + + let coin_spends = wallet.combine_cat(cats, fee, false, true).await?; + + transact(&state, &wallet, coin_spends).await?; + + Ok(()) +} + +#[command] +#[specta] +pub async fn split_cat( + state: State<'_, AppState>, + coin_ids: Vec, + output_count: u32, + fee: Amount, +) -> Result<()> { + let state = state.lock().await; + let wallet = state.wallet()?; + + if !state.keychain.has_secret_key(wallet.fingerprint) { + return Err(Error::no_secret_key()); + } + + let Some(fee) = fee.to_mojos(state.unit.decimals) else { + return Err(Error::invalid_amount(&fee)); + }; + + let coin_ids = coin_ids + .iter() + .map(|coin_id| Ok(hex::decode(coin_id)?.try_into()?)) + .collect::>>()?; + + let mut cats = Vec::new(); + + for coin_id in coin_ids { + let Some(cat) = wallet.db.cat_coin(coin_id).await? else { + return Err(Error::unknown_coin_id()); + }; + cats.push(cat); + } + + let coin_spends = wallet + .split_cat(cats, output_count as usize, fee, false, true) .await?; transact(&state, &wallet, coin_spends).await?; @@ -161,6 +247,7 @@ pub async fn split( pub async fn issue_cat( state: State<'_, AppState>, name: String, + ticker: String, amount: Amount, fee: Amount, ) -> Result<()> { @@ -188,7 +275,7 @@ pub async fn issue_cat( .maybe_insert_cat(CatRow { asset_id, name: Some(name), - ticker: None, + ticker: Some(ticker), description: None, icon_url: None, visible: true, @@ -264,6 +351,127 @@ pub async fn create_did(state: State<'_, AppState>, name: String, fee: Amount) - Ok(()) } +#[command] +#[specta] +pub async fn bulk_mint_nfts( + state: State<'_, AppState>, + request: BulkMintNfts, +) -> Result { + let state = state.lock().await; + let wallet = state.wallet()?; + + if !state.keychain.has_secret_key(wallet.fingerprint) { + return Err(Error::no_secret_key()); + } + + let Some(fee) = request.fee.to_mojos(state.unit.decimals) else { + return Err(Error::invalid_amount(&request.fee)); + }; + + let (launcher_id, prefix) = decode_address(&request.did_id)?; + + if prefix != "did:chia:" { + return Err(Error::invalid_prefix(&prefix)); + } + + let mut mints = Vec::with_capacity(request.nft_mints.len()); + + for item in request.nft_mints { + let royalty_puzzle_hash = item + .royalty_address + .map(|address| { + let (puzzle_hash, prefix) = decode_address(&address)?; + if prefix != state.network().address_prefix { + return Err(Error::invalid_prefix(&prefix)); + } + Ok(puzzle_hash.into()) + }) + .transpose()?; + + let Some(royalty_ten_thousandths) = item.royalty_percent.to_ten_thousandths() else { + return Err(Error::invalid_royalty(&item.royalty_percent)); + }; + + 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 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 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, + ) + }; + + mints.push(WalletNftMint { + metadata: NftMetadata { + edition_number: item.edition_number.map_or(1, Into::into), + edition_total: item.edition_total.map_or(1, Into::into), + data_uris: item.data_uris, + data_hash, + metadata_uris: item.metadata_uris, + metadata_hash, + license_uris: item.license_uris, + license_hash, + }, + royalty_puzzle_hash, + royalty_ten_thousandths, + }); + } + + let (coin_spends, nfts, _did) = wallet + .bulk_mint_nfts(fee, launcher_id.into(), mints, false, true) + .await?; + + let mut tx = wallet.db.tx().await?; + + for nft in &nfts { + tx.insert_new_nft(nft.info.launcher_id, true).await?; + } + + tx.commit().await?; + + transact(&state, &wallet, coin_spends).await?; + + Ok(BulkMintNftsResponse { + nft_ids: nfts + .into_iter() + .map(|nft| Result::Ok(encode_address(nft.info.launcher_id.into(), "nft")?)) + .collect::>()?, + }) +} + async fn transact( state: &MutexGuard<'_, AppStateInner>, wallet: &Wallet, diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index f8b74154..739475a2 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -14,7 +14,7 @@ use hex::FromHexError; use sage_api::Amount; use sage_database::DatabaseError; use sage_keychain::KeychainError; -use sage_wallet::{SyncCommand, WalletError}; +use sage_wallet::{SyncCommand, UriError, WalletError}; use serde::{Deserialize, Serialize}; use specta::Type; use sqlx::migrate::MigrateError; @@ -49,6 +49,13 @@ impl Error { } } + pub fn invalid_royalty(amount: &Amount) -> Self { + Self { + kind: ErrorKind::InvalidRoyalty, + reason: format!("Invalid royalty {amount}"), + } + } + pub fn invalid_prefix(prefix: &str) -> Self { Self { kind: ErrorKind::InvalidAddress, @@ -146,6 +153,7 @@ pub enum ErrorKind { InvalidMnemonic, InvalidKey, InvalidAmount, + InvalidRoyalty, InvalidAssetId, InvalidLauncherId, InsufficientFunds, @@ -409,3 +417,12 @@ impl From for Error { } } } + +impl From for Error { + fn from(value: UriError) -> Self { + Self { + kind: ErrorKind::Wallet, + reason: value.to_string(), + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e944a8ac..1c366138 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -58,13 +58,17 @@ pub fn run() { commands::update_cat_info, commands::remove_cat_info, commands::update_did, + commands::update_nft, // Transactions commands::send, commands::combine, commands::split, + commands::combine_cat, + commands::split_cat, commands::issue_cat, commands::send_cat, commands::create_did, + commands::bulk_mint_nfts, // Peers commands::get_peers, commands::add_peer, diff --git a/src/App.tsx b/src/App.tsx index 1301d599..3c1d15e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import CreateWallet from './pages/CreateWallet'; import ImportWallet from './pages/ImportWallet'; import IssueToken from './pages/IssueToken'; import Login from './pages/Login'; +import MintNft from './pages/MintNft'; import Nft from './pages/Nft'; import PeerList from './pages/PeerList'; import Receive from './pages/Receive'; @@ -55,6 +56,7 @@ const router = createHashRouter( }> } /> } /> + } /> }> } /> diff --git a/src/bindings.ts b/src/bindings.ts index cbeebbd7..2610345c 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -277,6 +277,14 @@ async updateDid(didId: string, name: string | null, visible: boolean) : Promise< else return { status: "error", error: e as any }; } }, +async updateNft(nftId: string, visible: boolean) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("update_nft", { nftId, visible }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, async send(address: string, amount: Amount, fee: Amount) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("send", { address, amount, fee }) }; @@ -301,9 +309,25 @@ 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) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async splitCat(coinIds: string[], outputCount: number, fee: Amount) : Promise> { try { - return { status: "ok", data: await TAURI_INVOKE("issue_cat", { name, amount, fee }) }; + return { status: "ok", data: await TAURI_INVOKE("split_cat", { coinIds, outputCount, fee }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +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) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; @@ -325,6 +349,14 @@ async createDid(name: string, fee: Amount) : Promise> { else return { status: "error", error: e as any }; } }, +async bulkMintNfts(request: BulkMintNfts) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("bulk_mint_nfts", { request }) }; +} 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") }; @@ -367,16 +399,19 @@ syncEvent: "sync-event" /** user-defined types **/ export type Amount = string +export type BulkMintNfts = { nft_mints: NftMint[]; did_id: string; fee: Amount } +export type BulkMintNftsResponse = { nft_ids: string[] } 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 } export type Error = { kind: ErrorKind; reason: string } -export type ErrorKind = "Io" | "Database" | "Client" | "Keychain" | "Logging" | "Serialization" | "InvalidAddress" | "InvalidMnemonic" | "InvalidKey" | "InvalidAmount" | "InvalidAssetId" | "InvalidLauncherId" | "InsufficientFunds" | "TransactionFailed" | "UnknownNetwork" | "UnknownFingerprint" | "NotLoggedIn" | "Sync" | "Wallet" +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 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 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 } +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 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 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" } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index c57aedc7..5c6c81ad 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -5,9 +5,8 @@ import { logoutAndUpdateState, useWalletState } from '@/state'; import { ChevronLeft, Cog, LogOut, Menu } from 'lucide-react'; import { PropsWithChildren, ReactNode, useMemo } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { navItems } from './Nav'; +import { Nav } from './Nav'; import { Button } from './ui/button'; -import { Separator } from './ui/separator'; import { Sheet, SheetContent, SheetTrigger } from './ui/sheet'; export default function Header( @@ -43,7 +42,7 @@ export default function Header( const hasBackButton = props.back || location.pathname.split('/').length > 2; return ( -
+
{hasBackButton ? ( diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8e2b23bf..5d0bf873 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,8 +6,7 @@ import { commands, WalletInfo } from '../bindings'; import { usePeers } from '@/contexts/PeerContext'; import icon from '@/icon.png'; import { logoutAndUpdateState, useWalletState } from '@/state'; -import { navItems } from './Nav'; -import { Separator } from './ui/separator'; +import { Nav } from './Nav'; export default function Layout(props: PropsWithChildren) { const navigate = useNavigate(); @@ -43,99 +42,67 @@ export default function Layout(props: PropsWithChildren) { }; return ( - <> -
-
-
-
+
+
+
+
+ + + {wallet?.name} + +
+
+
-
-
+ > + {isSynced ? ( + <> + {peers?.length} peers + {peerMaxHeight + ? ` at peak ${peerMaxHeight}` + : ' connecting...'} + + ) : ( + `Syncing ${walletState.sync.synced_coins} / ${walletState.sync.total_coins} coins` + )} + + + + Settings + + +
-
- {props.children} -
- - ); -} - -interface NavLinkProps { - url: string; -} - -function NavLink({ url, children }: PropsWithChildren) { - return ( - - {children} - +
+ {props.children} +
+
); } diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 5b3162d1..73cd7aca 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,53 +1,58 @@ import { HandCoins, + ImagePlus, Images, - LucideProps, SquareUserRound, + UserPlus, WalletIcon, } from 'lucide-react'; -import { ForwardRefExoticComponent, RefAttributes } from 'react'; +import { PropsWithChildren } from 'react'; +import { Link } from 'react-router-dom'; +import { Separator } from './ui/separator'; -export type NavItem = NavLink | NavSeparator; +export function Nav() { + return ( + + ); +} -export interface NavLink { - type: 'link'; - label: string; +interface NavLinkProps { url: string; - icon: ForwardRefExoticComponent< - Omit & RefAttributes - >; } -export interface NavSeparator { - type: 'separator'; +function NavLink({ url, children }: PropsWithChildren) { + return ( + + {children} + + ); } - -export const navItems: NavItem[] = [ - { - type: 'link', - label: 'Wallet', - url: '/wallet', - icon: WalletIcon, - }, - { - type: 'link', - label: 'NFTs', - url: '/nfts', - icon: Images, - }, - { - type: 'link', - label: 'Profiles', - url: '/dids', - icon: SquareUserRound, - }, - { - type: 'separator', - }, - { - type: 'link', - label: 'Issue Token', - url: '/wallet/issue-token', - icon: HandCoins, - }, -]; diff --git a/src/hooks/useDids.ts b/src/hooks/useDids.ts new file mode 100644 index 00000000..6fedab60 --- /dev/null +++ b/src/hooks/useDids.ts @@ -0,0 +1,55 @@ +import { commands, DidRecord, events } from '@/bindings'; +import { useEffect, useState } from 'react'; + +export function useDids() { + const [dids, setDids] = useState([]); + + const updateDids = async () => { + return await commands.getDids().then((result) => { + if (result.status === 'ok') { + setDids(result.data); + } else { + throw new Error('Failed to get DIDs'); + } + }); + }; + + useEffect(() => { + updateDids(); + + const unlisten = events.syncEvent.listen((event) => { + const type = event.payload.type; + + if ( + type === 'coin_state' || + type === 'puzzle_batch_synced' || + type === 'did_info' + ) { + updateDids(); + } + }); + + return () => { + unlisten.then((u) => u()); + }; + }, []); + + return { + dids: dids.sort((a, b) => { + if (a.visible !== b.visible) { + return a.visible ? -1 : 1; + } + + if (a.name && b.name) { + return a.name.localeCompare(b.name); + } else if (a.name) { + return -1; + } else if (b.name) { + return 1; + } else { + return a.coin_id.localeCompare(b.coin_id); + } + }), + updateDids, + }; +} diff --git a/src/invalid.png b/src/invalid.png new file mode 100644 index 00000000..1f40e842 Binary files /dev/null and b/src/invalid.png differ diff --git a/src/lib/nftUri.ts b/src/lib/nftUri.ts new file mode 100644 index 00000000..6b0e5e8b --- /dev/null +++ b/src/lib/nftUri.ts @@ -0,0 +1,13 @@ +import invalid from '@/invalid.png'; +import missing from '@/missing.png'; + +export function nftUri(mimeType: string | null, data: string | null): string { + if (data === null || mimeType === null) return missing; + + if ( + !['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(mimeType) + ) + return invalid; + + return `data:${mimeType};base64,${data}`; +} diff --git a/src/missing.jpg b/src/missing.jpg deleted file mode 100644 index 7e1a8884..00000000 Binary files a/src/missing.jpg and /dev/null differ diff --git a/src/missing.png b/src/missing.png new file mode 100644 index 00000000..72825e58 Binary files /dev/null and b/src/missing.png differ diff --git a/src/pages/IssueToken.tsx b/src/pages/IssueToken.tsx index 45da7261..e4d9ad4b 100644 --- a/src/pages/IssueToken.tsx +++ b/src/pages/IssueToken.tsx @@ -30,6 +30,7 @@ export default function IssueToken() { const formSchema = z.object({ name: z.string().min(1, 'Name is required'), + ticker: z.string().min(1, 'Ticker is required'), amount: positiveAmount(3), fee: amount(walletState.sync.unit.decimals).optional(), }); @@ -43,6 +44,7 @@ export default function IssueToken() { commands .issueCat( values.name, + values.ticker, values.amount.toString(), values.fee?.toString() || '0', ) @@ -64,19 +66,35 @@ export default function IssueToken() {
- ( - - Name - - - - - - )} - /> +
+ ( + + Name + + + + + + )} + /> + + ( + + Ticker + + + + + + )} + /> +
- - Token - + CAT
diff --git a/src/pages/MintNft.tsx b/src/pages/MintNft.tsx new file mode 100644 index 00000000..6cdd9ab4 --- /dev/null +++ b/src/pages/MintNft.tsx @@ -0,0 +1,274 @@ +import Header from '@/components/Header'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useDids } from '@/hooks/useDids'; +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 Container from '../components/Container'; +import ErrorDialog from '../components/ErrorDialog'; +import { useWalletState } from '../state'; + +export default function MintNft() { + const navigate = useNavigate(); + const walletState = useWalletState(); + + const { dids } = useDids(); + + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const formSchema = z.object({ + profile: z.string().min(1, 'Profile is required'), + fee: amount(walletState.sync.unit.decimals).optional(), + royaltyAddress: z.string().optional(), + royaltyPercent: amount(2), + dataUris: z.string(), + metadataUris: z.string(), + licenseUris: z.string().optional(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + }); + + console.log(form.getValues()); + + const onSubmit = (values: z.infer) => { + setPending(true); + commands + .bulkMintNfts({ + fee: values.fee?.toString() || '0', + did_id: values.profile, + nft_mints: [ + { + edition_number: null, + edition_total: null, + royalty_address: values.royaltyAddress || null, + royalty_percent: values.royaltyPercent, + data_uris: values.dataUris + .split(',') + .map((uri) => uri.trim()) + .filter(Boolean), + metadata_uris: values.metadataUris + .split(',') + .map((uri) => uri.trim()) + .filter(Boolean), + license_uris: (values.licenseUris ?? '') + .split(',') + .map((uri) => uri.trim()) + .filter(Boolean), + }, + ], + }) + .then((result) => { + if (result.status === 'error') { + console.error(result.error); + setError(result.error); + return; + } + navigate('/nfts'); + }) + .finally(() => setPending(false)); + }; + + return ( + <> +
+ + + + + ( + + Profile + + + + + + )} + /> + + ( + + Data URLs + + + + + + )} + /> + + ( + + Metadata URLs + + + + + + )} + /> + + ( + + License URLs + + + + + + )} + /> + + ( + + Royalty Address + + + + + + )} + /> + +
+ ( + + Royalty Percent + +
+ +
+
+ +
+ )} + /> +
+ +
+ ( + + Fee + +
+ +
+ + {walletState.sync.unit.ticker} + +
+
+
+ +
+ )} + /> +
+ + + + +
+ + + + ); +} diff --git a/src/pages/Nft.tsx b/src/pages/Nft.tsx index 84f478e5..54fae7bd 100644 --- a/src/pages/Nft.tsx +++ b/src/pages/Nft.tsx @@ -1,6 +1,7 @@ import Container from '@/components/Container'; import Header from '@/components/Header'; import { Button } from '@/components/ui/button'; +import { nftUri } from '@/lib/nftUri'; import { open } from '@tauri-apps/plugin-shell'; import { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -67,7 +68,7 @@ export default function Nft() {
NFT image
diff --git a/src/pages/PeerList.tsx b/src/pages/PeerList.tsx index 4bdcf79f..3ef0fee8 100644 --- a/src/pages/PeerList.tsx +++ b/src/pages/PeerList.tsx @@ -130,186 +130,179 @@ export default function NetworkList() { }, []); return ( - <> - -
+ +
- - - -
- Connected to {peers?.length ?? 0} peers - - - - - - - Add new peer - - Enter the IP address of the peer you want to connect to. - - -
-
- - setIp(e.target.value)} - /> -
-
- setTrusted(checked)} - /> - - - - - - - Prevents the peer from being banned. - - -
+ + + +
+ Connected to {peers?.length ?? 0} peers + + + + + + + Add new peer + + Enter the IP address of the peer you want to connect to. + + +
+
+ + setIp(e.target.value)} + />
- - - - - -
-
-
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No results. - +
+ setTrusted(checked)} + /> + + + + + + + Prevents the peer from being banned. + + +
+ + + + + + + + + + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} - )} - -
-
-
+ )) + ) : ( + + + No results. + + + )} + + + + - !open && setPeerToDelete(null)} - > - - - Are you sure you want to remove the peer? - + !open && setPeerToDelete(null)} + > + + Are you sure you want to remove the peer? - - This will remove the peer from your connections. If you are - currently syncing against this peer, a new one will be used to - replace it. - + + This will remove the peer from your connections. If you are + currently syncing against this peer, a new one will be used to + replace it. + -
- setBan(checked)} - /> - - - - - - - Will temporarily prevent the peer from being connected to. - - -
- - - - -
-
-
- - +
+ setBan(checked)} + /> + + + + + + + Will temporarily prevent the peer from being connected to. + + +
+ + + + + +
+ + ); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 05f495b6..a76eecfc 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -23,18 +23,16 @@ export default function Settings() { const { wallet } = useWallet(); return ( - <> - -
- -
- - - {wallet && } -
-
- - + +
+ +
+ + + {wallet && } +
+
+ ); } @@ -42,25 +40,23 @@ function GlobalSettings() { const { dark, setDark } = useContext(DarkModeContext); return ( - <> - - - Global - - -
-
- setDark(checked)} - /> - -
+ + + Global + + +
+
+ setDark(checked)} + /> +
- - - +
+
+
); } @@ -87,78 +83,74 @@ function NetworkSettings() { }, []); return ( - <> - - - Network - - -
-
- { - commands.setDiscoverPeers(checked); - setDiscoverPeers(checked); - }} - /> - -
-
- - setTargetPeers(event.target.value)} - // TODO error handling - onBlur={() => { - if (invalidTargetPeers) return; + + + Network + + +
+
+ { + commands.setDiscoverPeers(checked); + setDiscoverPeers(checked); + }} + /> + +
+
+ + setTargetPeers(event.target.value)} + // TODO error handling + onBlur={() => { + if (invalidTargetPeers) return; - if (targetPeers !== config?.target_peers) { - if (config) { - setConfig({ ...config, target_peers: targetPeers }); - } - commands.setTargetPeers(targetPeers); + if (targetPeers !== config?.target_peers) { + if (config) { + setConfig({ ...config, target_peers: targetPeers }); } - }} - /> -
-
- - { + if (networkId !== config?.network_id) { + if (config) { + setConfig({ ...config, network_id: networkId }); } - }} - > - - - - - Mainnet - Testnet 11 - - -
+ clearState(); + commands.setNetworkId(networkId).then(() => { + fetchState(); + }); + setNetworkId(networkId); + } + }} + > + + + + + Mainnet + Testnet 11 + +
-
-
- +
+ + ); } @@ -189,86 +181,80 @@ function WalletSettings(props: { wallet: WalletInfo }) { }, [props.wallet.fingerprint]); return ( - <> - - - Wallet - - -
-
- - setName(event.target.value)} - onBlur={() => { - if (name !== config?.name) { - if (config) { - setConfig({ ...config, name }); - } - if (name) - commands.renameWallet(props.wallet.fingerprint, name); + + + Wallet + + +
+
+ + setName(event.target.value)} + onBlur={() => { + if (name !== config?.name) { + if (config) { + setConfig({ ...config, name }); } - }} - /> -
-
- { - commands.setDeriveAutomatically( + }} + /> +
+
+ { + commands.setDeriveAutomatically( + props.wallet.fingerprint, + checked, + ); + setDeriveAutomatically(checked); + }} + /> + +
+
+ + setDerivationBatchSize(event.target.value)} + onBlur={() => { + if (invalidDerivationBatchSize) return; + + if (derivationBatchSize !== config?.derivation_batch_size) { + if (config) { + setConfig({ + ...config, + derivation_batch_size: derivationBatchSize, + }); + } + commands.setDerivationBatchSize( props.wallet.fingerprint, - checked, + derivationBatchSize, ); - setDeriveAutomatically(checked); - }} - /> - -
-
- - setDerivationBatchSize(event.target.value)} - onBlur={() => { - if (invalidDerivationBatchSize) return; - - if (derivationBatchSize !== config?.derivation_batch_size) { - if (config) { - setConfig({ - ...config, - derivation_batch_size: derivationBatchSize, - }); - } - commands.setDerivationBatchSize( - props.wallet.fingerprint, - derivationBatchSize, - ); - } - }} - /> -
+ }} + />
-
-
- +
+ + ); } diff --git a/src/pages/Token.tsx b/src/pages/Token.tsx index 022e4774..bfcf6cc8 100644 --- a/src/pages/Token.tsx +++ b/src/pages/Token.tsx @@ -223,8 +223,12 @@ export default function Token() {
@@ -354,8 +358,7 @@ function CoinCard({ }); const onCombineSubmit = (values: z.infer) => { - commands - .combine(selectedCoinIds, values.combineFee) + combineHandler?.(selectedCoinIds, values.combineFee) .then((result) => { setCombineOpen(false); @@ -383,8 +386,7 @@ function CoinCard({ }); const onSplitSubmit = (values: z.infer) => { - commands - .split(selectedCoinIds, values.outputCount, values.splitFee) + splitHandler?.(selectedCoinIds, values.outputCount, values.splitFee) .then((result) => { setSplitOpen(false); diff --git a/src/pages/Wallet.tsx b/src/pages/Wallet.tsx index caa09193..44cd8158 100644 --- a/src/pages/Wallet.tsx +++ b/src/pages/Wallet.tsx @@ -3,10 +3,8 @@ import { Outlet } from 'react-router-dom'; export default function Wallet() { return ( - <> - - - - + + + ); } diff --git a/src/pages/WalletDids.tsx b/src/pages/WalletDids.tsx index d2c11067..b459c555 100644 --- a/src/pages/WalletDids.tsx +++ b/src/pages/WalletDids.tsx @@ -22,6 +22,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; +import { useDids } from '@/hooks/useDids'; import { EyeIcon, EyeOff, @@ -30,45 +31,16 @@ import { UserIcon, UserRoundPlus, } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { commands, DidRecord, events } from '../bindings'; +import { commands, DidRecord } from '../bindings'; export function WalletDids() { const navigate = useNavigate(); const [showHidden, setShowHidden] = useState(false); - const [dids, setDids] = useState([]); - const updateDids = async () => { - return await commands.getDids().then((result) => { - if (result.status === 'ok') { - setDids(result.data); - } else { - throw new Error('Failed to get DIDs'); - } - }); - }; - - useEffect(() => { - updateDids(); - - const unlisten = events.syncEvent.listen((event) => { - const type = event.payload.type; - - if ( - type === 'coin_state' || - type === 'puzzle_batch_synced' || - type === 'did_info' - ) { - updateDids(); - } - }); - - return () => { - unlisten.then((u) => u()); - }; - }, []); + const { dids, updateDids } = useDids(); const visibleDids = showHidden ? dids : dids.filter((did) => did.visible); const hasHiddenDids = dids.findIndex((did) => !did.visible) > -1; @@ -79,13 +51,9 @@ export function WalletDids() {
- - {hasHiddenDids && ( -
- +
+ )} -
- {visibleDids - .sort((a, b) => { - if (a.visible !== b.visible) { - return a.visible ? -1 : 1; - } - - if (a.name && b.name) { - return a.name.localeCompare(b.name); - } else if (a.name) { - return -1; - } else if (b.name) { - return 1; - } else { - return a.coin_id.localeCompare(b.coin_id); - } - }) - .map((did) => { - return ( - - ); - })} +
+ {visibleDids.map((did) => { + return ( + + ); + })}
diff --git a/src/pages/WalletMain.tsx b/src/pages/WalletMain.tsx index df9f91f5..151bb847 100644 --- a/src/pages/WalletMain.tsx +++ b/src/pages/WalletMain.tsx @@ -1,8 +1,10 @@ import Container from '@/components/Container'; import Header from '@/components/Header'; import { ReceiveAddress } from '@/components/ReceiveAddress'; -import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { InfoIcon } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { CatRecord, commands, events } from '../bindings'; @@ -14,6 +16,30 @@ export function MainWallet() { const [cats, setCats] = useState([]); const [showHidden, setShowHidden] = useState(false); + cats.sort((a, b) => { + if (a.visible && !b.visible) { + return -1; + } + + if (!a.visible && b.visible) { + return 1; + } + + if (!a.name && b.name) { + return -1; + } + + if (a.name && !b.name) { + return 1; + } + + if (!a.name && !b.name) { + return 0; + } + + return a.name!.localeCompare(b.name!); + }); + const visibleCats = cats.filter((cat) => showHidden || cat.visible); const hasHiddenAssets = !!cats.find((cat) => !cat.visible); @@ -51,7 +77,29 @@ export function MainWallet() {
-
+ {walletState.sync.synced_coins < walletState.sync.total_coins && ( + + + Syncing in progress... + + The wallet is still syncing. Balances may not be accurate until it + completes. + + + )} + + {hasHiddenAssets && ( +
+ + setShowHidden(value)} + /> +
+ )} + +
@@ -97,17 +145,6 @@ export function MainWallet() { ))}
- {hasHiddenAssets && ( -
- -
- )} ); diff --git a/src/pages/WalletNfts.tsx b/src/pages/WalletNfts.tsx index 955f985a..a521186a 100644 --- a/src/pages/WalletNfts.tsx +++ b/src/pages/WalletNfts.tsx @@ -1,16 +1,35 @@ import Container from '@/components/Container'; import Header from '@/components/Header'; import { ReceiveAddress } from '@/components/ReceiveAddress'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; -import missing from '@/missing.jpg'; -import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Switch } from '@/components/ui/switch'; +import { nftUri } from '@/lib/nftUri'; +import { + ChevronLeftIcon, + ChevronRightIcon, + EyeIcon, + EyeOff, + Image, + MoreVerticalIcon, +} from 'lucide-react'; import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { commands, NftRecord } from '../bindings'; +import { Link, useNavigate } from 'react-router-dom'; +import { commands, events, NftRecord } from '../bindings'; export function WalletNfts() { + const navigate = useNavigate(); + const [page, setPage] = useState(0); const [totalPages, setTotalPages] = useState(1); + const [showHidden, setShowHidden] = useState(false); const [nfts, setNfts] = useState([]); const [loading, setLoading] = useState(false); @@ -52,56 +71,93 @@ export function WalletNfts() { }, []); useEffect(() => { - const interval = setInterval(() => { - updateNfts(page); - }, 5000); + const unlisten = events.syncEvent.listen((event) => { + const type = event.payload.type; + + if ( + type === 'coin_state' || + type === 'puzzle_batch_synced' || + type === 'nft_data' + ) { + updateNfts(page); + } + }); return () => { - clearInterval(interval); + unlisten.then((u) => u()); }; }, [page]); - console.log(nfts); + const visibleNfts = showHidden ? nfts : nfts.filter((nft) => nft.visible); + const hasHiddenNfts = nfts.findIndex((nft) => !nft.visible) > -1; return ( <>
- {' '}
+ -
- {nfts.map((nft, i) => ( - + {hasHiddenNfts && ( +
+ + setShowHidden(value)} + /> +
+ )} + + {visibleNfts.length === 0 ? ( + + + Mint an NFT? + + You do not currently have any {nfts.length > 0 ? 'visible ' : ''} + NFTs. Would you like to mint one? + + + ) : ( +
+ +

+ Page {page + 1} of {totalPages} +

+ +
+ )} + +
+ {visibleNfts.map((nft, i) => ( + updateNfts(page)} /> ))}
-
- -

- Page {page + 1} of {totalPages} -

- -
); } -function Nft({ nft }: { nft: NftRecord }) { +interface NftProps { + nft: NftRecord; + updateNfts: () => void; +} + +function Nft({ nft, updateNfts }: NftProps) { let json: any = {}; if (nft.metadata) { @@ -112,31 +168,66 @@ function Nft({ nft }: { nft: NftRecord }) { } } + const toggleVisibility = () => { + commands.updateNft(nft.launcher_id, !nft.visible).then((result) => { + if (result.status === 'ok') { + updateNfts(); + } else { + throw new Error('Failed to toggle visibility for NFT'); + } + }); + }; + return ( - - -
- {json.name} -
-
-
- - {json.name ?? 'Unknown NFT'} + +
+ {json.name} +
+
+ + + {json.name ?? 'Unknown NFT'} + +

+ {json.collection?.name ?? 'No collection'} +

-

- {json.collection && json.collection.name} -

+ + + + + + + + { + e.stopPropagation(); + toggleVisibility(); + }} + > + {nft.visible ? ( + + ) : ( + + )} + {nft.visible ? 'Hide' : 'Show'} + + + +
); diff --git a/src/state.ts b/src/state.ts index 83b4839f..d26d408c 100644 --- a/src/state.ts +++ b/src/state.ts @@ -73,6 +73,9 @@ events.syncEvent.listen((event) => { case 'derivation': updateSyncStatus(); break; + case 'puzzle_batch_synced': + updateSyncStatus(); + break; } });