diff --git a/.sqlx/query-006af5149bf02437c367f2033d6b68bf52920b3da3d95b8aea4ecf236a28b652.json b/.sqlx/query-006af5149bf02437c367f2033d6b68bf52920b3da3d95b8aea4ecf236a28b652.json new file mode 100644 index 000000000..ab8b59282 --- /dev/null +++ b/.sqlx/query-006af5149bf02437c367f2033d6b68bf52920b3da3d95b8aea4ecf236a28b652.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO transaction_history_coins (transaction_history_id, coin_id, is_input)\n VALUES ((SELECT id FROM transaction_history WHERE hash = ?), ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "006af5149bf02437c367f2033d6b68bf52920b3da3d95b8aea4ecf236a28b652" +} diff --git a/.sqlx/query-603b847cf399d14db60cb5ec3e9af45505d6b0b3e431bd279cb6bdf8660893c9.json b/.sqlx/query-603b847cf399d14db60cb5ec3e9af45505d6b0b3e431bd279cb6bdf8660893c9.json new file mode 100644 index 000000000..ec1fd5ed5 --- /dev/null +++ b/.sqlx/query-603b847cf399d14db60cb5ec3e9af45505d6b0b3e431bd279cb6bdf8660893c9.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT coin_id, is_input\n FROM transaction_history_coins\n WHERE transaction_history_id = (SELECT id FROM transaction_history WHERE hash = ?)\n ", + "describe": { + "columns": [ + { + "name": "coin_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "is_input", + "ordinal": 1, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "603b847cf399d14db60cb5ec3e9af45505d6b0b3e431bd279cb6bdf8660893c9" +} diff --git a/.sqlx/query-89b67bc2f03e7eeca58cfbf09c0cabb242c37bcc32fd09db167a425bbaddce35.json b/.sqlx/query-89b67bc2f03e7eeca58cfbf09c0cabb242c37bcc32fd09db167a425bbaddce35.json new file mode 100644 index 000000000..b414e798c --- /dev/null +++ b/.sqlx/query-89b67bc2f03e7eeca58cfbf09c0cabb242c37bcc32fd09db167a425bbaddce35.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT fee FROM mempool_items WHERE hash = ?\n ", + "describe": { + "columns": [ + { + "name": "fee", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "89b67bc2f03e7eeca58cfbf09c0cabb242c37bcc32fd09db167a425bbaddce35" +} diff --git a/.sqlx/query-8d4db4daceba76d98990efdf31ed02ca3ae1ff2f668c79a9d241485509a46dad.json b/.sqlx/query-8d4db4daceba76d98990efdf31ed02ca3ae1ff2f668c79a9d241485509a46dad.json new file mode 100644 index 000000000..0141a9bc6 --- /dev/null +++ b/.sqlx/query-8d4db4daceba76d98990efdf31ed02ca3ae1ff2f668c79a9d241485509a46dad.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT hash, height, fee, confirmed_timestamp\n FROM transaction_history\n WHERE hash = ?\n ", + "describe": { + "columns": [ + { + "name": "hash", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "height", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "fee", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "confirmed_timestamp", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "8d4db4daceba76d98990efdf31ed02ca3ae1ff2f668c79a9d241485509a46dad" +} diff --git a/.sqlx/query-90de2b2fbc881d60e9b1dd7d76f46a8a16d7da85f9f17119120c79ab2eea0812.json b/.sqlx/query-90de2b2fbc881d60e9b1dd7d76f46a8a16d7da85f9f17119120c79ab2eea0812.json new file mode 100644 index 000000000..98b444baf --- /dev/null +++ b/.sqlx/query-90de2b2fbc881d60e9b1dd7d76f46a8a16d7da85f9f17119120c79ab2eea0812.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT coins.hash AS coin_id, mempool_coins.is_input\n FROM mempool_coins\n INNER JOIN mempool_items ON mempool_items.id = mempool_coins.mempool_item_id\n INNER JOIN coins ON coins.id = mempool_coins.coin_id\n WHERE mempool_items.hash = ?\n ", + "describe": { + "columns": [ + { + "name": "coin_id", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "is_input", + "ordinal": 1, + "type_info": "Bool" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "90de2b2fbc881d60e9b1dd7d76f46a8a16d7da85f9f17119120c79ab2eea0812" +} diff --git a/.sqlx/query-bde44fb0ed16ae37308d0a454108753a91fc8cc8007eaff6344be30fdf885f53.json b/.sqlx/query-bde44fb0ed16ae37308d0a454108753a91fc8cc8007eaff6344be30fdf885f53.json deleted file mode 100644 index e57a9e7e0..000000000 --- a/.sqlx/query-bde44fb0ed16ae37308d0a454108753a91fc8cc8007eaff6344be30fdf885f53.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT mempool_items.hash AS mempool_item_hash \n FROM mempool_items\n INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id\n INNER JOIN coins ON coins.hash = ?\n WHERE mempool_coins.is_output = TRUE\n ", - "describe": { - "columns": [ - { - "name": "mempool_item_hash", - "ordinal": 0, - "type_info": "Blob" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "bde44fb0ed16ae37308d0a454108753a91fc8cc8007eaff6344be30fdf885f53" -} diff --git a/.sqlx/query-c69f76dc3c8b22196539576f65de17534c64abd64b4bc28ed5a54c7cacaf02c5.json b/.sqlx/query-c69f76dc3c8b22196539576f65de17534c64abd64b4bc28ed5a54c7cacaf02c5.json new file mode 100644 index 000000000..4bfb1fea9 --- /dev/null +++ b/.sqlx/query-c69f76dc3c8b22196539576f65de17534c64abd64b4bc28ed5a54c7cacaf02c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT mempool_items.hash AS mempool_item_hash\n FROM mempool_items\n INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id\n INNER JOIN coins ON coins.id = mempool_coins.coin_id\n WHERE coins.hash = ? AND mempool_coins.is_output = TRUE\n ", + "describe": { + "columns": [ + { + "name": "mempool_item_hash", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "c69f76dc3c8b22196539576f65de17534c64abd64b4bc28ed5a54c7cacaf02c5" +} diff --git a/.sqlx/query-c733f2fa758291143cd5bff21af79d285eb7bf04591725d3f9893b473f4bc5de.json b/.sqlx/query-c733f2fa758291143cd5bff21af79d285eb7bf04591725d3f9893b473f4bc5de.json new file mode 100644 index 000000000..06f4462f4 --- /dev/null +++ b/.sqlx/query-c733f2fa758291143cd5bff21af79d285eb7bf04591725d3f9893b473f4bc5de.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO transaction_history (hash, height, fee, confirmed_timestamp)\n VALUES (?, ?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "c733f2fa758291143cd5bff21af79d285eb7bf04591725d3f9893b473f4bc5de" +} diff --git a/.sqlx/query-e4e9561996f931ad5703c9434632218a11e9df3f9f3a5711001c95aee4c75e92.json b/.sqlx/query-e4e9561996f931ad5703c9434632218a11e9df3f9f3a5711001c95aee4c75e92.json deleted file mode 100644 index 8b39fb72f..000000000 --- a/.sqlx/query-e4e9561996f931ad5703c9434632218a11e9df3f9f3a5711001c95aee4c75e92.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT mempool_items.hash AS mempool_item_hash \n FROM mempool_items\n INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id\n INNER JOIN coins ON coins.hash = ?\n WHERE mempool_coins.is_input = TRUE\n ", - "describe": { - "columns": [ - { - "name": "mempool_item_hash", - "ordinal": 0, - "type_info": "Blob" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "e4e9561996f931ad5703c9434632218a11e9df3f9f3a5711001c95aee4c75e92" -} diff --git a/.sqlx/query-f5190992d0b4ed2d9dd80f282d63ab1eb082c0938dc48d0dd2960070e0d70cb7.json b/.sqlx/query-f5190992d0b4ed2d9dd80f282d63ab1eb082c0938dc48d0dd2960070e0d70cb7.json new file mode 100644 index 000000000..d97e93a9d --- /dev/null +++ b/.sqlx/query-f5190992d0b4ed2d9dd80f282d63ab1eb082c0938dc48d0dd2960070e0d70cb7.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT mempool_items.hash AS mempool_item_hash\n FROM mempool_items\n INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id\n INNER JOIN coins ON coins.id = mempool_coins.coin_id\n WHERE coins.hash = ? AND mempool_coins.is_input = TRUE\n ", + "describe": { + "columns": [ + { + "name": "mempool_item_hash", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "f5190992d0b4ed2d9dd80f282d63ab1eb082c0938dc48d0dd2960070e0d70cb7" +} diff --git a/Cargo.lock b/Cargo.lock index be527604b..c1c35ce2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1491,8 +1491,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -5802,13 +5804,16 @@ dependencies = [ "chia", "chia-puzzles", "chia-wallet-sdk", + "chrono", "clvmr", "hex", + "hmac", "indexmap 2.11.4", "itertools 0.13.0", "log", "rand 0.8.5", "rand_chacha 0.3.1", + "reqwest", "sage-api", "sage-assets", "sage-config", @@ -5817,6 +5822,7 @@ dependencies = [ "sage-wallet", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "thiserror 1.0.69", "tokio", @@ -5824,6 +5830,8 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "url", + "uuid", ] [[package]] @@ -5833,6 +5841,7 @@ dependencies = [ "sage-api-macro", "sage-config", "serde", + "serde_json", "specta", "tauri-specta", "utoipa", @@ -5917,6 +5926,7 @@ dependencies = [ "serde_with", "specta", "toml", + "utoipa", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 485fc6dd9..555d63495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,8 +108,13 @@ futures-util = "0.3.30" futures-lite = "2.3.0" sqlx = "0.8.0" reqwest = { version = "0.12.22", default-features = false } +uuid = { version = "1.10.0", features = ["v4", "serde"] } +chrono = { version = "0.4.38", features = ["serde"] } +hmac = "0.12.1" +sha2 = "0.10.8" # Utilities +url = "2.5.4" indexmap = "2.3.0" itertools = "0.13.0" anyhow = "1.0.86" diff --git a/crates/sage-api/Cargo.toml b/crates/sage-api/Cargo.toml index 7f7fd840f..cc3916ffb 100644 --- a/crates/sage-api/Cargo.toml +++ b/crates/sage-api/Cargo.toml @@ -16,12 +16,13 @@ workspace = true [features] tauri = ["dep:tauri-specta", "dep:specta"] -openapi = ["dep:utoipa", "dep:sage-api-macro"] +openapi = ["dep:utoipa", "dep:sage-api-macro", "sage-config/openapi"] [dependencies] sage-config = { workspace = true } sage-api-macro = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tauri-specta = { workspace = true, features = ["derive"], optional = true } specta = { workspace = true, features = ["derive", "bigdecimal"], optional = true } utoipa = { workspace = true, optional = true } diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 57b4ccc92..3d0d8ad94 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -20,6 +20,8 @@ "get_are_coins_spendable": true, "get_spendable_coin_count": true, "get_coins_by_ids": true, + "get_nfts_by_ids": true, + "get_assets_by_ids": true, "get_coins": true, "get_cats": true, "get_all_cats": true, @@ -97,5 +99,10 @@ "update_nft_collection": true, "redownload_nft": true, "increase_derivation_index": true, - "is_asset_owned": true + "is_asset_owned": true, + "register_webhook": true, + "unregister_webhook": true, + "get_webhooks": true, + "update_webhook": true, + "get_transaction_by_id": true } diff --git a/crates/sage-api/src/events.rs b/crates/sage-api/src/events.rs index daa72b685..396c35233 100644 --- a/crates/sage-api/src/events.rs +++ b/crates/sage-api/src/events.rs @@ -19,4 +19,6 @@ pub enum SyncEvent { CatInfo, DidInfo, NftData, + WebhooksChanged, + WebhookInvoked, } diff --git a/crates/sage-api/src/records/coin.rs b/crates/sage-api/src/records/coin.rs index ac4aa75e5..5121511d8 100644 --- a/crates/sage-api/src/records/coin.rs +++ b/crates/sage-api/src/records/coin.rs @@ -16,4 +16,5 @@ pub struct CoinRecord { pub spent_height: Option, pub spent_timestamp: Option, pub created_timestamp: Option, + pub asset_hash: Option, } diff --git a/crates/sage-api/src/requests.rs b/crates/sage-api/src/requests.rs index 99ac74a54..7e115d987 100644 --- a/crates/sage-api/src/requests.rs +++ b/crates/sage-api/src/requests.rs @@ -4,6 +4,7 @@ mod keys; mod offers; mod settings; mod transactions; +mod webhooks; pub use actions::*; pub use data::*; @@ -11,5 +12,6 @@ pub use keys::*; pub use offers::*; pub use settings::*; pub use transactions::*; +pub use webhooks::*; pub mod wallet_connect; diff --git a/crates/sage-api/src/requests/data.rs b/crates/sage-api/src/requests/data.rs index 0eac3b347..d55d00619 100644 --- a/crates/sage-api/src/requests/data.rs +++ b/crates/sage-api/src/requests/data.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use crate::{ - Amount, CoinRecord, DerivationRecord, DidRecord, NftCollectionRecord, NftData, NftRecord, - OptionRecord, PendingTransactionRecord, TokenRecord, TransactionRecord, Unit, + Amount, Asset, CoinRecord, DerivationRecord, DidRecord, NftCollectionRecord, NftData, + NftRecord, OptionRecord, PendingTransactionRecord, TokenRecord, TransactionRecord, Unit, }; /// Validate and check an address @@ -360,6 +360,62 @@ pub struct GetCoinsByIdsResponse { pub coins: Vec, } +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "NFTs", + description = "Retrieve specific NFTs by their launcher IDs." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetNftsByIds { + pub launcher_ids: Vec, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "NFTs", + description = "Retrieve specific NFTs by their launcher IDs." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetNftsByIdsResponse { + pub nfts: Vec, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Assets", + description = "Retrieve specific assets by their asset IDs." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetAssetsByIds { + pub asset_ids: Vec, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Assets", + description = "Retrieve specific assets by their asset IDs." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetAssetsByIdsResponse { + pub assets: Vec, +} + /// Get all known CAT tokens #[cfg_attr( feature = "openapi", @@ -660,6 +716,46 @@ pub struct GetTransactionsResponse { pub total: u32, } +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Transactions", + description = "Get detailed information about a specific transaction by ID." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetTransactionById { + pub transaction_id: String, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Transactions", + description = "Get detailed information about a specific transaction by ID." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct TransactionHistoryRecord { + pub transaction_id: String, + pub height: u32, + pub fee: Amount, + pub confirmed_at: u64, + pub input_coin_ids: Vec, + pub output_coin_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetTransactionByIdResponse { + pub transaction: Option, +} + /// List NFT collections #[cfg_attr( feature = "openapi", diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs new file mode 100644 index 000000000..b1df3d401 --- /dev/null +++ b/crates/sage-api/src/requests/webhooks.rs @@ -0,0 +1,87 @@ +use sage_config::WebhookEntry; +use serde::{Deserialize, Serialize}; + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Register a new webhook.") +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct RegisterWebhook { + pub url: String, + pub event_types: Option>, + pub secret: Option, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Register a new webhook.") +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct RegisterWebhookResponse { + pub webhook_id: String, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Unregister a webhook.") +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UnregisterWebhook { + pub webhook_id: String, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Unregister a webhook.") +)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UnregisterWebhookResponse {} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Get all webhooks.") +)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWebhooks {} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Get all webhooks.") +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWebhooksResponse { + pub webhooks: Vec, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Update a webhook.") +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct UpdateWebhook { + pub webhook_id: String, + pub enabled: bool, +} + +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Webhooks", description = "Update a webhook.") +)] +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct UpdateWebhookResponse {} diff --git a/crates/sage-config/Cargo.toml b/crates/sage-config/Cargo.toml index 03e2f8e62..0baaac6ab 100644 --- a/crates/sage-config/Cargo.toml +++ b/crates/sage-config/Cargo.toml @@ -14,6 +14,9 @@ categories = { workspace = true } [lints] workspace = true +[features] +openapi = ["dep:utoipa"] + [dependencies] chia-wallet-sdk = { workspace = true } chia = { workspace = true } @@ -22,6 +25,7 @@ serde_with = { workspace = true, features = ["hex"] } indexmap = { workspace = true, features = ["serde"] } specta = { workspace = true, features = ["derive", "indexmap"] } hex = { workspace = true } +utoipa = { workspace = true, optional = true } [dev-dependencies] expect-test = { workspace = true } diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index 4c7a8a0c1..1c2d04fa2 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { pub global: GlobalConfig, pub network: NetworkConfig, pub rpc: RpcConfig, + pub webhooks: WebhookConfig, } impl Default for Config { @@ -17,6 +18,7 @@ impl Default for Config { global: GlobalConfig::default(), network: NetworkConfig::default(), rpc: RpcConfig::default(), + webhooks: WebhookConfig::default(), } } } @@ -70,3 +72,26 @@ impl Default for RpcConfig { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct WebhookConfig { + pub webhooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct WebhookEntry { + pub id: String, + pub url: String, + /// None means "all events, including future ones" + pub events: Option>, + pub enabled: bool, + /// Optional secret for HMAC-SHA256 signature verification + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, + pub last_delivered_at: Option, + pub last_delivery_attempt_at: Option, + #[serde(default)] + pub consecutive_failures: u32, +} diff --git a/crates/sage-config/src/old.rs b/crates/sage-config/src/old.rs index 9f616d04f..607f3c4f1 100644 --- a/crates/sage-config/src/old.rs +++ b/crates/sage-config/src/old.rs @@ -8,7 +8,7 @@ use specta::Type; use crate::{ Config, GlobalConfig, InheritedNetwork, Network, NetworkConfig, NetworkList, RpcConfig, Wallet, - WalletConfig, WalletDefaults, + WalletConfig, WalletDefaults, WebhookConfig, }; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Type)] @@ -140,6 +140,7 @@ pub fn migrate_config(old: OldConfig) -> Result<(Config, WalletConfig), ParseInt enabled: old.rpc.run_on_startup, port: old.rpc.server_port, }, + webhooks: WebhookConfig::default(), }; let mut wallet_config = WalletConfig { diff --git a/crates/sage-database/src/tables.rs b/crates/sage-database/src/tables.rs index 30c5d3638..50f5b55e3 100644 --- a/crates/sage-database/src/tables.rs +++ b/crates/sage-database/src/tables.rs @@ -6,6 +6,7 @@ mod files; mod mempool_items; mod offers; mod p2_puzzles; +mod transaction_history; mod transactions; pub use assets::*; @@ -15,4 +16,5 @@ pub use files::*; pub use mempool_items::*; pub use offers::*; pub use p2_puzzles::*; +pub use transaction_history::*; pub use transactions::*; diff --git a/crates/sage-database/src/tables/assets/asset.rs b/crates/sage-database/src/tables/assets/asset.rs index 1b93d3ce2..1599b8173 100644 --- a/crates/sage-database/src/tables/assets/asset.rs +++ b/crates/sage-database/src/tables/assets/asset.rs @@ -1,5 +1,5 @@ use chia::protocol::Bytes32; -use sqlx::{query, SqliteExecutor}; +use sqlx::{query, Row, SqliteExecutor}; use crate::{Convert, Database, DatabaseError, DatabaseTx, Result}; @@ -38,6 +38,10 @@ pub struct Asset { } impl Database { + pub async fn assets_by_ids(&self, asset_ids: &[String]) -> Result> { + assets_by_ids(&self.pool, asset_ids).await + } + pub async fn is_asset_owned(&self, hash: Bytes32) -> Result { let hash = hash.as_ref(); @@ -256,3 +260,46 @@ async fn asset(conn: impl SqliteExecutor<'_>, hash: Bytes32) -> Result, asset_ids: &[String]) -> Result> { + let mut query = sqlx::QueryBuilder::new( + " + SELECT + hash, name, kind, ticker, precision, icon_url, description, is_sensitive_content, is_visible, hidden_puzzle_hash + FROM assets + WHERE hash IN (", + ); + + let mut separated = query.separated(", "); + + for asset_id in asset_ids { + separated.push(format!("X'{asset_id}'")); + } + separated.push_unseparated(")"); + + let rows = query.build().fetch_all(conn).await?; + let assets = rows + .into_iter() + .map(|row| { + Ok(Asset { + hash: row.get::, _>("hash").convert()?, + name: row.get::, _>("name"), + ticker: row.get::, _>("ticker"), + precision: row + .get::, _>("precision") + .map_or(0, |p| p as u8), + icon_url: row.get::, _>("icon_url"), + description: row.get::, _>("description"), + is_sensitive_content: row.get::("is_sensitive_content"), + is_visible: row.get::("is_visible"), + hidden_puzzle_hash: row + .get::>, _>("hidden_puzzle_hash") + .map(Convert::convert) + .transpose()?, + kind: row.get::("kind").convert()?, + }) + }) + .collect::>>()?; + + Ok(assets) +} diff --git a/crates/sage-database/src/tables/assets/did.rs b/crates/sage-database/src/tables/assets/did.rs index 925200a4e..34028e4e8 100644 --- a/crates/sage-database/src/tables/assets/did.rs +++ b/crates/sage-database/src/tables/assets/did.rs @@ -38,9 +38,10 @@ impl Database { .await? .into_iter() .map(|row| { + let asset_hash = row.asset_hash.convert()?; Ok(DidRow { asset: Asset { - hash: row.asset_hash.convert()?, + hash: asset_hash, name: row.asset_name, ticker: row.asset_ticker, precision: row.asset_precision.convert()?, @@ -71,6 +72,7 @@ impl Database { spent_height: row.spent_height.convert()?, created_timestamp: row.created_timestamp.convert()?, spent_timestamp: row.spent_timestamp.convert()?, + asset_hash: Some(asset_hash), }, }) }) diff --git a/crates/sage-database/src/tables/assets/nft.rs b/crates/sage-database/src/tables/assets/nft.rs index 98da8a7a5..8eec9fa50 100644 --- a/crates/sage-database/src/tables/assets/nft.rs +++ b/crates/sage-database/src/tables/assets/nft.rs @@ -1,5 +1,5 @@ use chia::protocol::{Bytes32, Coin, Program}; -use sqlx::{query, Row}; +use sqlx::{query, Row, SqliteExecutor}; use crate::{Asset, AssetKind, CoinKind, CoinRow, Convert, Database, DatabaseTx, Result}; @@ -60,6 +60,10 @@ pub struct NftOfferInfo { } impl Database { + pub async fn nfts_by_ids(&self, ids: &[String]) -> Result> { + nfts_by_ids(&self.pool, ids).await + } + pub async fn wallet_nft(&self, hash: Bytes32) -> Result> { let hash = hash.as_ref(); @@ -76,7 +80,7 @@ impl Database { offer_hash AS 'offer_hash?', created_timestamp, spent_timestamp, clawback_expiration_seconds AS 'clawback_timestamp?', asset_hidden_puzzle_hash FROM wallet_nfts - LEFT JOIN collections ON collections.id = wallet_nfts.collection_id + LEFT JOIN collections ON collections.id = wallet_nfts.collection_id WHERE wallet_nfts.asset_hash = ? ", hash @@ -84,9 +88,10 @@ impl Database { .fetch_optional(&self.pool) .await? .map(|row| { + let asset_hash = row.asset_hash.convert()?; Ok(NftRow { asset: Asset { - hash: row.asset_hash.convert()?, + hash: asset_hash, name: row.asset_name, ticker: row.asset_ticker, precision: row.asset_precision.convert()?, @@ -127,6 +132,7 @@ impl Database { spent_height: row.spent_height.convert()?, created_timestamp: row.created_timestamp.convert()?, spent_timestamp: row.spent_timestamp.convert()?, + asset_hash: Some(asset_hash), }, }) }) @@ -156,6 +162,7 @@ impl Database { clawback_expiration_seconds AS clawback_timestamp, COUNT(*) OVER() as total_count FROM owned_nfts LEFT JOIN collections ON collections.id = owned_nfts.collection_id + LEFT JOIN assets ON assets.id = owned_nfts.asset_id WHERE 1=1 ", ); @@ -220,9 +227,10 @@ impl Database { let nfts = rows .into_iter() .map(|row| { + let asset_hash = row.get::, _>("asset_hash").convert()?; Ok(NftRow { asset: Asset { - hash: row.get::, _>("asset_hash").convert()?, + hash: asset_hash, name: row.get::, _>("asset_name"), ticker: row.get::, _>("asset_ticker"), precision: row.get::("asset_precision").convert()?, @@ -278,6 +286,7 @@ impl Database { .get::, _>("created_timestamp") .convert()?, spent_timestamp: row.get::, _>("spent_timestamp").convert()?, + asset_hash: Some(asset_hash), }, }) }) @@ -515,3 +524,96 @@ impl DatabaseTx<'_> { Ok(()) } } + +async fn nfts_by_ids(conn: impl SqliteExecutor<'_>, ids: &[String]) -> Result> { + let mut query = sqlx::QueryBuilder::new( + " + SELECT + asset_hash, asset_name, asset_ticker, asset_precision, asset_icon_url, + asset_description, asset_is_sensitive_content, asset_hidden_puzzle_hash, + asset_is_visible AND (collections.id IS NULL OR collections.is_visible) as is_visible, + collections.hash AS collection_hash, collections.name AS collection_name, + owned_nfts.minter_hash, owner_hash, metadata, metadata_updater_puzzle_hash, + royalty_puzzle_hash, royalty_basis_points, data_hash, metadata_hash, license_hash, + parent_coin_hash, puzzle_hash, amount, p2_puzzle_hash, edition_number, edition_total, + created_height, spent_height, offer_hash, created_timestamp, spent_timestamp, + clawback_expiration_seconds AS clawback_timestamp, COUNT(*) OVER() as total_count + FROM owned_nfts + LEFT JOIN collections ON collections.id = owned_nfts.collection_id + LEFT JOIN assets ON assets.id = owned_nfts.asset_id + WHERE 1=1 AND asset_hash IN (", + ); + + let mut separated = query.separated(", "); + + for id in ids { + separated.push(format!("X'{id}'")); + } + separated.push_unseparated(")"); + + let rows = query.build().fetch_all(conn).await?; + + let nfts = rows + .into_iter() + .map(|row| { + let asset_hash = row.get::, _>("asset_hash").convert()?; + Ok(NftRow { + asset: Asset { + hash: asset_hash, + name: row.get::, _>("asset_name"), + ticker: row.get::, _>("asset_ticker"), + precision: row.get::("asset_precision").convert()?, + icon_url: row.get::, _>("asset_icon_url"), + description: row.get::, _>("asset_description"), + is_visible: row.get::("is_visible"), + is_sensitive_content: row.get::("asset_is_sensitive_content"), + hidden_puzzle_hash: row + .get::>, _>("asset_hidden_puzzle_hash") + .convert()?, + kind: AssetKind::Nft, + }, + nft_info: NftCoinInfo { + collection_hash: row + .get::>, _>("collection_hash") + .convert()? + .unwrap_or_default(), + collection_name: row.get::, _>("collection_name"), + minter_hash: row.get::>, _>("minter_hash").convert()?, + owner_hash: row.get::>, _>("owner_hash").convert()?, + metadata: row.get::, _>("metadata").into(), + metadata_updater_puzzle_hash: row + .get::, _>("metadata_updater_puzzle_hash") + .convert()?, + royalty_puzzle_hash: row.get::, _>("royalty_puzzle_hash").convert()?, + royalty_basis_points: row.get::("royalty_basis_points").convert()?, + data_hash: row.get::>, _>("data_hash").convert()?, + metadata_hash: row.get::>, _>("metadata_hash").convert()?, + license_hash: row.get::>, _>("license_hash").convert()?, + edition_number: row.get::, _>("edition_number").convert()?, + edition_total: row.get::, _>("edition_total").convert()?, + }, + coin_row: CoinRow { + coin: Coin::new( + row.get::, _>("parent_coin_hash").convert()?, + row.get::, _>("puzzle_hash").convert()?, + row.get::, _>("amount").convert()?, + ), + p2_puzzle_hash: row.get::, _>("p2_puzzle_hash").convert()?, + kind: CoinKind::Nft, + mempool_item_hash: None, + offer_hash: row.get::>, _>("offer_hash").convert()?, + clawback_timestamp: row + .get::, _>("clawback_timestamp") + .convert()?, + created_height: row.get::, _>("created_height").convert()?, + spent_height: row.get::, _>("spent_height").convert()?, + created_timestamp: row.get::, _>("created_timestamp").convert()?, + spent_timestamp: row.get::, _>("spent_timestamp").convert()?, + asset_hash: Some(asset_hash), + }, + }) + }) + .collect::>>()?; + + Ok(nfts) +} diff --git a/crates/sage-database/src/tables/assets/option.rs b/crates/sage-database/src/tables/assets/option.rs index bc4dec4df..094c34f84 100644 --- a/crates/sage-database/src/tables/assets/option.rs +++ b/crates/sage-database/src/tables/assets/option.rs @@ -113,9 +113,10 @@ impl Database { .fetch_optional(&self.pool) .await? .map(|row| { + let asset_hash = row.asset_hash.convert()?; Ok(OptionRow { asset: Asset { - hash: row.asset_hash.convert()?, + hash: asset_hash, name: row.asset_name, ticker: row.asset_ticker, precision: row.asset_precision.convert()?, @@ -169,6 +170,7 @@ impl Database { spent_height: row.spent_height.convert()?, created_timestamp: row.created_timestamp.convert()?, spent_timestamp: row.spent_timestamp.convert()?, + asset_hash: Some(asset_hash), }, }) }) @@ -545,6 +547,7 @@ async fn owned_options( spent_height: row.get::, _>("spent_height").convert()?, created_timestamp: row.get::, _>("created_timestamp").convert()?, spent_timestamp: row.get::, _>("spent_timestamp").convert()?, + asset_hash: row.get::>, _>("asset_hash").convert()?, }, }) }) diff --git a/crates/sage-database/src/tables/coins.rs b/crates/sage-database/src/tables/coins.rs index 07dc2d61b..e520202b5 100644 --- a/crates/sage-database/src/tables/coins.rs +++ b/crates/sage-database/src/tables/coins.rs @@ -56,6 +56,7 @@ pub struct CoinRow { pub spent_height: Option, pub created_timestamp: Option, pub spent_timestamp: Option, + pub asset_hash: Option, } #[derive(Debug, Clone, Copy)] @@ -575,8 +576,9 @@ async fn coins_by_ids(conn: impl SqliteExecutor<'_>, coin_ids: &[String]) -> Res " SELECT parent_coin_hash, puzzle_hash, amount, spent_height, created_height, p2_puzzle_hash, - mempool_item_hash, offer_hash, created_timestamp, spent_timestamp, clawback_expiration_seconds AS clawback_timestamp - FROM wallet_coins + mempool_item_hash, offer_hash, created_timestamp, spent_timestamp, clawback_expiration_seconds AS clawback_timestamp, + assets.hash AS asset_hash + FROM wallet_coins LEFT JOIN assets ON assets.id = asset_id WHERE coin_hash IN (", ); let mut separated = query.separated(", "); @@ -612,6 +614,10 @@ async fn coins_by_ids(conn: impl SqliteExecutor<'_>, coin_ids: &[String]) -> Res spent_height: row.get::, _>("spent_height"), created_timestamp: row.get::, _>("created_timestamp").convert()?, spent_timestamp: row.get::, _>("spent_timestamp").convert()?, + asset_hash: row + .get::>, _>("asset_hash") + .map(Convert::convert) + .transpose()?, }) }) .collect::>>()?; @@ -642,9 +648,10 @@ async fn coin_records( parent_coin_hash, puzzle_hash, amount, spent_height, created_height, p2_puzzle_hash, mempool_item_hash, offer_hash, created_timestamp, spent_timestamp, - clawback_expiration_seconds AS clawback_timestamp, + clawback_expiration_seconds AS clawback_timestamp, assets.hash AS asset_hash, COUNT(*) OVER () AS total_count FROM {table} + LEFT JOIN assets ON assets.id = asset_id ", )); @@ -714,6 +721,10 @@ async fn coin_records( .get::, _>("spent_timestamp") .map(TryInto::try_into) .transpose()?, + asset_hash: row + .get::>, _>("asset_hash") + .map(Convert::convert) + .transpose()?, }) }) .collect::>>()?; diff --git a/crates/sage-database/src/tables/mempool_items.rs b/crates/sage-database/src/tables/mempool_items.rs index 4c08f5aaa..d5e31db5e 100644 --- a/crates/sage-database/src/tables/mempool_items.rs +++ b/crates/sage-database/src/tables/mempool_items.rs @@ -235,11 +235,11 @@ async fn mempool_items_for_input( query!( " - SELECT mempool_items.hash AS mempool_item_hash + SELECT mempool_items.hash AS mempool_item_hash FROM mempool_items INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id - INNER JOIN coins ON coins.hash = ? - WHERE mempool_coins.is_input = TRUE + INNER JOIN coins ON coins.id = mempool_coins.coin_id + WHERE coins.hash = ? AND mempool_coins.is_input = TRUE ", coin_id ) @@ -258,11 +258,11 @@ async fn mempool_items_for_output( query!( " - SELECT mempool_items.hash AS mempool_item_hash + SELECT mempool_items.hash AS mempool_item_hash FROM mempool_items INNER JOIN mempool_coins ON mempool_coins.mempool_item_id = mempool_items.id - INNER JOIN coins ON coins.hash = ? - WHERE mempool_coins.is_output = TRUE + INNER JOIN coins ON coins.id = mempool_coins.coin_id + WHERE coins.hash = ? AND mempool_coins.is_output = TRUE ", coin_id ) diff --git a/crates/sage-database/src/tables/transaction_history.rs b/crates/sage-database/src/tables/transaction_history.rs new file mode 100644 index 000000000..60599d2ab --- /dev/null +++ b/crates/sage-database/src/tables/transaction_history.rs @@ -0,0 +1,209 @@ +use chia::protocol::Bytes32; +use sqlx::{SqliteExecutor, SqlitePool}; + +use crate::{Convert, Database, DatabaseTx, Result}; + +#[derive(Debug, Clone, Copy)] +pub struct TransactionHistory { + pub hash: Bytes32, + pub height: u32, + pub fee: u64, + pub confirmed_timestamp: u64, +} + +#[derive(Debug, Clone, Copy)] +pub struct TransactionHistoryCoin { + pub coin_id: Bytes32, + pub is_input: bool, +} + +#[derive(Debug, Clone)] +pub struct TransactionHistoryWithCoins { + pub hash: Bytes32, + pub height: u32, + pub fee: u64, + pub confirmed_timestamp: u64, + pub coins: Vec, +} + +impl Database { + pub async fn transaction_history_by_id( + &self, + hash: Bytes32, + ) -> Result> { + transaction_history_by_id(&self.pool, hash).await + } +} + +impl DatabaseTx<'_> { + pub async fn insert_transaction_history( + &mut self, + hash: Bytes32, + height: u32, + fee: u64, + confirmed_timestamp: u64, + ) -> Result<()> { + insert_transaction_history(&mut *self.tx, hash, height, fee, confirmed_timestamp).await + } + + pub async fn insert_transaction_history_coin( + &mut self, + transaction_hash: Bytes32, + coin_id: Bytes32, + is_input: bool, + ) -> Result<()> { + insert_transaction_history_coin(&mut *self.tx, transaction_hash, coin_id, is_input).await + } + + pub async fn mempool_coin_ids( + &mut self, + mempool_item_id: Bytes32, + ) -> Result> { + mempool_coin_ids(&mut *self.tx, mempool_item_id).await + } + + pub async fn mempool_item_fee(&mut self, mempool_item_id: Bytes32) -> Result> { + mempool_item_fee(&mut *self.tx, mempool_item_id).await + } +} + +async fn insert_transaction_history( + conn: impl SqliteExecutor<'_>, + hash: Bytes32, + height: u32, + fee: u64, + confirmed_timestamp: u64, +) -> Result<()> { + let hash = hash.as_ref(); + let fee = fee.to_be_bytes().to_vec(); + let confirmed_timestamp: i64 = confirmed_timestamp.try_into()?; + + sqlx::query!( + " + INSERT OR IGNORE INTO transaction_history (hash, height, fee, confirmed_timestamp) + VALUES (?, ?, ?, ?) + ", + hash, + height, + fee, + confirmed_timestamp + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn insert_transaction_history_coin( + conn: impl SqliteExecutor<'_>, + transaction_hash: Bytes32, + coin_id: Bytes32, + is_input: bool, +) -> Result<()> { + let transaction_hash = transaction_hash.as_ref(); + let coin_id = coin_id.as_ref(); + + sqlx::query!( + " + INSERT OR IGNORE INTO transaction_history_coins (transaction_history_id, coin_id, is_input) + VALUES ((SELECT id FROM transaction_history WHERE hash = ?), ?, ?) + ", + transaction_hash, + coin_id, + is_input + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn transaction_history_by_id( + pool: &SqlitePool, + hash: Bytes32, +) -> Result> { + let hash_bytes = hash.as_ref(); + + let row = sqlx::query!( + " + SELECT hash, height, fee, confirmed_timestamp + FROM transaction_history + WHERE hash = ? + ", + hash_bytes + ) + .fetch_optional(pool) + .await?; + + let Some(row) = row else { + return Ok(None); + }; + + let coins = sqlx::query!( + " + SELECT coin_id, is_input + FROM transaction_history_coins + WHERE transaction_history_id = (SELECT id FROM transaction_history WHERE hash = ?) + ", + hash_bytes + ) + .fetch_all(pool) + .await? + .into_iter() + .map(|r| { + Ok(TransactionHistoryCoin { + coin_id: r.coin_id.convert()?, + is_input: r.is_input, + }) + }) + .collect::>>()?; + + Ok(Some(TransactionHistoryWithCoins { + hash: row.hash.convert()?, + height: row.height.try_into()?, + fee: row.fee.convert()?, + confirmed_timestamp: (row.confirmed_timestamp as u64), + coins, + })) +} + +async fn mempool_coin_ids( + conn: impl SqliteExecutor<'_>, + mempool_item_id: Bytes32, +) -> Result> { + let mempool_item_id = mempool_item_id.as_ref(); + + sqlx::query!( + " + SELECT coins.hash AS coin_id, mempool_coins.is_input + FROM mempool_coins + INNER JOIN mempool_items ON mempool_items.id = mempool_coins.mempool_item_id + INNER JOIN coins ON coins.id = mempool_coins.coin_id + WHERE mempool_items.hash = ? + ", + mempool_item_id + ) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| Ok((row.coin_id.convert()?, row.is_input))) + .collect() +} + +async fn mempool_item_fee( + conn: impl SqliteExecutor<'_>, + mempool_item_id: Bytes32, +) -> Result> { + let mempool_item_id = mempool_item_id.as_ref(); + + let row = sqlx::query!( + " + SELECT fee FROM mempool_items WHERE hash = ? + ", + mempool_item_id + ) + .fetch_optional(conn) + .await?; + + row.map(|r| r.fee.convert()).transpose() +} diff --git a/crates/sage-wallet/src/queues/blocktime_queue.rs b/crates/sage-wallet/src/queues/blocktime_queue.rs index cee776329..9652b464b 100644 --- a/crates/sage-wallet/src/queues/blocktime_queue.rs +++ b/crates/sage-wallet/src/queues/blocktime_queue.rs @@ -69,7 +69,12 @@ impl BlockTimeQueue { } } - self.sync_sender.send(SyncEvent::CoinsUpdated).await.ok(); + self.sync_sender + .send(SyncEvent::CoinsUpdated { + coin_ids: Vec::new(), + }) + .await + .ok(); Ok(()) } diff --git a/crates/sage-wallet/src/queues/cat_queue.rs b/crates/sage-wallet/src/queues/cat_queue.rs index 10aab8b8a..b763a812a 100644 --- a/crates/sage-wallet/src/queues/cat_queue.rs +++ b/crates/sage-wallet/src/queues/cat_queue.rs @@ -42,7 +42,10 @@ impl CatQueue { let mut tx = self.db.tx().await?; + let mut asset_ids = Vec::with_capacity(cats.len()); + for cat in cats { + asset_ids.push(cat.hash); tx.insert_asset(Asset { hash: cat.hash, name: cat.name, @@ -60,7 +63,10 @@ impl CatQueue { tx.commit().await?; - self.sync_sender.send(SyncEvent::CatInfo).await.ok(); + self.sync_sender + .send(SyncEvent::CatInfo { asset_ids }) + .await + .ok(); Ok(()) } diff --git a/crates/sage-wallet/src/queues/nft_uri_queue.rs b/crates/sage-wallet/src/queues/nft_uri_queue.rs index 892d13f18..ff07feceb 100644 --- a/crates/sage-wallet/src/queues/nft_uri_queue.rs +++ b/crates/sage-wallet/src/queues/nft_uri_queue.rs @@ -1,5 +1,6 @@ -use std::time::Duration; +use std::{collections::HashSet, time::Duration}; +use chia::protocol::Bytes32; use futures_lite::StreamExt; use futures_util::stream::FuturesUnordered; use sage_assets::{base64_data_uri, fetch_uri}; @@ -58,6 +59,8 @@ impl NftUriQueue { }); } + let mut updated_launcher_ids: HashSet = HashSet::new(); + while let Some((item, result)) = futures.next().await { let mut tx = self.db.tx().await?; @@ -100,6 +103,8 @@ impl NftUriQueue { }, ) .await?; + + updated_launcher_ids.insert(nft.hash); } tx.update_file(item.hash, data.blob, data.mime_type, is_hash_match) @@ -132,7 +137,11 @@ impl NftUriQueue { tx.commit().await?; } - self.sync_sender.send(SyncEvent::NftData).await.ok(); + let launcher_ids: Vec = updated_launcher_ids.into_iter().collect(); + self.sync_sender + .send(SyncEvent::NftData { launcher_ids }) + .await + .ok(); Ok(()) } diff --git a/crates/sage-wallet/src/sync_manager/sync_event.rs b/crates/sage-wallet/src/sync_manager/sync_event.rs index fed1d8a00..e31506359 100644 --- a/crates/sage-wallet/src/sync_manager/sync_event.rs +++ b/crates/sage-wallet/src/sync_manager/sync_event.rs @@ -10,10 +10,16 @@ pub enum SyncEvent { DerivationIndex { next_index: u32, }, - CoinsUpdated, + CoinsUpdated { + coin_ids: Vec, + }, TransactionUpdated { transaction_id: Bytes32, }, + TransactionConfirmed { + transaction_id: Bytes32, + height: u32, + }, TransactionFailed { transaction_id: Bytes32, error: Option, @@ -23,7 +29,15 @@ pub enum SyncEvent { status: OfferStatus, }, PuzzleBatchSynced, - CatInfo, - DidInfo, - NftData, + CatInfo { + asset_ids: Vec, + }, + DidInfo { + launcher_id: Bytes32, + }, + NftData { + launcher_ids: Vec, + }, + WebhooksChanged, + WebhookInvoked, } diff --git a/crates/sage-wallet/src/sync_manager/wallet_sync.rs b/crates/sage-wallet/src/sync_manager/wallet_sync.rs index c612adab8..01c31e08a 100644 --- a/crates/sage-wallet/src/sync_manager/wallet_sync.rs +++ b/crates/sage-wallet/src/sync_manager/wallet_sync.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, sync::Arc, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use chia::protocol::{Bytes32, CoinState, CoinStateFilters}; use sage_database::DatabaseTx; @@ -216,7 +216,8 @@ pub async fn incremental_sync( command_sender: &mpsc::Sender, ) -> Result<(), WalletError> { let mut tx = wallet.db.tx().await?; - let mut confirmed_transactions = HashSet::new(); + // Map transaction_id -> confirmation height + let mut confirmed_transactions: HashMap = HashMap::new(); for &coin_state in &coin_states { if let Some(height) = coin_state.created_height { @@ -241,21 +242,51 @@ pub async fn incremental_sync( .await?; } - confirmed_transactions.extend( - tx.mempool_items_for_output(coin_state.coin.coin_id()) - .await?, - ); + // For outputs, use created_height as confirmation height + if let Some(height) = coin_state.created_height { + for tx_id in tx + .mempool_items_for_output(coin_state.coin.coin_id()) + .await? + { + confirmed_transactions.insert(tx_id, height); + } + } - if coin_state.spent_height.is_some() { - confirmed_transactions.extend( - tx.mempool_items_for_input(coin_state.coin.coin_id()) - .await?, - ); + // For inputs, use spent_height as confirmation height + if let Some(height) = coin_state.spent_height { + for tx_id in tx + .mempool_items_for_input(coin_state.coin.coin_id()) + .await? + { + confirmed_transactions.insert(tx_id, height); + } + } + } + + // Persist transaction history before removing mempool items + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + for (&transaction_id, &height) in &confirmed_transactions { + // Get fee and coin_ids before deletion + let fee = tx.mempool_item_fee(transaction_id).await?.unwrap_or(0); + let coin_ids = tx.mempool_coin_ids(transaction_id).await?; + + // Insert transaction history + tx.insert_transaction_history(transaction_id, height, fee, now) + .await?; + + // Insert associated coins + for (coin_id, is_input) in coin_ids { + tx.insert_transaction_history_coin(transaction_id, coin_id, is_input) + .await?; } } - for mempool_item_id in confirmed_transactions { - tx.remove_mempool_item(mempool_item_id).await?; + for mempool_item_id in confirmed_transactions.keys() { + tx.remove_mempool_item(*mempool_item_id).await?; } let mut new_derivations = Vec::new(); @@ -269,7 +300,22 @@ pub async fn incremental_sync( tx.commit().await?; if !coin_states.is_empty() { - sync_sender.send(SyncEvent::CoinsUpdated).await.ok(); + sync_sender + .send(SyncEvent::CoinsUpdated { + coin_ids: coin_states.iter().map(|cs| cs.coin.coin_id()).collect(), + }) + .await + .ok(); + } + + for (transaction_id, height) in confirmed_transactions { + sync_sender + .send(SyncEvent::TransactionConfirmed { + transaction_id, + height, + }) + .await + .ok(); } if !new_derivations.is_empty() { diff --git a/crates/sage-wallet/src/test.rs b/crates/sage-wallet/src/test.rs index c6645080c..21931d242 100644 --- a/crates/sage-wallet/src/test.rs +++ b/crates/sage-wallet/src/test.rs @@ -244,7 +244,7 @@ impl TestWallet { } pub async fn wait_for_coins(&mut self) { - self.consume_until(|event| matches!(event, SyncEvent::CoinsUpdated)) + self.consume_until(|event| matches!(event, SyncEvent::CoinsUpdated { .. })) .await; } diff --git a/crates/sage/Cargo.toml b/crates/sage/Cargo.toml index 2774300e4..67112f7f5 100644 --- a/crates/sage/Cargo.toml +++ b/crates/sage/Cargo.toml @@ -44,3 +44,9 @@ clvmr = { workspace = true } serde = { workspace = true, features = ["derive"] } bincode = { workspace = true } serde_json = { workspace = true } +reqwest = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +url = { workspace = true } diff --git a/crates/sage/src/endpoints.rs b/crates/sage/src/endpoints.rs index f2c605b41..b7690ff54 100644 --- a/crates/sage/src/endpoints.rs +++ b/crates/sage/src/endpoints.rs @@ -6,3 +6,4 @@ mod settings; mod themes; mod transactions; mod wallet_connect; +mod webhooks; diff --git a/crates/sage/src/endpoints/data.rs b/crates/sage/src/endpoints/data.rs index bcf3d5fb5..f7ce9d9b2 100644 --- a/crates/sage/src/endpoints/data.rs +++ b/crates/sage/src/endpoints/data.rs @@ -1,6 +1,6 @@ use crate::{ - address_kind, parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, parse_option_id, - Error, Result, Sage, + address_kind, encode_asset_kind, parse_asset_id, parse_collection_id, parse_did_id, + parse_nft_id, parse_option_id, Error, Result, Sage, }; use base64::{prelude::BASE64_STANDARD, Engine}; use chia::{ @@ -13,21 +13,22 @@ use clvmr::Allocator; use sage_api::{ Amount, CheckAddress, CheckAddressResponse, CoinFilterMode as ApiCoinFilterMode, CoinRecord, CoinSortMode as ApiCoinSortMode, DerivationRecord, DidRecord, GetAllCats, GetAllCatsResponse, - GetAreCoinsSpendable, GetAreCoinsSpendableResponse, GetCats, GetCatsResponse, GetCoins, - GetCoinsByIds, GetCoinsByIdsResponse, GetCoinsResponse, GetDatabaseStats, - GetDatabaseStatsResponse, GetDerivations, GetDerivationsResponse, GetDids, GetDidsResponse, - GetMinterDidIds, GetMinterDidIdsResponse, GetNft, GetNftCollection, GetNftCollectionResponse, - GetNftCollections, GetNftCollectionsResponse, GetNftData, GetNftDataResponse, GetNftIcon, - GetNftIconResponse, GetNftResponse, GetNftThumbnail, GetNftThumbnailResponse, GetNfts, - GetNftsResponse, GetOption, GetOptionResponse, GetOptions, GetOptionsResponse, - GetPendingTransactions, GetPendingTransactionsResponse, GetSpendableCoinCount, - GetSpendableCoinCountResponse, GetSyncStatus, GetSyncStatusResponse, GetToken, - GetTokenResponse, GetTransaction, GetTransactionResponse, GetTransactions, + GetAreCoinsSpendable, GetAreCoinsSpendableResponse, GetAssetsByIds, GetAssetsByIdsResponse, + GetCats, GetCatsResponse, GetCoins, GetCoinsByIds, GetCoinsByIdsResponse, GetCoinsResponse, + GetDatabaseStats, GetDatabaseStatsResponse, GetDerivations, GetDerivationsResponse, GetDids, + GetDidsResponse, GetMinterDidIds, GetMinterDidIdsResponse, GetNft, GetNftCollection, + GetNftCollectionResponse, GetNftCollections, GetNftCollectionsResponse, GetNftData, + GetNftDataResponse, GetNftIcon, GetNftIconResponse, GetNftResponse, GetNftThumbnail, + GetNftThumbnailResponse, GetNfts, GetNftsByIds, GetNftsByIdsResponse, GetNftsResponse, + GetOption, GetOptionResponse, GetOptions, GetOptionsResponse, GetPendingTransactions, + GetPendingTransactionsResponse, GetSpendableCoinCount, GetSpendableCoinCountResponse, + GetSyncStatus, GetSyncStatusResponse, GetToken, GetTokenResponse, GetTransaction, + GetTransactionById, GetTransactionByIdResponse, GetTransactionResponse, GetTransactions, GetTransactionsResponse, GetVersion, GetVersionResponse, IsAssetOwned, IsAssetOwnedResponse, NftCollectionRecord, NftData, NftRecord, NftSortMode as ApiNftSortMode, NftSpecialUseType, OptionRecord, OptionSortMode as ApiOptionSortMode, PendingTransactionRecord, PerformDatabaseMaintenance, PerformDatabaseMaintenanceResponse, TokenRecord, - TransactionCoinRecord, TransactionRecord, + TransactionCoinRecord, TransactionHistoryRecord, TransactionRecord, }; use sage_database::{ AssetFilter, CoinFilterMode, CoinSortMode, NftGroupSearch, NftRow, NftSortMode, OptionSortMode, @@ -204,6 +205,7 @@ impl Sage { spent_height: row.spent_height, created_timestamp: row.created_timestamp, spent_timestamp: row.spent_timestamp, + asset_hash: row.asset_hash.map(hex::encode), }); } Ok(GetCoinsByIdsResponse { coins }) @@ -255,12 +257,37 @@ impl Sage { spent_height: row.spent_height, created_timestamp: row.created_timestamp, spent_timestamp: row.spent_timestamp, + asset_hash: row.asset_hash.map(hex::encode), }); } Ok(GetCoinsResponse { coins, total }) } + pub async fn get_assets_by_ids(&self, req: GetAssetsByIds) -> Result { + let wallet = self.wallet()?; + let rows = wallet.db.assets_by_ids(&req.asset_ids).await?; + let mut assets = Vec::new(); + for row in rows { + assets.push(sage_api::Asset { + asset_id: Some(hex::encode(row.hash)), + name: row.name, + ticker: row.ticker, + precision: row.precision, + icon_url: row.icon_url, + description: row.description, + is_sensitive_content: row.is_sensitive_content, + is_visible: row.is_visible, + revocation_address: row + .hidden_puzzle_hash + .map(|puzzle_hash| Address::new(puzzle_hash, self.network().prefix()).encode()) + .transpose()?, + kind: encode_asset_kind(row.kind), + }); + } + Ok(GetAssetsByIdsResponse { assets }) + } + pub async fn get_all_cats(&self, _req: GetAllCats) -> Result { let wallet = self.wallet()?; @@ -289,6 +316,16 @@ impl Sage { Ok(GetAllCatsResponse { cats: records }) } + pub async fn get_nfts_by_ids(&self, req: GetNftsByIds) -> Result { + let wallet = self.wallet()?; + let rows = wallet.db.nfts_by_ids(&req.launcher_ids).await?; + let mut nfts = Vec::new(); + for row in rows { + nfts.push(self.nft_record(row)?); + } + Ok(GetNftsByIdsResponse { nfts }) + } + pub async fn get_cats(&self, _req: GetCats) -> Result { let wallet = self.wallet()?; @@ -548,6 +585,42 @@ impl Sage { }) } + pub async fn get_transaction_by_id( + &self, + req: GetTransactionById, + ) -> Result { + let wallet = self.wallet()?; + + let transaction_id: Bytes32 = hex::decode(&req.transaction_id) + .map_err(|_| Error::InvalidTransactionId(req.transaction_id.clone()))? + .try_into() + .map_err(|_| Error::InvalidTransactionId(req.transaction_id.clone()))?; + + let history = wallet.db.transaction_history_by_id(transaction_id).await?; + + let transaction = history.map(|h| { + let (input_coin_ids, output_coin_ids): (Vec<_>, Vec<_>) = + h.coins.into_iter().partition(|c| c.is_input); + + TransactionHistoryRecord { + transaction_id: hex::encode(h.hash), + height: h.height, + fee: Amount::u64(h.fee), + confirmed_at: h.confirmed_timestamp, + input_coin_ids: input_coin_ids + .into_iter() + .map(|c| hex::encode(c.coin_id)) + .collect(), + output_coin_ids: output_coin_ids + .into_iter() + .map(|c| hex::encode(c.coin_id)) + .collect(), + } + }); + + Ok(GetTransactionByIdResponse { transaction }) + } + pub async fn get_nft_collections( &self, req: GetNftCollections, diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs new file mode 100644 index 000000000..9d8bff4fe --- /dev/null +++ b/crates/sage/src/endpoints/webhooks.rs @@ -0,0 +1,98 @@ +use sage_api::{ + GetWebhooks, GetWebhooksResponse, RegisterWebhook, RegisterWebhookResponse, UnregisterWebhook, + UnregisterWebhookResponse, UpdateWebhook, UpdateWebhookResponse, +}; +use sage_config::WebhookEntry; +use sage_wallet::SyncEvent; +use url::Url; + +use crate::{Error, Result, Sage}; + +impl Sage { + pub async fn register_webhook( + &mut self, + req: RegisterWebhook, + ) -> Result { + // Validate URL before registering + let parsed_url = + Url::parse(&req.url).map_err(|e| Error::InvalidWebhookUrl(e.to_string()))?; + + if !matches!(parsed_url.scheme(), "http" | "https") { + return Err(Error::InvalidWebhookUrl( + "scheme must be http or https".to_string(), + )); + } + + if parsed_url.host().is_none() { + return Err(Error::InvalidWebhookUrl("missing host".to_string())); + } + + let webhook_id = self + .webhook_manager + .register_webhook(req.url, req.event_types, req.secret) + .await; + + self.save_webhooks_config().await?; + self.send_webhook_event(SyncEvent::WebhooksChanged).await; + + Ok(RegisterWebhookResponse { webhook_id }) + } + + pub async fn unregister_webhook( + &mut self, + req: UnregisterWebhook, + ) -> Result { + let removed = self + .webhook_manager + .unregister_webhook(&req.webhook_id) + .await; + + if !removed { + return Err(Error::UnknownWebhook(req.webhook_id)); + } + + self.save_webhooks_config().await?; + self.send_webhook_event(SyncEvent::WebhooksChanged).await; + + Ok(UnregisterWebhookResponse {}) + } + + pub async fn get_webhooks(&mut self, _req: GetWebhooks) -> Result { + let webhooks = self.webhook_manager.list_webhooks().await; + Ok(GetWebhooksResponse { + webhooks: webhooks + .into_iter() + .map(|w| WebhookEntry { + id: w.id, + url: w.url, + events: w.events, + enabled: w.active, + secret: None, // Don't expose secret in API responses + last_delivered_at: w.last_delivered_at, + last_delivery_attempt_at: w.last_delivery_attempt_at, + consecutive_failures: w.consecutive_failures, + }) + .collect(), + }) + } + + pub async fn update_webhook(&mut self, req: UpdateWebhook) -> Result { + let updated = self + .webhook_manager + .update_webhook(&req.webhook_id, req.enabled) + .await; + + if !updated { + return Err(Error::UnknownWebhook(req.webhook_id)); + } + + self.save_webhooks_config().await?; + self.send_webhook_event(SyncEvent::WebhooksChanged).await; + + Ok(UpdateWebhookResponse {}) + } + + async fn send_webhook_event(&self, event: SyncEvent) { + self.webhook_manager.send_sync_event(event).await; + } +} diff --git a/crates/sage/src/error.rs b/crates/sage/src/error.rs index 235229132..03971991b 100644 --- a/crates/sage/src/error.rs +++ b/crates/sage/src/error.rs @@ -115,6 +115,12 @@ pub enum Error { #[error("Unknown fingerprint")] UnknownFingerprint, + #[error("Unknown webhook: {0}")] + UnknownWebhook(String), + + #[error("Invalid webhook URL: {0}")] + InvalidWebhookUrl(String), + #[error("Not logged in")] NotLoggedIn, @@ -154,6 +160,9 @@ pub enum Error { #[error("Invalid offer id: {0}")] InvalidOfferId(String), + #[error("Invalid transaction id: {0}")] + InvalidTransactionId(String), + #[error("Invalid percentage: {0}")] InvalidPercentage(String), @@ -266,6 +275,7 @@ impl Error { | Self::Timeout(..) => ErrorKind::Internal, Self::UnknownFingerprint | Self::UnknownNetwork + | Self::UnknownWebhook(..) | Self::MissingCoin(..) | Self::MissingCatCoin(..) | Self::MissingDidCoin(..) @@ -293,6 +303,7 @@ impl Error { | Self::InvalidHash(..) | Self::InvalidAssetId(..) | Self::InvalidOfferId(..) + | Self::InvalidTransactionId(..) | Self::InvalidPercentage(..) | Self::InvalidSignature(..) | Self::InvalidPublicKey(..) @@ -305,7 +316,8 @@ impl Error { | Self::MissingAssetId | Self::InvalidGroup | Self::InvalidThemeJson - | Self::MissingThemeData => ErrorKind::Api, + | Self::MissingThemeData + | Self::InvalidWebhookUrl(..) => ErrorKind::Api, } } } diff --git a/crates/sage/src/lib.rs b/crates/sage/src/lib.rs index 5d6d4a713..61e88de17 100644 --- a/crates/sage/src/lib.rs +++ b/crates/sage/src/lib.rs @@ -5,6 +5,7 @@ mod error; mod peers; mod sage; mod utils; +mod webhook_manager; pub use error::*; pub use sage::*; diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index cc5f314b8..51fe0c51d 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -32,7 +32,7 @@ use tracing_subscriber::{ filter::filter_fn, fmt, layer::SubscriberExt, EnvFilter, Layer, Registry, }; -use crate::{peers::Peers, Error, Result}; +use crate::{peers::Peers, webhook_manager::WebhookManager, Error, Result}; #[derive(Debug)] pub struct Sage { @@ -45,6 +45,7 @@ pub struct Sage { pub peer_state: Arc>, pub command_sender: mpsc::Sender, pub unit: Unit, + pub webhook_manager: WebhookManager, } impl Sage { @@ -59,6 +60,7 @@ impl Sage { peer_state: Arc::new(Mutex::new(PeerState::default())), command_sender: mpsc::channel(1).0, unit: XCH.clone(), + webhook_manager: WebhookManager::new(), } } @@ -68,6 +70,13 @@ impl Sage { self.setup_keys()?; self.setup_config()?; self.setup_logging()?; + self.setup_webhooks().await?; + + // Initialize webhook manager with current fingerprint and network + self.webhook_manager + .set_fingerprint(self.config.global.fingerprint) + .await; + self.webhook_manager.set_network(self.network_id()).await; let receiver = self.setup_sync_manager()?; self.setup_peers().await?; @@ -238,10 +247,140 @@ impl Sage { tokio::spawn(sync_manager.sync()); self.command_sender = command_sender; - Ok(receiver) + // Create a broadcast channel to split events between Tauri app and webhook consumer + let (tx, _rx) = tokio::sync::broadcast::channel(100); + + // Create a receiver for the Tauri app before we move tx + let tauri_receiver = tx.subscribe(); + + // Spawn a task that forwards events from the sync manager to the broadcast channel + let webhook_manager = self.webhook_manager.clone(); + tokio::spawn(async move { + let mut receiver = receiver; + while let Some(event) = receiver.recv().await { + // Send to broadcast channel (for Tauri app) + let _ = tx.send(event.clone()); + + // Also handle for webhooks directly + Self::handle_sync_event_for_webhooks(&webhook_manager, event).await; + } + }); + + // Convert broadcast receiver to mpsc receiver for compatibility + let (tauri_tx, tauri_rx) = mpsc::channel(100); + + // Give webhook manager access to the event sender for internal webhook events + let webhook_manager_for_sender = self.webhook_manager.clone(); + let event_sender = tauri_tx.clone(); + tokio::spawn(async move { + webhook_manager_for_sender + .set_event_sender(event_sender) + .await; + }); + + tokio::spawn(async move { + let mut tauri_receiver = tauri_receiver; + while let Ok(event) = tauri_receiver.recv().await { + let _ = tauri_tx.send(event).await; + } + }); + + Ok(tauri_rx) + } + + async fn handle_sync_event_for_webhooks(webhook_manager: &WebhookManager, event: SyncEvent) { + // Convert wallet SyncEvent to webhook payload + // Skip internal webhook events - they should not be sent over webhooks + let (event_type, data) = match event { + SyncEvent::Start(ip) => ( + "start", + serde_json::json!({ + "ip": ip.to_string() + }), + ), + SyncEvent::Stop => ("stop", serde_json::json!({})), + SyncEvent::Subscribed => ("subscribed", serde_json::json!({})), + SyncEvent::DerivationIndex { next_index } => ( + "derivation", + serde_json::json!({ + "next_index": next_index + }), + ), + SyncEvent::TransactionUpdated { transaction_id } => ( + "transaction_updated", + serde_json::json!({ + "transaction_id": transaction_id.to_string() + }), + ), + SyncEvent::TransactionConfirmed { + transaction_id, + height, + } => ( + "transaction_confirmed", + serde_json::json!({ + "transaction_id": transaction_id.to_string(), + "height": height + }), + ), + SyncEvent::TransactionFailed { + transaction_id, + error, + } => ( + "transaction_failed", + serde_json::json!({ + "transaction_id": transaction_id.to_string(), + "error": error + }), + ), + SyncEvent::OfferUpdated { offer_id, status } => ( + "offer_updated", + serde_json::json!({ + "offer_id": offer_id.to_string(), + "status": format!("{:?}", status) + }), + ), + SyncEvent::CoinsUpdated { coin_ids } => { + if coin_ids.is_empty() { + return; + } + ( + "coins_updated", + serde_json::json!({ + "coin_ids": coin_ids.iter().map(ToString::to_string).collect::>() + }), + ) + } + SyncEvent::PuzzleBatchSynced => ("puzzle_batch_synced", serde_json::json!({})), + SyncEvent::CatInfo { asset_ids } => ( + "cat_info", + serde_json::json!({ + "asset_ids": asset_ids.iter().map(ToString::to_string).collect::>() + }), + ), + SyncEvent::DidInfo { launcher_id } => ( + "did_info", + serde_json::json!({ + "launcher_id": launcher_id.to_string() + }), + ), + SyncEvent::NftData { launcher_ids } => ( + "nft_data", + serde_json::json!({ + "launcher_ids": launcher_ids.iter().map(ToString::to_string).collect::>() + }), + ), + // Internal webhook notifications - do not send over webhooks + SyncEvent::WebhooksChanged | SyncEvent::WebhookInvoked => return, + }; + + webhook_manager + .send_event(event_type.to_string(), data) + .await; } pub async fn switch_network(&mut self) -> Result<()> { + self.webhook_manager.set_network(self.network_id()).await; + self.command_sender .send(SyncCommand::SwitchNetwork(self.network().clone())) .await?; @@ -254,6 +393,7 @@ impl Sage { let Some(fingerprint) = self.config.global.fingerprint else { self.wallet = None; + self.webhook_manager.set_fingerprint(None).await; self.command_sender .send(SyncCommand::SwitchWallet { @@ -298,6 +438,9 @@ impl Sage { ticker: self.network().ticker.clone(), precision: self.network().precision, }; + self.webhook_manager + .set_fingerprint(Some(fingerprint)) + .await; self.command_sender .send(SyncCommand::SwitchWallet { @@ -508,4 +651,72 @@ impl Sage { fs::write(self.path.join("keys.bin"), self.keychain.to_bytes()?)?; Ok(()) } + + pub async fn save_webhooks_config(&mut self) -> Result<()> { + use sage_config::WebhookEntry; + + let entries = self.webhook_manager.get_webhook_entries().await; + self.config.webhooks.webhooks = entries + .into_iter() + .map( + |( + id, + url, + events, + enabled, + secret, + last_delivered_at, + last_delivery_attempt_at, + consecutive_failures, + )| { + WebhookEntry { + id, + url, + events, + enabled, + secret, + last_delivered_at, + last_delivery_attempt_at, + consecutive_failures, + } + }, + ) + .collect(); + + self.save_config()?; + Ok(()) + } + + async fn setup_webhooks(&mut self) -> Result<()> { + use crate::webhook_manager::WebhookEntryTuple; + + let entries: Vec = self + .config + .webhooks + .webhooks + .iter() + .map(|w| { + ( + w.id.clone(), + w.url.clone(), + w.events.clone(), + w.enabled, + w.secret.clone(), + w.last_delivered_at, + w.last_delivery_attempt_at, + w.consecutive_failures, + ) + }) + .collect(); + + if !entries.is_empty() { + self.webhook_manager.load_webhooks(entries).await; + info!( + "Loaded {} webhooks from config", + self.config.webhooks.webhooks.len() + ); + } + + Ok(()) + } } diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs new file mode 100644 index 000000000..533c83677 --- /dev/null +++ b/crates/sage/src/webhook_manager.rs @@ -0,0 +1,329 @@ +use hmac::{Hmac, Mac}; +use reqwest::Client; +use sage_wallet::SyncEvent; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, RwLock}; +use tracing::{debug, error, warn}; +use uuid::Uuid; + +type HmacSha256 = Hmac; + +/// Webhook entry tuple: (id, url, events, enabled, secret, last_delivered_at, last_delivery_attempt_at, consecutive_failures) +pub type WebhookEntryTuple = ( + String, + String, + Option>, + bool, + Option, + Option, + Option, + u32, +); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub id: String, + pub url: String, + /// None means "all events, including those not yet defined" + pub events: Option>, + pub active: bool, + pub secret: Option, + pub last_delivered_at: Option, + pub last_delivery_attempt_at: Option, + pub consecutive_failures: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookEventPayload { + pub id: String, + pub fingerprint: Option, + pub network: String, + pub event_type: String, + pub timestamp: i64, + pub data: serde_json::Value, +} + +#[derive(Debug, Clone)] +pub struct WebhookManager { + webhooks: Arc>>, + client: Client, + fingerprint: Arc>>, + network: Arc>, + event_sender: Arc>>>, +} + +impl Default for WebhookManager { + fn default() -> Self { + Self { + webhooks: Arc::new(RwLock::new(HashMap::new())), + client: Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .expect("Failed to create HTTP client"), + fingerprint: Arc::new(RwLock::new(None)), + network: Arc::new(RwLock::new(String::new())), + event_sender: Arc::new(RwLock::new(None)), + } + } +} + +impl WebhookManager { + pub fn new() -> Self { + Self::default() + } + + pub async fn set_fingerprint(&self, fingerprint: Option) { + *self.fingerprint.write().await = fingerprint; + } + + pub async fn set_network(&self, network: String) { + *self.network.write().await = network; + } + + pub async fn set_event_sender(&self, sender: mpsc::Sender) { + *self.event_sender.write().await = Some(sender); + } + + pub async fn send_sync_event(&self, event: SyncEvent) { + if let Some(sender) = self.event_sender.read().await.as_ref() { + let _ = sender.send(event).await; + } + } + + pub async fn register_webhook( + &self, + url: String, + events: Option>, + secret: Option, + ) -> String { + let id = Uuid::new_v4().to_string(); + let config = WebhookConfig { + id: id.clone(), + url, + events, + active: true, + secret, + last_delivered_at: None, + last_delivery_attempt_at: None, + consecutive_failures: 0, + }; + + let mut webhooks = self.webhooks.write().await; + webhooks.insert(id.clone(), config); + id + } + + pub async fn unregister_webhook(&self, id: &str) -> bool { + let mut webhooks = self.webhooks.write().await; + webhooks.remove(id).is_some() + } + + pub async fn update_webhook(&self, id: &str, active: bool) -> bool { + let mut webhooks = self.webhooks.write().await; + if let Some(webhook) = webhooks.get_mut(id) { + webhook.active = active; + // Reset failure count when re-enabling to give the webhook a fresh start + if active { + webhook.consecutive_failures = 0; + } + true + } else { + false + } + } + + pub async fn send_event(&self, event_type: String, data: serde_json::Value) { + let fingerprint = *self.fingerprint.read().await; + let network = self.network.read().await.clone(); + let event = WebhookEventPayload { + id: Uuid::new_v4().to_string(), + fingerprint, + network, + event_type: event_type.clone(), + timestamp: chrono::Utc::now().timestamp(), + data, + }; + + let webhooks = self.webhooks.read().await; + let interested_webhooks: Vec = webhooks + .values() + .filter(|w| { + w.active + && match &w.events { + None => true, // None means all events + Some(events) => events.contains(&event_type), + } + }) + .cloned() + .collect(); + + let event_sender = self.event_sender.read().await.clone(); + + for webhook in interested_webhooks { + let client = self.client.clone(); + let event = event.clone(); + let webhooks = self.webhooks.clone(); + let event_sender = event_sender.clone(); + tokio::spawn(async move { + Self::deliver_webhook(client, webhooks, webhook, event, event_sender).await; + }); + } + } + + async fn deliver_webhook( + client: Client, + webhooks: Arc>>, + webhook: WebhookConfig, + event: WebhookEventPayload, + event_sender: Option>, + ) { + const MAX_RETRIES: u32 = 3; + + for attempt in 0..MAX_RETRIES { + let now = chrono::Utc::now().timestamp(); + + // block scope to ensure the lock is released + { + let mut webhooks_lock = webhooks.write().await; + if let Some(w) = webhooks_lock.get_mut(&webhook.id) { + w.last_delivery_attempt_at = Some(now); + } + } + + match Self::send_webhook_request(&client, &webhook, &event).await { + Ok(()) => { + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC"); + debug!("[{}] Webhook delivered to {}", timestamp, webhook.url); + + let mut webhooks_lock = webhooks.write().await; + if let Some(w) = webhooks_lock.get_mut(&webhook.id) { + w.last_delivered_at = Some(now); + w.consecutive_failures = 0; + } + + if let Some(sender) = &event_sender { + let _ = sender.send(SyncEvent::WebhookInvoked).await; + } + return; + } + Err(e) => { + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC"); + error!( + "[{}] Webhook delivery failed (attempt {}/{}): {} - {}", + timestamp, + attempt + 1, + MAX_RETRIES, + webhook.url, + e + ); + if attempt < MAX_RETRIES - 1 { + // Exponential backoff + let delay = std::time::Duration::from_secs(2u64.pow(attempt)); + tokio::time::sleep(delay).await; + } + } + } + } + + // All retries exhausted - increment failure count for monitoring + let mut webhooks_lock = webhooks.write().await; + if let Some(w) = webhooks_lock.get_mut(&webhook.id) { + w.consecutive_failures += 1; + warn!( + "Webhook {} failed after {} retries (total consecutive failures: {})", + webhook.url, MAX_RETRIES, w.consecutive_failures + ); + } + + if let Some(sender) = &event_sender { + let _ = sender.send(SyncEvent::WebhookInvoked).await; + } + } + + async fn send_webhook_request( + client: &Client, + webhook: &WebhookConfig, + event: &WebhookEventPayload, + ) -> Result<(), Box> { + let body = serde_json::to_vec(event)?; + + let mut request_builder = client + .post(&webhook.url) + .header("Content-Type", "application/json") + .body(body.clone()); + + // Compute HMAC signature if secret is provided + if let Some(secret) = &webhook.secret { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| format!("Invalid HMAC key: {e}"))?; + mac.update(&body); + let signature = mac.finalize(); + let signature_hex = hex::encode(signature.into_bytes()); + + request_builder = + request_builder.header("X-Webhook-Signature", format!("sha256={signature_hex}")); + } + + let response = request_builder.send().await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(format!("HTTP {}", response.status()).into()) + } + } + + pub async fn list_webhooks(&self) -> Vec { + let webhooks = self.webhooks.read().await; + webhooks.values().cloned().collect() + } + + pub async fn load_webhooks(&self, entries: Vec) { + let mut webhooks = self.webhooks.write().await; + for ( + id, + url, + events, + enabled, + secret, + last_delivered_at, + last_delivery_attempt_at, + consecutive_failures, + ) in entries + { + let config = WebhookConfig { + id: id.clone(), + url, + events, + active: enabled, + secret, + last_delivered_at, + last_delivery_attempt_at, + consecutive_failures, + }; + webhooks.insert(id, config); + } + } + + pub async fn get_webhook_entries(&self) -> Vec { + let webhooks = self.webhooks.read().await; + webhooks + .values() + .map(|w| { + ( + w.id.clone(), + w.url.clone(), + w.events.clone(), + w.active, + w.secret.clone(), + w.last_delivered_at, + w.last_delivery_attempt_at, + w.consecutive_failures, + ) + }) + .collect() + } +} diff --git a/migrations/0006_transaction_history.sql b/migrations/0006_transaction_history.sql new file mode 100644 index 000000000..0308594c7 --- /dev/null +++ b/migrations/0006_transaction_history.sql @@ -0,0 +1,22 @@ +-- Transaction history table to persist confirmed transaction data +-- This allows webhook consumers to look up transaction details by transaction_id +-- after the mempool item has been deleted + +CREATE TABLE transaction_history ( + id INTEGER NOT NULL PRIMARY KEY, + hash BLOB NOT NULL UNIQUE, + height INTEGER NOT NULL, + fee BLOB NOT NULL, + confirmed_timestamp INTEGER NOT NULL +); + +CREATE TABLE transaction_history_coins ( + id INTEGER NOT NULL PRIMARY KEY, + transaction_history_id INTEGER NOT NULL, + coin_id BLOB NOT NULL, + is_input BOOLEAN NOT NULL, + FOREIGN KEY (transaction_history_id) REFERENCES transaction_history(id) ON DELETE CASCADE +); + +CREATE INDEX idx_transaction_history_height ON transaction_history(height); +CREATE INDEX idx_transaction_history_coins_tx ON transaction_history_coins(transaction_history_id); diff --git a/src-tauri/src/app_state.rs b/src-tauri/src/app_state.rs index ed455ea49..788411ff0 100644 --- a/src-tauri/src/app_state.rs +++ b/src-tauri/src/app_state.rs @@ -30,13 +30,16 @@ pub async fn initialize(app_handle: AppHandle, sage: &mut Sage) -> Result<()> { error, }, // TODO: New event? - SyncEvent::CoinsUpdated + SyncEvent::CoinsUpdated { .. } | SyncEvent::TransactionUpdated { .. } + | SyncEvent::TransactionConfirmed { .. } | SyncEvent::OfferUpdated { .. } => ApiEvent::CoinState, SyncEvent::PuzzleBatchSynced => ApiEvent::PuzzleBatchSynced, - SyncEvent::CatInfo => ApiEvent::CatInfo, - SyncEvent::DidInfo => ApiEvent::DidInfo, - SyncEvent::NftData => ApiEvent::NftData, + SyncEvent::CatInfo { .. } => ApiEvent::CatInfo, + SyncEvent::DidInfo { .. } => ApiEvent::DidInfo, + SyncEvent::NftData { .. } => ApiEvent::NftData, + SyncEvent::WebhooksChanged => ApiEvent::WebhooksChanged, + SyncEvent::WebhookInvoked => ApiEvent::WebhookInvoked, }; if app_handle.emit("sync-event", event).is_err() { break; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b07633b4..3c542db64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,8 @@ pub fn run() { commands::get_are_coins_spendable, commands::get_spendable_coin_count, commands::get_coins_by_ids, + commands::get_nfts_by_ids, + commands::get_assets_by_ids, commands::get_coins, commands::get_cats, commands::get_all_cats, @@ -140,6 +142,11 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::register_webhook, + commands::unregister_webhook, + commands::get_webhooks, + commands::update_webhook, + commands::get_transaction_by_id, ]) .events(collect_events![SyncEvent]); diff --git a/src/bindings.ts b/src/bindings.ts index 836353790..838e177ad 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -140,6 +140,12 @@ async getSpendableCoinCount(req: GetSpendableCoinCount) : Promise { return await TAURI_INVOKE("get_coins_by_ids", { req }); }, +async getNftsByIds(req: GetNftsByIds) : Promise { + return await TAURI_INVOKE("get_nfts_by_ids", { req }); +}, +async getAssetsByIds(req: GetAssetsByIds) : Promise { + return await TAURI_INVOKE("get_assets_by_ids", { req }); +}, async getCoins(req: GetCoins) : Promise { return await TAURI_INVOKE("get_coins", { req }); }, @@ -355,6 +361,21 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async registerWebhook(req: RegisterWebhook) : Promise { + return await TAURI_INVOKE("register_webhook", { req }); +}, +async unregisterWebhook(req: UnregisterWebhook) : Promise { + return await TAURI_INVOKE("unregister_webhook", { req }); +}, +async getWebhooks(req: GetWebhooks) : Promise { + return await TAURI_INVOKE("get_webhooks", { req }); +}, +async updateWebhook(req: UpdateWebhook) : Promise { + return await TAURI_INVOKE("update_webhook", { req }); +}, +async getTransactionById(req: GetTransactionById) : Promise { + return await TAURI_INVOKE("get_transaction_by_id", { req }); } } @@ -667,7 +688,7 @@ puzzle_hash: string; amount: number } export type CoinFilterMode = "all" | "selectable" | "owned" | "spent" | "clawback" export type CoinJson = { parent_coin_info: string; puzzle_hash: string; amount: Amount } -export type CoinRecord = { coin_id: string; address: string; amount: Amount; transaction_id: string | null; offer_id: string | null; clawback_timestamp: number | null; created_height: number | null; spent_height: number | null; spent_timestamp: number | null; created_timestamp: number | null } +export type CoinRecord = { coin_id: string; address: string; amount: Amount; transaction_id: string | null; offer_id: string | null; clawback_timestamp: number | null; created_height: number | null; spent_height: number | null; spent_timestamp: number | null; created_timestamp: number | null; asset_hash: string | null } export type CoinSortMode = "coin_id" | "amount" | "created_height" | "spent_height" | "clawback_timestamp" /** * Coin spend structure @@ -904,6 +925,8 @@ offset?: number | null; * Number of results to return */ limit?: number | null } +export type GetAssetsByIds = { asset_ids: string[] } +export type GetAssetsByIdsResponse = { assets: Asset[] } /** * Get CAT tokens in wallet */ @@ -1260,6 +1283,8 @@ sort_mode: NftSortMode; * Include hidden NFTs */ include_hidden: boolean } +export type GetNftsByIds = { launcher_ids: string[] } +export type GetNftsByIdsResponse = { nfts: NftRecord[] } /** * Response with NFTs list */ @@ -1504,6 +1529,8 @@ export type GetTransaction = { * Transaction height/ID */ height: number } +export type GetTransactionById = { transaction_id: string } +export type GetTransactionByIdResponse = { transaction: TransactionHistoryRecord | null } /** * Response with transaction details */ @@ -1578,6 +1605,8 @@ export type GetVersionResponse = { * Semantic version string */ version: string } +export type GetWebhooks = Record +export type GetWebhooksResponse = { webhooks: WebhookEntry[] } /** * Import a wallet key */ @@ -1981,6 +2010,8 @@ nft_id: string } * Response after re-downloading an `NFT` */ export type RedownloadNftResponse = Record +export type RegisterWebhook = { url: string; event_types: string[] | null; secret: string | null } +export type RegisterWebhookResponse = { webhook_id: string } /** * Remove a peer from the connection list */ @@ -2376,7 +2407,7 @@ spend_bundle: SpendBundleJson } * Response for transaction submission */ export type SubmitTransactionResponse = Record -export type SyncEvent = { type: "start"; ip: string } | { type: "stop" } | { type: "subscribed" } | { type: "derivation" } | { type: "coin_state" } | { type: "transaction_failed"; transaction_id: string; error: string | null } | { type: "puzzle_batch_synced" } | { type: "cat_info" } | { type: "did_info" } | { type: "nft_data" } +export type SyncEvent = { type: "start"; ip: string } | { type: "stop" } | { type: "subscribed" } | { type: "derivation" } | { type: "coin_state" } | { type: "transaction_failed"; transaction_id: string; error: string | null } | { type: "puzzle_batch_synced" } | { type: "cat_info" } | { type: "did_info" } | { type: "nft_data" } | { type: "webhooks_changed" } | { type: "webhook_invoked" } /** * Accept an offer */ @@ -2411,6 +2442,7 @@ spend_bundle: SpendBundleJson; transaction_id: string } export type TokenRecord = { asset_id: string | null; name: string | null; ticker: string | null; precision: number; description: string | null; icon_url: string | null; visible: boolean; balance: Amount; revocation_address: string | null } export type TransactionCoinRecord = { coin_id: string; amount: Amount; address: string | null; address_kind: AddressKind; asset: Asset } +export type TransactionHistoryRecord = { transaction_id: string; height: number; fee: Amount; confirmed_at: number; input_coin_ids: string[]; output_coin_ids: string[] } export type TransactionInput = { coin_id: string; amount: Amount; address: string; asset: Asset | null; outputs: TransactionOutput[] } export type TransactionOutput = { coin_id: string; amount: Amount; address: string; receiving: boolean; burning: boolean } export type TransactionRecord = { height: number; timestamp: number | null; spent: TransactionCoinRecord[]; created: TransactionCoinRecord[] } @@ -2500,6 +2532,8 @@ clawback?: number | null; */ auto_submit?: boolean } export type Unit = { ticker: string; precision: number } +export type UnregisterWebhook = { webhook_id: string } +export type UnregisterWebhookResponse = Record /** * Update a `CAT` token's metadata and visibility */ @@ -2580,6 +2614,8 @@ visible: boolean } * Response after updating an option */ export type UpdateOptionResponse = Record +export type UpdateWebhook = { webhook_id: string; enabled: boolean } +export type UpdateWebhookResponse = Record /** * View coin spends without signing */ @@ -2618,6 +2654,15 @@ offer: OfferSummary; status: OfferRecordStatus } export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } export type WalletDefaults = { delta_sync: boolean } +export type WebhookEntry = { id: string; url: string; +/** + * None means "all events, including future ones" + */ +events: string[] | null; enabled: boolean; +/** + * Optional secret for HMAC-SHA256 signature verification + */ +secret?: string | null; last_delivered_at: number | null; last_delivery_attempt_at: number | null; consecutive_failures?: number } /** tauri-specta globals **/ diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8ef69fa88..6ad6f316c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -70,6 +70,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { z } from 'zod'; import { commands, + events, GetDatabaseStatsResponse, KeyInfo, LogFile, @@ -78,6 +79,7 @@ import { PerformDatabaseMaintenanceResponse, Wallet, WalletDefaults, + WebhookEntry, } from '../bindings'; import { ThemeSelectorSimple } from '../components/ThemeSelector'; @@ -199,6 +201,7 @@ export default function Settings() {
{!isMobile && } +
@@ -1601,3 +1604,256 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { ); } + +function WebhooksSettings() { + const { addError } = useErrors(); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [updatingId, setUpdatingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + const loadWebhooks = useCallback(() => { + setLoading(true); + commands + .getWebhooks({}) + .then((response) => { + setWebhooks(response.webhooks); + }) + .catch(addError) + .finally(() => setLoading(false)); + }, [addError]); + + const handleToggle = useCallback( + (webhookId: string, enabled: boolean) => { + setUpdatingId(webhookId); + commands + .updateWebhook({ webhook_id: webhookId, enabled }) + .then(() => { + loadWebhooks(); + }) + .catch(addError) + .finally(() => setUpdatingId(null)); + }, + [addError, loadWebhooks], + ); + + const handleDelete = useCallback( + (webhookId: string) => { + setDeletingId(webhookId); + commands + .unregisterWebhook({ webhook_id: webhookId }) + .then(() => { + loadWebhooks(); + }) + .catch(addError) + .finally(() => setDeletingId(null)); + }, + [addError, loadWebhooks], + ); + + useEffect(() => { + loadWebhooks(); + }, [loadWebhooks]); + + // Refresh webhooks when webhook-specific events occur + useEffect(() => { + const unlisten = events.syncEvent.listen((event) => { + // Only refresh on webhook-related events + if ( + event.payload.type === 'webhooks_changed' || + event.payload.type === 'webhook_invoked' + ) { + loadWebhooks(); + } + }); + + return () => { + unlisten.then((fn) => fn()); + }; + }, [loadWebhooks]); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (webhooks.length === 0) { + return ( + +
+
+ No webhooks registered +
+
+ + Register webhooks via the API to receive real-time notifications + about wallet events + +
+
+
+ ); + } + + return ( + + {webhooks.map((webhook, index) => { + // Determine status color and label + let statusColor = 'bg-gray-400'; + let statusLabel = Disabled; + + if (webhook.enabled) { + // Check health based on delivery attempts + if ( + webhook.last_delivered_at === null && + webhook.last_delivery_attempt_at !== null + ) { + // Never successfully delivered, but attempts have been made + statusColor = 'bg-red-500'; + statusLabel = Failing; + } else if ( + webhook.last_delivery_attempt_at !== null && + webhook.last_delivered_at !== null && + webhook.last_delivery_attempt_at > webhook.last_delivered_at + ) { + // Most recent attempt failed + statusColor = 'bg-yellow-500'; + statusLabel = Warning; + } else { + // Enabled and healthy + statusColor = 'bg-green-500'; + statusLabel = Enabled; + } + } + + return ( +
+
+ {/* Header with Status, ID, and Toggle */} +
+
+
{statusLabel}
+
+ {webhook.id} +
+ + handleToggle(webhook.id, checked) + } + /> + +
+ + {/* URL */} +
+ +
+ {webhook.url} +
+
+ + {/* Events */} +
+ +
+ {webhook.events === null ? ( + + All events + + ) : webhook.events.length === 0 ? ( + + No events + + ) : ( +
+ {webhook.events.map((event) => ( + + {event} + + ))} +
+ )} +
+
+ + {/* Delivery timestamps and failure count */} + {(webhook.last_delivered_at || + webhook.last_delivery_attempt_at || + failures > 0) && ( +
+ {webhook.last_delivered_at && ( +
+ +
+ {new Date( + webhook.last_delivered_at * 1000, + ).toLocaleString()} +
+
+ )} + {webhook.last_delivery_attempt_at && ( +
+ +
+ {new Date( + webhook.last_delivery_attempt_at * 1000, + ).toLocaleString()} +
+
+ )} + {failures > 0 && ( +
+ +
+ {failures} +
+
+ )} +
+ )} +
+ + {/* Divider between webhooks, but not after the last one */} + {index < webhooks.length - 1 && ( +
+ )} +
+ ); + })} + + ); +}