From ca8632d147db7691858b6a22d7cada68632a743a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Thu, 4 Sep 2025 18:46:46 +0100 Subject: [PATCH 01/22] Breez SDK token support --- crates/breez-sdk/cli/src/commands.rs | 11 +- crates/breez-sdk/core/src/models.rs | 104 +++- crates/breez-sdk/core/src/persist/mod.rs | 323 ++++++++++-- crates/breez-sdk/core/src/persist/sqlite.rs | 44 +- crates/breez-sdk/core/src/sdk.rs | 468 ++++++++++++++++-- .../breez-sdk/wasm/js/node-storage/index.cjs | 36 +- .../wasm/js/node-storage/migrations.cjs | 16 +- crates/breez-sdk/wasm/src/models.rs | 39 +- crates/internal/src/command/tokens.rs | 2 +- crates/macros/src/wasm_bindgen.rs | 12 + crates/spark-wallet/src/lib.rs | 6 +- crates/spark-wallet/src/model.rs | 3 +- crates/spark-wallet/src/wallet.rs | 18 +- crates/spark/src/services/models.rs | 33 +- crates/spark/src/services/tokens.rs | 99 +++- .../snippets/rust/src/send_payment.rs | 13 +- 16 files changed, 1076 insertions(+), 151 deletions(-) diff --git a/crates/breez-sdk/cli/src/commands.rs b/crates/breez-sdk/cli/src/commands.rs index 85424e0a..58d67b66 100644 --- a/crates/breez-sdk/cli/src/commands.rs +++ b/crates/breez-sdk/cli/src/commands.rs @@ -63,9 +63,14 @@ pub enum Command { #[arg(short = 'r', long)] payment_request: String, - /// Optional amount to pay in satoshis + /// Optional amount to pay. By default is denominated in sats. + /// If a token identifier is provided, the amount will be denominated in the token base units. #[arg(short = 'a', long)] amount: Option, + + /// Optional token identifier. May only be provided if the payment request is a spark address. + #[arg(short = 't', long)] + token_identifier: Option, }, /// Pay using LNURL @@ -285,11 +290,13 @@ pub(crate) async fn execute_command( Command::Pay { payment_request, amount, + token_identifier, } => { let prepared_payment = sdk .prepare_send_payment(PrepareSendPaymentRequest { payment_request, - amount_sats: amount, + amount, + token_identifier, }) .await; diff --git a/crates/breez-sdk/core/src/models.rs b/crates/breez-sdk/core/src/models.rs index 330b313f..4f2a6ad8 100644 --- a/crates/breez-sdk/core/src/models.rs +++ b/crates/breez-sdk/core/src/models.rs @@ -10,10 +10,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use spark_wallet::{ CoopExitFeeQuote, CoopExitSpeedFeeQuote, ExitSpeed, LightningSendPayment, LightningSendStatus, - Network as SparkNetwork, SspUserRequest, TransferDirection, TransferStatus, TransferType, - WalletTransfer, + Network as SparkNetwork, SspUserRequest, TokenTransactionStatus, TransferDirection, + TransferStatus, TransferType, WalletTransfer, }; -use std::{fmt::Display, str::FromStr, time::UNIX_EPOCH}; +use std::{collections::HashMap, fmt::Display, str::FromStr, time::UNIX_EPOCH}; use crate::sdk_builder::Seed; use crate::{SdkError, error::DepositClaimError}; @@ -96,6 +96,7 @@ impl FromStr for PaymentStatus { pub enum PaymentMethod { Lightning, Spark, + Token, Deposit, Withdraw, Unknown, @@ -106,6 +107,7 @@ impl Display for PaymentMethod { match self { PaymentMethod::Lightning => write!(f, "lightning"), PaymentMethod::Spark => write!(f, "spark"), + PaymentMethod::Token => write!(f, "token"), PaymentMethod::Deposit => write!(f, "deposit"), PaymentMethod::Withdraw => write!(f, "withdraw"), PaymentMethod::Unknown => write!(f, "unknown"), @@ -120,6 +122,7 @@ impl FromStr for PaymentMethod { match s { "lightning" => Ok(PaymentMethod::Lightning), "spark" => Ok(PaymentMethod::Spark), + "token" => Ok(PaymentMethod::Token), "deposit" => Ok(PaymentMethod::Deposit), "withdraw" => Ok(PaymentMethod::Withdraw), "unknown" => Ok(PaymentMethod::Unknown), @@ -169,6 +172,9 @@ pub struct Payment { #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum PaymentDetails { Spark, + Token { + metadata: TokenMetadata, + }, Lightning { /// Represents the invoice description description: Option, @@ -320,6 +326,26 @@ impl TryFrom for Payment { } } +impl PaymentStatus { + pub(crate) fn from_token_transaction_status( + status: TokenTransactionStatus, + is_transfer_transaction: bool, + ) -> Self { + match status { + TokenTransactionStatus::Started + | TokenTransactionStatus::Revealed + | TokenTransactionStatus::Unknown => PaymentStatus::Pending, + TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending, + TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => { + PaymentStatus::Completed + } + TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => { + PaymentStatus::Failed + } + } + } +} + impl Payment { pub fn from_lightning( payment: LightningSendPayment, @@ -537,6 +563,56 @@ pub struct GetInfoRequest { pub struct GetInfoResponse { /// The balance in satoshis pub balance_sats: u64, + /// The balances of the tokens in the wallet keyed by the token identifier + pub token_balances: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct TokenBalance { + pub balance: u64, + pub token_metadata: TokenMetadata, +} + +impl From for TokenBalance { + fn from(value: spark_wallet::TokenBalance) -> Self { + Self { + balance: value.balance.try_into().unwrap_or_default(), // balance will be changed to u128 or similar + token_metadata: value.token_metadata.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct TokenMetadata { + pub identifier: String, + /// Hex representation of the issuer public key + pub issuer_public_key: String, + pub name: String, + pub ticker: String, + /// Number of decimals the token uses + pub decimals: u32, + pub max_supply: u64, + pub is_freezable: bool, + pub creation_entity_public_key: Option, +} + +impl From for TokenMetadata { + fn from(value: spark_wallet::TokenMetadata) -> Self { + Self { + identifier: value.identifier, + issuer_public_key: hex::encode(value.issuer_public_key.serialize()), + name: value.name, + ticker: value.ticker, + decimals: value.decimals, + max_supply: value.max_supply.try_into().unwrap_or_default(), // max_supply will be changed to u128 or similar + is_freezable: value.is_freezable, + creation_entity_public_key: value + .creation_entity_public_key + .map(|pk| hex::encode(pk.serialize())), + } + } } /// Request to sync the wallet with the Spark network @@ -574,7 +650,12 @@ pub enum SendPaymentMethod { }, // should be replaced with the parsed invoice SparkAddress { address: String, - fee_sats: u64, + /// Fee to pay for the transaction + /// Denominated in sats if token identifier is empty, otherwise in the token base units + fee: u64, + /// The presence of this field indicates that the payment is for a token + /// If empty, it is a Bitcoin payment + token_identifier: Option, }, } @@ -755,15 +836,26 @@ impl From for OnchainConfirmationSpeed { #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct PrepareSendPaymentRequest { pub payment_request: String, + /// Amount to send. By default is denominated in sats. + /// If a token identifier is provided, the amount will be denominated in the token base units. #[cfg_attr(feature = "uniffi", uniffi(default=None))] - pub amount_sats: Option, + pub amount: Option, + /// If provided, the payment will be for a token + /// May only be provided if the payment request is a spark address + #[cfg_attr(feature = "uniffi", uniffi(default=None))] + pub token_identifier: Option, } #[derive(Debug, Clone, Serialize)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct PrepareSendPaymentResponse { pub payment_method: SendPaymentMethod, - pub amount_sats: u64, + /// Amount to send. By default is denominated in sats. + /// If a token identifier is provided, the amount will be denominated in the token base units. + pub amount: u64, + /// The presence of this field indicates that the payment is for a token + /// If empty, it is a Bitcoin payment + pub token_identifier: Option, } #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index af60fc13..f91259fd 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -1,13 +1,16 @@ #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub(crate) mod sqlite; -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use macros::async_trait; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, models::Payment}; +use crate::{ + DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, TokenBalance, + models::Payment, +}; const ACCOUNT_INFO_KEY: &str = "account_info"; const LIGHTNING_ADDRESS_KEY: &str = "lightning_address"; @@ -313,11 +316,15 @@ impl ObjectCacheRepository { #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedAccountInfo { pub(crate) balance_sats: u64, + #[serde(default)] + pub(crate) token_balances: HashMap, } #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedSyncInfo { pub(crate) offset: u64, + #[serde(default)] + pub(crate) token_offset: u64, } #[derive(Serialize, Deserialize, Default)] @@ -333,15 +340,18 @@ pub(crate) struct StaticDepositAddress { #[cfg(feature = "test-utils")] pub mod tests { use crate::{ - DepositClaimError, Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, - Storage, UpdateDepositPayload, + DepositClaimError, Payment, PaymentDetails, PaymentMetadata, PaymentMethod, PaymentStatus, + PaymentType, Storage, UpdateDepositPayload, }; use chrono::Utc; + #[allow(clippy::too_many_lines)] pub async fn test_sqlite_storage(storage: Box) { - // Create test payment - let payment = Payment { - id: "pmt123".to_string(), + use crate::models::{LnurlPayInfo, TokenMetadata}; + + // Test 1: Spark payment + let spark_payment = Payment { + id: "spark_pmt123".to_string(), payment_type: PaymentType::Send, status: PaymentStatus::Completed, amount: 100_000, @@ -351,30 +361,285 @@ pub mod tests { details: Some(PaymentDetails::Spark), }; - // Insert payment - storage.insert_payment(payment.clone()).await.unwrap(); + // Test 2: Token payment + let token_metadata = TokenMetadata { + identifier: "token123".to_string(), + issuer_public_key: + "02abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab".to_string(), + name: "Test Token".to_string(), + ticker: "TTK".to_string(), + decimals: 8, + max_supply: 21_000_000, + is_freezable: false, + creation_entity_public_key: Some( + "03fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321fe".to_string(), + ), + }; + let token_payment = Payment { + id: "token_pmt456".to_string(), + payment_type: PaymentType::Receive, + status: PaymentStatus::Pending, + amount: 50_000, + fees: 500, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: token_metadata.clone(), + }), + }; - // List payments + // Test 3: Lightning payment with full details + let metadata = PaymentMetadata { + lnurl_pay_info: Some(LnurlPayInfo { + ln_address: Some("test@example.com".to_string()), + comment: Some("Test comment".to_string()), + domain: Some("example.com".to_string()), + metadata: Some("[[\"text/plain\", \"Test metadata\"]]".to_string()), + processed_success_action: None, + raw_success_action: None, + }), + lnurl_description: None, + }; + let lightning_payment = Payment { + id: "lightning_pmt789".to_string(), + payment_type: PaymentType::Send, + status: PaymentStatus::Completed, + amount: 25_000, + fees: 250, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Lightning, + details: Some(PaymentDetails::Lightning { + description: Some("Test lightning payment".to_string()), + preimage: Some("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab".to_string()), + invoice: "lnbc250n1pjqxyz9pp5abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567890abcdefghijklmnopqrstuvwxyz".to_string(), + payment_hash: "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321".to_string(), + destination_pubkey: "03123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01".to_string(), + lnurl_pay_info: metadata.lnurl_pay_info.clone(), + }), + }; + + // Test 4: Lightning payment with minimal details + let lightning_minimal_payment = Payment { + id: "lightning_minimal_pmt012".to_string(), + payment_type: PaymentType::Receive, + status: PaymentStatus::Failed, + amount: 10_000, + fees: 100, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Lightning, + details: Some(PaymentDetails::Lightning { + description: None, + preimage: None, + invoice: "lnbc100n1pjqxyz9pp5def456ghi789jkl012mno345pqr678stu901vwx234yz567890abcdefghijklmnopqrstuvwxyz".to_string(), + payment_hash: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), + destination_pubkey: "02987654321fedcba0987654321fedcba0987654321fedcba0987654321fedcba09".to_string(), + lnurl_pay_info: None, + }), + }; + + // Test 5: Withdraw payment + let withdraw_payment = Payment { + id: "withdraw_pmt345".to_string(), + payment_type: PaymentType::Send, + status: PaymentStatus::Completed, + amount: 200_000, + fees: 2000, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Withdraw, + details: Some(PaymentDetails::Withdraw { + tx_id: "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12" + .to_string(), + }), + }; + + // Test 6: Deposit payment + let deposit_payment = Payment { + id: "deposit_pmt678".to_string(), + payment_type: PaymentType::Receive, + status: PaymentStatus::Completed, + amount: 150_000, + fees: 1500, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Deposit, + details: Some(PaymentDetails::Deposit { + tx_id: "fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321fe" + .to_string(), + }), + }; + + // Test 7: Payment with no details + let no_details_payment = Payment { + id: "no_details_pmt901".to_string(), + payment_type: PaymentType::Send, + status: PaymentStatus::Pending, + amount: 75_000, + fees: 750, + timestamp: Utc::now().timestamp().try_into().unwrap(), + method: PaymentMethod::Unknown, + details: None, + }; + + let test_payments = vec![ + spark_payment.clone(), + token_payment.clone(), + lightning_payment.clone(), + lightning_minimal_payment.clone(), + withdraw_payment.clone(), + deposit_payment.clone(), + no_details_payment.clone(), + ]; + + // Insert all payments + for payment in &test_payments { + storage.insert_payment(payment.clone()).await.unwrap(); + } + storage + .set_payment_metadata(lightning_payment.id.clone(), metadata) + .await + .unwrap(); + + // List all payments let payments = storage.list_payments(Some(0), Some(10)).await.unwrap(); - assert_eq!(payments.len(), 1); - assert_eq!(payments[0].id, payment.id); - assert_eq!(payments[0].payment_type, payment.payment_type); - assert_eq!(payments[0].status, payment.status); - assert_eq!(payments[0].amount, payment.amount); - assert_eq!(payments[0].fees, payment.fees); - assert!(matches!(payments[0].details, Some(PaymentDetails::Spark))); - - // Get payment by ID - let retrieved_payment = storage.get_payment_by_id(payment.id.clone()).await.unwrap(); - assert_eq!(retrieved_payment.id, payment.id); - assert_eq!(retrieved_payment.payment_type, payment.payment_type); - assert_eq!(retrieved_payment.status, payment.status); - assert_eq!(retrieved_payment.amount, payment.amount); - assert_eq!(retrieved_payment.fees, payment.fees); - assert!(matches!( - retrieved_payment.details, - Some(PaymentDetails::Spark) - )); + assert_eq!(payments.len(), 7); + + // Test each payment type individually + for (i, expected_payment) in test_payments.iter().enumerate() { + let retrieved_payment = storage + .get_payment_by_id(expected_payment.id.clone()) + .await + .unwrap(); + + // Basic fields + assert_eq!(retrieved_payment.id, expected_payment.id); + assert_eq!( + retrieved_payment.payment_type, + expected_payment.payment_type + ); + assert_eq!(retrieved_payment.status, expected_payment.status); + assert_eq!(retrieved_payment.amount, expected_payment.amount); + assert_eq!(retrieved_payment.fees, expected_payment.fees); + assert_eq!(retrieved_payment.method, expected_payment.method); + + // Test payment details persistence + match (&retrieved_payment.details, &expected_payment.details) { + (Some(PaymentDetails::Spark), Some(PaymentDetails::Spark)) | (None, None) => {} + ( + Some(PaymentDetails::Token { + metadata: retrieved_metadata, + }), + Some(PaymentDetails::Token { + metadata: expected_metadata, + }), + ) => { + assert_eq!(retrieved_metadata.identifier, expected_metadata.identifier); + assert_eq!( + retrieved_metadata.issuer_public_key, + expected_metadata.issuer_public_key + ); + assert_eq!(retrieved_metadata.name, expected_metadata.name); + assert_eq!(retrieved_metadata.ticker, expected_metadata.ticker); + assert_eq!(retrieved_metadata.decimals, expected_metadata.decimals); + assert_eq!(retrieved_metadata.max_supply, expected_metadata.max_supply); + assert_eq!( + retrieved_metadata.is_freezable, + expected_metadata.is_freezable + ); + assert_eq!( + retrieved_metadata.creation_entity_public_key, + expected_metadata.creation_entity_public_key + ); + } + ( + Some(PaymentDetails::Lightning { + description: r_description, + preimage: r_preimage, + invoice: r_invoice, + payment_hash: r_hash, + destination_pubkey: r_dest_pubkey, + lnurl_pay_info: r_lnurl, + }), + Some(PaymentDetails::Lightning { + description: e_description, + preimage: e_preimage, + invoice: e_invoice, + payment_hash: e_hash, + destination_pubkey: e_dest_pubkey, + lnurl_pay_info: e_lnurl, + }), + ) => { + assert_eq!(r_description, e_description); + assert_eq!(r_preimage, e_preimage); + assert_eq!(r_invoice, e_invoice); + assert_eq!(r_hash, e_hash); + assert_eq!(r_dest_pubkey, e_dest_pubkey); + + // Test LNURL pay info if present + match (r_lnurl, e_lnurl) { + (Some(r_info), Some(e_info)) => { + assert_eq!(r_info.ln_address, e_info.ln_address); + assert_eq!(r_info.comment, e_info.comment); + assert_eq!(r_info.domain, e_info.domain); + assert_eq!(r_info.metadata, e_info.metadata); + } + (None, None) => {} + _ => panic!( + "LNURL pay info mismatch for payment {}", + expected_payment.id + ), + } + } + ( + Some(PaymentDetails::Withdraw { tx_id: r_tx_id }), + Some(PaymentDetails::Withdraw { tx_id: e_tx_id }), + ) + | ( + Some(PaymentDetails::Deposit { tx_id: r_tx_id }), + Some(PaymentDetails::Deposit { tx_id: e_tx_id }), + ) => { + assert_eq!(r_tx_id, e_tx_id); + } + _ => panic!( + "Payment details mismatch for payment {} (index {})", + expected_payment.id, i + ), + } + } + + // Test filtering by payment type + let send_payments = payments + .iter() + .filter(|p| p.payment_type == PaymentType::Send) + .count(); + let receive_payments = payments + .iter() + .filter(|p| p.payment_type == PaymentType::Receive) + .count(); + assert_eq!(send_payments, 4); // spark, lightning, withdraw, no_details + assert_eq!(receive_payments, 3); // token, lightning_minimal, deposit + + // Test filtering by status + let completed_payments = payments + .iter() + .filter(|p| p.status == PaymentStatus::Completed) + .count(); + let pending_payments = payments + .iter() + .filter(|p| p.status == PaymentStatus::Pending) + .count(); + let failed_payments = payments + .iter() + .filter(|p| p.status == PaymentStatus::Failed) + .count(); + assert_eq!(completed_payments, 4); // spark, lightning, withdraw, deposit + assert_eq!(pending_payments, 2); // token, no_details + assert_eq!(failed_payments, 1); // lightning_minimal + + // Test filtering by method + let lightning_count = payments + .iter() + .filter(|p| p.method == PaymentMethod::Lightning) + .count(); + assert_eq!(lightning_count, 2); // lightning and lightning_minimal } pub async fn test_unclaimed_deposits_crud(storage: Box) { diff --git a/crates/breez-sdk/core/src/persist/sqlite.rs b/crates/breez-sdk/core/src/persist/sqlite.rs index 9ab174c1..1b29f69d 100644 --- a/crates/breez-sdk/core/src/persist/sqlite.rs +++ b/crates/breez-sdk/core/src/persist/sqlite.rs @@ -162,6 +162,7 @@ impl SqliteStorage { CREATE INDEX idx_payment_details_lightning_invoice ON payment_details_lightning(invoice); ", + "ALTER TABLE payments ADD COLUMN token_metadata TEXT;", ] } } @@ -198,6 +199,7 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -256,6 +258,12 @@ impl Storage for SqliteStorage { params![payment.id], )?; } + Some(PaymentDetails::Token { metadata }) => { + tx.execute( + "UPDATE payments SET token_metadata = ? WHERE id = ?", + params![serde_json::to_string(&metadata)?, payment.id], + )?; + } Some(PaymentDetails::Lightning { invoice, payment_hash, @@ -349,6 +357,7 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -382,6 +391,7 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -479,14 +489,21 @@ fn map_payment(row: &Row<'_>) -> Result { let withdraw_tx_id: Option = row.get(7)?; let deposit_tx_id: Option = row.get(8)?; let spark: Option = row.get(9)?; - let lightning_invoice: Option = row.get(10)?; - let details = match (lightning_invoice, withdraw_tx_id, deposit_tx_id, spark) { - (Some(invoice), _, _, _) => { - let payment_hash: String = row.get(11)?; - let destination_pubkey: String = row.get(12)?; - let description: Option = row.get(13)?; - let preimage: Option = row.get(14)?; - let lnurl_pay_info: Option = row.get(15)?; + let token_metadata: Option = row.get(10)?; + let lightning_invoice: Option = row.get(11)?; + let details = match ( + lightning_invoice, + withdraw_tx_id, + deposit_tx_id, + spark, + token_metadata, + ) { + (Some(invoice), _, _, _, _) => { + let payment_hash: String = row.get(12)?; + let destination_pubkey: String = row.get(13)?; + let description: Option = row.get(14)?; + let preimage: Option = row.get(15)?; + let lnurl_pay_info: Option = row.get(16)?; Some(PaymentDetails::Lightning { invoice, @@ -497,9 +514,14 @@ fn map_payment(row: &Row<'_>) -> Result { lnurl_pay_info, }) } - (_, Some(tx_id), _, _) => Some(PaymentDetails::Withdraw { tx_id }), - (_, _, Some(tx_id), _) => Some(PaymentDetails::Deposit { tx_id }), - (_, _, _, Some(_)) => Some(PaymentDetails::Spark), + (_, Some(tx_id), _, _, _) => Some(PaymentDetails::Withdraw { tx_id }), + (_, _, Some(tx_id), _, _) => Some(PaymentDetails::Deposit { tx_id }), + (_, _, _, Some(_), _) => Some(PaymentDetails::Spark), + (_, _, _, _, Some(metadata)) => Some(PaymentDetails::Token { + metadata: serde_json::from_str(&metadata).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, e.into()) + })?, + }), _ => None, }; Ok(Payment { diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 3ad4235a..328286b1 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -20,10 +20,10 @@ use breez_sdk_common::{ rest::RestClient, }; use spark_wallet::{ - ExitSpeed, InvoiceDescription, Order, PagingFilter, SparkAddress, SparkWallet, WalletEvent, - WalletTransfer, + ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, Order, PagingFilter, SparkAddress, + SparkWallet, TransferTokenOutput, WalletEvent, WalletTransfer, }; -use std::{str::FromStr, sync::Arc}; +use std::{str::FromStr, sync::Arc, time::UNIX_EPOCH}; use tracing::{error, info, trace}; use web_time::{Duration, SystemTime}; @@ -41,10 +41,10 @@ use crate::{ DepositInfo, Fee, GetPaymentRequest, GetPaymentResponse, LightningAddressInfo, ListFiatCurrenciesResponse, ListFiatRatesResponse, ListUnclaimedDepositsRequest, ListUnclaimedDepositsResponse, LnurlPayInfo, LnurlPayRequest, LnurlPayResponse, Logger, - Network, PaymentDetails, PaymentStatus, PrepareLnurlPayRequest, PrepareLnurlPayResponse, - RefundDepositRequest, RefundDepositResponse, RegisterLightningAddressRequest, - SendOnchainFeeQuote, SendPaymentOptions, WaitForPaymentIdentifier, WaitForPaymentRequest, - WaitForPaymentResponse, + Network, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, PrepareLnurlPayRequest, + PrepareLnurlPayResponse, RefundDepositRequest, RefundDepositResponse, + RegisterLightningAddressRequest, SendOnchainFeeQuote, SendPaymentOptions, TokenMetadata, + WaitForPaymentIdentifier, WaitForPaymentRequest, WaitForPaymentResponse, error::SdkError, events::{EventEmitter, EventListener, SdkEvent}, lnurl::LnurlServerClient, @@ -66,6 +66,8 @@ use crate::{ }, }; +const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; + #[derive(Clone, Debug)] enum SyncType { Full, @@ -374,8 +376,8 @@ impl BreezSdk { self.spark_wallet.sync().await?; info!("sync_wallet_internal: Synced with Spark network completed"); } - self.sync_payments_to_storage().await?; - info!("sync_wallet_internal: Synced payments to storage completed"); + self.sync_wallet_state_to_storage().await?; + info!("sync_wallet_internal: Synced wallet state to storage completed"); self.check_and_claim_static_deposits().await?; info!("sync_wallet_internal: Checked and claimed static deposits completed"); let elapsed = start_time.elapsed(); @@ -384,14 +386,23 @@ impl BreezSdk { Ok(()) } - /// Synchronizes payments from transfers to persistent storage - async fn sync_payments_to_storage(&self) -> Result<(), SdkError> { - const BATCH_SIZE: u64 = 50; + /// Synchronizes wallet state to persistent storage, making sure we have the latest balances and payments. + async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> { + update_balances(self.spark_wallet.clone(), self.storage.clone()).await?; - // Sync balance - update_balance(self.spark_wallet.clone(), self.storage.clone()).await?; let object_repository = ObjectCacheRepository::new(self.storage.clone()); + self.sync_bitcoin_payments_to_storage(&object_repository) + .await?; + self.sync_token_payments_to_storage(&object_repository) + .await?; + + Ok(()) + } + async fn sync_bitcoin_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { // Get the last offset we processed from storage let cached_sync_info = object_repository .fetch_sync_info() @@ -402,7 +413,7 @@ impl BreezSdk { // We'll keep querying in batches until we have all transfers let mut next_filter = Some(PagingFilter { offset: current_offset, - limit: BATCH_SIZE, + limit: PAYMENT_SYNC_BATCH_SIZE, order: Order::Ascending, }); info!("Syncing payments to storage, offset = {}", current_offset); @@ -415,7 +426,7 @@ impl BreezSdk { .await?; info!( - "Syncing payments to storage, offset = {}, transfers = {}", + "Syncing bitcoin payments to storage, offset = {}, transfers = {}", filter.offset, transfers_response.len() ); @@ -425,12 +436,12 @@ impl BreezSdk { let payment: Payment = transfer.clone().try_into()?; // Insert payment into storage if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert payment: {err:?}"); + error!("Failed to insert bitcoin payment: {err:?}"); } if payment.status == PaymentStatus::Pending { pending_payments = pending_payments.saturating_add(1); } - info!("Inserted payment: {payment:?}"); + info!("Inserted bitcoin payment: {payment:?}"); } // Check if we have more transfers to fetch @@ -443,11 +454,12 @@ impl BreezSdk { let save_res = object_repository .save_sync_info(&CachedSyncInfo { offset: cache_offset.saturating_sub(pending_payments), + token_offset: cached_sync_info.token_offset, }) .await; if let Err(err) = save_res { - error!("Failed to update last sync offset: {err:?}"); + error!("Failed to update last sync bitcoin offset: {err:?}"); } next_filter = transfers_response.next; @@ -456,6 +468,273 @@ impl BreezSdk { Ok(()) } + #[allow(clippy::too_many_lines)] + async fn sync_token_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { + // Get the last offsets we processed from storage + let cached_sync_info = object_repository + .fetch_sync_info() + .await? + .unwrap_or_default(); + let current_token_offset = cached_sync_info.token_offset; + + let our_public_key = self.spark_wallet.get_identity_public_key(); + + // We'll keep querying in batches until we have all transfers + let mut next_offset = current_token_offset; + let mut has_more = true; + info!("Syncing token payments to storage, offset = {next_offset}"); + while has_more { + // Get batch of token transactions starting from current offset + let token_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + Some(Order::Ascending), + )), + ..Default::default() + }) + .await? + .items; + + // Get prev out hashes of first input of each token transaction + // Assumes all inputs of a tx share the same owner public key + let token_transactions_prevout_hashes = token_transactions + .iter() + .filter_map(|tx| match &tx.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + token_transfer_input.outputs_to_spend.first().cloned() + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + None + } + }) + .map(|output| output.prev_token_tx_hash) + .collect::>(); + + // Since we are trying to fetch at most 1 parent transaction per token transaction, + // we can fetch all in one go using same batch size + let parent_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), + owner_public_keys: Some(Vec::new()), + token_transaction_hashes: token_transactions_prevout_hashes, + ..Default::default() + }) + .await? + .items; + + info!( + "Syncing token payments to storage, offset = {next_offset}, transactions = {}", + token_transactions.len() + ); + // Process transfers in this batch + for transaction in &token_transactions { + let tx_inputs_are_ours = match &transaction.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + let Some(first_input) = token_transfer_input.outputs_to_spend.first() + else { + return Err(SdkError::Generic( + "No input in token transfer input".to_string(), + )); + }; + let Some(parent_transaction) = parent_transactions + .iter() + .find(|tx| tx.hash == first_input.prev_token_tx_hash) + else { + return Err(SdkError::Generic( + "Parent transaction not found".to_string(), + )); + }; + let Some(output) = parent_transaction + .outputs + .get(first_input.prev_token_tx_vout as usize) + else { + return Err(SdkError::Generic("Output not found".to_string())); + }; + output.owner_public_key == our_public_key + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + false + } + }; + + // Create payment records + let payments = self + .token_transaction_to_payments(transaction, tx_inputs_are_ours) + .await?; + + for payment in payments { + // Insert payment into storage + if let Err(err) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert token payment: {err:?}"); + } + info!("Inserted token payment: {payment:?}"); + } + } + + // Check if we have more transfers to fetch + next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); + // Update our last processed offset in the storage + let save_res = object_repository + .save_sync_info(&CachedSyncInfo { + offset: cached_sync_info.offset, + token_offset: next_offset, + }) + .await; + + if let Err(err) = save_res { + error!("Failed to update last sync token offset: {err:?}"); + } + has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } + + Ok(()) + } + + /// Converts a token transaction to payments + /// + /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. + /// The id of the payment is the id of the first output in the group. + /// + /// Assumptions: + /// - All outputs of a token transaction share the same token identifier + /// - All inputs of a token transaction share the same owner public key + #[allow(clippy::too_many_lines)] + async fn token_transaction_to_payments( + &self, + transaction: &spark_wallet::TokenTransaction, + tx_inputs_are_ours: bool, + ) -> Result, SdkError> { + // Get token metadata for the first output (assuming all outputs have the same token) + let token_identifier = transaction + .outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in token transaction".to_string(), + ))? + .token_identifier + .as_ref(); + let metadata: TokenMetadata = self + .spark_wallet + .get_tokens_metadata(&[token_identifier]) + .await? + .first() + .ok_or(SdkError::Generic("Token metadata not found".to_string()))? + .clone() + .into(); + + let is_transfer_transaction = + matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); + + let timestamp = transaction + .created_timestamp + .duration_since(UNIX_EPOCH) + .map_err(|_| { + SdkError::Generic( + "Token transaction created timestamp is before UNIX_EPOCH".to_string(), + ) + })? + .as_secs(); + + // Group outputs by owner public key + let mut outputs_by_owner = std::collections::HashMap::new(); + for output in &transaction.outputs { + outputs_by_owner + .entry(output.owner_public_key) + .or_insert_with(Vec::new) + .push(output); + } + + let mut payments = Vec::new(); + + if tx_inputs_are_ours { + // If inputs are ours, add an outgoing payment for each output group that is not ours + for (owner_pubkey, outputs) in outputs_by_owner { + if owner_pubkey != self.spark_wallet.get_identity_public_key() { + // This is an outgoing payment to another user + let total_amount = outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = outputs + .first() + .ok_or(SdkError::Generic("No outputs in output group".to_string()))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Send, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, // TODO: calculate actual fees when they start being charged + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that belong to us (potential change outputs) + } + } else { + // If inputs are not ours, add an incoming payment for our output group + if let Some(our_outputs) = + outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) + { + let total_amount: u64 = our_outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = our_outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in our output group".to_string(), + ))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Receive, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { metadata }), + }; + + payments.push(payment); + } + // Ignore outputs that don't belong to us + } + + Ok(payments) + } + async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> { let to_claim = DepositChainSyncer::new( self.chain_service.clone(), @@ -645,6 +924,7 @@ impl BreezSdk { .unwrap_or_default(); Ok(GetInfoResponse { balance_sats: account_info.balance_sats, + token_balances: account_info.token_balances, }) } @@ -741,7 +1021,8 @@ impl BreezSdk { let prepare_response = self .prepare_send_payment(PrepareSendPaymentRequest { payment_request: success_data.pr, - amount_sats: Some(request.amount_sats), + amount: Some(request.amount_sats), + token_identifier: None, }) .await?; @@ -776,7 +1057,8 @@ impl BreezSdk { spark_transfer_fee_sats: None, lightning_fee_sats: request.prepare_response.fee_sats, }, - amount_sats: request.prepare_response.amount_sats, + amount: request.prepare_response.amount_sats, + token_identifier: None, }, options: None, }, @@ -828,22 +1110,63 @@ impl BreezSdk { }) } + #[allow(clippy::too_many_lines)] pub async fn prepare_send_payment( &self, request: PrepareSendPaymentRequest, ) -> Result { // First check for spark address if let Ok(spark_address) = request.payment_request.parse::() { + let payment_request_amount = if let Some(invoice_fields) = + &spark_address.spark_invoice_fields + && let Some(payment_type) = &invoice_fields.payment_type + { + match payment_type { + spark_wallet::SparkAddressPaymentType::SatsPayment(sats_payment_details) => { + if request.token_identifier.is_some() { + return Err(SdkError::InvalidInput( + "Token identifier can't be provided for this payment request: spark sats payment".to_string(), + )); + } + sats_payment_details.amount + } + spark_wallet::SparkAddressPaymentType::TokensPayment( + tokens_payment_details, + ) => { + if request.token_identifier.is_none() { + return Err(SdkError::InvalidInput( + "Token identifier is required for this payment request: spark tokens payment".to_string(), + )); + } + tokens_payment_details.amount + } + } + } else { + None + }; + return Ok(PrepareSendPaymentResponse { payment_method: SendPaymentMethod::SparkAddress { address: spark_address.to_string(), - fee_sats: 0, + fee: 0, + token_identifier: request.token_identifier.clone(), }, - amount_sats: request - .amount_sats + amount: payment_request_amount + .or(request.amount) .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?, + token_identifier: request.token_identifier, }); } + + if request.token_identifier.is_some() { + return Err(SdkError::InvalidInput( + "Token identifier can't be provided for this payment request: non-spark address" + .to_string(), + )); + } + + let amount_sats = request.amount; + // Then check for other types of inputs let parsed_input = parse(&request.payment_request).await?; match &parsed_input { @@ -860,10 +1183,7 @@ impl BreezSdk { let lightning_fee_sats = self .spark_wallet - .fetch_lightning_send_fee_estimate( - &request.payment_request, - request.amount_sats, - ) + .fetch_lightning_send_fee_estimate(&request.payment_request, amount_sats) .await?; Ok(PrepareSendPaymentResponse { @@ -872,10 +1192,10 @@ impl BreezSdk { spark_transfer_fee_sats, lightning_fee_sats, }, - amount_sats: request - .amount_sats + amount: amount_sats .or(detailed_bolt11_invoice.amount_msat.map(|msat| msat / 1000)) .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?, + token_identifier: None, }) } InputType::BitcoinAddress(withdrawal_address) => { @@ -884,8 +1204,7 @@ impl BreezSdk { .fetch_coop_exit_fee_quote( &withdrawal_address.address, Some( - request - .amount_sats + amount_sats .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?, ), ) @@ -895,9 +1214,9 @@ impl BreezSdk { address: withdrawal_address.clone(), fee_quote: fee_quote.into(), }, - amount_sats: request - .amount_sats + amount: amount_sats .ok_or(SdkError::InvalidInput("Amount is required".to_string()))?, + token_identifier: None, }) } _ => Err(SdkError::InvalidInput( @@ -913,14 +1232,20 @@ impl BreezSdk { self.send_payment_internal(request, false).await } + #[allow(clippy::too_many_lines)] async fn send_payment_internal( &self, request: SendPaymentRequest, suppress_payment_event: bool, ) -> Result { let res = match &request.prepare_response.payment_method { - SendPaymentMethod::SparkAddress { address, .. } => { - self.send_spark_address(address, &request).await + SendPaymentMethod::SparkAddress { + address, + token_identifier, + .. + } => { + self.send_spark_address(address, token_identifier.clone(), &request) + .await } SendPaymentMethod::Bolt11Invoice { invoice_details, @@ -957,18 +1282,52 @@ impl BreezSdk { async fn send_spark_address( &self, address: &str, + token_identifier: Option, request: &SendPaymentRequest, ) -> Result { let spark_address = address .parse::() .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?; - let transfer = self - .spark_wallet - .transfer(request.prepare_response.amount_sats, &spark_address) - .await?; - Ok(SendPaymentResponse { - payment: transfer.try_into()?, - }) + + let payment = if let Some(identifier) = token_identifier { + let tx_hash = self + .spark_wallet + .transfer_tokens(vec![TransferTokenOutput { + token_id: identifier.clone(), + amount: u128::from(request.prepare_response.amount), + receiver_address: spark_address, + }]) + .await?; + let tx = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + token_transaction_hashes: vec![tx_hash], + paging: None, + ..Default::default() + }) + .await? + .items + .first() + .ok_or(SdkError::Generic( + "Token transaction not found after being started".to_string(), + ))? + .clone(); + self.token_transaction_to_payments(&tx, true) + .await? + .first() + .ok_or(SdkError::Generic( + "No payment found in started token transaction".to_string(), + ))? + .clone() + } else { + let transfer = self + .spark_wallet + .transfer(request.prepare_response.amount, &spark_address) + .await?; + transfer.try_into()? + }; + + Ok(SendPaymentResponse { payment }) } async fn send_bolt11_invoice( @@ -982,7 +1341,7 @@ impl BreezSdk { // We are not sending amount in case the invoice contains it. Some(_) => None, // We are sending amount for zero amount invoice - None => Some(request.prepare_response.amount_sats), + None => Some(request.prepare_response.amount), }; let (prefer_spark, completion_timeout_secs) = match request.options { Some(SendPaymentOptions::Bolt11Invoice { @@ -1010,7 +1369,7 @@ impl BreezSdk { let ssp_id = lightning_payment.id.clone(); let payment = Payment::from_lightning( lightning_payment, - request.prepare_response.amount_sats, + request.prepare_response.amount, payment_response.transfer.id.to_string(), )?; self.poll_lightning_send_payment(&payment, ssp_id); @@ -1059,7 +1418,7 @@ impl BreezSdk { .spark_wallet .withdraw( &address.address, - Some(request.prepare_response.amount_sats), + Some(request.prepare_response.amount), exit_speed, fee_quote.clone().into(), ) @@ -1416,6 +1775,7 @@ fn is_payment_match(payment: &Payment, request: &WaitForPaymentRequest) -> bool invoice.to_lowercase() == payment_request.to_lowercase() } PaymentDetails::Spark + | PaymentDetails::Token { .. } | PaymentDetails::Withdraw { tx_id: _ } | PaymentDetails::Deposit { tx_id: _ } => false, } @@ -1445,7 +1805,7 @@ impl EventListener for BalanceWatcher { async fn on_event(&self, event: SdkEvent) { match event { SdkEvent::PaymentSucceeded { .. } | SdkEvent::ClaimDepositsSucceeded { .. } => { - match update_balance(self.spark_wallet.clone(), self.storage.clone()).await { + match update_balances(self.spark_wallet.clone(), self.storage.clone()).await { Ok(()) => info!("Balance updated successfully"), Err(e) => error!("Failed to update balance: {e:?}"), } @@ -1455,15 +1815,23 @@ impl EventListener for BalanceWatcher { } } -async fn update_balance( +async fn update_balances( spark_wallet: Arc, storage: Arc, ) -> Result<(), SdkError> { - let balance = spark_wallet.get_balance().await?; + let balance_sats = spark_wallet.get_balance().await?; + let token_balances = spark_wallet + .get_token_balances() + .await? + .into_iter() + .map(|(k, v)| (k, v.into())) + .collect(); let object_repository = ObjectCacheRepository::new(storage.clone()); + object_repository .save_account_info(&CachedAccountInfo { - balance_sats: balance, + balance_sats, + token_balances, }) .await?; Ok(()) diff --git a/crates/breez-sdk/wasm/js/node-storage/index.cjs b/crates/breez-sdk/wasm/js/node-storage/index.cjs index d3890379..e5ab73a3 100644 --- a/crates/breez-sdk/wasm/js/node-storage/index.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/index.cjs @@ -136,6 +136,7 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -170,8 +171,8 @@ class SqliteStorage { } const paymentInsert = this.db.prepare( - `INSERT OR REPLACE INTO payments (id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark) - VALUES (@id, @paymentType, @status, @amount, @fees, @timestamp, @method, @withdrawTxId, @depositTxId, @spark)` + `INSERT OR REPLACE INTO payments (id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark, token_metadata) + VALUES (@id, @paymentType, @status, @amount, @fees, @timestamp, @method, @withdrawTxId, @depositTxId, @spark, @tokenMetadata)` ); const lightningInsert = this.db.prepare( `INSERT OR REPLACE INTO payment_details_lightning @@ -187,12 +188,18 @@ class SqliteStorage { fees: payment.fees, timestamp: payment.timestamp, method: payment.method ? JSON.stringify(payment.method) : null, - withdrawTxId: payment.details?.type === 'withdraw' ? payment.details.txId : null, - depositTxId: payment.details?.type === 'deposit' ? payment.details.txId : null, - spark: payment.details?.type === 'spark' ? 1 : null, + withdrawTxId: + payment.details?.type === "withdraw" ? payment.details.txId : null, + depositTxId: + payment.details?.type === "deposit" ? payment.details.txId : null, + spark: payment.details?.type === "spark" ? 1 : null, + tokenMetadata: + payment.details?.type === "token" + ? JSON.stringify(payment.details.metadata) + : null, }); - if (payment.details?.type === 'lightning') { + if (payment.details?.type === "lightning") { lightningInsert.run({ id: payment.id, invoice: payment.details.invoice, @@ -203,7 +210,7 @@ class SqliteStorage { }); } }); - + transaction(); return Promise.resolve(); } catch (error) { @@ -235,6 +242,7 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -286,6 +294,7 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark + , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey @@ -444,7 +453,7 @@ class SqliteStorage { let details = null; if (row.lightning_invoice) { details = { - type: 'lightning', + type: "lightning", invoice: row.lightning_invoice, paymentHash: row.lightning_payment_hash, destinationPubkey: row.lightning_destination_pubkey, @@ -464,19 +473,24 @@ class SqliteStorage { } } else if (row.withdraw_tx_id) { details = { - type: 'withdraw', + type: "withdraw", txId: row.withdraw_tx_id, }; } else if (row.deposit_tx_id) { details = { - type: 'deposit', + type: "deposit", txId: row.deposit_tx_id, }; } else if (row.spark) { details = { - type: 'spark', + type: "spark", amount: row.spark, }; + } else if (row.token_metadata) { + details = { + type: "token", + metadata: JSON.parse(row.token_metadata), + }; } let method = null; diff --git a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs index 0b4fa70b..67bdf473 100644 --- a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs @@ -169,14 +169,18 @@ class MigrationManager { json_extract(details, '$.Lightning.destination_pubkey'), json_extract(details, '$.Lightning.description'), json_extract(details, '$.Lightning.preimage') FROM payments WHERE json_extract(details, '$.Lightning.invoice') IS NOT NULL`, - `UPDATE payments SET withdraw_tx_id = json_extract(details, '$.Withdraw.tx_id') + `UPDATE payments SET withdraw_tx_id = json_extract(details, '$.Withdraw.tx_id') WHERE json_extract(details, '$.Withdraw.tx_id') IS NOT NULL`, - `UPDATE payments SET deposit_tx_id = json_extract(details, '$.Deposit.tx_id') + `UPDATE payments SET deposit_tx_id = json_extract(details, '$.Deposit.tx_id') WHERE json_extract(details, '$.Deposit.tx_id') IS NOT NULL`, - `ALTER TABLE payments DROP COLUMN details`, - `CREATE INDEX idx_payment_details_lightning_invoice ON payment_details_lightning(invoice)`, - ] - } + `ALTER TABLE payments DROP COLUMN details`, + `CREATE INDEX idx_payment_details_lightning_invoice ON payment_details_lightning(invoice)`, + ], + }, + { + name: "Add token_metadata column to payments", + sql: `ALTER TABLE payments ADD COLUMN token_metadata TEXT`, + }, ]; } } diff --git a/crates/breez-sdk/wasm/src/models.rs b/crates/breez-sdk/wasm/src/models.rs index 343245f1..2d469e01 100644 --- a/crates/breez-sdk/wasm/src/models.rs +++ b/crates/breez-sdk/wasm/src/models.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; + +use wasm_bindgen::prelude::wasm_bindgen; + #[allow(clippy::large_enum_variant)] #[macros::extern_wasm_bindgen(breez_sdk_spark::SdkEvent)] pub enum SdkEvent { @@ -367,6 +371,9 @@ pub struct Payment { #[macros::extern_wasm_bindgen(breez_sdk_spark::PaymentDetails)] pub enum PaymentDetails { Spark, + Token { + metadata: TokenMetadata, + }, Lightning { description: Option, preimage: Option, @@ -387,6 +394,7 @@ pub enum PaymentDetails { pub enum PaymentMethod { Lightning, Spark, + Token, Deposit, Withdraw, Unknown, @@ -492,6 +500,28 @@ pub struct GetInfoRequest { #[macros::extern_wasm_bindgen(breez_sdk_spark::GetInfoResponse)] pub struct GetInfoResponse { pub balance_sats: u64, + pub token_balances: HashMap, +} + +#[macros::extern_wasm_bindgen(breez_sdk_spark::TokenBalance)] +pub struct TokenBalance { + pub balance: u64, + pub token_metadata: TokenMetadata, +} + +#[macros::extern_wasm_bindgen(breez_sdk_spark::TokenMetadata)] +pub struct TokenMetadata { + pub identifier: String, + /// Hex representation of the issuer public key + pub issuer_public_key: String, + pub name: String, + pub ticker: String, + /// Number of decimals the token uses + pub decimals: u32, + /// Decimal representation of the token max supply (unsigned 128-bit integer) + pub max_supply: u64, + pub is_freezable: bool, + pub creation_entity_public_key: Option, } #[macros::extern_wasm_bindgen(breez_sdk_spark::SyncWalletRequest)] @@ -538,7 +568,8 @@ pub enum SendPaymentMethod { }, // should be replaced with the parsed invoice SparkAddress { address: String, - fee_sats: u64, + fee: u64, + token_identifier: Option, }, } @@ -585,13 +616,15 @@ pub struct LnurlPayResponse { #[macros::extern_wasm_bindgen(breez_sdk_spark::PrepareSendPaymentRequest)] pub struct PrepareSendPaymentRequest { pub payment_request: String, - pub amount_sats: Option, + pub amount: Option, + pub token_identifier: Option, } #[macros::extern_wasm_bindgen(breez_sdk_spark::PrepareSendPaymentResponse)] pub struct PrepareSendPaymentResponse { pub payment_method: SendPaymentMethod, - pub amount_sats: u64, + pub amount: u64, + pub token_identifier: Option, } #[macros::extern_wasm_bindgen(breez_sdk_spark::OnchainConfirmationSpeed)] diff --git a/crates/internal/src/command/tokens.rs b/crates/internal/src/command/tokens.rs index 0abba887..e95de7d2 100644 --- a/crates/internal/src/command/tokens.rs +++ b/crates/internal/src/command/tokens.rs @@ -110,7 +110,7 @@ pub async fn handle_command( .map(|o| o.try_into()) .collect::, _>>()?; let transfer_id = wallet.transfer_tokens(outputs).await?; - println!("Transaction ID: {transfer_id}"); + println!("Transaction ID: {transfer_id:?}"); Ok(()) } TokensCommand::ListTransactions { limit, offset } => { diff --git a/crates/macros/src/wasm_bindgen.rs b/crates/macros/src/wasm_bindgen.rs index 90c121e1..e2f66067 100644 --- a/crates/macros/src/wasm_bindgen.rs +++ b/crates/macros/src/wasm_bindgen.rs @@ -251,9 +251,13 @@ fn get_struct_fields(fields: &Fields) -> Vec { let segment = &type_path.path.segments[0]; if segment.ident == "Vec" { quote! { #ident: val.#ident.into_iter().map(|i| i.into()).collect() } + } else if segment.ident == "HashMap" { + quote! { #ident: val.#ident.into_iter().map(|(k, v)| (k, v.into())).collect() } } else if segment.ident == "Option" { if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "Vec") { quote! { #ident: val.#ident.map(|i| i.into_iter().map(|a| a.into()).collect()) } + } else if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "HashMap") { + quote! { #ident: val.#ident.map(|i| i.into_iter().map(|(k, v)| (k, v.into())).collect()) } } else { quote! { #ident: val.#ident.map(|i| i.into()) } } @@ -280,9 +284,13 @@ fn get_enum_fields(fields: &Fields) -> Vec { let segment = &type_path.path.segments[0]; if segment.ident == "Vec" { quote! { #ident: #ident.into_iter().map(|i| i.into()).collect() } + } else if segment.ident == "HashMap" { + quote! { #ident: #ident.into_iter().map(|(k, v)| (k, v.into())).collect() } } else if segment.ident == "Option" { if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "Vec") { quote! { #ident: #ident.map(|i| i.into_iter().map(|a| a.into()).collect()) } + } else if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "HashMap") { + quote! { #ident: #ident.map(|i| i.into_iter().map(|(k, v)| (k, v.into())).collect()) } } else { quote! { #ident: #ident.map(|i| i.into()) } } @@ -308,9 +316,13 @@ fn get_enum_fields(fields: &Fields) -> Vec { let segment = &type_path.path.segments[0]; if segment.ident == "Vec" { quote! { #ident.into_iter().map(|i| i.into()).collect() } + } else if segment.ident == "HashMap" { + quote! { #ident.into_iter().map(|(k, v)| (k, v.into())).collect() } } else if segment.ident == "Option" { if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "Vec") { quote! { #ident.map(|i| i.into_iter().map(|a| a.into()).collect()) } + } else if get_path(&segment.arguments).is_some_and(|tp| tp.path.segments[0].ident == "HashMap") { + quote! { #ident.map(|i| i.into_iter().map(|(k, v)| (k, v.into())).collect()) } } else { quote! { #ident.map(|i| i.into()) } } diff --git a/crates/spark-wallet/src/lib.rs b/crates/spark-wallet/src/lib.rs index 5224b2cd..874b9a77 100644 --- a/crates/spark-wallet/src/lib.rs +++ b/crates/spark-wallet/src/lib.rs @@ -12,13 +12,13 @@ pub use model::*; pub use spark::operator::{OperatorConfig, OperatorError, OperatorPoolConfig}; pub use spark::{ Identifier, Network, - address::SparkAddress, + address::{SparkAddress, SparkAddressPaymentType}, operator::rpc::{ConnectionManager, DefaultConnectionManager}, services::TokensConfig, services::{ CoopExitFeeQuote, CoopExitSpeedFeeQuote, CpfpUtxo, ExitSpeed, Fee, InvoiceDescription, - LightningSendPayment, LightningSendStatus, TransferStatus, TransferTokenOutput, - TransferType, Utxo, + LightningSendPayment, LightningSendStatus, TokenInputs, TokenMetadata, TokenTransaction, + TokenTransactionStatus, TransferStatus, TransferTokenOutput, TransferType, Utxo, }, session_manager::*, signer::{DefaultSigner, DefaultSignerError, KeySet, KeySetType, Signer}, diff --git a/crates/spark-wallet/src/model.rs b/crates/spark-wallet/src/model.rs index fe020751..391c07bd 100644 --- a/crates/spark-wallet/src/model.rs +++ b/crates/spark-wallet/src/model.rs @@ -212,7 +212,8 @@ pub struct TokenBalance { #[derive(Default)] pub struct ListTokenTransactionsRequest { pub paging: Option, - pub owner_public_keys: Vec, + /// If not provided, will use our own public key + pub owner_public_keys: Option>, pub issuer_public_keys: Vec, pub token_transaction_hashes: Vec, pub token_ids: Vec, diff --git a/crates/spark-wallet/src/wallet.rs b/crates/spark-wallet/src/wallet.rs index 538971e0..2bd9c0cc 100644 --- a/crates/spark-wallet/src/wallet.rs +++ b/crates/spark-wallet/src/wallet.rs @@ -19,8 +19,8 @@ use spark::{ CoopExitFeeQuote, CoopExitService, CpfpUtxo, DepositService, ExitSpeed, Fee, InvoiceDescription, LeafTxCpfpPsbts, LightningReceivePayment, LightningSendPayment, LightningService, QueryTokenTransactionsFilter, StaticDepositQuote, Swap, TimelockManager, - TokenService, TokenTransaction, Transfer, TransferService, TransferTokenOutput, - UnilateralExitService, Utxo, + TokenMetadata, TokenService, TokenTransaction, Transfer, TransferService, + TransferTokenOutput, UnilateralExitService, Utxo, }, session_manager::{InMemorySessionManager, SessionManager}, signer::Signer, @@ -214,6 +214,10 @@ impl SparkWallet { } impl SparkWallet { + pub fn get_identity_public_key(&self) -> PublicKey { + self.identity_public_key + } + pub async fn list_leaves(&self) -> Result { let leaves = self.tree_service.list_leaves().await?; Ok(leaves.into()) @@ -797,6 +801,16 @@ impl SparkWallet { ) .to_string()) } + + pub async fn get_tokens_metadata( + &self, + token_identifiers: &[&str], + ) -> Result, SparkWalletError> { + self.token_service + .get_tokens_metadata(token_identifiers) + .await + .map_err(Into::into) + } } async fn claim_pending_transfers( diff --git a/crates/spark/src/services/models.rs b/crates/spark/src/services/models.rs index 90ec9c67..98f8044d 100644 --- a/crates/spark/src/services/models.rs +++ b/crates/spark/src/services/models.rs @@ -1,6 +1,7 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::{Debug, Display}; use std::str::FromStr; +use std::time::SystemTime; use bitcoin::secp256k1::ecdsa::Signature; use bitcoin::{Transaction, TxOut, Txid}; @@ -820,9 +821,11 @@ impl #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TokenTransaction { + pub hash: String, pub inputs: TokenInputs, pub outputs: Vec, pub status: TokenTransactionStatus, + pub created_timestamp: SystemTime, } impl @@ -843,6 +846,8 @@ impl "Missing token transaction".to_string(), ))?; + let hash = hex::encode(transaction.token_transaction_hash); + let inputs = token_transaction .token_inputs .ok_or(ServiceError::Generic("Missing token inputs".to_string()))? @@ -859,10 +864,24 @@ impl .map_err(|_| ServiceError::Generic("Invalid token transaction status".to_string()))? .into(); + // client_created_timestamp will always be filled for V2 transactions and V1 transactions will be discontinued soon + let created_timestamp = token_transaction + .client_created_timestamp + .map(|ts| { + std::time::UNIX_EPOCH + + std::time::Duration::from_secs(ts.seconds as u64) + + std::time::Duration::from_nanos(ts.nanos as u64) + }) + .ok_or(ServiceError::Generic( + "Missing client created timestamp. Could this be a V1 transaction?".to_string(), + ))?; + Ok(TokenTransaction { + hash, inputs, outputs, status, + created_timestamp, }) } } @@ -934,8 +953,8 @@ impl TryFrom for TokenTransferInp #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TokenOutputToSpend { - prev_token_tx_hash: String, - prev_token_tx_vout: u32, + pub prev_token_tx_hash: String, + pub prev_token_tx_vout: u32, } impl TryFrom for TokenOutputToSpend { @@ -1004,11 +1023,18 @@ impl TryFrom for TokenCreateInput { #[derive(Debug, Clone, Copy, Deserialize, Serialize)] pub enum TokenTransactionStatus { + /// Transaction was successfully constructed and validated by the Operator. Started, + /// If not transfer transaction, transaction was accepted by the Operator and outputs are spendable. + /// Else, transaction was accepted by the Operator, inputs are 'locked' until consensus or until transaction expiry (in the event that consensus is not reached) Signed, + /// Operator has shared its revocation secret shares with other operators and is waiting for the system to collect enough shares to finalize the transaction. Revealed, + /// Transaction has reached consensus across operators. Transaction is final. Finalized, + /// Transaction was cancelled and cannot be recovered. StartedCancelled, + /// Transaction was cancelled and cannot be recovered. SignedCancelled, Unknown, } @@ -1043,7 +1069,8 @@ impl From for TokenTransactio #[derive(Debug, Clone)] pub struct QueryTokenTransactionsFilter { - pub owner_public_keys: Vec, + /// If not provided, will use our own public key + pub owner_public_keys: Option>, pub issuer_public_keys: Vec, pub token_transaction_hashes: Vec, pub token_ids: Vec, diff --git a/crates/spark/src/services/tokens.rs b/crates/spark/src/services/tokens.rs index 618b1958..73fd8870 100644 --- a/crates/spark/src/services/tokens.rs +++ b/crates/spark/src/services/tokens.rs @@ -116,16 +116,7 @@ impl TokenService { // Fetch metadata for owned tokens let token_identifiers = outputs_map.keys().cloned().collect(); - let metadata = self - .operator_pool - .get_coordinator() - .client - .query_token_metadata(QueryTokenMetadataRequest { - token_identifiers, - ..Default::default() - }) - .await? - .token_metadata; + let metadata = self.query_tokens_metadata_inner(token_identifiers).await?; if metadata.len() != outputs_map.keys().len() { return Err(ServiceError::Generic( @@ -154,6 +145,76 @@ impl TokenService { Ok(()) } + /// Returns the metadata for the given token identifiers. + /// + /// For token identifiers that are not found in the local cache, the metadata will be queried from the SE. + pub async fn get_tokens_metadata( + &self, + token_identifiers: &[&str], + ) -> Result, ServiceError> { + let cached_outputs = { self.tokens_outputs.lock().await.clone() }; + + // Separate token identifiers into cached and uncached + let mut cached_metadata = Vec::new(); + let mut uncached_identifiers = Vec::new(); + + for token_id in token_identifiers { + if let Some(outputs) = cached_outputs.get(*token_id) { + cached_metadata.push(outputs.metadata.clone()); + } else { + uncached_identifiers.push(*token_id); + } + } + + // Query metadata for uncached tokens + let mut queried_metadata = Vec::new(); + if !uncached_identifiers.is_empty() { + queried_metadata = self.query_tokens_metadata(&uncached_identifiers).await?; + } + + // Combine cached and queried metadata + let mut all_metadata = cached_metadata; + all_metadata.extend(queried_metadata); + + Ok(all_metadata) + } + + async fn query_tokens_metadata( + &self, + token_identifiers: &[&str], + ) -> Result, ServiceError> { + let token_identifiers = token_identifiers + .iter() + .map(|id| { + bech32m_decode_token_id(id, Some(self.network)) + .map_err(|_| ServiceError::Generic("Invalid token id".to_string())) + }) + .collect::>, _>>()?; + let metadata = self.query_tokens_metadata_inner(token_identifiers).await?; + let metadata = metadata + .into_iter() + .map(|m| (m, self.network).try_into()) + .collect::, _>>()?; + Ok(metadata) + } + + async fn query_tokens_metadata_inner( + &self, + token_identifiers: Vec>, + ) -> Result, ServiceError> { + let metadata = self + .operator_pool + .get_coordinator() + .client + .query_token_metadata(QueryTokenMetadataRequest { + token_identifiers, + ..Default::default() + }) + .await? + .token_metadata; + Ok(metadata) + } + /// Returns owned token outputs from the local cache. pub async fn get_tokens_outputs(&self) -> HashMap { self.tokens_outputs.lock().await.clone() @@ -164,14 +225,16 @@ impl TokenService { filter: QueryTokenTransactionsFilter, paging: PagingFilter, ) -> Result, ServiceError> { - let mut owner_public_keys = filter - .owner_public_keys - .iter() - .map(|k| k.serialize().to_vec()) - .collect::>(); - if owner_public_keys.is_empty() { - owner_public_keys.push(self.signer.get_identity_public_key()?.serialize().to_vec()); - } + let owner_public_keys = match filter.owner_public_keys { + Some(keys) => keys + .iter() + .map(|k| k.serialize().to_vec()) + .collect::>(), + None => vec![self.signer.get_identity_public_key()?.serialize().to_vec()], + }; + + // TODO: ask for ordering field to be added to QueryTokenTransactionsRequest + // until then, PagingFilter's order is not being respected let response = self .operator_pool .get_coordinator() diff --git a/docs/breez-sdk/snippets/rust/src/send_payment.rs b/docs/breez-sdk/snippets/rust/src/send_payment.rs index 266661b7..572cb37a 100644 --- a/docs/breez-sdk/snippets/rust/src/send_payment.rs +++ b/docs/breez-sdk/snippets/rust/src/send_payment.rs @@ -10,7 +10,8 @@ async fn prepare_send_payment_lightning_bolt11(sdk: &BreezSdk) -> Result<()> { let prepare_response = sdk .prepare_send_payment(PrepareSendPaymentRequest { payment_request, - amount_sats: optional_amount_sats, + amount: optional_amount_sats, + token_identifier: None, }) .await?; @@ -38,7 +39,8 @@ async fn prepare_send_payment_lightning_onchain(sdk: &BreezSdk) -> Result<()> { let prepare_response = sdk .prepare_send_payment(PrepareSendPaymentRequest { payment_request, - amount_sats, + amount: amount_sats, + token_identifier: None, }) .await?; @@ -63,13 +65,14 @@ async fn prepare_send_payment_spark(sdk: &BreezSdk) -> Result<()> { let prepare_response = sdk .prepare_send_payment(PrepareSendPaymentRequest { payment_request, - amount_sats, + amount: amount_sats, + token_identifier: None, }) .await?; // If the fees are acceptable, continue to create the Send Payment - if let SendPaymentMethod::SparkAddress { fee_sats, .. } = prepare_response.payment_method { - info!("Fees: {} sats", fee_sats); + if let SendPaymentMethod::SparkAddress { fee, .. } = prepare_response.payment_method { + info!("Fees: {} sats", fee); } // ANCHOR_END: prepare-send-payment-spark Ok(()) From 0a449a12bd86bae86218b8a136c84d5446f3bf07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 9 Sep 2025 11:40:19 +0100 Subject: [PATCH 02/22] Use transaction timestamp for syncing token payments --- crates/breez-sdk/core/src/persist/mod.rs | 4 +-- crates/breez-sdk/core/src/sdk.rs | 44 +++++++++++++++++++----- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index f91259fd..f6d9dac1 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -1,7 +1,7 @@ #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub(crate) mod sqlite; -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::SystemTime}; use macros::async_trait; use serde::{Deserialize, Serialize}; @@ -324,7 +324,7 @@ pub(crate) struct CachedAccountInfo { pub(crate) struct CachedSyncInfo { pub(crate) offset: u64, #[serde(default)] - pub(crate) token_offset: u64, + pub(crate) last_synced_token_timestamp: Option, } #[derive(Serialize, Deserialize, Default)] diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 328286b1..f45661b1 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -454,7 +454,7 @@ impl BreezSdk { let save_res = object_repository .save_sync_info(&CachedSyncInfo { offset: cache_offset.saturating_sub(pending_payments), - token_offset: cached_sync_info.token_offset, + last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, }) .await; @@ -478,14 +478,18 @@ impl BreezSdk { .fetch_sync_info() .await? .unwrap_or_default(); - let current_token_offset = cached_sync_info.token_offset; + let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; let our_public_key = self.spark_wallet.get_identity_public_key(); + let mut latest_token_transaction_timestamp = None; + // We'll keep querying in batches until we have all transfers - let mut next_offset = current_token_offset; + let mut next_offset = 0; let mut has_more = true; - info!("Syncing token payments to storage, offset = {next_offset}"); + info!( + "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" + ); while has_more { // Get batch of token transactions starting from current offset let token_transactions = self @@ -494,13 +498,25 @@ impl BreezSdk { paging: Some(PagingFilter::new( Some(next_offset), Some(PAYMENT_SYNC_BATCH_SIZE), - Some(Order::Ascending), + None, )), ..Default::default() }) .await? .items; + // On first iteration, set the latest token transaction timestamp to the first transaction timestamp + if next_offset == 0 { + latest_token_transaction_timestamp = + token_transactions.first().map(|tx| tx.created_timestamp); + } + + // On first iteration, set the latest token transaction timestamp to the first transaction timestamp + if next_offset == 0 { + latest_token_transaction_timestamp = + token_transactions.first().map(|tx| tx.created_timestamp); + } + // Get prev out hashes of first input of each token transaction // Assumes all inputs of a tx share the same owner public key let token_transactions_prevout_hashes = token_transactions @@ -535,6 +551,13 @@ impl BreezSdk { ); // Process transfers in this batch for transaction in &token_transactions { + // Stop syncing if we have reached the last synced token transaction timestamp + if let Some(last_synced_token_timestamp) = last_synced_token_timestamp + && transaction.created_timestamp <= last_synced_token_timestamp + { + break; + } + let tx_inputs_are_ours = match &transaction.inputs { spark_wallet::TokenInputs::Transfer(token_transfer_input) => { let Some(first_input) = token_transfer_input.outputs_to_spend.first() @@ -580,18 +603,21 @@ impl BreezSdk { // Check if we have more transfers to fetch next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); - // Update our last processed offset in the storage + has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } + + // Update our last processed transaction timestamp in the storage + if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { let save_res = object_repository .save_sync_info(&CachedSyncInfo { offset: cached_sync_info.offset, - token_offset: next_offset, + last_synced_token_timestamp: Some(latest_token_transaction_timestamp), }) .await; if let Err(err) = save_res { - error!("Failed to update last sync token offset: {err:?}"); + error!("Failed to update last sync token timestamp: {err:?}"); } - has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; } Ok(()) From 33b6edfeee22e242385beec3ad7bc332e512e1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 9 Sep 2025 12:11:57 +0100 Subject: [PATCH 03/22] Adjust frb mirrors --- packages/flutter/rust/src/models.rs | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/flutter/rust/src/models.rs b/packages/flutter/rust/src/models.rs index ac2b6d85..e6b72615 100644 --- a/packages/flutter/rust/src/models.rs +++ b/packages/flutter/rust/src/models.rs @@ -5,6 +5,7 @@ pub use breez_sdk_common::lnurl::pay::*; pub use breez_sdk_common::network::BitcoinNetwork; pub use breez_sdk_spark::*; use flutter_rust_bridge::frb; +use std::collections::HashMap; #[frb(mirror(BitcoinAddressDetails))] pub struct _BitcoinAddressDetails { @@ -90,6 +91,25 @@ pub struct _GetInfoRequest { #[frb(mirror(GetInfoResponse))] pub struct _GetInfoResponse { pub balance_sats: u64, + pub token_balances: HashMap, +} + +#[frb(mirror(TokenBalance))] +pub struct _TokenBalance { + pub balance: u64, + pub token_metadata: TokenMetadata, +} + +#[frb(mirror(TokenMetadata))] +pub struct _TokenMetadata { + pub identifier: String, + pub issuer_public_key: String, + pub name: String, + pub ticker: String, + pub decimals: u32, + pub max_supply: u64, + pub is_freezable: bool, + pub creation_entity_public_key: Option, } #[frb(mirror(GetPaymentRequest))] @@ -187,13 +207,15 @@ pub struct _PrepareLnurlPayResponse { #[frb(mirror(PrepareSendPaymentRequest))] pub struct _PrepareSendPaymentRequest { pub payment_request: String, - pub amount_sats: Option, + pub amount: Option, + pub token_identifier: Option, } #[frb(mirror(PrepareSendPaymentResponse))] pub struct _PrepareSendPaymentResponse { pub payment_method: SendPaymentMethod, - pub amount_sats: u64, + pub amount: u64, + pub token_identifier: Option, } #[frb(mirror(ReceivePaymentMethod))] @@ -259,7 +281,8 @@ pub enum _SendPaymentMethod { }, SparkAddress { address: String, - fee_sats: u64, + fee: u64, + token_identifier: Option, }, } @@ -357,6 +380,9 @@ pub struct _Payment { #[frb(mirror(PaymentDetails))] pub enum _PaymentDetails { Spark, + Token { + metadata: TokenMetadata, + }, Lightning { description: Option, preimage: Option, @@ -383,6 +409,7 @@ pub struct _PaymentMetadata { pub enum _PaymentMethod { Lightning, Spark, + Token, Deposit, Withdraw, Unknown, From 8b9c64ce3508762612b2059f8b10aca44b628f0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 9 Sep 2025 13:08:26 +0100 Subject: [PATCH 04/22] Address review --- crates/breez-sdk/core/src/sdk.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index f45661b1..f1c3d5e4 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -1154,6 +1154,11 @@ impl BreezSdk { "Token identifier can't be provided for this payment request: spark sats payment".to_string(), )); } + if sats_payment_details.amount.is_some() && request.amount.is_some() { + return Err(SdkError::InvalidInput( + "Amount can't be provided for this payment request: spark invoice defines amount".to_string(), + )); + } sats_payment_details.amount } spark_wallet::SparkAddressPaymentType::TokensPayment( @@ -1164,6 +1169,11 @@ impl BreezSdk { "Token identifier is required for this payment request: spark tokens payment".to_string(), )); } + if tokens_payment_details.amount.is_some() && request.amount.is_some() { + return Err(SdkError::InvalidInput( + "Amount can't be provided for this payment request: spark invoice defines amount".to_string(), + )); + } tokens_payment_details.amount } } From c65b040ab3e5d33d5964159ff48dee88101bfe0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Fri, 12 Sep 2025 17:10:25 +0100 Subject: [PATCH 05/22] Sync using sparkscan --- Cargo.lock | 229 +++++++ crates/breez-sdk/core/Cargo.toml | 1 + crates/breez-sdk/core/src/error.rs | 9 + crates/breez-sdk/core/src/lnurl.rs | 6 +- .../breez-sdk/core/src/models/adaptors/mod.rs | 2 + .../core/src/models/adaptors/spark_sdk.rs | 317 ++++++++++ .../core/src/models/adaptors/sparkscan.rs | 398 ++++++++++++ .../core/src/{models.rs => models/mod.rs} | 320 +--------- crates/breez-sdk/core/src/persist/mod.rs | 51 +- crates/breez-sdk/core/src/persist/sqlite.rs | 70 ++- crates/breez-sdk/core/src/sdk.rs | 581 +++++++----------- .../breez-sdk/wasm/js/node-storage/index.cjs | 60 +- .../wasm/js/node-storage/migrations.cjs | 12 +- crates/breez-sdk/wasm/js/web-storage/index.js | 15 +- crates/breez-sdk/wasm/src/models.rs | 3 +- crates/breez-sdk/wasm/src/persist/mod.rs | 6 +- crates/internal/src/command/mod.rs | 2 +- crates/internal/src/command/transfer.rs | 2 +- crates/spark-wallet/src/wallet.rs | 28 +- crates/spark/src/services/transfer.rs | 13 +- crates/spark/src/ssp/graphql/models.rs | 51 +- 21 files changed, 1435 insertions(+), 741 deletions(-) create mode 100644 crates/breez-sdk/core/src/models/adaptors/mod.rs create mode 100644 crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs create mode 100644 crates/breez-sdk/core/src/models/adaptors/sparkscan.rs rename crates/breez-sdk/core/src/{models.rs => models/mod.rs} (63%) diff --git a/Cargo.lock b/Cargo.lock index fb2e0dbb..c8a8b41e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -667,6 +673,7 @@ dependencies = [ "serde_json", "shellwords", "spark-wallet", + "sparkscan", "tempdir", "thiserror 2.0.14", "tokio", @@ -1479,6 +1486,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1884,6 +1897,11 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" @@ -2890,6 +2908,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.10.0", + "serde", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.73" @@ -3261,6 +3290,72 @@ dependencies = [ "yansi", ] +[[package]] +name = "progenitor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b99ef43fdd69d70aa4df8869db24b10ac704a2dbbc387ffac51944a1f3c0a8" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3832a961a5f1b0b5a5ccda5fbf67cae2ba708f6add667401007764ba504ffebf" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7646201b823e61712dd72f37428ceecaa8fb2a6c841e5d7cf909edb9a17f5677" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap 2.10.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.105", + "thiserror 2.0.14", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e710a11140d9b4241b7d8a90748f6125b6796d7a1205238eddb08dc790ce3830" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.105", +] + [[package]] name = "prost" version = "0.13.5" @@ -3614,6 +3709,16 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "regress" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "relative-path" version = "1.9.3" @@ -3639,6 +3744,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3663,12 +3769,14 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots 1.0.2", ] @@ -3920,6 +4028,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3944,6 +4066,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.105", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4121,6 +4255,18 @@ dependencies = [ "syn 2.0.105", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.105", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4401,6 +4547,42 @@ dependencies = [ "uuid", ] +[[package]] +name = "sparkscan" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce37d4f7be4e6d03dbbce1edd8a362853a257f14960cc8f465004a08d87601fe" +dependencies = [ + "cfg-if", + "chrono", + "futures", + "prettyplease", + "progenitor", + "regress", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "sparkscan-client", + "syn 2.0.105", +] + +[[package]] +name = "sparkscan-client" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31dc0e035e973e408121b00a8c31d4562874725a2ff01bd3a458a66a88aeec6c" +dependencies = [ + "bytes", + "cfg-if", + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + [[package]] name = "spin" version = "0.9.8" @@ -5228,6 +5410,53 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "typify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.105", + "thiserror 2.0.14", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.105", + "typify-impl", +] + [[package]] name = "uncased" version = "0.9.10" diff --git a/crates/breez-sdk/core/Cargo.toml b/crates/breez-sdk/core/Cargo.toml index 2299569e..fd1917aa 100644 --- a/crates/breez-sdk/core/Cargo.toml +++ b/crates/breez-sdk/core/Cargo.toml @@ -54,6 +54,7 @@ uuid.workspace = true uniffi = { workspace = true, optional = true } web-time.workspace = true x509-parser = { version = "0.16.0" } +sparkscan = { version = "0.3.7" } # Non-Wasm dependencies [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] diff --git a/crates/breez-sdk/core/src/error.rs b/crates/breez-sdk/core/src/error.rs index dd203fd1..422f38cb 100644 --- a/crates/breez-sdk/core/src/error.rs +++ b/crates/breez-sdk/core/src/error.rs @@ -52,6 +52,9 @@ pub enum SdkError { #[error("Lnurl error: {0}")] LnurlError(String), + #[error("SparkScan error: {0}")] + SparkScanApiError(String), + #[error("Error: {0}")] Generic(String), } @@ -140,6 +143,12 @@ impl From for SdkError { } } +impl From> for SdkError { + fn from(e: sparkscan::Error) -> Self { + SdkError::SparkScanApiError(format!("{e:?}")) + } +} + impl From for SdkError { fn from(value: LnurlServerError) -> Self { match value { diff --git a/crates/breez-sdk/core/src/lnurl.rs b/crates/breez-sdk/core/src/lnurl.rs index cea28f15..340173b5 100644 --- a/crates/breez-sdk/core/src/lnurl.rs +++ b/crates/breez-sdk/core/src/lnurl.rs @@ -134,7 +134,7 @@ impl LnurlServerClient for ReqwestLnurlServerClient { &self, ) -> Result, LnurlServerError> { // Get the pubkey from the wallet - let spark_address = self.wallet.get_spark_address().await.map_err(|e| { + let spark_address = self.wallet.get_spark_address().map_err(|e| { LnurlServerError::SigningError(format!("Failed to get spark address: {e}")) })?; let pubkey = spark_address.identity_public_key; @@ -183,7 +183,7 @@ impl LnurlServerClient for ReqwestLnurlServerClient { request: &RegisterLightningAddressRequest, ) -> Result { // Get the pubkey from the wallet - let spark_address = self.wallet.get_spark_address().await.map_err(|e| { + let spark_address = self.wallet.get_spark_address().map_err(|e| { LnurlServerError::SigningError(format!("Failed to get spark address: {e}")) })?; let pubkey = spark_address.identity_public_key; @@ -236,7 +236,7 @@ impl LnurlServerClient for ReqwestLnurlServerClient { request: &UnregisterLightningAddressRequest, ) -> Result<(), LnurlServerError> { // Get the pubkey from the wallet - let spark_address = self.wallet.get_spark_address().await.map_err(|e| { + let spark_address = self.wallet.get_spark_address().map_err(|e| { LnurlServerError::SigningError(format!("Failed to get spark address: {e}")) })?; let pubkey = spark_address.identity_public_key; diff --git a/crates/breez-sdk/core/src/models/adaptors/mod.rs b/crates/breez-sdk/core/src/models/adaptors/mod.rs new file mode 100644 index 00000000..ab48b88b --- /dev/null +++ b/crates/breez-sdk/core/src/models/adaptors/mod.rs @@ -0,0 +1,2 @@ +mod spark_sdk; +pub(crate) mod sparkscan; diff --git a/crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs b/crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs new file mode 100644 index 00000000..8327c634 --- /dev/null +++ b/crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs @@ -0,0 +1,317 @@ +use std::time::UNIX_EPOCH; + +use breez_sdk_common::input; +use spark_wallet::{ + CoopExitFeeQuote, CoopExitSpeedFeeQuote, ExitSpeed, LightningSendPayment, LightningSendStatus, + Network as SparkNetwork, SspUserRequest, TokenTransactionStatus, TransferDirection, + TransferStatus, TransferType, WalletTransfer, +}; + +use crate::{ + Fee, Network, OnchainConfirmationSpeed, Payment, PaymentDetails, PaymentMethod, PaymentStatus, + PaymentType, SdkError, SendOnchainFeeQuote, SendOnchainSpeedFeeQuote, TokenBalance, + TokenMetadata, +}; + +impl From for PaymentMethod { + fn from(value: TransferType) -> Self { + match value { + TransferType::PreimageSwap => PaymentMethod::Lightning, + TransferType::CooperativeExit => PaymentMethod::Withdraw, + TransferType::Transfer => PaymentMethod::Spark, + TransferType::UtxoSwap => PaymentMethod::Deposit, + _ => PaymentMethod::Unknown, + } + } +} + +impl TryFrom for PaymentDetails { + type Error = SdkError; + fn try_from(user_request: SspUserRequest) -> Result { + let details = match user_request { + SspUserRequest::CoopExitRequest(request) => PaymentDetails::Withdraw { + tx_id: request.coop_exit_txid, + }, + SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark, + SspUserRequest::LightningReceiveRequest(request) => { + let invoice_details = input::parse_invoice(&request.invoice.encoded_invoice) + .ok_or(SdkError::Generic( + "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(), + ))?; + PaymentDetails::Lightning { + description: invoice_details.description, + preimage: request.lightning_receive_payment_preimage, + invoice: request.invoice.encoded_invoice, + payment_hash: request.invoice.payment_hash, + destination_pubkey: invoice_details.payee_pubkey, + lnurl_pay_info: None, + } + } + SspUserRequest::LightningSendRequest(request) => { + let invoice_details = + input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic( + "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(), + ))?; + PaymentDetails::Lightning { + description: invoice_details.description, + preimage: request.lightning_send_payment_preimage, + invoice: request.encoded_invoice, + payment_hash: invoice_details.payment_hash, + destination_pubkey: invoice_details.payee_pubkey, + lnurl_pay_info: None, + } + } + SspUserRequest::ClaimStaticDeposit(request) => PaymentDetails::Deposit { + tx_id: request.transaction_id, + }, + }; + Ok(details) + } +} + +impl TryFrom for Payment { + type Error = SdkError; + fn try_from(transfer: WalletTransfer) -> Result { + let payment_type = match transfer.direction { + TransferDirection::Incoming => PaymentType::Receive, + TransferDirection::Outgoing => PaymentType::Send, + }; + let mut status = match transfer.status { + TransferStatus::Completed => PaymentStatus::Completed, + TransferStatus::SenderKeyTweaked + if transfer.direction == TransferDirection::Outgoing => + { + PaymentStatus::Completed + } + TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed, + _ => PaymentStatus::Pending, + }; + let (fees_sat, mut amount_sat): (u64, u64) = match transfer.clone().user_request { + Some(user_request) => match user_request { + SspUserRequest::LightningSendRequest(r) => { + // TODO: if we have the preimage it is not pending. This is a workaround + // until spark will implement incremental syncing based on updated time. + if r.lightning_send_payment_preimage.is_some() { + status = PaymentStatus::Completed; + } + let fee_sat = r.fee.as_sats().unwrap_or(0); + (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat)) + } + SspUserRequest::CoopExitRequest(r) => { + let fee_sat = r + .fee + .as_sats() + .unwrap_or(0) + .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0)); + (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat)) + } + SspUserRequest::ClaimStaticDeposit(r) => { + let fee_sat = r.max_fee.as_sats().unwrap_or(0); + (fee_sat, transfer.total_value_sat) + } + _ => (0, transfer.total_value_sat), + }, + None => (0, transfer.total_value_sat), + }; + + let details: Option = if let Some(user_request) = transfer.user_request { + Some(user_request.try_into()?) + } else { + // in case we have a completed status without user object we want + // to keep syncing this payment + if status == PaymentStatus::Completed + && [ + TransferType::CooperativeExit, + TransferType::PreimageSwap, + TransferType::UtxoSwap, + ] + .contains(&transfer.transfer_type) + { + status = PaymentStatus::Pending; + } + amount_sat = transfer.total_value_sat; + None + }; + + Ok(Payment { + id: transfer.id.to_string(), + payment_type, + status, + amount: amount_sat, + fees: fees_sat, + timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) { + Some(Ok(duration)) => duration.as_secs(), + _ => 0, + }, + method: transfer.transfer_type.into(), + details, + }) + } +} + +impl Payment { + pub fn from_lightning( + payment: LightningSendPayment, + amount_sat: u64, + transfer_id: String, + ) -> Result { + let mut status = match payment.status { + LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed, + LightningSendStatus::LightningPaymentFailed + | LightningSendStatus::TransferFailed + | LightningSendStatus::PreimageProvidingFailed + | LightningSendStatus::UserSwapReturnFailed + | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed, + _ => PaymentStatus::Pending, + }; + if payment.payment_preimage.is_some() { + status = PaymentStatus::Completed; + } + + let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or( + SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()), + )?; + let details = PaymentDetails::Lightning { + description: invoice_details.description, + preimage: payment.payment_preimage, + invoice: payment.encoded_invoice, + payment_hash: invoice_details.payment_hash, + destination_pubkey: invoice_details.payee_pubkey, + lnurl_pay_info: None, + }; + + Ok(Payment { + id: transfer_id, + payment_type: PaymentType::Send, + status, + amount: amount_sat, + fees: payment.fee_sat, + timestamp: payment.created_at.cast_unsigned(), + method: PaymentMethod::Lightning, + details: Some(details), + }) + } +} + +impl From for SparkNetwork { + fn from(network: Network) -> Self { + match network { + Network::Mainnet => SparkNetwork::Mainnet, + Network::Regtest => SparkNetwork::Regtest, + } + } +} + +impl From for spark_wallet::Fee { + fn from(fee: Fee) -> Self { + match fee { + Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount }, + Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte }, + } + } +} + +impl From for TokenBalance { + fn from(value: spark_wallet::TokenBalance) -> Self { + Self { + balance: value.balance.try_into().unwrap_or_default(), // balance will be changed to u128 or similar + token_metadata: value.token_metadata.into(), + } + } +} + +impl From for TokenMetadata { + fn from(value: spark_wallet::TokenMetadata) -> Self { + Self { + identifier: value.identifier, + issuer_public_key: hex::encode(value.issuer_public_key.serialize()), + name: value.name, + ticker: value.ticker, + decimals: value.decimals, + max_supply: value.max_supply.try_into().unwrap_or_default(), // max_supply will be changed to u128 or similar + is_freezable: value.is_freezable, + } + } +} + +impl From for SendOnchainFeeQuote { + fn from(value: CoopExitFeeQuote) -> Self { + Self { + id: value.id, + expires_at: value.expires_at, + speed_fast: value.speed_fast.into(), + speed_medium: value.speed_medium.into(), + speed_slow: value.speed_slow.into(), + } + } +} + +impl From for CoopExitFeeQuote { + fn from(value: SendOnchainFeeQuote) -> Self { + Self { + id: value.id, + expires_at: value.expires_at, + speed_fast: value.speed_fast.into(), + speed_medium: value.speed_medium.into(), + speed_slow: value.speed_slow.into(), + } + } +} + +impl From for SendOnchainSpeedFeeQuote { + fn from(value: CoopExitSpeedFeeQuote) -> Self { + Self { + user_fee_sat: value.user_fee_sat, + l1_broadcast_fee_sat: value.l1_broadcast_fee_sat, + } + } +} + +impl From for CoopExitSpeedFeeQuote { + fn from(value: SendOnchainSpeedFeeQuote) -> Self { + Self { + user_fee_sat: value.user_fee_sat, + l1_broadcast_fee_sat: value.l1_broadcast_fee_sat, + } + } +} + +impl From for ExitSpeed { + fn from(speed: OnchainConfirmationSpeed) -> Self { + match speed { + OnchainConfirmationSpeed::Fast => ExitSpeed::Fast, + OnchainConfirmationSpeed::Medium => ExitSpeed::Medium, + OnchainConfirmationSpeed::Slow => ExitSpeed::Slow, + } + } +} + +impl From for OnchainConfirmationSpeed { + fn from(speed: ExitSpeed) -> Self { + match speed { + ExitSpeed::Fast => OnchainConfirmationSpeed::Fast, + ExitSpeed::Medium => OnchainConfirmationSpeed::Medium, + ExitSpeed::Slow => OnchainConfirmationSpeed::Slow, + } + } +} + +impl PaymentStatus { + pub(crate) fn from_token_transaction_status( + status: TokenTransactionStatus, + is_transfer_transaction: bool, + ) -> Self { + match status { + TokenTransactionStatus::Started + | TokenTransactionStatus::Revealed + | TokenTransactionStatus::Unknown => PaymentStatus::Pending, + TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending, + TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => { + PaymentStatus::Completed + } + TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => { + PaymentStatus::Failed + } + } + } +} diff --git a/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs b/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs new file mode 100644 index 00000000..62ec59d8 --- /dev/null +++ b/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs @@ -0,0 +1,398 @@ +use breez_sdk_common::input; +use spark_wallet::SspUserRequest; +use sparkscan::types::{ + AddressTransaction, AddressTransactionDirection, AddressTransactionStatus, + AddressTransactionType, MultiIoDetails, TokenTransactionMetadata, TokenTransactionStatus, +}; +use tracing::warn; + +use crate::{ + Network, Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError, + TokenMetadata, +}; + +impl From for PaymentStatus { + fn from(status: AddressTransactionStatus) -> Self { + match status { + AddressTransactionStatus::Confirmed => PaymentStatus::Completed, + AddressTransactionStatus::Sent | AddressTransactionStatus::Pending => { + PaymentStatus::Pending + } + AddressTransactionStatus::Expired | AddressTransactionStatus::Failed => { + PaymentStatus::Failed + } + } + } +} + +impl From for PaymentStatus { + fn from(status: TokenTransactionStatus) -> Self { + match status { + TokenTransactionStatus::Confirmed => PaymentStatus::Completed, + TokenTransactionStatus::Sent | TokenTransactionStatus::Pending => { + PaymentStatus::Pending + } + TokenTransactionStatus::Expired | TokenTransactionStatus::Failed => { + PaymentStatus::Failed + } + } + } +} + +impl From for sparkscan::types::Network { + fn from(network: Network) -> Self { + match network { + Network::Mainnet => sparkscan::types::Network::Mainnet, + Network::Regtest => sparkscan::types::Network::Regtest, + } + } +} + +impl TryFrom for TokenMetadata { + type Error = SdkError; + + fn try_from(value: TokenTransactionMetadata) -> Result { + Ok(Self { + identifier: value.token_address, + issuer_public_key: value.issuer_public_key, + name: value.name, + ticker: value.ticker, + decimals: value.decimals.try_into()?, + max_supply: value + .max_supply + .ok_or(SdkError::Generic("Max supply is not set".to_string()))? + .try_into()?, // max_supply will be changed to u128 or similar + is_freezable: value + .is_freezable + .ok_or(SdkError::Generic("Is freezable is not set".to_string()))?, + }) + } +} + +/// Context for payment conversion containing common data +#[derive(Debug)] +struct PaymentCommonContext { + timestamp: u64, + status: PaymentStatus, +} + +/// Information about payment method, details, and fees +#[derive(Debug)] +struct PaymentMethodInfo { + method: PaymentMethod, + details: Option, + fees: u64, +} + +/// Converts a Sparkscan address transaction into Payment objects +pub(crate) fn payments_from_address_transaction_and_ssp_request( + transaction: &AddressTransaction, + ssp_user_request: Option<&SspUserRequest>, + our_spark_address: &str, +) -> Result, SdkError> { + let context = extract_conversion_context(transaction)?; + let method_info = extract_payment_method_and_details(transaction, ssp_user_request)?; + + if transaction.multi_io_details.is_some() { + create_multi_io_payments(transaction, &method_info, &context, our_spark_address) + } else { + let payment = create_single_payment(transaction, &method_info, &context)?; + Ok(vec![payment]) + } +} + +/// Extracts common conversion context from transaction +fn extract_conversion_context( + transaction: &AddressTransaction, +) -> Result { + let timestamp = transaction + .created_at + .ok_or(SdkError::Generic( + "Transaction created at is not set".to_string(), + ))? + .timestamp() + .try_into()?; + + let status = transaction.status.into(); + + Ok(PaymentCommonContext { timestamp, status }) +} + +/// Determines payment method, details, and fees based on transaction type +fn extract_payment_method_and_details( + transaction: &AddressTransaction, + ssp_user_request: Option<&SspUserRequest>, +) -> Result { + match transaction.type_ { + AddressTransactionType::SparkTransfer => Ok(PaymentMethodInfo { + method: PaymentMethod::Spark, + details: Some(PaymentDetails::Spark), + fees: 0, + }), + AddressTransactionType::LightningPayment => { + create_lightning_payment_info(transaction, ssp_user_request) + } + AddressTransactionType::BitcoinDeposit => { + Ok(create_deposit_payment_info(transaction, ssp_user_request)) + } + AddressTransactionType::BitcoinWithdrawal => { + Ok(create_withdraw_payment_info(transaction, ssp_user_request)) + } + AddressTransactionType::TokenTransfer + | AddressTransactionType::TokenMint + | AddressTransactionType::TokenBurn + | AddressTransactionType::TokenMultiTransfer + | AddressTransactionType::UnknownTokenOp => create_token_payment_info(transaction), + } +} + +/// Creates payment info for Lightning transactions +fn create_lightning_payment_info( + transaction: &AddressTransaction, + ssp_user_request: Option<&SspUserRequest>, +) -> Result { + if let Some(request) = ssp_user_request { + let invoice = request.get_lightning_invoice().ok_or(SdkError::Generic( + "No invoice in SspUserRequest".to_string(), + ))?; + let invoice_details = input::parse_invoice(&invoice).ok_or(SdkError::Generic( + "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(), + ))?; + let preimage = request.get_lightning_preimage(); + let fees = request.get_total_fees_sats(); + + Ok(PaymentMethodInfo { + method: PaymentMethod::Lightning, + details: Some(PaymentDetails::Lightning { + description: invoice_details.description.clone(), + preimage, + invoice, + payment_hash: invoice_details.payment_hash.clone(), + destination_pubkey: invoice_details.payee_pubkey.clone(), + lnurl_pay_info: None, + }), + fees, + }) + } else { + warn!( + "No SspUserRequest found for LightningPayment with transfer id {}", + transaction.id + ); + Ok(PaymentMethodInfo { + method: PaymentMethod::Lightning, + details: None, + fees: 0, + }) + } +} + +/// Creates payment info for Bitcoin deposit transactions +fn create_deposit_payment_info( + transaction: &AddressTransaction, + ssp_user_request: Option<&SspUserRequest>, +) -> PaymentMethodInfo { + if let Some(SspUserRequest::ClaimStaticDeposit(request)) = ssp_user_request { + let fees = request.get_total_fees_sats(); + PaymentMethodInfo { + method: PaymentMethod::Deposit, + details: Some(PaymentDetails::Deposit { + tx_id: request.transaction_id.clone(), + }), + fees, + } + } else { + warn!( + "No SspUserRequest found for BitcoinDeposit with transfer id {}", + transaction.id + ); + PaymentMethodInfo { + method: PaymentMethod::Deposit, + details: None, + fees: 0, + } + } +} + +/// Creates payment info for Bitcoin withdrawal transactions +fn create_withdraw_payment_info( + transaction: &AddressTransaction, + ssp_user_request: Option<&SspUserRequest>, +) -> PaymentMethodInfo { + if let Some(SspUserRequest::CoopExitRequest(request)) = ssp_user_request { + let fees = request.get_total_fees_sats(); + PaymentMethodInfo { + method: PaymentMethod::Withdraw, + details: Some(PaymentDetails::Withdraw { + tx_id: request.coop_exit_txid.clone(), + }), + fees, + } + } else { + warn!( + "No SspUserRequest found for BitcoinWithdrawal with transfer id {}", + transaction.id + ); + PaymentMethodInfo { + method: PaymentMethod::Withdraw, + details: None, + fees: 0, + } + } +} + +/// Creates payment info for token transactions +fn create_token_payment_info( + transaction: &AddressTransaction, +) -> Result { + let Some(metadata) = &transaction.token_metadata else { + return Err(SdkError::Generic( + "No token metadata in transaction".to_string(), + )); + }; + + Ok(PaymentMethodInfo { + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone().try_into()?, + tx_hash: transaction.id.clone(), + }), + fees: 0, + }) +} + +/// Creates multiple payments for multi-IO token transactions +fn create_multi_io_payments( + transaction: &AddressTransaction, + method_info: &PaymentMethodInfo, + context: &PaymentCommonContext, + our_spark_address: &str, +) -> Result, SdkError> { + let multi_io_details = transaction.multi_io_details.as_ref().unwrap(); + + let payment_type = determine_multi_io_payment_type(multi_io_details, our_spark_address)?; + + let mut payments = Vec::new(); + + for (index, output) in multi_io_details.outputs.iter().enumerate() { + // Create payments for outputs that are not ours (for send payments) or ours (for receive payments) + if should_include_output(payment_type, &output.address, our_spark_address) { + let id = format!("{}:{}", transaction.id, index); + let amount = output.amount.try_into()?; + + payments.push(Payment { + id, + payment_type, + status: context.status, + amount, + fees: 0, + timestamp: context.timestamp, + method: method_info.method, + details: method_info.details.clone(), + }); + } + } + + Ok(payments) +} + +/// Determines payment type for multi-IO transactions based on input ownership +fn determine_multi_io_payment_type( + multi_io_details: &MultiIoDetails, + our_spark_address: &str, +) -> Result { + let first_input = multi_io_details.inputs.first().ok_or(SdkError::Generic( + "No inputs in multi IO details".to_string(), + ))?; + + if first_input.address == our_spark_address { + Ok(PaymentType::Send) + } else { + Ok(PaymentType::Receive) + } +} + +/// Determines if an output should be included in the payment list +fn should_include_output( + payment_type: PaymentType, + output_address: &str, + our_spark_address: &str, +) -> bool { + match payment_type { + PaymentType::Send => output_address != our_spark_address, + PaymentType::Receive => output_address == our_spark_address, + } +} + +/// Creates a single payment for non-multi-IO transactions +fn create_single_payment( + transaction: &AddressTransaction, + method_info: &PaymentMethodInfo, + context: &PaymentCommonContext, +) -> Result { + let id = transaction.id.clone(); + let payment_type = determine_single_payment_type(transaction, method_info.method)?; + let amount = calculate_payment_amount(transaction, method_info)?; + + Ok(Payment { + id, + payment_type, + status: context.status, + amount, + fees: method_info.fees, + timestamp: context.timestamp, + method: method_info.method, + details: method_info.details.clone(), + }) +} + +/// Determines payment type for single transactions based on method and transaction details +fn determine_single_payment_type( + transaction: &AddressTransaction, + method: PaymentMethod, +) -> Result { + match method { + PaymentMethod::Lightning | PaymentMethod::Spark => match transaction.direction { + AddressTransactionDirection::Incoming => Ok(PaymentType::Receive), + AddressTransactionDirection::Outgoing => Ok(PaymentType::Send), + _ => Err(SdkError::Generic(format!( + "Invalid direction in transaction {}", + transaction.id + ))), + }, + PaymentMethod::Token => match transaction.type_ { + AddressTransactionType::TokenMint => Ok(PaymentType::Receive), + AddressTransactionType::TokenBurn => Ok(PaymentType::Send), + _ => Err(SdkError::Generic(format!( + "Invalid type in TokenTransaction transaction {}", + transaction.id + ))), + }, + PaymentMethod::Deposit => Ok(PaymentType::Receive), + PaymentMethod::Withdraw => Ok(PaymentType::Send), + PaymentMethod::Unknown => Err(SdkError::Generic(format!( + "Unexpected payment method in transaction {}", + transaction.id + ))), + } +} + +/// Calculates the payment amount, considering fees for different payment methods +fn calculate_payment_amount( + transaction: &AddressTransaction, + method_info: &PaymentMethodInfo, +) -> Result { + let transaction_amount: u64 = transaction + .amount_sats + .or(transaction.token_amount) + .ok_or(SdkError::Generic( + "Amount not found in transaction".to_string(), + ))? + .try_into()?; + + // For deposits, we don't subtract fees from the amount + if method_info.method == PaymentMethod::Deposit { + Ok(transaction_amount) + } else { + Ok(transaction_amount.saturating_sub(method_info.fees)) + } +} diff --git a/crates/breez-sdk/core/src/models.rs b/crates/breez-sdk/core/src/models/mod.rs similarity index 63% rename from crates/breez-sdk/core/src/models.rs rename to crates/breez-sdk/core/src/models/mod.rs index 4f2a6ad8..af63810d 100644 --- a/crates/breez-sdk/core/src/models.rs +++ b/crates/breez-sdk/core/src/models/mod.rs @@ -1,6 +1,8 @@ +pub(crate) mod adaptors; + use breez_sdk_common::{ fiat::{FiatCurrency, Rate}, - input::{self, BitcoinAddressDetails, Bolt11InvoiceDetails}, + input::{BitcoinAddressDetails, Bolt11InvoiceDetails}, lnurl::pay::{LnurlPayRequestDetails, SuccessAction, SuccessActionProcessed}, network::BitcoinNetwork, }; @@ -8,15 +10,10 @@ use core::fmt; use lnurl_models::RecoverLnurlPayResponse; use serde::{Deserialize, Serialize}; use serde_json::Value; -use spark_wallet::{ - CoopExitFeeQuote, CoopExitSpeedFeeQuote, ExitSpeed, LightningSendPayment, LightningSendStatus, - Network as SparkNetwork, SspUserRequest, TokenTransactionStatus, TransferDirection, - TransferStatus, TransferType, WalletTransfer, -}; -use std::{collections::HashMap, fmt::Display, str::FromStr, time::UNIX_EPOCH}; +use std::{collections::HashMap, fmt::Display, str::FromStr}; +use crate::error::DepositClaimError; use crate::sdk_builder::Seed; -use crate::{SdkError, error::DepositClaimError}; #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ConnectRequest { @@ -131,17 +128,6 @@ impl FromStr for PaymentMethod { } } -impl From for PaymentMethod { - fn from(value: TransferType) -> Self { - match value { - TransferType::PreimageSwap => PaymentMethod::Lightning, - TransferType::CooperativeExit => PaymentMethod::Withdraw, - TransferType::Transfer => PaymentMethod::Spark, - TransferType::UtxoSwap => PaymentMethod::Deposit, - _ => PaymentMethod::Unknown, - } - } -} /// Represents a payment (sent or received) #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -174,6 +160,7 @@ pub enum PaymentDetails { Spark, Token { metadata: TokenMetadata, + tx_hash: String, }, Lightning { /// Represents the invoice description @@ -202,194 +189,6 @@ pub enum PaymentDetails { }, } -impl TryFrom for PaymentDetails { - type Error = SdkError; - fn try_from(user_request: SspUserRequest) -> Result { - let details = match user_request { - SspUserRequest::CoopExitRequest(request) => PaymentDetails::Withdraw { - tx_id: request.coop_exit_txid, - }, - SspUserRequest::LeavesSwapRequest(_) => PaymentDetails::Spark, - SspUserRequest::LightningReceiveRequest(request) => { - let invoice_details = input::parse_invoice(&request.invoice.encoded_invoice) - .ok_or(SdkError::Generic( - "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(), - ))?; - PaymentDetails::Lightning { - description: invoice_details.description, - preimage: request.lightning_receive_payment_preimage, - invoice: request.invoice.encoded_invoice, - payment_hash: request.invoice.payment_hash, - destination_pubkey: invoice_details.payee_pubkey, - lnurl_pay_info: None, - } - } - SspUserRequest::LightningSendRequest(request) => { - let invoice_details = - input::parse_invoice(&request.encoded_invoice).ok_or(SdkError::Generic( - "Invalid invoice in SspUserRequest::LightningSendRequest".to_string(), - ))?; - PaymentDetails::Lightning { - description: invoice_details.description, - preimage: request.lightning_send_payment_preimage, - invoice: request.encoded_invoice, - payment_hash: invoice_details.payment_hash, - destination_pubkey: invoice_details.payee_pubkey, - lnurl_pay_info: None, - } - } - SspUserRequest::ClaimStaticDeposit(request) => PaymentDetails::Deposit { - tx_id: request.transaction_id, - }, - }; - Ok(details) - } -} - -impl TryFrom for Payment { - type Error = SdkError; - fn try_from(transfer: WalletTransfer) -> Result { - let payment_type = match transfer.direction { - TransferDirection::Incoming => PaymentType::Receive, - TransferDirection::Outgoing => PaymentType::Send, - }; - let mut status = match transfer.status { - TransferStatus::Completed => PaymentStatus::Completed, - TransferStatus::SenderKeyTweaked - if transfer.direction == TransferDirection::Outgoing => - { - PaymentStatus::Completed - } - TransferStatus::Expired | TransferStatus::Returned => PaymentStatus::Failed, - _ => PaymentStatus::Pending, - }; - let (fees_sat, mut amount_sat): (u64, u64) = match transfer.clone().user_request { - Some(user_request) => match user_request { - SspUserRequest::LightningSendRequest(r) => { - // TODO: if we have the preimage it is not pending. This is a workaround - // until spark will implement incremental syncing based on updated time. - if r.lightning_send_payment_preimage.is_some() { - status = PaymentStatus::Completed; - } - let fee_sat = r.fee.as_sats().unwrap_or(0); - (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat)) - } - SspUserRequest::CoopExitRequest(r) => { - let fee_sat = r - .fee - .as_sats() - .unwrap_or(0) - .saturating_add(r.l1_broadcast_fee.as_sats().unwrap_or(0)); - (fee_sat, transfer.total_value_sat.saturating_sub(fee_sat)) - } - SspUserRequest::ClaimStaticDeposit(r) => { - let fee_sat = r.max_fee.as_sats().unwrap_or(0); - (fee_sat, transfer.total_value_sat) - } - _ => (0, transfer.total_value_sat), - }, - None => (0, transfer.total_value_sat), - }; - - let details: Option = if let Some(user_request) = transfer.user_request { - Some(user_request.try_into()?) - } else { - // in case we have a completed status without user object we want - // to keep syncing this payment - if status == PaymentStatus::Completed - && [ - TransferType::CooperativeExit, - TransferType::PreimageSwap, - TransferType::UtxoSwap, - ] - .contains(&transfer.transfer_type) - { - status = PaymentStatus::Pending; - } - amount_sat = transfer.total_value_sat; - None - }; - - Ok(Payment { - id: transfer.id.to_string(), - payment_type, - status, - amount: amount_sat, - fees: fees_sat, - timestamp: match transfer.created_at.map(|t| t.duration_since(UNIX_EPOCH)) { - Some(Ok(duration)) => duration.as_secs(), - _ => 0, - }, - method: transfer.transfer_type.into(), - details, - }) - } -} - -impl PaymentStatus { - pub(crate) fn from_token_transaction_status( - status: TokenTransactionStatus, - is_transfer_transaction: bool, - ) -> Self { - match status { - TokenTransactionStatus::Started - | TokenTransactionStatus::Revealed - | TokenTransactionStatus::Unknown => PaymentStatus::Pending, - TokenTransactionStatus::Signed if is_transfer_transaction => PaymentStatus::Pending, - TokenTransactionStatus::Finalized | TokenTransactionStatus::Signed => { - PaymentStatus::Completed - } - TokenTransactionStatus::StartedCancelled | TokenTransactionStatus::SignedCancelled => { - PaymentStatus::Failed - } - } - } -} - -impl Payment { - pub fn from_lightning( - payment: LightningSendPayment, - amount_sat: u64, - transfer_id: String, - ) -> Result { - let mut status = match payment.status { - LightningSendStatus::LightningPaymentSucceeded => PaymentStatus::Completed, - LightningSendStatus::LightningPaymentFailed - | LightningSendStatus::TransferFailed - | LightningSendStatus::PreimageProvidingFailed - | LightningSendStatus::UserSwapReturnFailed - | LightningSendStatus::UserSwapReturned => PaymentStatus::Failed, - _ => PaymentStatus::Pending, - }; - if payment.payment_preimage.is_some() { - status = PaymentStatus::Completed; - } - - let invoice_details = input::parse_invoice(&payment.encoded_invoice).ok_or( - SdkError::Generic("Invalid invoice in LightnintSendPayment".to_string()), - )?; - let details = PaymentDetails::Lightning { - description: invoice_details.description, - preimage: payment.payment_preimage, - invoice: payment.encoded_invoice, - payment_hash: invoice_details.payment_hash, - destination_pubkey: invoice_details.payee_pubkey, - lnurl_pay_info: None, - }; - - Ok(Payment { - id: transfer_id, - payment_type: PaymentType::Send, - status, - amount: amount_sat, - fees: payment.fee_sat, - timestamp: payment.created_at.cast_unsigned(), - method: PaymentMethod::Lightning, - details: Some(details), - }) - } -} - #[derive(Debug, Clone, Copy)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum Network { @@ -406,15 +205,6 @@ impl std::fmt::Display for Network { } } -impl From for SparkNetwork { - fn from(network: Network) -> Self { - match network { - Network::Mainnet => SparkNetwork::Mainnet, - Network::Regtest => SparkNetwork::Regtest, - } - } -} - impl From for BitcoinNetwork { fn from(network: Network) -> Self { match network { @@ -454,6 +244,7 @@ pub struct Config { /// lightning when sending and receiving. This has the benefit of lower fees /// but is at the cost of privacy. pub prefer_spark_over_lightning: bool, + pub sparkscan_api_url: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -474,15 +265,6 @@ impl Fee { } } -impl From for spark_wallet::Fee { - fn from(fee: Fee) -> Self { - match fee { - Fee::Fixed { amount } => spark_wallet::Fee::Fixed { amount }, - Fee::Rate { sat_per_vbyte } => spark_wallet::Fee::Rate { sat_per_vbyte }, - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct DepositInfo { @@ -574,15 +356,6 @@ pub struct TokenBalance { pub token_metadata: TokenMetadata, } -impl From for TokenBalance { - fn from(value: spark_wallet::TokenBalance) -> Self { - Self { - balance: value.balance.try_into().unwrap_or_default(), // balance will be changed to u128 or similar - token_metadata: value.token_metadata.into(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct TokenMetadata { @@ -595,24 +368,6 @@ pub struct TokenMetadata { pub decimals: u32, pub max_supply: u64, pub is_freezable: bool, - pub creation_entity_public_key: Option, -} - -impl From for TokenMetadata { - fn from(value: spark_wallet::TokenMetadata) -> Self { - Self { - identifier: value.identifier, - issuer_public_key: hex::encode(value.issuer_public_key.serialize()), - name: value.name, - ticker: value.ticker, - decimals: value.decimals, - max_supply: value.max_supply.try_into().unwrap_or_default(), // max_supply will be changed to u128 or similar - is_freezable: value.is_freezable, - creation_entity_public_key: value - .creation_entity_public_key - .map(|pk| hex::encode(pk.serialize())), - } - } } /// Request to sync the wallet with the Spark network @@ -669,29 +424,6 @@ pub struct SendOnchainFeeQuote { pub speed_slow: SendOnchainSpeedFeeQuote, } -impl From for SendOnchainFeeQuote { - fn from(value: CoopExitFeeQuote) -> Self { - Self { - id: value.id, - expires_at: value.expires_at, - speed_fast: value.speed_fast.into(), - speed_medium: value.speed_medium.into(), - speed_slow: value.speed_slow.into(), - } - } -} - -impl From for CoopExitFeeQuote { - fn from(value: SendOnchainFeeQuote) -> Self { - Self { - id: value.id, - expires_at: value.expires_at, - speed_fast: value.speed_fast.into(), - speed_medium: value.speed_medium.into(), - speed_slow: value.speed_slow.into(), - } - } -} #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] #[derive(Debug, Clone, Serialize)] pub struct SendOnchainSpeedFeeQuote { @@ -705,24 +437,6 @@ impl SendOnchainSpeedFeeQuote { } } -impl From for SendOnchainSpeedFeeQuote { - fn from(value: CoopExitSpeedFeeQuote) -> Self { - Self { - user_fee_sat: value.user_fee_sat, - l1_broadcast_fee_sat: value.l1_broadcast_fee_sat, - } - } -} - -impl From for CoopExitSpeedFeeQuote { - fn from(value: SendOnchainSpeedFeeQuote) -> Self { - Self { - user_fee_sat: value.user_fee_sat, - l1_broadcast_fee_sat: value.l1_broadcast_fee_sat, - } - } -} - #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct ReceivePaymentRequest { pub payment_method: ReceivePaymentMethod, @@ -813,26 +527,6 @@ pub enum OnchainConfirmationSpeed { Slow, } -impl From for ExitSpeed { - fn from(speed: OnchainConfirmationSpeed) -> Self { - match speed { - OnchainConfirmationSpeed::Fast => ExitSpeed::Fast, - OnchainConfirmationSpeed::Medium => ExitSpeed::Medium, - OnchainConfirmationSpeed::Slow => ExitSpeed::Slow, - } - } -} - -impl From for OnchainConfirmationSpeed { - fn from(speed: ExitSpeed) -> Self { - match speed { - ExitSpeed::Fast => OnchainConfirmationSpeed::Fast, - ExitSpeed::Medium => OnchainConfirmationSpeed::Medium, - ExitSpeed::Slow => OnchainConfirmationSpeed::Slow, - } - } -} - #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] pub struct PrepareSendPaymentRequest { pub payment_request: String, diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index f6d9dac1..ad35c6cf 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -1,23 +1,26 @@ #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub(crate) mod sqlite; -use std::{collections::HashMap, sync::Arc, time::SystemTime}; +use std::{collections::HashMap, sync::Arc}; use macros::async_trait; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ - DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, TokenBalance, - models::Payment, + DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, PaymentStatus, + TokenBalance, models::Payment, }; const ACCOUNT_INFO_KEY: &str = "account_info"; const LIGHTNING_ADDRESS_KEY: &str = "lightning_address"; -const SYNC_OFFSET_KEY: &str = "sync_offset"; +const SPARKSCAN_SYNC_INFO_KEY: &str = "sparkscan_sync_info"; const TX_CACHE_KEY: &str = "tx_cache"; const STATIC_DEPOSIT_ADDRESS_CACHE_KEY: &str = "static_deposit_address"; +// Old keys (avoid using them) +// const SYNC_OFFSET_KEY: &str = "sync_offset"; + #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum UpdateDepositPayload { ClaimError { @@ -79,6 +82,7 @@ pub trait Storage: Send + Sync { &self, offset: Option, limit: Option, + status: Option, ) -> Result, StorageError>; /// Inserts a payment into storage @@ -216,7 +220,10 @@ impl ObjectCacheRepository { pub(crate) async fn save_sync_info(&self, value: &CachedSyncInfo) -> Result<(), StorageError> { self.storage - .set_cached_item(SYNC_OFFSET_KEY.to_string(), serde_json::to_string(value)?) + .set_cached_item( + SPARKSCAN_SYNC_INFO_KEY.to_string(), + serde_json::to_string(value)?, + ) .await?; Ok(()) } @@ -224,7 +231,7 @@ impl ObjectCacheRepository { pub(crate) async fn fetch_sync_info(&self) -> Result, StorageError> { let value = self .storage - .get_cached_item(SYNC_OFFSET_KEY.to_string()) + .get_cached_item(SPARKSCAN_SYNC_INFO_KEY.to_string()) .await?; match value { Some(value) => Ok(Some(serde_json::from_str(&value)?)), @@ -322,9 +329,7 @@ pub(crate) struct CachedAccountInfo { #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedSyncInfo { - pub(crate) offset: u64, - #[serde(default)] - pub(crate) last_synced_token_timestamp: Option, + pub(crate) last_synced_payment_id: String, } #[derive(Serialize, Deserialize, Default)] @@ -339,11 +344,12 @@ pub(crate) struct StaticDepositAddress { #[cfg(feature = "test-utils")] pub mod tests { + use chrono::Utc; + use crate::{ DepositClaimError, Payment, PaymentDetails, PaymentMetadata, PaymentMethod, PaymentStatus, PaymentType, Storage, UpdateDepositPayload, }; - use chrono::Utc; #[allow(clippy::too_many_lines)] pub async fn test_sqlite_storage(storage: Box) { @@ -356,7 +362,7 @@ pub mod tests { status: PaymentStatus::Completed, amount: 100_000, fees: 1000, - timestamp: Utc::now().timestamp().try_into().unwrap(), + timestamp: 5000, method: PaymentMethod::Spark, details: Some(PaymentDetails::Spark), }; @@ -371,9 +377,6 @@ pub mod tests { decimals: 8, max_supply: 21_000_000, is_freezable: false, - creation_entity_public_key: Some( - "03fedcba0987654321fedcba0987654321fedcba0987654321fedcba0987654321fe".to_string(), - ), }; let token_payment = Payment { id: "token_pmt456".to_string(), @@ -385,6 +388,7 @@ pub mod tests { method: PaymentMethod::Token, details: Some(PaymentDetails::Token { metadata: token_metadata.clone(), + tx_hash: "tx_hash".to_string(), }), }; @@ -499,7 +503,10 @@ pub mod tests { .unwrap(); // List all payments - let payments = storage.list_payments(Some(0), Some(10)).await.unwrap(); + let payments = storage + .list_payments(Some(0), Some(10), None) + .await + .unwrap(); assert_eq!(payments.len(), 7); // Test each payment type individually @@ -526,9 +533,11 @@ pub mod tests { ( Some(PaymentDetails::Token { metadata: retrieved_metadata, + tx_hash: retrieved_tx_hash, }), Some(PaymentDetails::Token { metadata: expected_metadata, + tx_hash: expected_tx_hash, }), ) => { assert_eq!(retrieved_metadata.identifier, expected_metadata.identifier); @@ -544,10 +553,7 @@ pub mod tests { retrieved_metadata.is_freezable, expected_metadata.is_freezable ); - assert_eq!( - retrieved_metadata.creation_entity_public_key, - expected_metadata.creation_entity_public_key - ); + assert_eq!(retrieved_tx_hash, expected_tx_hash); } ( Some(PaymentDetails::Lightning { @@ -640,6 +646,13 @@ pub mod tests { .filter(|p| p.method == PaymentMethod::Lightning) .count(); assert_eq!(lightning_count, 2); // lightning and lightning_minimal + + // Test listing pending payments + let pending_payments = storage + .list_payments(Some(0), Some(10), Some(PaymentStatus::Pending)) + .await + .unwrap(); + assert_eq!(pending_payments.len(), 2); // token and no_details } pub async fn test_unclaimed_deposits_crud(storage: Box) { diff --git a/crates/breez-sdk/core/src/persist/sqlite.rs b/crates/breez-sdk/core/src/persist/sqlite.rs index 1b29f69d..fb6dc746 100644 --- a/crates/breez-sdk/core/src/persist/sqlite.rs +++ b/crates/breez-sdk/core/src/persist/sqlite.rs @@ -1,11 +1,15 @@ +use std::fmt::Write; +use std::path::{Path, PathBuf}; + use macros::async_trait; +use rusqlite::params_from_iter; use rusqlite::{ Connection, Row, ToSql, params, types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, }; use rusqlite_migration::{M, Migrations, SchemaVersion}; -use std::path::{Path, PathBuf}; +use crate::PaymentStatus; use crate::{ DepositInfo, LnurlPayInfo, PaymentDetails, PaymentMethod, error::DepositClaimError, @@ -162,7 +166,12 @@ impl SqliteStorage { CREATE INDEX idx_payment_details_lightning_invoice ON payment_details_lightning(invoice); ", - "ALTER TABLE payments ADD COLUMN token_metadata TEXT;", + "CREATE TABLE payment_details_token ( + payment_id TEXT PRIMARY KEY, + metadata TEXT NOT NULL, + tx_hash TEXT NOT NULL, + FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE + );", ] } } @@ -185,10 +194,11 @@ impl Storage for SqliteStorage { &self, offset: Option, limit: Option, + status: Option, ) -> Result, StorageError> { let connection = self.get_connection()?; - - let query = format!( + let mut params: Vec> = Vec::new(); + let mut query = String::from( "SELECT p.id , p.payment_type , p.status @@ -199,25 +209,36 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id - LEFT JOIN payment_metadata pm ON p.id = pm.payment_id - ORDER BY p.timestamp DESC - LIMIT {} OFFSET {}", + LEFT JOIN payment_details_token t ON p.id = t.payment_id + LEFT JOIN payment_metadata pm ON p.id = pm.payment_id", + ); + + if let Some(status) = status { + query.push_str(" WHERE p.status = ?"); + params.push(Box::new(status.to_string())); + } + + write!( + query, + " ORDER BY p.timestamp DESC LIMIT {} OFFSET {}", limit.unwrap_or(u32::MAX), offset.unwrap_or(0) - ); + ) + .map_err(|e| StorageError::Implementation(e.to_string()))?; let mut stmt = connection.prepare(&query)?; let payments = stmt - .query_map(params![], map_payment)? + .query_map(params_from_iter(params), map_payment)? .collect::, _>>()?; Ok(payments) } @@ -258,10 +279,10 @@ impl Storage for SqliteStorage { params![payment.id], )?; } - Some(PaymentDetails::Token { metadata }) => { + Some(PaymentDetails::Token { metadata, tx_hash }) => { tx.execute( - "UPDATE payments SET token_metadata = ? WHERE id = ?", - params![serde_json::to_string(&metadata)?, payment.id], + "INSERT OR REPLACE INTO payment_details_token (payment_id, metadata, tx_hash) VALUES (?, ?, ?)", + params![payment.id, serde_json::to_string(&metadata)?, tx_hash], )?; } Some(PaymentDetails::Lightning { @@ -357,15 +378,17 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id + LEFT JOIN payment_details_token t ON p.id = t.payment_id LEFT JOIN payment_metadata pm ON p.id = pm.payment_id WHERE p.id = ?", )?; @@ -391,15 +414,17 @@ impl Storage for SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id + LEFT JOIN payment_details_token t ON p.id = t.payment_id LEFT JOIN payment_metadata pm ON p.id = pm.payment_id WHERE l.invoice = ?", )?; @@ -489,8 +514,8 @@ fn map_payment(row: &Row<'_>) -> Result { let withdraw_tx_id: Option = row.get(7)?; let deposit_tx_id: Option = row.get(8)?; let spark: Option = row.get(9)?; - let token_metadata: Option = row.get(10)?; - let lightning_invoice: Option = row.get(11)?; + let lightning_invoice: Option = row.get(10)?; + let token_metadata: Option = row.get(16)?; let details = match ( lightning_invoice, withdraw_tx_id, @@ -499,11 +524,11 @@ fn map_payment(row: &Row<'_>) -> Result { token_metadata, ) { (Some(invoice), _, _, _, _) => { - let payment_hash: String = row.get(12)?; - let destination_pubkey: String = row.get(13)?; - let description: Option = row.get(14)?; - let preimage: Option = row.get(15)?; - let lnurl_pay_info: Option = row.get(16)?; + let payment_hash: String = row.get(11)?; + let destination_pubkey: String = row.get(12)?; + let description: Option = row.get(13)?; + let preimage: Option = row.get(14)?; + let lnurl_pay_info: Option = row.get(15)?; Some(PaymentDetails::Lightning { invoice, @@ -521,6 +546,7 @@ fn map_payment(row: &Row<'_>) -> Result { metadata: serde_json::from_str(&metadata).map_err(|e| { rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, e.into()) })?, + tx_hash: row.get(17)?, }), _ => None, }; diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index f1c3d5e4..594166e5 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -20,10 +20,10 @@ use breez_sdk_common::{ rest::RestClient, }; use spark_wallet::{ - ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, Order, PagingFilter, SparkAddress, - SparkWallet, TransferTokenOutput, WalletEvent, WalletTransfer, + ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, SparkAddress, SparkWallet, + TokenInputs, TransferTokenOutput, WalletEvent, WalletTransfer, }; -use std::{str::FromStr, sync::Arc, time::UNIX_EPOCH}; +use std::{str::FromStr, sync::Arc}; use tracing::{error, info, trace}; use web_time::{Duration, SystemTime}; @@ -43,8 +43,9 @@ use crate::{ ListUnclaimedDepositsResponse, LnurlPayInfo, LnurlPayRequest, LnurlPayResponse, Logger, Network, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, PrepareLnurlPayRequest, PrepareLnurlPayResponse, RefundDepositRequest, RefundDepositResponse, - RegisterLightningAddressRequest, SendOnchainFeeQuote, SendPaymentOptions, TokenMetadata, + RegisterLightningAddressRequest, SendOnchainFeeQuote, SendPaymentOptions, WaitForPaymentIdentifier, WaitForPaymentRequest, WaitForPaymentResponse, + adaptors::sparkscan::payments_from_address_transaction_and_ssp_request, error::SdkError, events::{EventEmitter, EventListener, SdkEvent}, lnurl::LnurlServerClient, @@ -66,6 +67,7 @@ use crate::{ }, }; +const SPARKSCAN_API_URL: &str = "https://api.sparkscan.io"; const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; #[derive(Clone, Debug)] @@ -194,6 +196,7 @@ pub fn default_config(network: Network) -> Config { max_deposit_claim_fee: None, lnurl_domain: Some("breez.tips".to_string()), prefer_spark_over_lightning: false, + sparkscan_api_url: SPARKSCAN_API_URL.to_string(), } } @@ -391,15 +394,13 @@ impl BreezSdk { update_balances(self.spark_wallet.clone(), self.storage.clone()).await?; let object_repository = ObjectCacheRepository::new(self.storage.clone()); - self.sync_bitcoin_payments_to_storage(&object_repository) - .await?; - self.sync_token_payments_to_storage(&object_repository) - .await?; + self.sync_pending_payments().await?; + self.sync_payments_to_storage(&object_repository).await?; Ok(()) } - async fn sync_bitcoin_payments_to_storage( + async fn sync_payments_to_storage( &self, object_repository: &ObjectCacheRepository, ) -> Result<(), SdkError> { @@ -408,357 +409,212 @@ impl BreezSdk { .fetch_sync_info() .await? .unwrap_or_default(); - let current_offset = cached_sync_info.offset; + let last_synced_id = cached_sync_info.last_synced_payment_id; - // We'll keep querying in batches until we have all transfers - let mut next_filter = Some(PagingFilter { - offset: current_offset, - limit: PAYMENT_SYNC_BATCH_SIZE, - order: Order::Ascending, - }); - info!("Syncing payments to storage, offset = {}", current_offset); - let mut pending_payments: u64 = 0; - while let Some(filter) = next_filter { - // Get batch of transfers starting from current offset - let transfers_response = self + let spark_address = self.spark_wallet.get_spark_address()?.to_string(); + + let mut payments_to_sync = Vec::new(); + + // We'll keep querying in batches until we have all payments or we find the last synced payment + let mut next_offset = 0_u64; + let mut has_more = true; + let mut found_last_synced = false; + info!("Syncing payments to storage, offset = {next_offset}"); + while has_more && !found_last_synced { + // Get batch of address transactions starting from current offset + let response = sparkscan::Client::new(&self.config.sparkscan_api_url) + .get_address_transactions_v1_address_address_transactions_get() + .network(sparkscan::types::Network::from(self.config.network)) + .address(spark_address.to_string()) + .offset(next_offset) + .limit(PAYMENT_SYNC_BATCH_SIZE) + .send() + .await?; + let address_transactions = &response.data; + + let ssp_transfer_types = [ + sparkscan::types::AddressTransactionType::BitcoinDeposit, + sparkscan::types::AddressTransactionType::BitcoinWithdrawal, + sparkscan::types::AddressTransactionType::LightningPayment, + ]; + let ssp_user_requests = self .spark_wallet - .list_transfers(Some(filter.clone())) + .query_ssp_user_requests( + address_transactions + .iter() + .filter(|tx| ssp_transfer_types.contains(&tx.type_)) + .map(|tx| tx.id.clone()) + .collect(), + ) .await?; info!( - "Syncing bitcoin payments to storage, offset = {}, transfers = {}", - filter.offset, - transfers_response.len() + "Syncing payments to storage, offset = {next_offset}, transactions = {}", + address_transactions.len() ); - // Process transfers in this batch - for transfer in &transfers_response.items { - // Create a payment record - let payment: Payment = transfer.clone().try_into()?; - // Insert payment into storage - if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert bitcoin payment: {err:?}"); + // Process transactions in this batch + for transaction in address_transactions { + // Create payment records + let payments = payments_from_address_transaction_and_ssp_request( + transaction, + ssp_user_requests.get(&transaction.id), + &spark_address, + )?; + + for payment in payments { + if payment.id == last_synced_id { + info!( + "Last synced payment id found ({last_synced_id}), stopping sync and proceeding to insert {} payments", + payments_to_sync.len() + ); + found_last_synced = true; + break; + } + payments_to_sync.push(payment); } - if payment.status == PaymentStatus::Pending { - pending_payments = pending_payments.saturating_add(1); + + // If we found the last synced payment, stop processing this batch + if found_last_synced { + break; } - info!("Inserted bitcoin payment: {payment:?}"); } // Check if we have more transfers to fetch - let cache_offset = filter - .offset - .saturating_add(u64::try_from(transfers_response.len())?); + next_offset = next_offset.saturating_add(u64::try_from(address_transactions.len())?); + has_more = address_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } - // Update our last processed offset in the storage. We should remove pending payments - // from the offset as they might be removed from the list later. - let save_res = object_repository + // Insert payment into storage from oldest to newest + for payment in payments_to_sync.iter().rev() { + self.storage.insert_payment(payment.clone()).await?; + info!("Inserted payment: {payment:?}"); + object_repository .save_sync_info(&CachedSyncInfo { - offset: cache_offset.saturating_sub(pending_payments), - last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, + last_synced_payment_id: payment.id.clone(), }) - .await; - - if let Err(err) = save_res { - error!("Failed to update last sync bitcoin offset: {err:?}"); - } - - next_filter = transfers_response.next; + .await?; } Ok(()) } - #[allow(clippy::too_many_lines)] - async fn sync_token_payments_to_storage( - &self, - object_repository: &ObjectCacheRepository, - ) -> Result<(), SdkError> { - // Get the last offsets we processed from storage - let cached_sync_info = object_repository - .fetch_sync_info() - .await? - .unwrap_or_default(); - let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; - - let our_public_key = self.spark_wallet.get_identity_public_key(); + async fn sync_pending_payments(&self) -> Result<(), SdkError> { + let pending_payments = self + .storage + .list_payments(None, None, Some(PaymentStatus::Pending)) + .await?; - let mut latest_token_transaction_timestamp = None; + let (pending_token_payments, pending_bitcoin_payments): (Vec<_>, Vec<_>) = pending_payments + .iter() + .partition(|p| p.method == PaymentMethod::Token); - // We'll keep querying in batches until we have all transfers - let mut next_offset = 0; - let mut has_more = true; info!( - "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" + "Syncing pending bitcoin payments: {}", + pending_bitcoin_payments.len() ); - while has_more { - // Get batch of token transactions starting from current offset - let token_transactions = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - paging: Some(PagingFilter::new( - Some(next_offset), - Some(PAYMENT_SYNC_BATCH_SIZE), - None, - )), - ..Default::default() - }) - .await? - .items; - - // On first iteration, set the latest token transaction timestamp to the first transaction timestamp - if next_offset == 0 { - latest_token_transaction_timestamp = - token_transactions.first().map(|tx| tx.created_timestamp); - } - - // On first iteration, set the latest token transaction timestamp to the first transaction timestamp - if next_offset == 0 { - latest_token_transaction_timestamp = - token_transactions.first().map(|tx| tx.created_timestamp); - } - - // Get prev out hashes of first input of each token transaction - // Assumes all inputs of a tx share the same owner public key - let token_transactions_prevout_hashes = token_transactions - .iter() - .filter_map(|tx| match &tx.inputs { - spark_wallet::TokenInputs::Transfer(token_transfer_input) => { - token_transfer_input.outputs_to_spend.first().cloned() - } - spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { - None - } - }) - .map(|output| output.prev_token_tx_hash) - .collect::>(); - - // Since we are trying to fetch at most 1 parent transaction per token transaction, - // we can fetch all in one go using same batch size - let parent_transactions = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), - owner_public_keys: Some(Vec::new()), - token_transaction_hashes: token_transactions_prevout_hashes, - ..Default::default() - }) - .await? - .items; - - info!( - "Syncing token payments to storage, offset = {next_offset}, transactions = {}", - token_transactions.len() - ); - // Process transfers in this batch - for transaction in &token_transactions { - // Stop syncing if we have reached the last synced token transaction timestamp - if let Some(last_synced_token_timestamp) = last_synced_token_timestamp - && transaction.created_timestamp <= last_synced_token_timestamp - { - break; - } - - let tx_inputs_are_ours = match &transaction.inputs { - spark_wallet::TokenInputs::Transfer(token_transfer_input) => { - let Some(first_input) = token_transfer_input.outputs_to_spend.first() - else { - return Err(SdkError::Generic( - "No input in token transfer input".to_string(), - )); - }; - let Some(parent_transaction) = parent_transactions - .iter() - .find(|tx| tx.hash == first_input.prev_token_tx_hash) - else { - return Err(SdkError::Generic( - "Parent transaction not found".to_string(), - )); - }; - let Some(output) = parent_transaction - .outputs - .get(first_input.prev_token_tx_vout as usize) - else { - return Err(SdkError::Generic("Output not found".to_string())); - }; - output.owner_public_key == our_public_key - } - spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { - false - } - }; - - // Create payment records - let payments = self - .token_transaction_to_payments(transaction, tx_inputs_are_ours) - .await?; + self.sync_pending_bitcoin_payments(&pending_bitcoin_payments) + .await?; + info!( + "Syncing pending token payments: {}", + pending_token_payments.len() + ); + self.sync_pending_token_payments(&pending_token_payments) + .await?; - for payment in payments { - // Insert payment into storage - if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert token payment: {err:?}"); - } - info!("Inserted token payment: {payment:?}"); - } - } + Ok(()) + } - // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); - has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + async fn sync_pending_bitcoin_payments( + &self, + pending_bitcoin_payments: &[&Payment], + ) -> Result<(), SdkError> { + if pending_bitcoin_payments.is_empty() { + return Ok(()); } - // Update our last processed transaction timestamp in the storage - if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { - let save_res = object_repository - .save_sync_info(&CachedSyncInfo { - offset: cached_sync_info.offset, - last_synced_token_timestamp: Some(latest_token_transaction_timestamp), - }) - .await; + let transfer_ids: Vec<_> = pending_bitcoin_payments + .iter() + .map(|p| p.id.clone()) + .collect(); - if let Err(err) = save_res { - error!("Failed to update last sync token timestamp: {err:?}"); - } + // TODO: Deal with paging (is necessary if there are a lot of pending payments) + let transfers = self + .spark_wallet + .list_transfers(None, Some(transfer_ids.clone())) + .await? + .items; + + for transfer in transfers { + let payment = Payment::try_from(transfer)?; + info!("Inserting previously pending bitcoin payment: {payment:?}"); + self.storage.insert_payment(payment).await?; } Ok(()) } - /// Converts a token transaction to payments - /// - /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. - /// The id of the payment is the id of the first output in the group. - /// - /// Assumptions: - /// - All outputs of a token transaction share the same token identifier - /// - All inputs of a token transaction share the same owner public key - #[allow(clippy::too_many_lines)] - async fn token_transaction_to_payments( + async fn sync_pending_token_payments( &self, - transaction: &spark_wallet::TokenTransaction, - tx_inputs_are_ours: bool, - ) -> Result, SdkError> { - // Get token metadata for the first output (assuming all outputs have the same token) - let token_identifier = transaction - .outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in token transaction".to_string(), - ))? - .token_identifier - .as_ref(); - let metadata: TokenMetadata = self - .spark_wallet - .get_tokens_metadata(&[token_identifier]) - .await? - .first() - .ok_or(SdkError::Generic("Token metadata not found".to_string()))? - .clone() - .into(); - - let is_transfer_transaction = - matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); - - let timestamp = transaction - .created_timestamp - .duration_since(UNIX_EPOCH) - .map_err(|_| { - SdkError::Generic( - "Token transaction created timestamp is before UNIX_EPOCH".to_string(), - ) - })? - .as_secs(); - - // Group outputs by owner public key - let mut outputs_by_owner = std::collections::HashMap::new(); - for output in &transaction.outputs { - outputs_by_owner - .entry(output.owner_public_key) - .or_insert_with(Vec::new) - .push(output); + pending_token_payments: &[&Payment], + ) -> Result<(), SdkError> { + if pending_token_payments.is_empty() { + return Ok(()); } - let mut payments = Vec::new(); - - if tx_inputs_are_ours { - // If inputs are ours, add an outgoing payment for each output group that is not ours - for (owner_pubkey, outputs) in outputs_by_owner { - if owner_pubkey != self.spark_wallet.get_identity_public_key() { - // This is an outgoing payment to another user - let total_amount = outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = outputs - .first() - .ok_or(SdkError::Generic("No outputs in output group".to_string()))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Send, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, // TODO: calculate actual fees when they start being charged - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.clone(), - }), - }; - - payments.push(payment); + let hash_pending_token_payments_map = pending_token_payments.iter().try_fold( + std::collections::HashMap::new(), + |mut acc: std::collections::HashMap<&_, Vec<_>>, payment| { + let details = payment + .details + .as_ref() + .ok_or_else(|| SdkError::Generic("Payment details missing".to_string()))?; + + if let PaymentDetails::Token { tx_hash, .. } = details { + acc.entry(tx_hash).or_default().push(payment); + Ok(acc) + } else { + Err(SdkError::Generic( + "Payment is not a token payment".to_string(), + )) } - // Ignore outputs that belong to us (potential change outputs) - } - } else { - // If inputs are not ours, add an incoming payment for our output group - if let Some(our_outputs) = - outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) - { - let total_amount: u64 = our_outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = our_outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in our output group".to_string(), - ))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Receive, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { metadata }), - }; + }, + )?; - payments.push(payment); + // TODO: Deal with paging (is necessary if there are a lot of pending payments) + let token_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + token_transaction_hashes: hash_pending_token_payments_map + .keys() + .map(|k| (*k).to_string()) + .collect(), + ..Default::default() + }) + .await? + .items; + + for token_transaction in token_transactions { + let is_transfer_transaction = + matches!(token_transaction.inputs, TokenInputs::Transfer(..)); + let payment_status = PaymentStatus::from_token_transaction_status( + token_transaction.status, + is_transfer_transaction, + ); + if payment_status != PaymentStatus::Pending { + let payments_to_update = hash_pending_token_payments_map + .get(&token_transaction.hash) + .ok_or(SdkError::Generic("Payment not found".to_string()))?; + for payment in payments_to_update { + // For now, updating the status is enough + let mut updated_payment = (**payment).clone(); + updated_payment.status = payment_status; + info!("Inserting previously pending token payment: {updated_payment:?}"); + self.storage.insert_payment(updated_payment).await?; + } } - // Ignore outputs that don't belong to us } - Ok(payments) + Ok(()) } async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> { @@ -961,7 +817,7 @@ impl BreezSdk { match &request.payment_method { ReceivePaymentMethod::SparkAddress => Ok(ReceivePaymentResponse { fee_sats: 0, - payment_request: self.spark_wallet.get_spark_address().await?.to_string(), + payment_request: self.spark_wallet.get_spark_address()?.to_string(), }), ReceivePaymentMethod::BitcoinAddress => { // TODO: allow passing amount @@ -1326,35 +1182,12 @@ impl BreezSdk { .map_err(|_| SdkError::InvalidInput("Invalid spark address".to_string()))?; let payment = if let Some(identifier) = token_identifier { - let tx_hash = self - .spark_wallet - .transfer_tokens(vec![TransferTokenOutput { - token_id: identifier.clone(), - amount: u128::from(request.prepare_response.amount), - receiver_address: spark_address, - }]) - .await?; - let tx = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - token_transaction_hashes: vec![tx_hash], - paging: None, - ..Default::default() - }) - .await? - .items - .first() - .ok_or(SdkError::Generic( - "Token transaction not found after being started".to_string(), - ))? - .clone(); - self.token_transaction_to_payments(&tx, true) - .await? - .first() - .ok_or(SdkError::Generic( - "No payment found in started token transaction".to_string(), - ))? - .clone() + self.send_spark_token_payment( + identifier, + request.prepare_response.amount.into(), + spark_address, + ) + .await? } else { let transfer = self .spark_wallet @@ -1551,7 +1384,7 @@ impl BreezSdk { ) -> Result { let payments = self .storage - .list_payments(request.offset, request.limit) + .list_payments(request.offset, request.limit, None) .await?; Ok(ListPaymentsResponse { payments }) } @@ -1801,6 +1634,54 @@ impl BreezSdk { } } +impl BreezSdk { + async fn send_spark_token_payment( + &self, + token_identifier: String, + amount: u128, + receiver_address: SparkAddress, + ) -> Result { + // Get token metadata before sending the payment to make sure we get it from cache + let metadata = self + .spark_wallet + .get_tokens_metadata(&[&token_identifier]) + .await? + .first() + .ok_or(SdkError::Generic("Token metadata not found".to_string()))? + .clone(); + + let tx_hash = self + .spark_wallet + .transfer_tokens(vec![TransferTokenOutput { + token_id: token_identifier, + amount, + receiver_address: receiver_address.clone(), + }]) + .await?; + + // Build and insert pending payment into storage as it may take some time for sparkscan to detect it + let payment = Payment { + id: format!("{tx_hash}:0"), // Transaction output index 0 is for the receiver + payment_type: PaymentType::Send, + status: PaymentStatus::Pending, + amount: amount.try_into()?, + fees: 0, + timestamp: SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map_err(|_| SdkError::Generic("Failed to get current timestamp".to_string()))? + .as_secs(), + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.into(), + tx_hash, + }), + }; + self.storage.insert_payment(payment.clone()).await?; + + Ok(payment) + } +} + fn is_payment_match(payment: &Payment, request: &WaitForPaymentRequest) -> bool { match &request.identifier { WaitForPaymentIdentifier::PaymentId(payment_id) => payment.id == *payment_id, diff --git a/crates/breez-sdk/wasm/js/node-storage/index.cjs b/crates/breez-sdk/wasm/js/node-storage/index.cjs index e5ab73a3..a9f61de7 100644 --- a/crates/breez-sdk/wasm/js/node-storage/index.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/index.cjs @@ -119,14 +119,14 @@ class SqliteStorage { // ===== Payment Operations ===== - listPayments(offset = null, limit = null) { + listPayments(offset = null, limit = null, status = null) { try { // Handle null values by using default values const actualOffset = offset !== null ? offset : 0; const actualLimit = limit !== null ? limit : 4294967295; // u32::MAX - const stmt = this.db.prepare(` - SELECT p.id + let query = ` + SELECT p.id , p.payment_type , p.status , p.amount @@ -136,26 +136,36 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id - LEFT JOIN payment_metadata pm ON p.id = pm.payment_id - ORDER BY p.timestamp DESC - LIMIT ? OFFSET ? - `); + LEFT JOIN payment_details_token t ON p.id = t.payment_id + LEFT JOIN payment_metadata pm ON p.id = pm.payment_id`; + + const queryParams = []; + + if (status !== null) { + query += ` WHERE p.status = ?`; + queryParams.push(status); + } - const rows = stmt.all(actualLimit, actualOffset); + query += ` ORDER BY p.timestamp DESC LIMIT ? OFFSET ?`; + queryParams.push(actualLimit, actualOffset); + + const stmt = this.db.prepare(query); + const rows = stmt.all(...queryParams); return Promise.resolve(rows.map(this._rowToPayment.bind(this))); } catch (error) { return Promise.reject( new StorageError( - `Failed to list payments (offset: ${offset}, limit: ${limit}): ${error.message}`, + `Failed to list payments (offset: ${offset}, limit: ${limit}, status: ${status}): ${error.message}`, error ) ); @@ -171,14 +181,19 @@ class SqliteStorage { } const paymentInsert = this.db.prepare( - `INSERT OR REPLACE INTO payments (id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark, token_metadata) - VALUES (@id, @paymentType, @status, @amount, @fees, @timestamp, @method, @withdrawTxId, @depositTxId, @spark, @tokenMetadata)` + `INSERT OR REPLACE INTO payments (id, payment_type, status, amount, fees, timestamp, method, withdraw_tx_id, deposit_tx_id, spark) + VALUES (@id, @paymentType, @status, @amount, @fees, @timestamp, @method, @withdrawTxId, @depositTxId, @spark)` ); const lightningInsert = this.db.prepare( `INSERT OR REPLACE INTO payment_details_lightning (payment_id, invoice, payment_hash, destination_pubkey, description, preimage) VALUES (@id, @invoice, @paymentHash, @destinationPubkey, @description, @preimage)` ); + const tokenInsert = this.db.prepare( + `INSERT OR REPLACE INTO payment_details_token + (payment_id, metadata, tx_hash) + VALUES (@id, @metadata, @txHash)` + ); const transaction = this.db.transaction(() => { paymentInsert.run({ id: payment.id, @@ -193,10 +208,6 @@ class SqliteStorage { depositTxId: payment.details?.type === "deposit" ? payment.details.txId : null, spark: payment.details?.type === "spark" ? 1 : null, - tokenMetadata: - payment.details?.type === "token" - ? JSON.stringify(payment.details.metadata) - : null, }); if (payment.details?.type === "lightning") { @@ -209,6 +220,14 @@ class SqliteStorage { preimage: payment.details.preimage, }); } + + if (payment.details?.type === "token") { + tokenInsert.run({ + id: payment.id, + metadata: JSON.stringify(payment.details.metadata), + txHash: payment.details.txHash, + }); + } }); transaction(); @@ -242,15 +261,17 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id + LEFT JOIN payment_details_token t ON p.id = t.payment_id LEFT JOIN payment_metadata pm ON p.id = pm.payment_id WHERE p.id = ? `); @@ -294,15 +315,17 @@ class SqliteStorage { , p.withdraw_tx_id , p.deposit_tx_id , p.spark - , p.token_metadata , l.invoice AS lightning_invoice , l.payment_hash AS lightning_payment_hash , l.destination_pubkey AS lightning_destination_pubkey , COALESCE(l.description, pm.lnurl_description) AS lightning_description , l.preimage AS lightning_preimage , pm.lnurl_pay_info + , t.metadata AS token_metadata + , t.tx_hash AS token_tx_hash FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id + LEFT JOIN payment_details_token t ON p.id = t.payment_id LEFT JOIN payment_metadata pm ON p.id = pm.payment_id WHERE l.invoice = ? `); @@ -490,6 +513,7 @@ class SqliteStorage { details = { type: "token", metadata: JSON.parse(row.token_metadata), + txHash: row.token_tx_hash, }; } diff --git a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs index 67bdf473..1b4bf07e 100644 --- a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs @@ -178,8 +178,16 @@ class MigrationManager { ], }, { - name: "Add token_metadata column to payments", - sql: `ALTER TABLE payments ADD COLUMN token_metadata TEXT`, + name: "Create payment_details_token table", + sql: [ + `CREATE TABLE IF NOT EXISTS payment_details_token ( + payment_id TEXT PRIMARY KEY, + metadata TEXT, + tx_hash TEXT, + FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_payment_details_token_payment_id ON payment_details_token(payment_id)`, + ], }, ]; } diff --git a/crates/breez-sdk/wasm/js/web-storage/index.js b/crates/breez-sdk/wasm/js/web-storage/index.js index 49a0f24a..81ccc9e3 100644 --- a/crates/breez-sdk/wasm/js/web-storage/index.js +++ b/crates/breez-sdk/wasm/js/web-storage/index.js @@ -292,7 +292,7 @@ class IndexedDBStorage { // ===== Payment Operations ===== - async listPayments(offset = null, limit = null) { + async listPayments(offset = null, limit = null, status = null) { if (!this.db) { throw new StorageError("Database not initialized"); } @@ -332,6 +332,12 @@ class IndexedDBStorage { const payment = cursor.value; + // Filter by status if provided + if (status !== null && payment.status !== status) { + cursor.continue(); + return; + } + // Get metadata for this payment const metadataRequest = metadataStore.get(payment.id); metadataRequest.onsuccess = () => { @@ -355,7 +361,7 @@ class IndexedDBStorage { request.onerror = () => { reject( new StorageError( - `Failed to list payments: ${ + `Failed to list payments (offset: ${offset}, limit: ${limit}, status: ${status}): ${ request.error?.message || "Unknown error" }`, request.error @@ -751,7 +757,10 @@ class IndexedDBStorage { } } -export async function createDefaultStorage(dbName = "BreezSdkSpark", logger = null) { +export async function createDefaultStorage( + dbName = "BreezSdkSpark", + logger = null +) { const storage = new IndexedDBStorage(dbName, logger); await storage.initialize(); return storage; diff --git a/crates/breez-sdk/wasm/src/models.rs b/crates/breez-sdk/wasm/src/models.rs index 2d469e01..252e8b5c 100644 --- a/crates/breez-sdk/wasm/src/models.rs +++ b/crates/breez-sdk/wasm/src/models.rs @@ -373,6 +373,7 @@ pub enum PaymentDetails { Spark, Token { metadata: TokenMetadata, + tx_hash: String, }, Lightning { description: Option, @@ -478,6 +479,7 @@ pub struct Config { pub max_deposit_claim_fee: Option, pub lnurl_domain: Option, pub prefer_spark_over_lightning: bool, + pub sparkscan_api_url: String, } #[macros::extern_wasm_bindgen(breez_sdk_spark::Fee)] @@ -521,7 +523,6 @@ pub struct TokenMetadata { /// Decimal representation of the token max supply (unsigned 128-bit integer) pub max_supply: u64, pub is_freezable: bool, - pub creation_entity_public_key: Option, } #[macros::extern_wasm_bindgen(breez_sdk_spark::SyncWalletRequest)] diff --git a/crates/breez-sdk/wasm/src/persist/mod.rs b/crates/breez-sdk/wasm/src/persist/mod.rs index eaf85bf1..84b9762e 100644 --- a/crates/breez-sdk/wasm/src/persist/mod.rs +++ b/crates/breez-sdk/wasm/src/persist/mod.rs @@ -6,7 +6,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::js_sys::Promise; -use crate::models::{DepositInfo, Payment, PaymentMetadata, UpdateDepositPayload}; +use crate::models::{DepositInfo, Payment, PaymentMetadata, PaymentStatus, UpdateDepositPayload}; pub struct WasmStorage { pub storage: Storage, @@ -72,10 +72,11 @@ impl breez_sdk_spark::Storage for WasmStorage { &self, offset: Option, limit: Option, + status: Option, ) -> Result, breez_sdk_spark::StorageError> { let promise = self .storage - .list_payments(offset, limit) + .list_payments(offset, limit, status.map(|s| s.into())) .map_err(js_error_to_storage_error)?; let future = JsFuture::from(promise); let result = future.await.map_err(js_error_to_storage_error)?; @@ -240,6 +241,7 @@ extern "C" { this: &Storage, offset: Option, limit: Option, + status: Option, ) -> Result; #[wasm_bindgen(structural, method, js_name = insertPayment, catch)] diff --git a/crates/internal/src/command/mod.rs b/crates/internal/src/command/mod.rs index 4d54f722..017e7f5f 100644 --- a/crates/internal/src/command/mod.rs +++ b/crates/internal/src/command/mod.rs @@ -88,7 +88,7 @@ pub(crate) async fn handle_command( println!("Signature: {signature}"); } Command::SparkAddress => { - let spark_address = wallet.get_spark_address().await?; + let spark_address = wallet.get_spark_address()?; println!("{spark_address}") } Command::Sync => { diff --git a/crates/internal/src/command/transfer.rs b/crates/internal/src/command/transfer.rs index 3fdd8c66..45a648d9 100644 --- a/crates/internal/src/command/transfer.rs +++ b/crates/internal/src/command/transfer.rs @@ -58,7 +58,7 @@ pub async fn handle_command( None }; - let transfers = wallet.list_transfers(paging).await?; + let transfers = wallet.list_transfers(paging, None).await?; println!( "Transfers: {}", serde_json::to_string_pretty(&transfers.items)? diff --git a/crates/spark-wallet/src/wallet.rs b/crates/spark-wallet/src/wallet.rs index 2bd9c0cc..6ff27d56 100644 --- a/crates/spark-wallet/src/wallet.rs +++ b/crates/spark-wallet/src/wallet.rs @@ -24,7 +24,7 @@ use spark::{ }, session_manager::{InMemorySessionManager, SessionManager}, signer::Signer, - ssp::{ServiceProvider, SspTransfer}, + ssp::{ServiceProvider, SspTransfer, SspUserRequest}, tree::{ InMemoryTreeStore, LeavesReservation, SynchronousTreeService, TargetAmounts, TreeNode, TreeNodeId, TreeService, TreeStore, select_leaves_by_amounts, with_reserved_leaves, @@ -539,7 +539,7 @@ impl SparkWallet { } } - pub async fn get_spark_address(&self) -> Result { + pub fn get_spark_address(&self) -> Result { Ok(SparkAddress::new( self.identity_public_key, self.config.network, @@ -555,9 +555,13 @@ impl SparkWallet { pub async fn list_transfers( &self, paging: Option, + transfer_ids: Option>, ) -> Result, SparkWalletError> { let our_pubkey = self.identity_public_key; - let transfers = self.transfer_service.query_transfers(paging).await?; + let transfers = self + .transfer_service + .query_transfers(paging, transfer_ids) + .await?; create_transfers(transfers, &self.ssp_client, our_pubkey).await } @@ -573,6 +577,24 @@ impl SparkWallet { create_transfers(transfers, &self.ssp_client, our_pubkey).await } + /// Queries the SSP for user requests by their associated transfer IDs + /// and returns a map of transfer IDs to user requests + pub async fn query_ssp_user_requests( + &self, + transfer_ids: Vec, + ) -> Result, SparkWalletError> { + let transfers = self.ssp_client.get_transfers(transfer_ids).await?; + Ok(transfers + .into_iter() + .filter_map( + |transfer| match (transfer.spark_id, transfer.user_request) { + (Some(spark_id), Some(user_request)) => Some((spark_id, user_request)), + _ => None, + }, + ) + .collect()) + } + /// Signs a message with the identity key using ECDSA and returns the signature. /// /// If exposing this, consider adding a prefix to prevent mistakenly signing messages. diff --git a/crates/spark/src/services/transfer.rs b/crates/spark/src/services/transfer.rs index 236b60bb..539c9f80 100644 --- a/crates/spark/src/services/transfer.rs +++ b/crates/spark/src/services/transfer.rs @@ -1045,6 +1045,7 @@ impl TransferService { async fn query_transfers_inner( &self, paging: PagingFilter, + transfer_ids: Option>, ) -> Result, ServiceError> { trace!( "Querying transfers with limit: {:?}, offset: {:?}", @@ -1069,6 +1070,7 @@ impl TransferService { operator_rpc::spark::TransferType::CooperativeExit.into(), operator_rpc::spark::TransferType::UtxoSwap.into(), ], + transfer_ids: transfer_ids.unwrap_or_default(), ..Default::default() }) .await?; @@ -1087,10 +1089,17 @@ impl TransferService { pub async fn query_transfers( &self, paging: Option, + transfer_ids: Option>, ) -> Result, ServiceError> { let transfers = match paging { - Some(paging) => self.query_transfers_inner(paging).await?, - None => pager(|f| self.query_transfers_inner(f), PagingFilter::default()).await?, + Some(paging) => self.query_transfers_inner(paging, transfer_ids).await?, + None => { + pager( + |f| self.query_transfers_inner(f, transfer_ids.clone()), + PagingFilter::default(), + ) + .await? + } }; Ok(transfers) } diff --git a/crates/spark/src/ssp/graphql/models.rs b/crates/spark/src/ssp/graphql/models.rs index d0e912e9..ba7cad6a 100644 --- a/crates/spark/src/ssp/graphql/models.rs +++ b/crates/spark/src/ssp/graphql/models.rs @@ -349,6 +349,40 @@ pub enum SspUserRequest { LightningSendRequest(LightningSendRequest), } +impl SspUserRequest { + pub fn get_lightning_invoice(&self) -> Option { + let invoice = match self { + SspUserRequest::LightningReceiveRequest(request) => { + Some(request.invoice.encoded_invoice.clone()) + } + SspUserRequest::LightningSendRequest(request) => Some(request.encoded_invoice.clone()), + _ => None, + }; + invoice.map(|i| i.to_lowercase()) + } + + pub fn get_lightning_preimage(&self) -> Option { + match self { + SspUserRequest::LightningReceiveRequest(request) => { + request.lightning_receive_payment_preimage.clone() + } + SspUserRequest::LightningSendRequest(request) => { + request.lightning_send_payment_preimage.clone() + } + _ => None, + } + } + + pub fn get_total_fees_sats(&self) -> u64 { + match self { + SspUserRequest::LightningSendRequest(request) => request.fee.as_sats().unwrap_or(0), + SspUserRequest::CoopExitRequest(request) => request.get_total_fees_sats(), + SspUserRequest::ClaimStaticDeposit(request) => request.get_total_fees_sats(), + _ => 0, + } + } +} + #[macros::derive_from(TransfersClaimStaticDepositFragment)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct ClaimStaticDepositInfo { @@ -365,7 +399,13 @@ pub struct ClaimStaticDepositInfo { pub transfer_spark_id: Option, } -#[derive(Debug, Clone, Copy, Deserialize, Serialize, Eq, PartialEq)] +impl ClaimStaticDepositInfo { + pub fn get_total_fees_sats(&self) -> u64 { + self.max_fee.as_sats().unwrap_or(0) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ClaimStaticDepositStatus { Created, @@ -494,6 +534,15 @@ pub struct CoopExitRequest { pub transfer: Option, } +impl CoopExitRequest { + pub fn get_total_fees_sats(&self) -> u64 { + self.fee + .as_sats() + .unwrap_or(0) + .saturating_add(self.l1_broadcast_fee.as_sats().unwrap_or(0)) + } +} + /// CoopExitFeeQuote structure #[derive(Debug, Clone, Deserialize)] #[macros::derive_from(CoopExitFeeQuoteCoopExitFeeQuoteQuote)] From c0d5deb5999df6e643abf64f4fdc47864d0d0d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 15 Sep 2025 13:17:55 +0100 Subject: [PATCH 06/22] Update flutter bindings --- packages/flutter/rust/Cargo.lock | 243 ++++++++++++++++++++++++++++ packages/flutter/rust/src/errors.rs | 1 + packages/flutter/rust/src/models.rs | 3 +- packages/flutter/test/helper.dart | 6 + 4 files changed, 252 insertions(+), 1 deletion(-) diff --git a/packages/flutter/rust/Cargo.lock b/packages/flutter/rust/Cargo.lock index 0b8b133a..60689cb1 100644 --- a/packages/flutter/rust/Cargo.lock +++ b/packages/flutter/rust/Cargo.lock @@ -84,6 +84,12 @@ dependencies = [ "backtrace", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -552,6 +558,7 @@ dependencies = [ "serde_json", "shellwords", "spark-wallet", + "sparkscan", "tempdir", "thiserror 2.0.16", "tokio", @@ -1636,6 +1643,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -2557,6 +2575,17 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.11.0", + "serde", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.73" @@ -2834,6 +2863,72 @@ dependencies = [ "yansi", ] +[[package]] +name = "progenitor" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135a23fcb9ad36a46ef4be323d006e195ad5121779c9da64ef95cf0600771b77" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920f044db9ec07a3339175729794d3701e11d338dcf8cfd946df838102307780" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8276d558f1dfd4cc7fc4cceee0a51dab482b5a4be2e69e7eab8c57fbfb1472f4" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap 2.11.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.106", + "thiserror 2.0.16", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd79317ec8ab905738484d2744d368beee6e357fc043944d985f85a0174f1f7" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.106", +] + [[package]] name = "prost" version = "0.13.5" @@ -3067,6 +3162,16 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "regress" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -3086,6 +3191,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3107,12 +3213,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -3284,6 +3392,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "chrono", + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3308,6 +3430,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.106", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3396,6 +3530,9 @@ name = "semver" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +dependencies = [ + "serde", +] [[package]] name = "serde" @@ -3417,6 +3554,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde_json" version = "1.0.143" @@ -3429,6 +3577,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.106", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3633,6 +3793,42 @@ dependencies = [ "uuid", ] +[[package]] +name = "sparkscan" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce37d4f7be4e6d03dbbce1edd8a362853a257f14960cc8f465004a08d87601fe" +dependencies = [ + "cfg-if", + "chrono", + "futures", + "prettyplease", + "progenitor", + "regress", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "sparkscan-client", + "syn 2.0.106", +] + +[[package]] +name = "sparkscan-client" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31dc0e035e973e408121b00a8c31d4562874725a2ff01bd3a458a66a88aeec6c" +dependencies = [ + "bytes", + "cfg-if", + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + [[package]] name = "spin" version = "0.9.8" @@ -4224,6 +4420,53 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "typify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.106", + "thiserror 2.0.16", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.106", + "typify-impl", +] + [[package]] name = "uncased" version = "0.9.10" diff --git a/packages/flutter/rust/src/errors.rs b/packages/flutter/rust/src/errors.rs index c7ed1d89..d19c0211 100644 --- a/packages/flutter/rust/src/errors.rs +++ b/packages/flutter/rust/src/errors.rs @@ -38,6 +38,7 @@ pub enum _SdkError { vout: u32, }, LnurlError(String), + SparkScanApiError(String), Generic(String), } diff --git a/packages/flutter/rust/src/models.rs b/packages/flutter/rust/src/models.rs index e6b72615..c5035c05 100644 --- a/packages/flutter/rust/src/models.rs +++ b/packages/flutter/rust/src/models.rs @@ -31,6 +31,7 @@ pub struct _Config { pub max_deposit_claim_fee: Option, pub lnurl_domain: Option, pub prefer_spark_over_lightning: bool, + pub sparkscan_api_url: String, } #[frb(mirror(Seed))] @@ -109,7 +110,6 @@ pub struct _TokenMetadata { pub decimals: u32, pub max_supply: u64, pub is_freezable: bool, - pub creation_entity_public_key: Option, } #[frb(mirror(GetPaymentRequest))] @@ -382,6 +382,7 @@ pub enum _PaymentDetails { Spark, Token { metadata: TokenMetadata, + tx_hash: String, }, Lightning { description: Option, diff --git a/packages/flutter/test/helper.dart b/packages/flutter/test/helper.dart index 477ccd2d..e6e640cf 100644 --- a/packages/flutter/test/helper.dart +++ b/packages/flutter/test/helper.dart @@ -6,12 +6,18 @@ extension ConfigCopyWith on Config { Network? network, int? syncIntervalSecs, Fee? maxDepositClaimFee, + String? lnurlDomain, + bool? preferSparkOverLightning, + String? sparkscanApiUrl, }) { return Config( apiKey: apiKey ?? this.apiKey, network: network ?? this.network, syncIntervalSecs: syncIntervalSecs ?? this.syncIntervalSecs, maxDepositClaimFee: maxDepositClaimFee ?? this.maxDepositClaimFee, + lnurlDomain: lnurlDomain ?? this.lnurlDomain, + preferSparkOverLightning: preferSparkOverLightning ?? this.preferSparkOverLightning, + sparkscanApiUrl: sparkscanApiUrl ?? this.sparkscanApiUrl, ); } } From 0a421d5160e5dac873eb7894a22712bd8fd4a5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 16 Sep 2025 00:33:29 +0100 Subject: [PATCH 07/22] Small adjustments --- .../core/src/models/adaptors/sparkscan.rs | 16 ++++++++++++---- crates/breez-sdk/core/src/sdk.rs | 15 ++++++++++++--- packages/wasm/examples/web/src/main.js | 15 ++++++++------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs b/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs index 62ec59d8..f7f8f825 100644 --- a/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs +++ b/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs @@ -93,8 +93,14 @@ pub(crate) fn payments_from_address_transaction_and_ssp_request( let context = extract_conversion_context(transaction)?; let method_info = extract_payment_method_and_details(transaction, ssp_user_request)?; - if transaction.multi_io_details.is_some() { - create_multi_io_payments(transaction, &method_info, &context, our_spark_address) + if let Some(multi_io_details) = transaction.multi_io_details.as_ref() { + create_multi_io_payments( + multi_io_details, + transaction, + &method_info, + &context, + our_spark_address, + ) } else { let payment = create_single_payment(transaction, &method_info, &context)?; Ok(vec![payment]) @@ -262,13 +268,12 @@ fn create_token_payment_info( /// Creates multiple payments for multi-IO token transactions fn create_multi_io_payments( + multi_io_details: &MultiIoDetails, transaction: &AddressTransaction, method_info: &PaymentMethodInfo, context: &PaymentCommonContext, our_spark_address: &str, ) -> Result, SdkError> { - let multi_io_details = transaction.multi_io_details.as_ref().unwrap(); - let payment_type = determine_multi_io_payment_type(multi_io_details, our_spark_address)?; let mut payments = Vec::new(); @@ -276,6 +281,9 @@ fn create_multi_io_payments( for (index, output) in multi_io_details.outputs.iter().enumerate() { // Create payments for outputs that are not ours (for send payments) or ours (for receive payments) if should_include_output(payment_type, &output.address, our_spark_address) { + // TODO: fix construction of id + // Currently sparkscan doesn't order outputs by vout making this construction unreliable + // There's no other output identifier we can use for now let id = format!("{}:{}", transaction.id, index); let amount = output.amount.try_into()?; diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 594166e5..2bd8dc5f 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -404,7 +404,7 @@ impl BreezSdk { &self, object_repository: &ObjectCacheRepository, ) -> Result<(), SdkError> { - // Get the last offset we processed from storage + // Get the last payment id we processed from storage let cached_sync_info = object_repository .fetch_sync_info() .await? @@ -498,7 +498,18 @@ impl BreezSdk { Ok(()) } + /// Syncs pending payments so that we have their latest status + /// Uses the Spark SDK API (SparkWallet) to get the latest status of the payments async fn sync_pending_payments(&self) -> Result<(), SdkError> { + // TODO: implement pending payment syncing using sparkscan API (including live updates) + // Advantages: + // - No need to maintain payment adapter code for both models + // - Can use live updates from sparkscan API + // Why it can't be done now: + // - Sparkscan needs one of the following: + // - Batch transaction querying by id + // - Sorting by updated_at timestamp in address transactions query (simpler) + let pending_payments = self .storage .list_payments(None, None, Some(PaymentStatus::Pending)) @@ -537,7 +548,6 @@ impl BreezSdk { .map(|p| p.id.clone()) .collect(); - // TODO: Deal with paging (is necessary if there are a lot of pending payments) let transfers = self .spark_wallet .list_transfers(None, Some(transfer_ids.clone())) @@ -580,7 +590,6 @@ impl BreezSdk { }, )?; - // TODO: Deal with paging (is necessary if there are a lot of pending payments) let token_transactions = self .spark_wallet .list_token_transactions(ListTokenTransactionsRequest { diff --git a/packages/wasm/examples/web/src/main.js b/packages/wasm/examples/web/src/main.js index d9ab8608..b52fbfd1 100644 --- a/packages/wasm/examples/web/src/main.js +++ b/packages/wasm/examples/web/src/main.js @@ -201,12 +201,12 @@ async function initializeSdk() { } if (!CONFIG.apiKey) { - throw new Error('API key is required') + throw new Error("API key is required"); } // Get default config - const config = defaultConfig(CONFIG.network) - config.apiKey = CONFIG.apiKey + const config = defaultConfig(CONFIG.network); + config.apiKey = CONFIG.apiKey; // Create SDK builder let storage = await defaultStorage("BreezSDK"); @@ -304,11 +304,12 @@ function displayPayments(payments) {
${direction}: ${amount.toLocaleString()} sats
${description}
${new Date( - payment.timestamp * 1000 - ).toLocaleString()}
+ payment.timestamp * 1000 + ).toLocaleString()} -
${payment.status - }
+
${ + payment.status + }
`; elements.paymentsList.appendChild(paymentDiv); From cf0e07d14366a029e731999cafa7c39f96b8419e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 16 Sep 2025 14:10:10 +0100 Subject: [PATCH 08/22] Use sparkscan client for to workaround CORS issue --- Cargo.lock | 9 ++++----- crates/breez-sdk/core/Cargo.toml | 3 ++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8a8b41e..5783a2be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4549,15 +4549,15 @@ dependencies = [ [[package]] name = "sparkscan" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce37d4f7be4e6d03dbbce1edd8a362853a257f14960cc8f465004a08d87601fe" +version = "0.3.8" +source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" dependencies = [ "cfg-if", "chrono", "futures", "prettyplease", "progenitor", + "quote", "regress", "reqwest", "schemars 0.8.22", @@ -4570,8 +4570,7 @@ dependencies = [ [[package]] name = "sparkscan-client" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31dc0e035e973e408121b00a8c31d4562874725a2ff01bd3a458a66a88aeec6c" +source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" dependencies = [ "bytes", "cfg-if", diff --git a/crates/breez-sdk/core/Cargo.toml b/crates/breez-sdk/core/Cargo.toml index fd1917aa..c128b5f4 100644 --- a/crates/breez-sdk/core/Cargo.toml +++ b/crates/breez-sdk/core/Cargo.toml @@ -54,7 +54,8 @@ uuid.workspace = true uniffi = { workspace = true, optional = true } web-time.workspace = true x509-parser = { version = "0.16.0" } -sparkscan = { version = "0.3.7" } +# TODO: switch to published version once CORS issue with `api-version` header is fixed +sparkscan = { git = "https://github.com/breez/sparkscan-rs", rev = "250753cf6c9fd95a79c9f450b1f59534329251ab" } # Non-Wasm dependencies [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] From 086e03f462c5d82a9a4bb7ab361c5d7fc2d4b9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 16 Sep 2025 14:38:26 +0100 Subject: [PATCH 09/22] Use transfer id as id of outgoing lightning payments --- crates/breez-sdk/core/src/sdk.rs | 2 +- crates/spark/src/services/lightning.rs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 2bd8dc5f..0df2ab10 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -499,7 +499,7 @@ impl BreezSdk { } /// Syncs pending payments so that we have their latest status - /// Uses the Spark SDK API (SparkWallet) to get the latest status of the payments + /// Uses the Spark SDK API to get the latest status of the payments async fn sync_pending_payments(&self) -> Result<(), SdkError> { // TODO: implement pending payment syncing using sparkscan API (including live updates) // Advantages: diff --git a/crates/spark/src/services/lightning.rs b/crates/spark/src/services/lightning.rs index 62f9f93a..8e22a023 100644 --- a/crates/spark/src/services/lightning.rs +++ b/crates/spark/src/services/lightning.rs @@ -350,7 +350,11 @@ impl LightningService { .transfer_service .deliver_transfer_package(&swap.transfer, &swap.leaves, Default::default()) .await?; - let lightning_send_payment = self.finalize_lightning_swap(&swap).await?; + let mut lightning_send_payment = self.finalize_lightning_swap(&swap).await?; + // If ssp doesn't return a transfer id, we use the transfer id from the transfer service + if lightning_send_payment.transfer_id.is_none() { + lightning_send_payment.transfer_id = Some(transfer.id.clone()); + } Ok(PayLightningResult { lightning_send_payment, transfer, From be605ac1ddf65d687ce1913f721caa1088629596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Tue, 16 Sep 2025 16:34:34 +0100 Subject: [PATCH 10/22] Use legacy address with sparkscan --- crates/breez-sdk/core/src/sdk.rs | 10 +++++++--- crates/spark/src/address/mod.rs | 28 ++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 0df2ab10..4d482ac5 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -411,7 +411,11 @@ impl BreezSdk { .unwrap_or_default(); let last_synced_id = cached_sync_info.last_synced_payment_id; - let spark_address = self.spark_wallet.get_spark_address()?.to_string(); + // TODO: use new spark address format once sparkscan supports it + let legacy_spark_address = self + .spark_wallet + .get_spark_address()? + .to_string_with_hrp_legacy(); let mut payments_to_sync = Vec::new(); @@ -425,7 +429,7 @@ impl BreezSdk { let response = sparkscan::Client::new(&self.config.sparkscan_api_url) .get_address_transactions_v1_address_address_transactions_get() .network(sparkscan::types::Network::from(self.config.network)) - .address(spark_address.to_string()) + .address(legacy_spark_address.to_string()) .offset(next_offset) .limit(PAYMENT_SYNC_BATCH_SIZE) .send() @@ -458,7 +462,7 @@ impl BreezSdk { let payments = payments_from_address_transaction_and_ssp_request( transaction, ssp_user_requests.get(&transaction.id), - &spark_address, + &legacy_spark_address, )?; for payment in payments { diff --git a/crates/spark/src/address/mod.rs b/crates/spark/src/address/mod.rs index c8e3e350..14dad799 100644 --- a/crates/spark/src/address/mod.rs +++ b/crates/spark/src/address/mod.rs @@ -221,6 +221,15 @@ impl SparkAddress { } } + fn network_to_hrp_legacy(network: &Network) -> Hrp { + match network { + Network::Mainnet => HRP_LEGACY_MAINNET, + Network::Testnet => HRP_LEGACY_TESTNET, + Network::Regtest => HRP_LEGACY_REGTEST, + Network::Signet => HRP_LEGACY_SIGNET, + } + } + fn hrp_to_network(hrp: &Hrp) -> Result { match hrp { hrp if hrp == &HRP_MAINNET || hrp == &HRP_LEGACY_MAINNET => Ok(Network::Mainnet), @@ -234,6 +243,15 @@ impl SparkAddress { impl Display for SparkAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let hrp = Self::network_to_hrp(&self.network); + + let address = self.to_string_with_hrp(hrp); + write!(f, "{address}") + } +} + +impl SparkAddress { + fn to_string_with_hrp(&self, hrp: Hrp) -> String { let spark_invoice_fields: Option = self.spark_invoice_fields.clone().map(|f| f.into()); @@ -245,11 +263,13 @@ impl Display for SparkAddress { let payload_bytes = proto_address.encode_to_vec(); - let hrp = Self::network_to_hrp(&self.network); - // This is safe to unwrap, because we are using a valid HRP and payload - let address = bech32::encode::(hrp, &payload_bytes).unwrap(); - write!(f, "{address}") + bech32::encode::(hrp, &payload_bytes).unwrap() + } + + pub fn to_string_with_hrp_legacy(&self) -> String { + let hrp = Self::network_to_hrp_legacy(&self.network); + self.to_string_with_hrp(hrp) } } From 9c70b2708b459cd1cc74158718223230ecd62829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Thu, 2 Oct 2025 09:55:04 +0100 Subject: [PATCH 11/22] Fix rebase issue --- packages/flutter/rust/Cargo.lock | 505 +++++++++++++++---------------- 1 file changed, 237 insertions(+), 268 deletions(-) diff --git a/packages/flutter/rust/Cargo.lock b/packages/flutter/rust/Cargo.lock index 60689cb1..194a2c99 100644 --- a/packages/flutter/rust/Cargo.lock +++ b/packages/flutter/rust/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -90,12 +90,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_log-sys" version = "0.3.2" @@ -140,9 +134,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -175,9 +169,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arrayref" @@ -348,9 +342,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", @@ -358,7 +352,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link 0.2.0", ] [[package]] @@ -524,7 +518,7 @@ dependencies = [ "spark", "spark-wallet", "strum", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tonic", "tonic-build", @@ -560,7 +554,7 @@ dependencies = [ "spark-wallet", "sparkscan", "tempdir", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio_with_wasm", "tracing", @@ -622,9 +616,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.35" +version = "1.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +checksum = "e1354349954c6fc9cb0deab020f27f783cf0b604e8bb754dc4658ecf0d29c35f" dependencies = [ "find-msvc-tools", "shlex", @@ -638,17 +632,16 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -663,9 +656,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -673,9 +666,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -707,7 +700,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -852,9 +845,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" dependencies = [ "darling_core", "darling_macro", @@ -862,9 +855,9 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" dependencies = [ "fnv", "ident_case", @@ -876,9 +869,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", @@ -953,12 +946,12 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -1169,12 +1162,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1232,9 +1225,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" [[package]] name = "fixedbitset" @@ -1290,6 +1283,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1331,7 +1330,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serdect", - "thiserror 2.0.16", + "thiserror 2.0.17", "thiserror-nostd-notrait", "visibility", "zeroize", @@ -1459,20 +1458,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generator" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1506,7 +1491,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.3+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -1521,9 +1506,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "graphql-introspection-query" @@ -1606,7 +1591,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.11.0", + "indexmap 2.11.4", "slab", "tokio", "tokio-util", @@ -1645,20 +1630,20 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "ahash", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] name = "hashlink" @@ -1748,7 +1733,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustls-pki-types", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "tinyvec", "tokio", @@ -1772,7 +1757,7 @@ dependencies = [ "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -1911,9 +1896,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ "base64", "bytes", @@ -1937,9 +1922,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -2085,13 +2070,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -2172,9 +2158,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" dependencies = [ "once_cell", "wasm-bindgen", @@ -2202,9 +2188,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libm" @@ -2306,9 +2292,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" @@ -2345,19 +2331,6 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - [[package]] name = "macros" version = "0.1.0" @@ -2394,9 +2367,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "mime" @@ -2432,20 +2405,19 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.10" +version = "0.12.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" +checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" dependencies = [ "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", - "loom", + "equivalent", "parking_lot", "portable-atomic", "rustc_version", "smallvec", "tagptr", - "thiserror 1.0.69", "uuid", ] @@ -2537,9 +2509,9 @@ dependencies = [ [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] @@ -2581,7 +2553,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap 2.11.0", + "indexmap 2.11.4", "serde", "serde_json", ] @@ -2620,9 +2592,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" -version = "300.5.2+3.5.2" +version = "300.5.3+3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d270b79e2926f5150189d475bc7e9d2c69f9c4697b185fa917d5a32b792d21b4" +checksum = "dc6bad8cd0233b63971e232cc9c5e83039375b8586d2312f31fda85db8f888c2" dependencies = [ "cc", ] @@ -2716,7 +2688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap 2.11.0", + "indexmap 2.11.4", ] [[package]] @@ -2897,7 +2869,7 @@ checksum = "8276d558f1dfd4cc7fc4cceee0a51dab482b5a4be2e69e7eab8c57fbfb1472f4" dependencies = [ "heck 0.5.0", "http", - "indexmap 2.11.0", + "indexmap 2.11.4", "openapiv3", "proc-macro2", "quote", @@ -2906,7 +2878,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.106", - "thiserror 2.0.16", + "thiserror 2.0.17", "typify", "unicode-ident", ] @@ -2995,9 +2967,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -3115,18 +3087,18 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.24" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", @@ -3135,9 +3107,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -3147,9 +3119,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", @@ -3226,9 +3198,9 @@ dependencies = [ [[package]] name = "resolv-conf" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" +checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" [[package]] name = "rfc6979" @@ -3304,22 +3276,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] name = "rustls" -version = "0.23.31" +version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ "log", "once_cell", @@ -3339,7 +3311,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.3.0", + "security-framework 3.5.1", ] [[package]] @@ -3362,9 +3334,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" dependencies = [ "ring", "rustls-pki-types", @@ -3385,11 +3357,11 @@ checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.1", ] [[package]] @@ -3442,12 +3414,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -3504,9 +3470,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.3.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fb1d92c5028aa318b4b8bd7302a5bfcf48be96a37fc6fc790f806b0004ee0c" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags", "core-foundation 0.10.1", @@ -3517,9 +3483,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", @@ -3527,27 +3493,38 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", + "serde_core", ] [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3567,14 +3544,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -3603,15 +3581,15 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "c522100790450cf78eeac1507263d0a350d4d5b30df0c8e1fe051a10c22b376e" dependencies = [ "base64", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.11.0", + "indexmap 2.11.4", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -3623,9 +3601,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "327ada00f7d64abaac1e55a6911e90cf665aa051b9a561c7006c157f4633135e" dependencies = [ "darling", "proc-macro2", @@ -3639,7 +3617,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.11.0", + "indexmap 2.11.4", "itoa", "ryu", "serde", @@ -3764,7 +3742,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio_with_wasm", "tonic", @@ -3786,7 +3764,7 @@ dependencies = [ "hex", "serde", "spark", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio_with_wasm", "tracing", @@ -3795,15 +3773,15 @@ dependencies = [ [[package]] name = "sparkscan" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce37d4f7be4e6d03dbbce1edd8a362853a257f14960cc8f465004a08d87601fe" +version = "0.3.8" +source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" dependencies = [ "cfg-if", "chrono", "futures", "prettyplease", "progenitor", + "quote", "regress", "reqwest", "schemars 0.8.22", @@ -3816,8 +3794,7 @@ dependencies = [ [[package]] name = "sparkscan-client" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31dc0e035e973e408121b00a8c31d4562874725a2ff01bd3a458a66a88aeec6c" +source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" dependencies = [ "bytes", "cfg-if", @@ -3968,15 +3945,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -3990,11 +3967,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -4010,9 +3987,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -4059,11 +4036,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.43" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde", @@ -4153,9 +4131,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -4416,9 +4394,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "typify" @@ -4446,7 +4424,7 @@ dependencies = [ "serde", "serde_json", "syn 2.0.106", - "thiserror 2.0.16", + "thiserror 2.0.17", "unicode-ident", ] @@ -4478,9 +4456,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-normalization" @@ -4594,30 +4572,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.3+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" dependencies = [ "bumpalo", "log", @@ -4629,9 +4617,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" dependencies = [ "cfg-if", "js-sys", @@ -4642,9 +4630,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4652,9 +4640,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", @@ -4665,9 +4653,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" dependencies = [ "unicode-ident", ] @@ -4687,9 +4675,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" dependencies = [ "js-sys", "wasm-bindgen", @@ -4751,57 +4739,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-link", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", + "windows-link 0.2.0", + "windows-result 0.4.0", + "windows-strings 0.5.0", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" dependencies = [ "proc-macro2", "quote", @@ -4810,9 +4765,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" dependencies = [ "proc-macro2", "quote", @@ -4826,14 +4781,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-numerics" +name = "windows-link" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core", - "windows-link", -] +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" [[package]] name = "windows-registry" @@ -4841,9 +4792,9 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -4852,7 +4803,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -4861,7 +4821,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -4897,7 +4866,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.4", +] + +[[package]] +name = "windows-sys" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +dependencies = [ + "windows-link 0.2.0", ] [[package]] @@ -4933,11 +4911,11 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" dependencies = [ - "windows-link", + "windows-link 0.2.0", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -4948,15 +4926,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5107,9 +5076,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" @@ -5166,18 +5135,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", @@ -5207,9 +5176,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" dependencies = [ "zeroize_derive", ] From 7016c8c4972a84276d6e9690a1891125bafa84da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 14:56:07 +0100 Subject: [PATCH 12/22] Revert to bitcoin/token scan --- .../{adaptors/spark_sdk.rs => adaptors.rs} | 0 .../breez-sdk/core/src/models/adaptors/mod.rs | 2 - .../core/src/models/adaptors/sparkscan.rs | 406 -------------- crates/breez-sdk/core/src/models/mod.rs | 1 - crates/breez-sdk/core/src/persist/mod.rs | 17 +- crates/breez-sdk/core/src/sdk.rs | 501 +++++++++++------- crates/breez-sdk/wasm/src/models.rs | 1 - 7 files changed, 320 insertions(+), 608 deletions(-) rename crates/breez-sdk/core/src/models/{adaptors/spark_sdk.rs => adaptors.rs} (100%) delete mode 100644 crates/breez-sdk/core/src/models/adaptors/mod.rs delete mode 100644 crates/breez-sdk/core/src/models/adaptors/sparkscan.rs diff --git a/crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs b/crates/breez-sdk/core/src/models/adaptors.rs similarity index 100% rename from crates/breez-sdk/core/src/models/adaptors/spark_sdk.rs rename to crates/breez-sdk/core/src/models/adaptors.rs diff --git a/crates/breez-sdk/core/src/models/adaptors/mod.rs b/crates/breez-sdk/core/src/models/adaptors/mod.rs deleted file mode 100644 index ab48b88b..00000000 --- a/crates/breez-sdk/core/src/models/adaptors/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod spark_sdk; -pub(crate) mod sparkscan; diff --git a/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs b/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs deleted file mode 100644 index f7f8f825..00000000 --- a/crates/breez-sdk/core/src/models/adaptors/sparkscan.rs +++ /dev/null @@ -1,406 +0,0 @@ -use breez_sdk_common::input; -use spark_wallet::SspUserRequest; -use sparkscan::types::{ - AddressTransaction, AddressTransactionDirection, AddressTransactionStatus, - AddressTransactionType, MultiIoDetails, TokenTransactionMetadata, TokenTransactionStatus, -}; -use tracing::warn; - -use crate::{ - Network, Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError, - TokenMetadata, -}; - -impl From for PaymentStatus { - fn from(status: AddressTransactionStatus) -> Self { - match status { - AddressTransactionStatus::Confirmed => PaymentStatus::Completed, - AddressTransactionStatus::Sent | AddressTransactionStatus::Pending => { - PaymentStatus::Pending - } - AddressTransactionStatus::Expired | AddressTransactionStatus::Failed => { - PaymentStatus::Failed - } - } - } -} - -impl From for PaymentStatus { - fn from(status: TokenTransactionStatus) -> Self { - match status { - TokenTransactionStatus::Confirmed => PaymentStatus::Completed, - TokenTransactionStatus::Sent | TokenTransactionStatus::Pending => { - PaymentStatus::Pending - } - TokenTransactionStatus::Expired | TokenTransactionStatus::Failed => { - PaymentStatus::Failed - } - } - } -} - -impl From for sparkscan::types::Network { - fn from(network: Network) -> Self { - match network { - Network::Mainnet => sparkscan::types::Network::Mainnet, - Network::Regtest => sparkscan::types::Network::Regtest, - } - } -} - -impl TryFrom for TokenMetadata { - type Error = SdkError; - - fn try_from(value: TokenTransactionMetadata) -> Result { - Ok(Self { - identifier: value.token_address, - issuer_public_key: value.issuer_public_key, - name: value.name, - ticker: value.ticker, - decimals: value.decimals.try_into()?, - max_supply: value - .max_supply - .ok_or(SdkError::Generic("Max supply is not set".to_string()))? - .try_into()?, // max_supply will be changed to u128 or similar - is_freezable: value - .is_freezable - .ok_or(SdkError::Generic("Is freezable is not set".to_string()))?, - }) - } -} - -/// Context for payment conversion containing common data -#[derive(Debug)] -struct PaymentCommonContext { - timestamp: u64, - status: PaymentStatus, -} - -/// Information about payment method, details, and fees -#[derive(Debug)] -struct PaymentMethodInfo { - method: PaymentMethod, - details: Option, - fees: u64, -} - -/// Converts a Sparkscan address transaction into Payment objects -pub(crate) fn payments_from_address_transaction_and_ssp_request( - transaction: &AddressTransaction, - ssp_user_request: Option<&SspUserRequest>, - our_spark_address: &str, -) -> Result, SdkError> { - let context = extract_conversion_context(transaction)?; - let method_info = extract_payment_method_and_details(transaction, ssp_user_request)?; - - if let Some(multi_io_details) = transaction.multi_io_details.as_ref() { - create_multi_io_payments( - multi_io_details, - transaction, - &method_info, - &context, - our_spark_address, - ) - } else { - let payment = create_single_payment(transaction, &method_info, &context)?; - Ok(vec![payment]) - } -} - -/// Extracts common conversion context from transaction -fn extract_conversion_context( - transaction: &AddressTransaction, -) -> Result { - let timestamp = transaction - .created_at - .ok_or(SdkError::Generic( - "Transaction created at is not set".to_string(), - ))? - .timestamp() - .try_into()?; - - let status = transaction.status.into(); - - Ok(PaymentCommonContext { timestamp, status }) -} - -/// Determines payment method, details, and fees based on transaction type -fn extract_payment_method_and_details( - transaction: &AddressTransaction, - ssp_user_request: Option<&SspUserRequest>, -) -> Result { - match transaction.type_ { - AddressTransactionType::SparkTransfer => Ok(PaymentMethodInfo { - method: PaymentMethod::Spark, - details: Some(PaymentDetails::Spark), - fees: 0, - }), - AddressTransactionType::LightningPayment => { - create_lightning_payment_info(transaction, ssp_user_request) - } - AddressTransactionType::BitcoinDeposit => { - Ok(create_deposit_payment_info(transaction, ssp_user_request)) - } - AddressTransactionType::BitcoinWithdrawal => { - Ok(create_withdraw_payment_info(transaction, ssp_user_request)) - } - AddressTransactionType::TokenTransfer - | AddressTransactionType::TokenMint - | AddressTransactionType::TokenBurn - | AddressTransactionType::TokenMultiTransfer - | AddressTransactionType::UnknownTokenOp => create_token_payment_info(transaction), - } -} - -/// Creates payment info for Lightning transactions -fn create_lightning_payment_info( - transaction: &AddressTransaction, - ssp_user_request: Option<&SspUserRequest>, -) -> Result { - if let Some(request) = ssp_user_request { - let invoice = request.get_lightning_invoice().ok_or(SdkError::Generic( - "No invoice in SspUserRequest".to_string(), - ))?; - let invoice_details = input::parse_invoice(&invoice).ok_or(SdkError::Generic( - "Invalid invoice in SspUserRequest::LightningReceiveRequest".to_string(), - ))?; - let preimage = request.get_lightning_preimage(); - let fees = request.get_total_fees_sats(); - - Ok(PaymentMethodInfo { - method: PaymentMethod::Lightning, - details: Some(PaymentDetails::Lightning { - description: invoice_details.description.clone(), - preimage, - invoice, - payment_hash: invoice_details.payment_hash.clone(), - destination_pubkey: invoice_details.payee_pubkey.clone(), - lnurl_pay_info: None, - }), - fees, - }) - } else { - warn!( - "No SspUserRequest found for LightningPayment with transfer id {}", - transaction.id - ); - Ok(PaymentMethodInfo { - method: PaymentMethod::Lightning, - details: None, - fees: 0, - }) - } -} - -/// Creates payment info for Bitcoin deposit transactions -fn create_deposit_payment_info( - transaction: &AddressTransaction, - ssp_user_request: Option<&SspUserRequest>, -) -> PaymentMethodInfo { - if let Some(SspUserRequest::ClaimStaticDeposit(request)) = ssp_user_request { - let fees = request.get_total_fees_sats(); - PaymentMethodInfo { - method: PaymentMethod::Deposit, - details: Some(PaymentDetails::Deposit { - tx_id: request.transaction_id.clone(), - }), - fees, - } - } else { - warn!( - "No SspUserRequest found for BitcoinDeposit with transfer id {}", - transaction.id - ); - PaymentMethodInfo { - method: PaymentMethod::Deposit, - details: None, - fees: 0, - } - } -} - -/// Creates payment info for Bitcoin withdrawal transactions -fn create_withdraw_payment_info( - transaction: &AddressTransaction, - ssp_user_request: Option<&SspUserRequest>, -) -> PaymentMethodInfo { - if let Some(SspUserRequest::CoopExitRequest(request)) = ssp_user_request { - let fees = request.get_total_fees_sats(); - PaymentMethodInfo { - method: PaymentMethod::Withdraw, - details: Some(PaymentDetails::Withdraw { - tx_id: request.coop_exit_txid.clone(), - }), - fees, - } - } else { - warn!( - "No SspUserRequest found for BitcoinWithdrawal with transfer id {}", - transaction.id - ); - PaymentMethodInfo { - method: PaymentMethod::Withdraw, - details: None, - fees: 0, - } - } -} - -/// Creates payment info for token transactions -fn create_token_payment_info( - transaction: &AddressTransaction, -) -> Result { - let Some(metadata) = &transaction.token_metadata else { - return Err(SdkError::Generic( - "No token metadata in transaction".to_string(), - )); - }; - - Ok(PaymentMethodInfo { - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.clone().try_into()?, - tx_hash: transaction.id.clone(), - }), - fees: 0, - }) -} - -/// Creates multiple payments for multi-IO token transactions -fn create_multi_io_payments( - multi_io_details: &MultiIoDetails, - transaction: &AddressTransaction, - method_info: &PaymentMethodInfo, - context: &PaymentCommonContext, - our_spark_address: &str, -) -> Result, SdkError> { - let payment_type = determine_multi_io_payment_type(multi_io_details, our_spark_address)?; - - let mut payments = Vec::new(); - - for (index, output) in multi_io_details.outputs.iter().enumerate() { - // Create payments for outputs that are not ours (for send payments) or ours (for receive payments) - if should_include_output(payment_type, &output.address, our_spark_address) { - // TODO: fix construction of id - // Currently sparkscan doesn't order outputs by vout making this construction unreliable - // There's no other output identifier we can use for now - let id = format!("{}:{}", transaction.id, index); - let amount = output.amount.try_into()?; - - payments.push(Payment { - id, - payment_type, - status: context.status, - amount, - fees: 0, - timestamp: context.timestamp, - method: method_info.method, - details: method_info.details.clone(), - }); - } - } - - Ok(payments) -} - -/// Determines payment type for multi-IO transactions based on input ownership -fn determine_multi_io_payment_type( - multi_io_details: &MultiIoDetails, - our_spark_address: &str, -) -> Result { - let first_input = multi_io_details.inputs.first().ok_or(SdkError::Generic( - "No inputs in multi IO details".to_string(), - ))?; - - if first_input.address == our_spark_address { - Ok(PaymentType::Send) - } else { - Ok(PaymentType::Receive) - } -} - -/// Determines if an output should be included in the payment list -fn should_include_output( - payment_type: PaymentType, - output_address: &str, - our_spark_address: &str, -) -> bool { - match payment_type { - PaymentType::Send => output_address != our_spark_address, - PaymentType::Receive => output_address == our_spark_address, - } -} - -/// Creates a single payment for non-multi-IO transactions -fn create_single_payment( - transaction: &AddressTransaction, - method_info: &PaymentMethodInfo, - context: &PaymentCommonContext, -) -> Result { - let id = transaction.id.clone(); - let payment_type = determine_single_payment_type(transaction, method_info.method)?; - let amount = calculate_payment_amount(transaction, method_info)?; - - Ok(Payment { - id, - payment_type, - status: context.status, - amount, - fees: method_info.fees, - timestamp: context.timestamp, - method: method_info.method, - details: method_info.details.clone(), - }) -} - -/// Determines payment type for single transactions based on method and transaction details -fn determine_single_payment_type( - transaction: &AddressTransaction, - method: PaymentMethod, -) -> Result { - match method { - PaymentMethod::Lightning | PaymentMethod::Spark => match transaction.direction { - AddressTransactionDirection::Incoming => Ok(PaymentType::Receive), - AddressTransactionDirection::Outgoing => Ok(PaymentType::Send), - _ => Err(SdkError::Generic(format!( - "Invalid direction in transaction {}", - transaction.id - ))), - }, - PaymentMethod::Token => match transaction.type_ { - AddressTransactionType::TokenMint => Ok(PaymentType::Receive), - AddressTransactionType::TokenBurn => Ok(PaymentType::Send), - _ => Err(SdkError::Generic(format!( - "Invalid type in TokenTransaction transaction {}", - transaction.id - ))), - }, - PaymentMethod::Deposit => Ok(PaymentType::Receive), - PaymentMethod::Withdraw => Ok(PaymentType::Send), - PaymentMethod::Unknown => Err(SdkError::Generic(format!( - "Unexpected payment method in transaction {}", - transaction.id - ))), - } -} - -/// Calculates the payment amount, considering fees for different payment methods -fn calculate_payment_amount( - transaction: &AddressTransaction, - method_info: &PaymentMethodInfo, -) -> Result { - let transaction_amount: u64 = transaction - .amount_sats - .or(transaction.token_amount) - .ok_or(SdkError::Generic( - "Amount not found in transaction".to_string(), - ))? - .try_into()?; - - // For deposits, we don't subtract fees from the amount - if method_info.method == PaymentMethod::Deposit { - Ok(transaction_amount) - } else { - Ok(transaction_amount.saturating_sub(method_info.fees)) - } -} diff --git a/crates/breez-sdk/core/src/models/mod.rs b/crates/breez-sdk/core/src/models/mod.rs index af63810d..97852031 100644 --- a/crates/breez-sdk/core/src/models/mod.rs +++ b/crates/breez-sdk/core/src/models/mod.rs @@ -244,7 +244,6 @@ pub struct Config { /// lightning when sending and receiving. This has the benefit of lower fees /// but is at the cost of privacy. pub prefer_spark_over_lightning: bool, - pub sparkscan_api_url: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index ad35c6cf..5ba21313 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -1,7 +1,7 @@ #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub(crate) mod sqlite; -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::SystemTime}; use macros::async_trait; use serde::{Deserialize, Serialize}; @@ -14,13 +14,10 @@ use crate::{ const ACCOUNT_INFO_KEY: &str = "account_info"; const LIGHTNING_ADDRESS_KEY: &str = "lightning_address"; -const SPARKSCAN_SYNC_INFO_KEY: &str = "sparkscan_sync_info"; +const SYNC_OFFSET_KEY: &str = "sync_offset"; const TX_CACHE_KEY: &str = "tx_cache"; const STATIC_DEPOSIT_ADDRESS_CACHE_KEY: &str = "static_deposit_address"; -// Old keys (avoid using them) -// const SYNC_OFFSET_KEY: &str = "sync_offset"; - #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] pub enum UpdateDepositPayload { ClaimError { @@ -220,10 +217,7 @@ impl ObjectCacheRepository { pub(crate) async fn save_sync_info(&self, value: &CachedSyncInfo) -> Result<(), StorageError> { self.storage - .set_cached_item( - SPARKSCAN_SYNC_INFO_KEY.to_string(), - serde_json::to_string(value)?, - ) + .set_cached_item(SYNC_OFFSET_KEY.to_string(), serde_json::to_string(value)?) .await?; Ok(()) } @@ -231,7 +225,7 @@ impl ObjectCacheRepository { pub(crate) async fn fetch_sync_info(&self) -> Result, StorageError> { let value = self .storage - .get_cached_item(SPARKSCAN_SYNC_INFO_KEY.to_string()) + .get_cached_item(SYNC_OFFSET_KEY.to_string()) .await?; match value { Some(value) => Ok(Some(serde_json::from_str(&value)?)), @@ -329,7 +323,8 @@ pub(crate) struct CachedAccountInfo { #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedSyncInfo { - pub(crate) last_synced_payment_id: String, + pub(crate) offset: u64, + pub(crate) last_synced_token_timestamp: Option, } #[derive(Serialize, Deserialize, Default)] diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 4d482ac5..225138db 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -20,10 +20,10 @@ use breez_sdk_common::{ rest::RestClient, }; use spark_wallet::{ - ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, SparkAddress, SparkWallet, - TokenInputs, TransferTokenOutput, WalletEvent, WalletTransfer, + ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, Order, PagingFilter, SparkAddress, + SparkWallet, TokenMetadata, TransferTokenOutput, WalletEvent, WalletTransfer, }; -use std::{str::FromStr, sync::Arc}; +use std::{str::FromStr, sync::Arc, time::UNIX_EPOCH}; use tracing::{error, info, trace}; use web_time::{Duration, SystemTime}; @@ -45,7 +45,6 @@ use crate::{ PrepareLnurlPayResponse, RefundDepositRequest, RefundDepositResponse, RegisterLightningAddressRequest, SendOnchainFeeQuote, SendPaymentOptions, WaitForPaymentIdentifier, WaitForPaymentRequest, WaitForPaymentResponse, - adaptors::sparkscan::payments_from_address_transaction_and_ssp_request, error::SdkError, events::{EventEmitter, EventListener, SdkEvent}, lnurl::LnurlServerClient, @@ -67,7 +66,6 @@ use crate::{ }, }; -const SPARKSCAN_API_URL: &str = "https://api.sparkscan.io"; const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; #[derive(Clone, Debug)] @@ -196,7 +194,6 @@ pub fn default_config(network: Network) -> Config { max_deposit_claim_fee: None, lnurl_domain: Some("breez.tips".to_string()), prefer_spark_over_lightning: false, - sparkscan_api_url: SPARKSCAN_API_URL.to_string(), } } @@ -394,240 +391,370 @@ impl BreezSdk { update_balances(self.spark_wallet.clone(), self.storage.clone()).await?; let object_repository = ObjectCacheRepository::new(self.storage.clone()); - self.sync_pending_payments().await?; - self.sync_payments_to_storage(&object_repository).await?; + self.sync_bitcoin_payments_to_storage(&object_repository) + .await?; + self.sync_token_payments_to_storage(&object_repository) + .await?; Ok(()) } - async fn sync_payments_to_storage( + async fn sync_bitcoin_payments_to_storage( &self, object_repository: &ObjectCacheRepository, ) -> Result<(), SdkError> { - // Get the last payment id we processed from storage + // Get the last offset we processed from storage let cached_sync_info = object_repository .fetch_sync_info() .await? .unwrap_or_default(); - let last_synced_id = cached_sync_info.last_synced_payment_id; - - // TODO: use new spark address format once sparkscan supports it - let legacy_spark_address = self - .spark_wallet - .get_spark_address()? - .to_string_with_hrp_legacy(); + let current_offset = cached_sync_info.offset; - let mut payments_to_sync = Vec::new(); - - // We'll keep querying in batches until we have all payments or we find the last synced payment - let mut next_offset = 0_u64; + // We'll keep querying in batches until we have all transfers + let mut next_offset = current_offset; let mut has_more = true; - let mut found_last_synced = false; info!("Syncing payments to storage, offset = {next_offset}"); - while has_more && !found_last_synced { - // Get batch of address transactions starting from current offset - let response = sparkscan::Client::new(&self.config.sparkscan_api_url) - .get_address_transactions_v1_address_address_transactions_get() - .network(sparkscan::types::Network::from(self.config.network)) - .address(legacy_spark_address.to_string()) - .offset(next_offset) - .limit(PAYMENT_SYNC_BATCH_SIZE) - .send() - .await?; - let address_transactions = &response.data; - - let ssp_transfer_types = [ - sparkscan::types::AddressTransactionType::BitcoinDeposit, - sparkscan::types::AddressTransactionType::BitcoinWithdrawal, - sparkscan::types::AddressTransactionType::LightningPayment, - ]; - let ssp_user_requests = self + let mut pending_payments: u64 = 0; + while has_more { + // Get batch of transfers starting from current offset + let transfers_response = self .spark_wallet - .query_ssp_user_requests( - address_transactions - .iter() - .filter(|tx| ssp_transfer_types.contains(&tx.type_)) - .map(|tx| tx.id.clone()) - .collect(), + .list_transfers( + Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + Some(Order::Ascending), + )), + None, ) - .await?; + .await? + .items; info!( - "Syncing payments to storage, offset = {next_offset}, transactions = {}", - address_transactions.len() + "Syncing bitcoin payments to storage, offset = {next_offset}, transfers = {}", + transfers_response.len() ); - // Process transactions in this batch - for transaction in address_transactions { - // Create payment records - let payments = payments_from_address_transaction_and_ssp_request( - transaction, - ssp_user_requests.get(&transaction.id), - &legacy_spark_address, - )?; - - for payment in payments { - if payment.id == last_synced_id { - info!( - "Last synced payment id found ({last_synced_id}), stopping sync and proceeding to insert {} payments", - payments_to_sync.len() - ); - found_last_synced = true; - break; - } - payments_to_sync.push(payment); + // Process transfers in this batch + for transfer in &transfers_response { + // Create a payment record + let payment: Payment = transfer.clone().try_into()?; + // Insert payment into storage + if let Err(err) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert bitcoin payment: {err:?}"); } - - // If we found the last synced payment, stop processing this batch - if found_last_synced { - break; + if payment.status == PaymentStatus::Pending { + pending_payments = pending_payments.saturating_add(1); } + info!("Inserted bitcoin payment: {payment:?}"); } // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(u64::try_from(address_transactions.len())?); - has_more = address_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; - } - - // Insert payment into storage from oldest to newest - for payment in payments_to_sync.iter().rev() { - self.storage.insert_payment(payment.clone()).await?; - info!("Inserted payment: {payment:?}"); - object_repository + next_offset = next_offset.saturating_add(u64::try_from(transfers_response.len())?); + // Update our last processed offset in the storage. We should remove pending payments + // from the offset as they might be removed from the list later. + let save_res = object_repository .save_sync_info(&CachedSyncInfo { - last_synced_payment_id: payment.id.clone(), + offset: next_offset.saturating_sub(pending_payments), + last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, }) - .await?; + .await; + if let Err(err) = save_res { + error!("Failed to update last sync bitcoin offset: {err:?}"); + } + has_more = transfers_response.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; } Ok(()) } - /// Syncs pending payments so that we have their latest status - /// Uses the Spark SDK API to get the latest status of the payments - async fn sync_pending_payments(&self) -> Result<(), SdkError> { - // TODO: implement pending payment syncing using sparkscan API (including live updates) - // Advantages: - // - No need to maintain payment adapter code for both models - // - Can use live updates from sparkscan API - // Why it can't be done now: - // - Sparkscan needs one of the following: - // - Batch transaction querying by id - // - Sorting by updated_at timestamp in address transactions query (simpler) - - let pending_payments = self - .storage - .list_payments(None, None, Some(PaymentStatus::Pending)) - .await?; + #[allow(clippy::too_many_lines)] + async fn sync_token_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { + // Get the last offsets we processed from storage + let cached_sync_info = object_repository + .fetch_sync_info() + .await? + .unwrap_or_default(); + let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; - let (pending_token_payments, pending_bitcoin_payments): (Vec<_>, Vec<_>) = pending_payments - .iter() - .partition(|p| p.method == PaymentMethod::Token); + let our_public_key = self.spark_wallet.get_identity_public_key(); + let mut latest_token_transaction_timestamp = None; + + // We'll keep querying in batches until we have all transfers + let mut next_offset = 0; + let mut has_more = true; info!( - "Syncing pending bitcoin payments: {}", - pending_bitcoin_payments.len() - ); - self.sync_pending_bitcoin_payments(&pending_bitcoin_payments) - .await?; - info!( - "Syncing pending token payments: {}", - pending_token_payments.len() + "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" ); - self.sync_pending_token_payments(&pending_token_payments) - .await?; + while has_more { + // Get batch of token transactions starting from current offset + let token_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + None, + )), + ..Default::default() + }) + .await? + .items; - Ok(()) - } + // On first iteration, set the latest token transaction timestamp to the first transaction timestamp + if next_offset == 0 { + latest_token_transaction_timestamp = + token_transactions.first().map(|tx| tx.created_timestamp); + } - async fn sync_pending_bitcoin_payments( - &self, - pending_bitcoin_payments: &[&Payment], - ) -> Result<(), SdkError> { - if pending_bitcoin_payments.is_empty() { - return Ok(()); - } + // Get prev out hashes of first input of each token transaction + // Assumes all inputs of a tx share the same owner public key + let token_transactions_prevout_hashes = token_transactions + .iter() + .filter_map(|tx| match &tx.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + token_transfer_input.outputs_to_spend.first().cloned() + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + None + } + }) + .map(|output| output.prev_token_tx_hash) + .collect::>(); - let transfer_ids: Vec<_> = pending_bitcoin_payments - .iter() - .map(|p| p.id.clone()) - .collect(); + // Since we are trying to fetch at most 1 parent transaction per token transaction, + // we can fetch all in one go using same batch size + let parent_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), + owner_public_keys: Some(Vec::new()), + token_transaction_hashes: token_transactions_prevout_hashes, + ..Default::default() + }) + .await? + .items; - let transfers = self - .spark_wallet - .list_transfers(None, Some(transfer_ids.clone())) - .await? - .items; + info!( + "Syncing token payments to storage, offset = {next_offset}, transactions = {}", + token_transactions.len() + ); + // Process transfers in this batch + for transaction in &token_transactions { + // Stop syncing if we have reached the last synced token transaction timestamp + if let Some(last_synced_token_timestamp) = last_synced_token_timestamp + && transaction.created_timestamp <= last_synced_token_timestamp + { + break; + } + + let tx_inputs_are_ours = match &transaction.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + let Some(first_input) = token_transfer_input.outputs_to_spend.first() + else { + return Err(SdkError::Generic( + "No input in token transfer input".to_string(), + )); + }; + let Some(parent_transaction) = parent_transactions + .iter() + .find(|tx| tx.hash == first_input.prev_token_tx_hash) + else { + return Err(SdkError::Generic( + "Parent transaction not found".to_string(), + )); + }; + let Some(output) = parent_transaction + .outputs + .get(first_input.prev_token_tx_vout as usize) + else { + return Err(SdkError::Generic("Output not found".to_string())); + }; + output.owner_public_key == our_public_key + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + false + } + }; + + // Create payment records + let payments = self + .token_transaction_to_payments(transaction, tx_inputs_are_ours) + .await?; + + for payment in payments { + // Insert payment into storage + if let Err(err) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert token payment: {err:?}"); + } + info!("Inserted token payment: {payment:?}"); + } + } + + // Check if we have more transfers to fetch + next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); + has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } + + // Update our last processed transaction timestamp in the storage + if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { + let save_res = object_repository + .save_sync_info(&CachedSyncInfo { + offset: cached_sync_info.offset, + last_synced_token_timestamp: Some(latest_token_transaction_timestamp), + }) + .await; - for transfer in transfers { - let payment = Payment::try_from(transfer)?; - info!("Inserting previously pending bitcoin payment: {payment:?}"); - self.storage.insert_payment(payment).await?; + if let Err(err) = save_res { + error!("Failed to update last sync token timestamp: {err:?}"); + } } Ok(()) } - async fn sync_pending_token_payments( + /// Converts a token transaction to payments + /// + /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. + /// The id of the payment is the id of the first output in the group. + /// + /// Assumptions: + /// - All outputs of a token transaction share the same token identifier + /// - All inputs of a token transaction share the same owner public key + #[allow(clippy::too_many_lines)] + async fn token_transaction_to_payments( &self, - pending_token_payments: &[&Payment], - ) -> Result<(), SdkError> { - if pending_token_payments.is_empty() { - return Ok(()); + transaction: &spark_wallet::TokenTransaction, + tx_inputs_are_ours: bool, + ) -> Result, SdkError> { + // Get token metadata for the first output (assuming all outputs have the same token) + let token_identifier = transaction + .outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in token transaction".to_string(), + ))? + .token_identifier + .as_ref(); + let metadata: TokenMetadata = self + .spark_wallet + .get_tokens_metadata(&[token_identifier]) + .await? + .first() + .ok_or(SdkError::Generic("Token metadata not found".to_string()))? + .clone(); + + let is_transfer_transaction = + matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); + + let timestamp = transaction + .created_timestamp + .duration_since(UNIX_EPOCH) + .map_err(|_| { + SdkError::Generic( + "Token transaction created timestamp is before UNIX_EPOCH".to_string(), + ) + })? + .as_secs(); + + // Group outputs by owner public key + let mut outputs_by_owner = std::collections::HashMap::new(); + for output in &transaction.outputs { + outputs_by_owner + .entry(output.owner_public_key) + .or_insert_with(Vec::new) + .push(output); } - let hash_pending_token_payments_map = pending_token_payments.iter().try_fold( - std::collections::HashMap::new(), - |mut acc: std::collections::HashMap<&_, Vec<_>>, payment| { - let details = payment - .details - .as_ref() - .ok_or_else(|| SdkError::Generic("Payment details missing".to_string()))?; - - if let PaymentDetails::Token { tx_hash, .. } = details { - acc.entry(tx_hash).or_default().push(payment); - Ok(acc) - } else { - Err(SdkError::Generic( - "Payment is not a token payment".to_string(), - )) - } - }, - )?; + let mut payments = Vec::new(); - let token_transactions = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - token_transaction_hashes: hash_pending_token_payments_map - .keys() - .map(|k| (*k).to_string()) - .collect(), - ..Default::default() - }) - .await? - .items; - - for token_transaction in token_transactions { - let is_transfer_transaction = - matches!(token_transaction.inputs, TokenInputs::Transfer(..)); - let payment_status = PaymentStatus::from_token_transaction_status( - token_transaction.status, - is_transfer_transaction, - ); - if payment_status != PaymentStatus::Pending { - let payments_to_update = hash_pending_token_payments_map - .get(&token_transaction.hash) - .ok_or(SdkError::Generic("Payment not found".to_string()))?; - for payment in payments_to_update { - // For now, updating the status is enough - let mut updated_payment = (**payment).clone(); - updated_payment.status = payment_status; - info!("Inserting previously pending token payment: {updated_payment:?}"); - self.storage.insert_payment(updated_payment).await?; + if tx_inputs_are_ours { + // If inputs are ours, add an outgoing payment for each output group that is not ours + for (owner_pubkey, outputs) in outputs_by_owner { + if owner_pubkey != self.spark_wallet.get_identity_public_key() { + // This is an outgoing payment to another user + let total_amount = outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = outputs + .first() + .ok_or(SdkError::Generic("No outputs in output group".to_string()))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Send, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, // TODO: calculate actual fees when they start being charged + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone().into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); } + // Ignore outputs that belong to us (potential change outputs) } + } else { + // If inputs are not ours, add an incoming payment for our output group + if let Some(our_outputs) = + outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) + { + let total_amount: u64 = our_outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = our_outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in our output group".to_string(), + ))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Receive, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that don't belong to us } - Ok(()) + Ok(payments) } async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> { diff --git a/crates/breez-sdk/wasm/src/models.rs b/crates/breez-sdk/wasm/src/models.rs index 252e8b5c..697e355d 100644 --- a/crates/breez-sdk/wasm/src/models.rs +++ b/crates/breez-sdk/wasm/src/models.rs @@ -479,7 +479,6 @@ pub struct Config { pub max_deposit_claim_fee: Option, pub lnurl_domain: Option, pub prefer_spark_over_lightning: bool, - pub sparkscan_api_url: String, } #[macros::extern_wasm_bindgen(breez_sdk_spark::Fee)] From 74abf2b3ef8a0a5bd70861af5c0cbe3d33c6fadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 15:11:29 +0100 Subject: [PATCH 13/22] Extract sync implementation to service --- crates/breez-sdk/core/src/lib.rs | 1 + crates/breez-sdk/core/src/sdk.rs | 378 +---------------------------- crates/breez-sdk/core/src/sync.rs | 391 ++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+), 370 deletions(-) create mode 100644 crates/breez-sdk/core/src/sync.rs diff --git a/crates/breez-sdk/core/src/lib.rs b/crates/breez-sdk/core/src/lib.rs index 8d87ef43..196e258d 100644 --- a/crates/breez-sdk/core/src/lib.rs +++ b/crates/breez-sdk/core/src/lib.rs @@ -9,6 +9,7 @@ mod models; mod persist; mod sdk; mod sdk_builder; +mod sync; mod utils; #[cfg(feature = "uniffi")] diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 225138db..ed87e5a3 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -20,10 +20,10 @@ use breez_sdk_common::{ rest::RestClient, }; use spark_wallet::{ - ExitSpeed, InvoiceDescription, ListTokenTransactionsRequest, Order, PagingFilter, SparkAddress, - SparkWallet, TokenMetadata, TransferTokenOutput, WalletEvent, WalletTransfer, + ExitSpeed, InvoiceDescription, SparkAddress, SparkWallet, TransferTokenOutput, WalletEvent, + WalletTransfer, }; -use std::{str::FromStr, sync::Arc, time::UNIX_EPOCH}; +use std::{str::FromStr, sync::Arc}; use tracing::{error, info, trace}; use web_time::{Duration, SystemTime}; @@ -56,9 +56,10 @@ use crate::{ SendPaymentResponse, SyncWalletRequest, SyncWalletResponse, }, persist::{ - CachedAccountInfo, CachedSyncInfo, ObjectCacheRepository, PaymentMetadata, - StaticDepositAddress, Storage, UpdateDepositPayload, + CachedAccountInfo, ObjectCacheRepository, PaymentMetadata, StaticDepositAddress, Storage, + UpdateDepositPayload, }, + sync::SparkSyncService, utils::{ deposit_chain_syncer::DepositChainSyncer, run_with_shutdown, @@ -66,8 +67,6 @@ use crate::{ }, }; -const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; - #[derive(Clone, Debug)] enum SyncType { Full, @@ -390,373 +389,12 @@ impl BreezSdk { async fn sync_wallet_state_to_storage(&self) -> Result<(), SdkError> { update_balances(self.spark_wallet.clone(), self.storage.clone()).await?; - let object_repository = ObjectCacheRepository::new(self.storage.clone()); - self.sync_bitcoin_payments_to_storage(&object_repository) - .await?; - self.sync_token_payments_to_storage(&object_repository) - .await?; - - Ok(()) - } - - async fn sync_bitcoin_payments_to_storage( - &self, - object_repository: &ObjectCacheRepository, - ) -> Result<(), SdkError> { - // Get the last offset we processed from storage - let cached_sync_info = object_repository - .fetch_sync_info() - .await? - .unwrap_or_default(); - let current_offset = cached_sync_info.offset; - - // We'll keep querying in batches until we have all transfers - let mut next_offset = current_offset; - let mut has_more = true; - info!("Syncing payments to storage, offset = {next_offset}"); - let mut pending_payments: u64 = 0; - while has_more { - // Get batch of transfers starting from current offset - let transfers_response = self - .spark_wallet - .list_transfers( - Some(PagingFilter::new( - Some(next_offset), - Some(PAYMENT_SYNC_BATCH_SIZE), - Some(Order::Ascending), - )), - None, - ) - .await? - .items; - - info!( - "Syncing bitcoin payments to storage, offset = {next_offset}, transfers = {}", - transfers_response.len() - ); - // Process transfers in this batch - for transfer in &transfers_response { - // Create a payment record - let payment: Payment = transfer.clone().try_into()?; - // Insert payment into storage - if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert bitcoin payment: {err:?}"); - } - if payment.status == PaymentStatus::Pending { - pending_payments = pending_payments.saturating_add(1); - } - info!("Inserted bitcoin payment: {payment:?}"); - } - - // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(u64::try_from(transfers_response.len())?); - // Update our last processed offset in the storage. We should remove pending payments - // from the offset as they might be removed from the list later. - let save_res = object_repository - .save_sync_info(&CachedSyncInfo { - offset: next_offset.saturating_sub(pending_payments), - last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, - }) - .await; - if let Err(err) = save_res { - error!("Failed to update last sync bitcoin offset: {err:?}"); - } - has_more = transfers_response.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; - } - - Ok(()) - } - - #[allow(clippy::too_many_lines)] - async fn sync_token_payments_to_storage( - &self, - object_repository: &ObjectCacheRepository, - ) -> Result<(), SdkError> { - // Get the last offsets we processed from storage - let cached_sync_info = object_repository - .fetch_sync_info() - .await? - .unwrap_or_default(); - let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; - - let our_public_key = self.spark_wallet.get_identity_public_key(); - - let mut latest_token_transaction_timestamp = None; - - // We'll keep querying in batches until we have all transfers - let mut next_offset = 0; - let mut has_more = true; - info!( - "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" - ); - while has_more { - // Get batch of token transactions starting from current offset - let token_transactions = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - paging: Some(PagingFilter::new( - Some(next_offset), - Some(PAYMENT_SYNC_BATCH_SIZE), - None, - )), - ..Default::default() - }) - .await? - .items; - - // On first iteration, set the latest token transaction timestamp to the first transaction timestamp - if next_offset == 0 { - latest_token_transaction_timestamp = - token_transactions.first().map(|tx| tx.created_timestamp); - } - - // Get prev out hashes of first input of each token transaction - // Assumes all inputs of a tx share the same owner public key - let token_transactions_prevout_hashes = token_transactions - .iter() - .filter_map(|tx| match &tx.inputs { - spark_wallet::TokenInputs::Transfer(token_transfer_input) => { - token_transfer_input.outputs_to_spend.first().cloned() - } - spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { - None - } - }) - .map(|output| output.prev_token_tx_hash) - .collect::>(); - - // Since we are trying to fetch at most 1 parent transaction per token transaction, - // we can fetch all in one go using same batch size - let parent_transactions = self - .spark_wallet - .list_token_transactions(ListTokenTransactionsRequest { - paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), - owner_public_keys: Some(Vec::new()), - token_transaction_hashes: token_transactions_prevout_hashes, - ..Default::default() - }) - .await? - .items; - - info!( - "Syncing token payments to storage, offset = {next_offset}, transactions = {}", - token_transactions.len() - ); - // Process transfers in this batch - for transaction in &token_transactions { - // Stop syncing if we have reached the last synced token transaction timestamp - if let Some(last_synced_token_timestamp) = last_synced_token_timestamp - && transaction.created_timestamp <= last_synced_token_timestamp - { - break; - } - - let tx_inputs_are_ours = match &transaction.inputs { - spark_wallet::TokenInputs::Transfer(token_transfer_input) => { - let Some(first_input) = token_transfer_input.outputs_to_spend.first() - else { - return Err(SdkError::Generic( - "No input in token transfer input".to_string(), - )); - }; - let Some(parent_transaction) = parent_transactions - .iter() - .find(|tx| tx.hash == first_input.prev_token_tx_hash) - else { - return Err(SdkError::Generic( - "Parent transaction not found".to_string(), - )); - }; - let Some(output) = parent_transaction - .outputs - .get(first_input.prev_token_tx_vout as usize) - else { - return Err(SdkError::Generic("Output not found".to_string())); - }; - output.owner_public_key == our_public_key - } - spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { - false - } - }; - - // Create payment records - let payments = self - .token_transaction_to_payments(transaction, tx_inputs_are_ours) - .await?; - - for payment in payments { - // Insert payment into storage - if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert token payment: {err:?}"); - } - info!("Inserted token payment: {payment:?}"); - } - } - - // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); - has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; - } - - // Update our last processed transaction timestamp in the storage - if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { - let save_res = object_repository - .save_sync_info(&CachedSyncInfo { - offset: cached_sync_info.offset, - last_synced_token_timestamp: Some(latest_token_transaction_timestamp), - }) - .await; - - if let Err(err) = save_res { - error!("Failed to update last sync token timestamp: {err:?}"); - } - } + let sync_service = SparkSyncService::new(self.spark_wallet.clone(), self.storage.clone()); + sync_service.sync_payments().await?; Ok(()) } - /// Converts a token transaction to payments - /// - /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. - /// The id of the payment is the id of the first output in the group. - /// - /// Assumptions: - /// - All outputs of a token transaction share the same token identifier - /// - All inputs of a token transaction share the same owner public key - #[allow(clippy::too_many_lines)] - async fn token_transaction_to_payments( - &self, - transaction: &spark_wallet::TokenTransaction, - tx_inputs_are_ours: bool, - ) -> Result, SdkError> { - // Get token metadata for the first output (assuming all outputs have the same token) - let token_identifier = transaction - .outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in token transaction".to_string(), - ))? - .token_identifier - .as_ref(); - let metadata: TokenMetadata = self - .spark_wallet - .get_tokens_metadata(&[token_identifier]) - .await? - .first() - .ok_or(SdkError::Generic("Token metadata not found".to_string()))? - .clone(); - - let is_transfer_transaction = - matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); - - let timestamp = transaction - .created_timestamp - .duration_since(UNIX_EPOCH) - .map_err(|_| { - SdkError::Generic( - "Token transaction created timestamp is before UNIX_EPOCH".to_string(), - ) - })? - .as_secs(); - - // Group outputs by owner public key - let mut outputs_by_owner = std::collections::HashMap::new(); - for output in &transaction.outputs { - outputs_by_owner - .entry(output.owner_public_key) - .or_insert_with(Vec::new) - .push(output); - } - - let mut payments = Vec::new(); - - if tx_inputs_are_ours { - // If inputs are ours, add an outgoing payment for each output group that is not ours - for (owner_pubkey, outputs) in outputs_by_owner { - if owner_pubkey != self.spark_wallet.get_identity_public_key() { - // This is an outgoing payment to another user - let total_amount = outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = outputs - .first() - .ok_or(SdkError::Generic("No outputs in output group".to_string()))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Send, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, // TODO: calculate actual fees when they start being charged - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.clone().into(), - tx_hash: transaction.hash.clone(), - }), - }; - - payments.push(payment); - } - // Ignore outputs that belong to us (potential change outputs) - } - } else { - // If inputs are not ours, add an incoming payment for our output group - if let Some(our_outputs) = - outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) - { - let total_amount: u64 = our_outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = our_outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in our output group".to_string(), - ))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Receive, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.into(), - tx_hash: transaction.hash.clone(), - }), - }; - - payments.push(payment); - } - // Ignore outputs that don't belong to us - } - - Ok(payments) - } - async fn check_and_claim_static_deposits(&self) -> Result<(), SdkError> { let to_claim = DepositChainSyncer::new( self.chain_service.clone(), diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs new file mode 100644 index 00000000..3d6e5798 --- /dev/null +++ b/crates/breez-sdk/core/src/sync.rs @@ -0,0 +1,391 @@ +use std::{sync::Arc, time::UNIX_EPOCH}; + +use spark_wallet::{ListTokenTransactionsRequest, Order, PagingFilter, SparkWallet, TokenMetadata}; +use tracing::{error, info}; + +use crate::{ + Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError, Storage, + persist::{CachedSyncInfo, ObjectCacheRepository}, +}; + +const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; + +pub(crate) struct SparkSyncService { + spark_wallet: Arc, + storage: Arc, +} + +impl SparkSyncService { + pub fn new(spark_wallet: Arc, storage: Arc) -> Self { + Self { + spark_wallet, + storage, + } + } + + pub async fn sync_payments(&self) -> Result<(), SdkError> { + let object_repository = ObjectCacheRepository::new(self.storage.clone()); + self.sync_bitcoin_payments_to_storage(&object_repository) + .await?; + self.sync_token_payments_to_storage(&object_repository) + .await + } + + async fn sync_bitcoin_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { + // Get the last offset we processed from storage + let cached_sync_info = object_repository + .fetch_sync_info() + .await? + .unwrap_or_default(); + let current_offset = cached_sync_info.offset; + + // We'll keep querying in batches until we have all transfers + let mut next_offset = current_offset; + let mut has_more = true; + info!("Syncing payments to storage, offset = {next_offset}"); + let mut pending_payments: u64 = 0; + while has_more { + // Get batch of transfers starting from current offset + let transfers_response = self + .spark_wallet + .list_transfers( + Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + Some(Order::Ascending), + )), + None, + ) + .await? + .items; + + info!( + "Syncing bitcoin payments to storage, offset = {next_offset}, transfers = {}", + transfers_response.len() + ); + // Process transfers in this batch + for transfer in &transfers_response { + // Create a payment record + let payment: Payment = transfer.clone().try_into()?; + // Insert payment into storage + if let Err(err) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert bitcoin payment: {err:?}"); + } + if payment.status == PaymentStatus::Pending { + pending_payments = pending_payments.saturating_add(1); + } + info!("Inserted bitcoin payment: {payment:?}"); + } + + // Check if we have more transfers to fetch + next_offset = next_offset.saturating_add(u64::try_from(transfers_response.len())?); + // Update our last processed offset in the storage. We should remove pending payments + // from the offset as they might be removed from the list later. + let save_res = object_repository + .save_sync_info(&CachedSyncInfo { + offset: next_offset.saturating_sub(pending_payments), + last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, + }) + .await; + if let Err(err) = save_res { + error!("Failed to update last sync bitcoin offset: {err:?}"); + } + has_more = transfers_response.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } + + Ok(()) + } + + #[allow(clippy::too_many_lines)] + async fn sync_token_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { + // Get the last offsets we processed from storage + let cached_sync_info = object_repository + .fetch_sync_info() + .await? + .unwrap_or_default(); + let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; + + let our_public_key = self.spark_wallet.get_identity_public_key(); + + let mut latest_token_transaction_timestamp = None; + + // We'll keep querying in batches until we have all transfers + let mut next_offset = 0; + let mut has_more = true; + info!( + "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" + ); + while has_more { + // Get batch of token transactions starting from current offset + let token_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + None, + )), + ..Default::default() + }) + .await? + .items; + + // On first iteration, set the latest token transaction timestamp to the first transaction timestamp + if next_offset == 0 { + latest_token_transaction_timestamp = + token_transactions.first().map(|tx| tx.created_timestamp); + } + + // Get prev out hashes of first input of each token transaction + // Assumes all inputs of a tx share the same owner public key + let token_transactions_prevout_hashes = token_transactions + .iter() + .filter_map(|tx| match &tx.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + token_transfer_input.outputs_to_spend.first().cloned() + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + None + } + }) + .map(|output| output.prev_token_tx_hash) + .collect::>(); + + // Since we are trying to fetch at most 1 parent transaction per token transaction, + // we can fetch all in one go using same batch size + let parent_transactions = self + .spark_wallet + .list_token_transactions(ListTokenTransactionsRequest { + paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), + owner_public_keys: Some(Vec::new()), + token_transaction_hashes: token_transactions_prevout_hashes, + ..Default::default() + }) + .await? + .items; + + info!( + "Syncing token payments to storage, offset = {next_offset}, transactions = {}", + token_transactions.len() + ); + // Process transfers in this batch + for transaction in &token_transactions { + // Stop syncing if we have reached the last synced token transaction timestamp + if let Some(last_synced_token_timestamp) = last_synced_token_timestamp + && transaction.created_timestamp <= last_synced_token_timestamp + { + break; + } + + let tx_inputs_are_ours = match &transaction.inputs { + spark_wallet::TokenInputs::Transfer(token_transfer_input) => { + let Some(first_input) = token_transfer_input.outputs_to_spend.first() + else { + return Err(SdkError::Generic( + "No input in token transfer input".to_string(), + )); + }; + let Some(parent_transaction) = parent_transactions + .iter() + .find(|tx| tx.hash == first_input.prev_token_tx_hash) + else { + return Err(SdkError::Generic( + "Parent transaction not found".to_string(), + )); + }; + let Some(output) = parent_transaction + .outputs + .get(first_input.prev_token_tx_vout as usize) + else { + return Err(SdkError::Generic("Output not found".to_string())); + }; + output.owner_public_key == our_public_key + } + spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + false + } + }; + + // Create payment records + let payments = self + .token_transaction_to_payments(transaction, tx_inputs_are_ours) + .await?; + + for payment in payments { + // Insert payment into storage + if let Err(err) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert token payment: {err:?}"); + } + info!("Inserted token payment: {payment:?}"); + } + } + + // Check if we have more transfers to fetch + next_offset = next_offset.saturating_add(u64::try_from(token_transactions.len())?); + has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + } + + // Update our last processed transaction timestamp in the storage + if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { + let save_res = object_repository + .save_sync_info(&CachedSyncInfo { + offset: cached_sync_info.offset, + last_synced_token_timestamp: Some(latest_token_transaction_timestamp), + }) + .await; + + if let Err(err) = save_res { + error!("Failed to update last sync token timestamp: {err:?}"); + } + } + + Ok(()) + } + + /// Converts a token transaction to payments + /// + /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. + /// The id of the payment is the id of the first output in the group. + /// + /// Assumptions: + /// - All outputs of a token transaction share the same token identifier + /// - All inputs of a token transaction share the same owner public key + #[allow(clippy::too_many_lines)] + async fn token_transaction_to_payments( + &self, + transaction: &spark_wallet::TokenTransaction, + tx_inputs_are_ours: bool, + ) -> Result, SdkError> { + // Get token metadata for the first output (assuming all outputs have the same token) + let token_identifier = transaction + .outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in token transaction".to_string(), + ))? + .token_identifier + .as_ref(); + let metadata: TokenMetadata = self + .spark_wallet + .get_tokens_metadata(&[token_identifier]) + .await? + .first() + .ok_or(SdkError::Generic("Token metadata not found".to_string()))? + .clone(); + + let is_transfer_transaction = + matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); + + let timestamp = transaction + .created_timestamp + .duration_since(UNIX_EPOCH) + .map_err(|_| { + SdkError::Generic( + "Token transaction created timestamp is before UNIX_EPOCH".to_string(), + ) + })? + .as_secs(); + + // Group outputs by owner public key + let mut outputs_by_owner = std::collections::HashMap::new(); + for output in &transaction.outputs { + outputs_by_owner + .entry(output.owner_public_key) + .or_insert_with(Vec::new) + .push(output); + } + + let mut payments = Vec::new(); + + if tx_inputs_are_ours { + // If inputs are ours, add an outgoing payment for each output group that is not ours + for (owner_pubkey, outputs) in outputs_by_owner { + if owner_pubkey != self.spark_wallet.get_identity_public_key() { + // This is an outgoing payment to another user + let total_amount = outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = outputs + .first() + .ok_or(SdkError::Generic("No outputs in output group".to_string()))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Send, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, // TODO: calculate actual fees when they start being charged + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone().into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that belong to us (potential change outputs) + } + } else { + // If inputs are not ours, add an incoming payment for our output group + if let Some(our_outputs) = + outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) + { + let total_amount: u64 = our_outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = our_outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in our output group".to_string(), + ))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Receive, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that don't belong to us + } + + Ok(payments) + } +} From 01d46a6476bd070bb006bfaf7955d86ae6e2410f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 16:00:16 +0100 Subject: [PATCH 14/22] Stop token syncing when we see an already stored finalized payment --- crates/breez-sdk/core/src/persist/mod.rs | 3 +- crates/breez-sdk/core/src/sync.rs | 108 ++++++++--------------- 2 files changed, 37 insertions(+), 74 deletions(-) diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index 5ba21313..0a5332db 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -1,7 +1,7 @@ #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub(crate) mod sqlite; -use std::{collections::HashMap, sync::Arc, time::SystemTime}; +use std::{collections::HashMap, sync::Arc}; use macros::async_trait; use serde::{Deserialize, Serialize}; @@ -324,7 +324,6 @@ pub(crate) struct CachedAccountInfo { #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedSyncInfo { pub(crate) offset: u64, - pub(crate) last_synced_token_timestamp: Option, } #[derive(Serialize, Deserialize, Default)] diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs index 3d6e5798..f707ba38 100644 --- a/crates/breez-sdk/core/src/sync.rs +++ b/crates/breez-sdk/core/src/sync.rs @@ -24,18 +24,13 @@ impl SparkSyncService { } pub async fn sync_payments(&self) -> Result<(), SdkError> { - let object_repository = ObjectCacheRepository::new(self.storage.clone()); - self.sync_bitcoin_payments_to_storage(&object_repository) - .await?; - self.sync_token_payments_to_storage(&object_repository) - .await + self.sync_bitcoin_payments_to_storage().await?; + self.sync_token_payments_to_storage().await } - async fn sync_bitcoin_payments_to_storage( - &self, - object_repository: &ObjectCacheRepository, - ) -> Result<(), SdkError> { + async fn sync_bitcoin_payments_to_storage(&self) -> Result<(), SdkError> { // Get the last offset we processed from storage + let object_repository = ObjectCacheRepository::new(self.storage.clone()); let cached_sync_info = object_repository .fetch_sync_info() .await? @@ -87,7 +82,6 @@ impl SparkSyncService { let save_res = object_repository .save_sync_info(&CachedSyncInfo { offset: next_offset.saturating_sub(pending_payments), - last_synced_token_timestamp: cached_sync_info.last_synced_token_timestamp, }) .await; if let Err(err) = save_res { @@ -100,28 +94,15 @@ impl SparkSyncService { } #[allow(clippy::too_many_lines)] - async fn sync_token_payments_to_storage( - &self, - object_repository: &ObjectCacheRepository, - ) -> Result<(), SdkError> { - // Get the last offsets we processed from storage - let cached_sync_info = object_repository - .fetch_sync_info() - .await? - .unwrap_or_default(); - let last_synced_token_timestamp = cached_sync_info.last_synced_token_timestamp; - + async fn sync_token_payments_to_storage(&self) -> Result<(), SdkError> { + info!("Syncing token payments to storage"); let our_public_key = self.spark_wallet.get_identity_public_key(); - let mut latest_token_transaction_timestamp = None; - - // We'll keep querying in batches until we have all transfers let mut next_offset = 0; let mut has_more = true; - info!( - "Syncing token payments to storage, last synced token timestamp = {last_synced_token_timestamp:?}" - ); - while has_more { + // We'll keep querying in pages until we already have a completed or failed payment stored + // or we have fetched all transfers + 'page_loop: while has_more { // Get batch of token transactions starting from current offset let token_transactions = self .spark_wallet @@ -136,10 +117,8 @@ impl SparkSyncService { .await? .items; - // On first iteration, set the latest token transaction timestamp to the first transaction timestamp - if next_offset == 0 { - latest_token_transaction_timestamp = - token_transactions.first().map(|tx| tx.created_timestamp); + if token_transactions.is_empty() { + break 'page_loop; } // Get prev out hashes of first input of each token transaction @@ -174,40 +153,26 @@ impl SparkSyncService { "Syncing token payments to storage, offset = {next_offset}, transactions = {}", token_transactions.len() ); - // Process transfers in this batch + // Process transfers in this page for transaction in &token_transactions { - // Stop syncing if we have reached the last synced token transaction timestamp - if let Some(last_synced_token_timestamp) = last_synced_token_timestamp - && transaction.created_timestamp <= last_synced_token_timestamp - { - break; - } - let tx_inputs_are_ours = match &transaction.inputs { spark_wallet::TokenInputs::Transfer(token_transfer_input) => { - let Some(first_input) = token_transfer_input.outputs_to_spend.first() - else { - return Err(SdkError::Generic( - "No input in token transfer input".to_string(), - )); - }; - let Some(parent_transaction) = parent_transactions + let first_input = token_transfer_input.outputs_to_spend.first().ok_or( + SdkError::Generic("No input in token transfer input".to_string()), + )?; + let parent_transaction = parent_transactions .iter() .find(|tx| tx.hash == first_input.prev_token_tx_hash) - else { - return Err(SdkError::Generic( + .ok_or(SdkError::Generic( "Parent transaction not found".to_string(), - )); - }; - let Some(output) = parent_transaction + ))?; + let output = parent_transaction .outputs .get(first_input.prev_token_tx_vout as usize) - else { - return Err(SdkError::Generic("Output not found".to_string())); - }; + .ok_or(SdkError::Generic("Output not found".to_string()))?; output.owner_public_key == our_public_key } - spark_wallet::TokenInputs::Mint(..) | spark_wallet::TokenInputs::Create(..) => { + spark_wallet::TokenInputs::Mint(_) | spark_wallet::TokenInputs::Create(_) => { false } }; @@ -218,11 +183,24 @@ impl SparkSyncService { .await?; for payment in payments { + // Stop syncing if we encounter a finalized payment that we have already processed + if let Ok(Payment { + status: PaymentStatus::Completed | PaymentStatus::Failed, + .. + }) = self.storage.get_payment_by_id(payment.id.clone()).await + { + info!( + "Encountered already finalized payment {}, stopping sync", + payment.id + ); + break 'page_loop; + } + // Insert payment into storage - if let Err(err) = self.storage.insert_payment(payment.clone()).await { + info!("Inserting token payment: {payment:?}"); + if let Err(err) = self.storage.insert_payment(payment).await { error!("Failed to insert token payment: {err:?}"); } - info!("Inserted token payment: {payment:?}"); } } @@ -231,20 +209,6 @@ impl SparkSyncService { has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; } - // Update our last processed transaction timestamp in the storage - if let Some(latest_token_transaction_timestamp) = latest_token_transaction_timestamp { - let save_res = object_repository - .save_sync_info(&CachedSyncInfo { - offset: cached_sync_info.offset, - last_synced_token_timestamp: Some(latest_token_transaction_timestamp), - }) - .await; - - if let Err(err) = save_res { - error!("Failed to update last sync token timestamp: {err:?}"); - } - } - Ok(()) } From 506e2189efb72106923f6c87d6622b15f8b91e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 16:13:30 +0100 Subject: [PATCH 15/22] Fix send token payment id forever pending --- crates/breez-sdk/core/src/sdk.rs | 50 +++----- crates/breez-sdk/core/src/sync.rs | 157 ++--------------------- crates/breez-sdk/core/src/utils/mod.rs | 1 + crates/breez-sdk/core/src/utils/token.rs | 143 +++++++++++++++++++++ crates/spark-wallet/src/wallet.rs | 6 +- crates/spark/src/services/models.rs | 45 ++++++- crates/spark/src/services/tokens.rs | 7 +- 7 files changed, 222 insertions(+), 187 deletions(-) create mode 100644 crates/breez-sdk/core/src/utils/token.rs diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index ed87e5a3..ecb2a227 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -41,10 +41,10 @@ use crate::{ DepositInfo, Fee, GetPaymentRequest, GetPaymentResponse, LightningAddressInfo, ListFiatCurrenciesResponse, ListFiatRatesResponse, ListUnclaimedDepositsRequest, ListUnclaimedDepositsResponse, LnurlPayInfo, LnurlPayRequest, LnurlPayResponse, Logger, - Network, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, PrepareLnurlPayRequest, - PrepareLnurlPayResponse, RefundDepositRequest, RefundDepositResponse, - RegisterLightningAddressRequest, SendOnchainFeeQuote, SendPaymentOptions, - WaitForPaymentIdentifier, WaitForPaymentRequest, WaitForPaymentResponse, + Network, PaymentDetails, PaymentStatus, PrepareLnurlPayRequest, PrepareLnurlPayResponse, + RefundDepositRequest, RefundDepositResponse, RegisterLightningAddressRequest, + SendOnchainFeeQuote, SendPaymentOptions, WaitForPaymentIdentifier, WaitForPaymentRequest, + WaitForPaymentResponse, error::SdkError, events::{EventEmitter, EventListener, SdkEvent}, lnurl::LnurlServerClient, @@ -63,6 +63,7 @@ use crate::{ utils::{ deposit_chain_syncer::DepositChainSyncer, run_with_shutdown, + token::token_transaction_to_payments, utxo_fetcher::{CachedUtxoFetcher, DetailedUtxo}, }, }; @@ -1419,16 +1420,7 @@ impl BreezSdk { amount: u128, receiver_address: SparkAddress, ) -> Result { - // Get token metadata before sending the payment to make sure we get it from cache - let metadata = self - .spark_wallet - .get_tokens_metadata(&[&token_identifier]) - .await? - .first() - .ok_or(SdkError::Generic("Token metadata not found".to_string()))? - .clone(); - - let tx_hash = self + let token_transaction = self .spark_wallet .transfer_tokens(vec![TransferTokenOutput { token_id: token_identifier, @@ -1437,26 +1429,18 @@ impl BreezSdk { }]) .await?; - // Build and insert pending payment into storage as it may take some time for sparkscan to detect it - let payment = Payment { - id: format!("{tx_hash}:0"), // Transaction output index 0 is for the receiver - payment_type: PaymentType::Send, - status: PaymentStatus::Pending, - amount: amount.try_into()?, - fees: 0, - timestamp: SystemTime::now() - .duration_since(web_time::UNIX_EPOCH) - .map_err(|_| SdkError::Generic("Failed to get current timestamp".to_string()))? - .as_secs(), - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.into(), - tx_hash, - }), - }; - self.storage.insert_payment(payment.clone()).await?; + let payments = + token_transaction_to_payments(&self.spark_wallet, &token_transaction, true).await?; + for payment in &payments { + self.storage.insert_payment(payment.clone()).await?; + } - Ok(payment) + payments + .first() + .ok_or(SdkError::Generic( + "No payment created from token transfer".to_string(), + )) + .cloned() } } diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs index f707ba38..4bdb7069 100644 --- a/crates/breez-sdk/core/src/sync.rs +++ b/crates/breez-sdk/core/src/sync.rs @@ -1,11 +1,12 @@ -use std::{sync::Arc, time::UNIX_EPOCH}; +use std::sync::Arc; -use spark_wallet::{ListTokenTransactionsRequest, Order, PagingFilter, SparkWallet, TokenMetadata}; +use spark_wallet::{ListTokenTransactionsRequest, Order, PagingFilter, SparkWallet}; use tracing::{error, info}; use crate::{ - Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError, Storage, + Payment, PaymentStatus, SdkError, Storage, persist::{CachedSyncInfo, ObjectCacheRepository}, + utils::token::token_transaction_to_payments, }; const PAYMENT_SYNC_BATCH_SIZE: u64 = 50; @@ -178,9 +179,12 @@ impl SparkSyncService { }; // Create payment records - let payments = self - .token_transaction_to_payments(transaction, tx_inputs_are_ours) - .await?; + let payments = token_transaction_to_payments( + &self.spark_wallet, + transaction, + tx_inputs_are_ours, + ) + .await?; for payment in payments { // Stop syncing if we encounter a finalized payment that we have already processed @@ -211,145 +215,4 @@ impl SparkSyncService { Ok(()) } - - /// Converts a token transaction to payments - /// - /// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. - /// The id of the payment is the id of the first output in the group. - /// - /// Assumptions: - /// - All outputs of a token transaction share the same token identifier - /// - All inputs of a token transaction share the same owner public key - #[allow(clippy::too_many_lines)] - async fn token_transaction_to_payments( - &self, - transaction: &spark_wallet::TokenTransaction, - tx_inputs_are_ours: bool, - ) -> Result, SdkError> { - // Get token metadata for the first output (assuming all outputs have the same token) - let token_identifier = transaction - .outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in token transaction".to_string(), - ))? - .token_identifier - .as_ref(); - let metadata: TokenMetadata = self - .spark_wallet - .get_tokens_metadata(&[token_identifier]) - .await? - .first() - .ok_or(SdkError::Generic("Token metadata not found".to_string()))? - .clone(); - - let is_transfer_transaction = - matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); - - let timestamp = transaction - .created_timestamp - .duration_since(UNIX_EPOCH) - .map_err(|_| { - SdkError::Generic( - "Token transaction created timestamp is before UNIX_EPOCH".to_string(), - ) - })? - .as_secs(); - - // Group outputs by owner public key - let mut outputs_by_owner = std::collections::HashMap::new(); - for output in &transaction.outputs { - outputs_by_owner - .entry(output.owner_public_key) - .or_insert_with(Vec::new) - .push(output); - } - - let mut payments = Vec::new(); - - if tx_inputs_are_ours { - // If inputs are ours, add an outgoing payment for each output group that is not ours - for (owner_pubkey, outputs) in outputs_by_owner { - if owner_pubkey != self.spark_wallet.get_identity_public_key() { - // This is an outgoing payment to another user - let total_amount = outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = outputs - .first() - .ok_or(SdkError::Generic("No outputs in output group".to_string()))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Send, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, // TODO: calculate actual fees when they start being charged - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.clone().into(), - tx_hash: transaction.hash.clone(), - }), - }; - - payments.push(payment); - } - // Ignore outputs that belong to us (potential change outputs) - } - } else { - // If inputs are not ours, add an incoming payment for our output group - if let Some(our_outputs) = - outputs_by_owner.get(&self.spark_wallet.get_identity_public_key()) - { - let total_amount: u64 = our_outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = our_outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in our output group".to_string(), - ))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Receive, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.into(), - tx_hash: transaction.hash.clone(), - }), - }; - - payments.push(payment); - } - // Ignore outputs that don't belong to us - } - - Ok(payments) - } } diff --git a/crates/breez-sdk/core/src/utils/mod.rs b/crates/breez-sdk/core/src/utils/mod.rs index 51ba9820..b3309886 100644 --- a/crates/breez-sdk/core/src/utils/mod.rs +++ b/crates/breez-sdk/core/src/utils/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod deposit_chain_syncer; +pub(crate) mod token; pub(crate) mod utxo_fetcher; /// Runs a future until completion or until a shutdown signal is received. diff --git a/crates/breez-sdk/core/src/utils/token.rs b/crates/breez-sdk/core/src/utils/token.rs new file mode 100644 index 00000000..603779b0 --- /dev/null +++ b/crates/breez-sdk/core/src/utils/token.rs @@ -0,0 +1,143 @@ +use std::time::UNIX_EPOCH; + +use spark_wallet::{SparkWallet, TokenMetadata}; + +use crate::{Payment, PaymentDetails, PaymentMethod, PaymentStatus, PaymentType, SdkError}; + +/// Converts a token transaction to payments +/// +/// Each resulting payment corresponds to a potential group of outputs that share the same owner public key. +/// The id of the payment is the id of the first output in the group. +/// +/// Assumptions: +/// - All outputs of a token transaction share the same token identifier +/// - All inputs of a token transaction share the same owner public key +#[allow(clippy::too_many_lines)] +pub async fn token_transaction_to_payments( + spark_wallet: &SparkWallet, + transaction: &spark_wallet::TokenTransaction, + tx_inputs_are_ours: bool, +) -> Result, SdkError> { + // Get token metadata for the first output (assuming all outputs have the same token) + let token_identifier = transaction + .outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in token transaction".to_string(), + ))? + .token_identifier + .as_ref(); + let metadata: TokenMetadata = spark_wallet + .get_tokens_metadata(&[token_identifier]) + .await? + .first() + .ok_or(SdkError::Generic("Token metadata not found".to_string()))? + .clone(); + + let is_transfer_transaction = + matches!(&transaction.inputs, spark_wallet::TokenInputs::Transfer(..)); + + let timestamp = transaction + .created_timestamp + .duration_since(UNIX_EPOCH) + .map_err(|_| { + SdkError::Generic( + "Token transaction created timestamp is before UNIX_EPOCH".to_string(), + ) + })? + .as_secs(); + + // Group outputs by owner public key + let mut outputs_by_owner = std::collections::HashMap::new(); + for output in &transaction.outputs { + outputs_by_owner + .entry(output.owner_public_key) + .or_insert_with(Vec::new) + .push(output); + } + + let mut payments = Vec::new(); + + if tx_inputs_are_ours { + // If inputs are ours, add an outgoing payment for each output group that is not ours + for (owner_pubkey, outputs) in outputs_by_owner { + if owner_pubkey != spark_wallet.get_identity_public_key() { + // This is an outgoing payment to another user + let total_amount = outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = outputs + .first() + .ok_or(SdkError::Generic("No outputs in output group".to_string()))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Send, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, // TODO: calculate actual fees when they start being charged + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone().into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that belong to us (potential change outputs) + } + } else { + // If inputs are not ours, add an incoming payment for our output group + if let Some(our_outputs) = outputs_by_owner.get(&spark_wallet.get_identity_public_key()) { + let total_amount: u64 = our_outputs + .iter() + .map(|output| { + let amount: u64 = output.token_amount.try_into().unwrap_or_default(); + amount + }) + .sum(); + + let id = our_outputs + .first() + .ok_or(SdkError::Generic( + "No outputs in our output group".to_string(), + ))? + .id + .clone(); + + let payment = Payment { + id, + payment_type: PaymentType::Receive, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: total_amount, + fees: 0, + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.into(), + tx_hash: transaction.hash.clone(), + }), + }; + + payments.push(payment); + } + // Ignore outputs that don't belong to us + } + + Ok(payments) +} diff --git a/crates/spark-wallet/src/wallet.rs b/crates/spark-wallet/src/wallet.rs index 6ff27d56..3ba82e43 100644 --- a/crates/spark-wallet/src/wallet.rs +++ b/crates/spark-wallet/src/wallet.rs @@ -789,9 +789,9 @@ impl SparkWallet { pub async fn transfer_tokens( &self, outputs: Vec, - ) -> Result { - let tx_hash = self.token_service.transfer_tokens(outputs).await?; - Ok(tx_hash) + ) -> Result { + let tx = self.token_service.transfer_tokens(outputs).await?; + Ok(tx) } pub async fn list_token_transactions( diff --git a/crates/spark/src/services/models.rs b/crates/spark/src/services/models.rs index 98f8044d..4947b674 100644 --- a/crates/spark/src/services/models.rs +++ b/crates/spark/src/services/models.rs @@ -17,7 +17,7 @@ use uuid::Uuid; use crate::address::SparkAddress; use crate::core::Network; use crate::operator::rpc as operator_rpc; -use crate::services::bech32m_encode_token_id; +use crate::services::{HashableTokenTransaction, bech32m_encode_token_id}; use crate::signer::{FrostSigningCommitmentsWithNonces, PrivateKeySource}; use crate::tree::{SigningKeyshare, TreeNode, TreeNodeId}; use crate::{ssp::BitcoinNetwork, utils::refund::SignedTx}; @@ -828,6 +828,49 @@ pub struct TokenTransaction { pub created_timestamp: SystemTime, } +impl TryFrom<(operator_rpc::spark_token::TokenTransaction, Network)> for TokenTransaction { + type Error = ServiceError; + + fn try_from( + (token_transaction, network): (operator_rpc::spark_token::TokenTransaction, Network), + ) -> Result { + let hash = hex::encode(token_transaction.compute_hash(false)?); + + let inputs = token_transaction + .token_inputs + .ok_or(ServiceError::Generic("Missing token inputs".to_string()))? + .try_into()?; + + let outputs = token_transaction + .token_outputs + .into_iter() + .map(|output| (output, network).try_into()) + .collect::, _>>()?; + + let status = TokenTransactionStatus::Unknown; + + // client_created_timestamp will always be filled for V2 transactions and V1 transactions will be discontinued soon + let created_timestamp = token_transaction + .client_created_timestamp + .map(|ts| { + std::time::UNIX_EPOCH + + std::time::Duration::from_secs(ts.seconds as u64) + + std::time::Duration::from_nanos(ts.nanos as u64) + }) + .ok_or(ServiceError::Generic( + "Missing client created timestamp. Could this be a V1 transaction?".to_string(), + ))?; + + Ok(TokenTransaction { + hash, + inputs, + outputs, + status, + created_timestamp, + }) + } +} + impl TryFrom<( operator_rpc::spark_token::TokenTransactionWithStatus, diff --git a/crates/spark/src/services/tokens.rs b/crates/spark/src/services/tokens.rs index 73fd8870..3cfab45f 100644 --- a/crates/spark/src/services/tokens.rs +++ b/crates/spark/src/services/tokens.rs @@ -300,7 +300,7 @@ impl TokenService { pub async fn transfer_tokens( &self, receiver_outputs: Vec, - ) -> Result { + ) -> Result { // Validate parameters if receiver_outputs.is_empty() { return Err(ServiceError::Generic( @@ -347,6 +347,7 @@ impl TokenService { let identity_public_key_bytes = self.signer.get_identity_public_key()?.serialize(); final_tx .token_outputs + .clone() .into_iter() .enumerate() .filter(|(_, o)| o.owner_public_key == identity_public_key_bytes) @@ -359,7 +360,7 @@ impl TokenService { Ok(()) })?; - Ok(txid) + (final_tx, self.network).try_into() } /// Selects tokens to match a given amount. @@ -831,7 +832,7 @@ pub(crate) fn bech32m_decode_token_id( const TOKEN_TRANSACTION_TRANSFER_TYPE: u32 = 3; -trait HashableTokenTransaction { +pub trait HashableTokenTransaction { fn compute_hash(&self, partial: bool) -> Result, ServiceError>; } From 35445558a2661c9327931aced027a4040e445fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 16:29:47 +0100 Subject: [PATCH 16/22] Sync to the last synced token payment id --- crates/breez-sdk/core/src/models/mod.rs | 7 ++ crates/breez-sdk/core/src/persist/mod.rs | 1 + crates/breez-sdk/core/src/sdk.rs | 16 ++-- crates/breez-sdk/core/src/sync.rs | 109 +++++++++++++++++------ 4 files changed, 100 insertions(+), 33 deletions(-) diff --git a/crates/breez-sdk/core/src/models/mod.rs b/crates/breez-sdk/core/src/models/mod.rs index 97852031..beb2f69c 100644 --- a/crates/breez-sdk/core/src/models/mod.rs +++ b/crates/breez-sdk/core/src/models/mod.rs @@ -65,6 +65,13 @@ pub enum PaymentStatus { Failed, } +impl PaymentStatus { + /// Returns true if the payment status is final (either Completed or Failed) + pub fn is_final(&self) -> bool { + matches!(self, PaymentStatus::Completed | PaymentStatus::Failed) + } +} + impl fmt::Display for PaymentStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index 0a5332db..30161af0 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -324,6 +324,7 @@ pub(crate) struct CachedAccountInfo { #[derive(Serialize, Deserialize, Default)] pub(crate) struct CachedSyncInfo { pub(crate) offset: u64, + pub(crate) last_synced_token_payment_id: Option, } #[derive(Serialize, Deserialize, Default)] diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index ecb2a227..61ad79d7 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -372,14 +372,16 @@ impl BreezSdk { let start_time = Instant::now(); if let SyncType::Full = sync_type { // Sync with the Spark network - info!("sync_wallet_internal: Syncing with Spark network"); - self.spark_wallet.sync().await?; - info!("sync_wallet_internal: Synced with Spark network completed"); + if let Err(e) = self.spark_wallet.sync().await { + error!("sync_wallet_internal: Failed to sync with Spark network: {e:?}"); + } + } + if let Err(e) = self.sync_wallet_state_to_storage().await { + error!("sync_wallet_internal: Failed to sync wallet state to storage: {e:?}"); + } + if let Err(e) = self.check_and_claim_static_deposits().await { + error!("sync_wallet_internal: Failed to check and claim static deposits: {e:?}"); } - self.sync_wallet_state_to_storage().await?; - info!("sync_wallet_internal: Synced wallet state to storage completed"); - self.check_and_claim_static_deposits().await?; - info!("sync_wallet_internal: Checked and claimed static deposits completed"); let elapsed = start_time.elapsed(); info!("sync_wallet_internal: Wallet sync completed in {elapsed:?}"); self.event_emitter.emit(&SdkEvent::Synced {}).await; diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs index 4bdb7069..874f53f6 100644 --- a/crates/breez-sdk/core/src/sync.rs +++ b/crates/breez-sdk/core/src/sync.rs @@ -25,18 +25,24 @@ impl SparkSyncService { } pub async fn sync_payments(&self) -> Result<(), SdkError> { - self.sync_bitcoin_payments_to_storage().await?; - self.sync_token_payments_to_storage().await + let object_repository = ObjectCacheRepository::new(self.storage.clone()); + self.sync_bitcoin_payments_to_storage(&object_repository) + .await?; + self.sync_token_payments_to_storage(&object_repository) + .await } - async fn sync_bitcoin_payments_to_storage(&self) -> Result<(), SdkError> { + async fn sync_bitcoin_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { // Get the last offset we processed from storage - let object_repository = ObjectCacheRepository::new(self.storage.clone()); let cached_sync_info = object_repository .fetch_sync_info() .await? .unwrap_or_default(); let current_offset = cached_sync_info.offset; + let last_synced_token_payment_id = cached_sync_info.last_synced_token_payment_id; // We'll keep querying in batches until we have all transfers let mut next_offset = current_offset; @@ -44,6 +50,7 @@ impl SparkSyncService { info!("Syncing payments to storage, offset = {next_offset}"); let mut pending_payments: u64 = 0; while has_more { + info!("Fetching transfers, offset = {next_offset}"); // Get batch of transfers starting from current offset let transfers_response = self .spark_wallet @@ -83,6 +90,7 @@ impl SparkSyncService { let save_res = object_repository .save_sync_info(&CachedSyncInfo { offset: next_offset.saturating_sub(pending_payments), + last_synced_token_payment_id: last_synced_token_payment_id.clone(), }) .await; if let Err(err) = save_res { @@ -95,17 +103,29 @@ impl SparkSyncService { } #[allow(clippy::too_many_lines)] - async fn sync_token_payments_to_storage(&self) -> Result<(), SdkError> { + async fn sync_token_payments_to_storage( + &self, + object_repository: &ObjectCacheRepository, + ) -> Result<(), SdkError> { info!("Syncing token payments to storage"); + // Get the last synced token payment id we processed from storage + let cached_sync_info = object_repository + .fetch_sync_info() + .await? + .unwrap_or_default(); + let last_synced_token_payment_id = cached_sync_info.last_synced_token_payment_id; let our_public_key = self.spark_wallet.get_identity_public_key(); + // We'll keep querying in batches until we have all token tranactions + let mut payments_to_sync = Vec::new(); let mut next_offset = 0; let mut has_more = true; // We'll keep querying in pages until we already have a completed or failed payment stored // or we have fetched all transfers 'page_loop: while has_more { + info!("Fetching token transactions, offset = {next_offset}"); // Get batch of token transactions starting from current offset - let token_transactions = self + let Ok(token_transactions) = self .spark_wallet .list_token_transactions(ListTokenTransactionsRequest { paging: Some(PagingFilter::new( @@ -115,9 +135,15 @@ impl SparkSyncService { )), ..Default::default() }) - .await? - .items; - + .await + else { + error!( + "Failed to fetch address transactions, stopping sync and processing {} payments", + payments_to_sync.len() + ); + break 'page_loop; + }; + // If no token transactions to sync if token_transactions.is_empty() { break 'page_loop; } @@ -125,6 +151,7 @@ impl SparkSyncService { // Get prev out hashes of first input of each token transaction // Assumes all inputs of a tx share the same owner public key let token_transactions_prevout_hashes = token_transactions + .items .iter() .filter_map(|tx| match &tx.inputs { spark_wallet::TokenInputs::Transfer(token_transfer_input) => { @@ -139,7 +166,7 @@ impl SparkSyncService { // Since we are trying to fetch at most 1 parent transaction per token transaction, // we can fetch all in one go using same batch size - let parent_transactions = self + let Ok(parent_transactions) = self .spark_wallet .list_token_transactions(ListTokenTransactionsRequest { paging: Some(PagingFilter::new(None, Some(PAYMENT_SYNC_BATCH_SIZE), None)), @@ -147,21 +174,28 @@ impl SparkSyncService { token_transaction_hashes: token_transactions_prevout_hashes, ..Default::default() }) - .await? - .items; + .await + else { + error!( + "Failed to fetch parent transactions, stopping sync and processing {} payments", + payments_to_sync.len() + ); + break 'page_loop; + }; info!( "Syncing token payments to storage, offset = {next_offset}, transactions = {}", token_transactions.len() ); // Process transfers in this page - for transaction in &token_transactions { + for transaction in &token_transactions.items { let tx_inputs_are_ours = match &transaction.inputs { spark_wallet::TokenInputs::Transfer(token_transfer_input) => { let first_input = token_transfer_input.outputs_to_spend.first().ok_or( SdkError::Generic("No input in token transfer input".to_string()), )?; let parent_transaction = parent_transactions + .items .iter() .find(|tx| tx.hash == first_input.prev_token_tx_hash) .ok_or(SdkError::Generic( @@ -187,24 +221,18 @@ impl SparkSyncService { .await?; for payment in payments { - // Stop syncing if we encounter a finalized payment that we have already processed - if let Ok(Payment { - status: PaymentStatus::Completed | PaymentStatus::Failed, - .. - }) = self.storage.get_payment_by_id(payment.id.clone()).await + if last_synced_token_payment_id + .as_ref() + .is_some_and(|id| payment.id == *id) { info!( - "Encountered already finalized payment {}, stopping sync", - payment.id + "Last synced token payment id found ({last_synced_token_payment_id:?}), stopping sync and processing {} payments", + payments_to_sync.len() ); + has_more = false; break 'page_loop; } - - // Insert payment into storage - info!("Inserting token payment: {payment:?}"); - if let Err(err) = self.storage.insert_payment(payment).await { - error!("Failed to insert token payment: {err:?}"); - } + payments_to_sync.push(payment); } } @@ -213,6 +241,35 @@ impl SparkSyncService { has_more = token_transactions.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; } + // Insert what synced payments we have into storage, oldest to newest + payments_to_sync.sort_by_key(|p| p.timestamp); + for payment in &payments_to_sync { + info!("Inserting token payment: {payment:?}"); + if let Err(e) = self.storage.insert_payment(payment.clone()).await { + error!("Failed to insert token payment: {e:?}"); + } + } + + // We have synced all token transactions or found the last synced payment id. + // If there was a failure to fetch transactions or no transactions exist, + // we won't update the last synced token payment id + if !has_more + && let Some(last_synced_token_payment_id) = payments_to_sync + .into_iter() + .filter(|p| p.status.is_final()) + .next_back() + .map(|p| p.id) + { + // Update last synced token payment id to the newest final payment we have processed + info!("Updating last synced token payment id to {last_synced_token_payment_id}"); + object_repository + .save_sync_info(&CachedSyncInfo { + offset: cached_sync_info.offset, + last_synced_token_payment_id: Some(last_synced_token_payment_id), + }) + .await?; + } + Ok(()) } } From 3b45258cc017c57c2da3bc9134967a4feae0a148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 16:34:20 +0100 Subject: [PATCH 17/22] Do not group outputs and use hash:vout id format --- crates/breez-sdk/core/src/utils/token.rs | 114 ++++++----------------- 1 file changed, 29 insertions(+), 85 deletions(-) diff --git a/crates/breez-sdk/core/src/utils/token.rs b/crates/breez-sdk/core/src/utils/token.rs index 603779b0..a0c40dd0 100644 --- a/crates/breez-sdk/core/src/utils/token.rs +++ b/crates/breez-sdk/core/src/utils/token.rs @@ -47,96 +47,40 @@ pub async fn token_transaction_to_payments( })? .as_secs(); - // Group outputs by owner public key - let mut outputs_by_owner = std::collections::HashMap::new(); - for output in &transaction.outputs { - outputs_by_owner - .entry(output.owner_public_key) - .or_insert_with(Vec::new) - .push(output); - } + let identity_public_key = spark_wallet.get_identity_public_key(); let mut payments = Vec::new(); - if tx_inputs_are_ours { - // If inputs are ours, add an outgoing payment for each output group that is not ours - for (owner_pubkey, outputs) in outputs_by_owner { - if owner_pubkey != spark_wallet.get_identity_public_key() { - // This is an outgoing payment to another user - let total_amount = outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = outputs - .first() - .ok_or(SdkError::Generic("No outputs in output group".to_string()))? - .id - .clone(); - - let payment = Payment { - id, - payment_type: PaymentType::Send, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, // TODO: calculate actual fees when they start being charged - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.clone().into(), - tx_hash: transaction.hash.clone(), - }), - }; - - payments.push(payment); - } - // Ignore outputs that belong to us (potential change outputs) - } - } else { - // If inputs are not ours, add an incoming payment for our output group - if let Some(our_outputs) = outputs_by_owner.get(&spark_wallet.get_identity_public_key()) { - let total_amount: u64 = our_outputs - .iter() - .map(|output| { - let amount: u64 = output.token_amount.try_into().unwrap_or_default(); - amount - }) - .sum(); - - let id = our_outputs - .first() - .ok_or(SdkError::Generic( - "No outputs in our output group".to_string(), - ))? - .id - .clone(); + for (vout, output) in transaction.outputs.iter().enumerate() { + let payment_type = if tx_inputs_are_ours && output.owner_public_key != identity_public_key { + // If inputs are ours and outputs are not ours, add an outgoing payment + PaymentType::Send + } else if !tx_inputs_are_ours && output.owner_public_key == identity_public_key { + // If inputs are not ours and outputs are ours, add an incoming payment + PaymentType::Receive + } else { + continue; + }; - let payment = Payment { - id, - payment_type: PaymentType::Receive, - status: PaymentStatus::from_token_transaction_status( - transaction.status, - is_transfer_transaction, - ), - amount: total_amount, - fees: 0, - timestamp, - method: PaymentMethod::Token, - details: Some(PaymentDetails::Token { - metadata: metadata.into(), - tx_hash: transaction.hash.clone(), - }), - }; + let id = format!("{}:{}", transaction.hash, vout); - payments.push(payment); - } - // Ignore outputs that don't belong to us + let payment = Payment { + id, + payment_type, + status: PaymentStatus::from_token_transaction_status( + transaction.status, + is_transfer_transaction, + ), + amount: output.token_amount.try_into().unwrap_or_default(), + fees: 0, // TODO: calculate actual fees when they start being charged + timestamp, + method: PaymentMethod::Token, + details: Some(PaymentDetails::Token { + metadata: metadata.clone().into(), + tx_hash: transaction.hash.clone(), + }), + }; + payments.push(payment); } Ok(payments) From dc5ab70bc3159de2d80a42ed4cb0dd59fef5b4cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 16:42:26 +0100 Subject: [PATCH 18/22] Remove sparkscan leftovers --- Cargo.lock | 228 ------------------------- crates/breez-sdk/core/Cargo.toml | 2 - crates/breez-sdk/core/src/error.rs | 9 - packages/flutter/Makefile | 2 +- packages/flutter/rust/Cargo.lock | 249 ---------------------------- packages/flutter/rust/src/errors.rs | 1 - packages/flutter/rust/src/models.rs | 1 - packages/flutter/test/helper.dart | 2 - 8 files changed, 1 insertion(+), 493 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5783a2be..fb2e0dbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,12 +73,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android-tzdata" version = "0.1.1" @@ -673,7 +667,6 @@ dependencies = [ "serde_json", "shellwords", "spark-wallet", - "sparkscan", "tempdir", "thiserror 2.0.14", "tokio", @@ -1486,12 +1479,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1897,11 +1884,6 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] [[package]] name = "hashlink" @@ -2908,17 +2890,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openapiv3" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" -dependencies = [ - "indexmap 2.10.0", - "serde", - "serde_json", -] - [[package]] name = "openssl" version = "0.10.73" @@ -3290,72 +3261,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "progenitor" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7b99ef43fdd69d70aa4df8869db24b10ac704a2dbbc387ffac51944a1f3c0a8" -dependencies = [ - "progenitor-client", - "progenitor-impl", - "progenitor-macro", -] - -[[package]] -name = "progenitor-client" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3832a961a5f1b0b5a5ccda5fbf67cae2ba708f6add667401007764ba504ffebf" -dependencies = [ - "bytes", - "futures-core", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", -] - -[[package]] -name = "progenitor-impl" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7646201b823e61712dd72f37428ceecaa8fb2a6c841e5d7cf909edb9a17f5677" -dependencies = [ - "heck 0.5.0", - "http", - "indexmap 2.10.0", - "openapiv3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "syn 2.0.105", - "thiserror 2.0.14", - "typify", - "unicode-ident", -] - -[[package]] -name = "progenitor-macro" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e710a11140d9b4241b7d8a90748f6125b6796d7a1205238eddb08dc790ce3830" -dependencies = [ - "openapiv3", - "proc-macro2", - "progenitor-impl", - "quote", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_tokenstream", - "serde_yaml", - "syn 2.0.105", -] - [[package]] name = "prost" version = "0.13.5" @@ -3709,16 +3614,6 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" -[[package]] -name = "regress" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" -dependencies = [ - "hashbrown 0.15.5", - "memchr", -] - [[package]] name = "relative-path" version = "1.9.3" @@ -3744,7 +3639,6 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -3769,14 +3663,12 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", - "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", "webpki-roots 1.0.2", ] @@ -4028,20 +3920,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "chrono", - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "schemars" version = "0.9.0" @@ -4066,18 +3944,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.105", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -4255,18 +4121,6 @@ dependencies = [ "syn 2.0.105", ] -[[package]] -name = "serde_tokenstream" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.105", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4547,41 +4401,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "sparkscan" -version = "0.3.8" -source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" -dependencies = [ - "cfg-if", - "chrono", - "futures", - "prettyplease", - "progenitor", - "quote", - "regress", - "reqwest", - "schemars 0.8.22", - "serde", - "serde_json", - "sparkscan-client", - "syn 2.0.105", -] - -[[package]] -name = "sparkscan-client" -version = "0.1.1" -source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" -dependencies = [ - "bytes", - "cfg-if", - "futures-core", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", -] - [[package]] name = "spin" version = "0.9.8" @@ -5409,53 +5228,6 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "typify" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" -dependencies = [ - "typify-impl", - "typify-macro", -] - -[[package]] -name = "typify-impl" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" -dependencies = [ - "heck 0.5.0", - "log", - "proc-macro2", - "quote", - "regress", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "syn 2.0.105", - "thiserror 2.0.14", - "unicode-ident", -] - -[[package]] -name = "typify-macro" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" -dependencies = [ - "proc-macro2", - "quote", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "serde_tokenstream", - "syn 2.0.105", - "typify-impl", -] - [[package]] name = "uncased" version = "0.9.10" diff --git a/crates/breez-sdk/core/Cargo.toml b/crates/breez-sdk/core/Cargo.toml index c128b5f4..2299569e 100644 --- a/crates/breez-sdk/core/Cargo.toml +++ b/crates/breez-sdk/core/Cargo.toml @@ -54,8 +54,6 @@ uuid.workspace = true uniffi = { workspace = true, optional = true } web-time.workspace = true x509-parser = { version = "0.16.0" } -# TODO: switch to published version once CORS issue with `api-version` header is fixed -sparkscan = { git = "https://github.com/breez/sparkscan-rs", rev = "250753cf6c9fd95a79c9f450b1f59534329251ab" } # Non-Wasm dependencies [target.'cfg(not(all(target_family = "wasm", target_os = "unknown")))'.dependencies] diff --git a/crates/breez-sdk/core/src/error.rs b/crates/breez-sdk/core/src/error.rs index 422f38cb..dd203fd1 100644 --- a/crates/breez-sdk/core/src/error.rs +++ b/crates/breez-sdk/core/src/error.rs @@ -52,9 +52,6 @@ pub enum SdkError { #[error("Lnurl error: {0}")] LnurlError(String), - #[error("SparkScan error: {0}")] - SparkScanApiError(String), - #[error("Error: {0}")] Generic(String), } @@ -143,12 +140,6 @@ impl From for SdkError { } } -impl From> for SdkError { - fn from(e: sparkscan::Error) -> Self { - SdkError::SparkScanApiError(format!("{e:?}")) - } -} - impl From for SdkError { fn from(value: LnurlServerError) -> Self { match value { diff --git a/packages/flutter/Makefile b/packages/flutter/Makefile index e063fe20..9712cb20 100644 --- a/packages/flutter/Makefile +++ b/packages/flutter/Makefile @@ -1,4 +1,4 @@ -UNAME := $(shell uname) +maUNAME := $(shell uname) TARGET_DIR := rust/target/ RELEASE_DIR := $(TARGET_DIR)release INSTALL_PREFIX := CARGO_TARGET_DIR="$(TARGET_DIR)" diff --git a/packages/flutter/rust/Cargo.lock b/packages/flutter/rust/Cargo.lock index 194a2c99..bd193ee9 100644 --- a/packages/flutter/rust/Cargo.lock +++ b/packages/flutter/rust/Cargo.lock @@ -84,12 +84,6 @@ dependencies = [ "backtrace", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_log-sys" version = "0.3.2" @@ -552,7 +546,6 @@ dependencies = [ "serde_json", "shellwords", "spark-wallet", - "sparkscan", "tempdir", "thiserror 2.0.17", "tokio", @@ -1283,12 +1276,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1628,17 +1615,6 @@ dependencies = [ "ahash", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - [[package]] name = "hashbrown" version = "0.16.0" @@ -2547,17 +2523,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openapiv3" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" -dependencies = [ - "indexmap 2.11.4", - "serde", - "serde_json", -] - [[package]] name = "openssl" version = "0.10.73" @@ -2835,72 +2800,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "progenitor" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135a23fcb9ad36a46ef4be323d006e195ad5121779c9da64ef95cf0600771b77" -dependencies = [ - "progenitor-client", - "progenitor-impl", - "progenitor-macro", -] - -[[package]] -name = "progenitor-client" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920f044db9ec07a3339175729794d3701e11d338dcf8cfd946df838102307780" -dependencies = [ - "bytes", - "futures-core", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", -] - -[[package]] -name = "progenitor-impl" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8276d558f1dfd4cc7fc4cceee0a51dab482b5a4be2e69e7eab8c57fbfb1472f4" -dependencies = [ - "heck 0.5.0", - "http", - "indexmap 2.11.4", - "openapiv3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "syn 2.0.106", - "thiserror 2.0.17", - "typify", - "unicode-ident", -] - -[[package]] -name = "progenitor-macro" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd79317ec8ab905738484d2744d368beee6e357fc043944d985f85a0174f1f7" -dependencies = [ - "openapiv3", - "proc-macro2", - "progenitor-impl", - "quote", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_tokenstream", - "serde_yaml", - "syn 2.0.106", -] - [[package]] name = "prost" version = "0.13.5" @@ -3134,16 +3033,6 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" -[[package]] -name = "regress" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145bb27393fe455dd64d6cbc8d059adfa392590a45eadf079c01b11857e7b010" -dependencies = [ - "hashbrown 0.15.5", - "memchr", -] - [[package]] name = "remove_dir_all" version = "0.5.3" @@ -3163,7 +3052,6 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -3185,14 +3073,12 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-util", "tower 0.5.2", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] @@ -3364,20 +3250,6 @@ dependencies = [ "windows-sys 0.61.1", ] -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "chrono", - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "schemars" version = "0.9.0" @@ -3402,18 +3274,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.106", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -3496,10 +3356,6 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "serde" @@ -3531,17 +3387,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "serde_json" version = "1.0.145" @@ -3555,18 +3400,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_tokenstream" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.106", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3771,41 +3604,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "sparkscan" -version = "0.3.8" -source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" -dependencies = [ - "cfg-if", - "chrono", - "futures", - "prettyplease", - "progenitor", - "quote", - "regress", - "reqwest", - "schemars 0.8.22", - "serde", - "serde_json", - "sparkscan-client", - "syn 2.0.106", -] - -[[package]] -name = "sparkscan-client" -version = "0.1.1" -source = "git+https://github.com/breez/sparkscan-rs?rev=250753cf6c9fd95a79c9f450b1f59534329251ab#250753cf6c9fd95a79c9f450b1f59534329251ab" -dependencies = [ - "bytes", - "cfg-if", - "futures-core", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", -] - [[package]] name = "spin" version = "0.9.8" @@ -4398,53 +4196,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "typify" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" -dependencies = [ - "typify-impl", - "typify-macro", -] - -[[package]] -name = "typify-impl" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" -dependencies = [ - "heck 0.5.0", - "log", - "proc-macro2", - "quote", - "regress", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "syn 2.0.106", - "thiserror 2.0.17", - "unicode-ident", -] - -[[package]] -name = "typify-macro" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" -dependencies = [ - "proc-macro2", - "quote", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "serde_tokenstream", - "syn 2.0.106", - "typify-impl", -] - [[package]] name = "uncased" version = "0.9.10" diff --git a/packages/flutter/rust/src/errors.rs b/packages/flutter/rust/src/errors.rs index d19c0211..c7ed1d89 100644 --- a/packages/flutter/rust/src/errors.rs +++ b/packages/flutter/rust/src/errors.rs @@ -38,7 +38,6 @@ pub enum _SdkError { vout: u32, }, LnurlError(String), - SparkScanApiError(String), Generic(String), } diff --git a/packages/flutter/rust/src/models.rs b/packages/flutter/rust/src/models.rs index c5035c05..53f6d8ad 100644 --- a/packages/flutter/rust/src/models.rs +++ b/packages/flutter/rust/src/models.rs @@ -31,7 +31,6 @@ pub struct _Config { pub max_deposit_claim_fee: Option, pub lnurl_domain: Option, pub prefer_spark_over_lightning: bool, - pub sparkscan_api_url: String, } #[frb(mirror(Seed))] diff --git a/packages/flutter/test/helper.dart b/packages/flutter/test/helper.dart index e6e640cf..46c7b9e4 100644 --- a/packages/flutter/test/helper.dart +++ b/packages/flutter/test/helper.dart @@ -8,7 +8,6 @@ extension ConfigCopyWith on Config { Fee? maxDepositClaimFee, String? lnurlDomain, bool? preferSparkOverLightning, - String? sparkscanApiUrl, }) { return Config( apiKey: apiKey ?? this.apiKey, @@ -17,7 +16,6 @@ extension ConfigCopyWith on Config { maxDepositClaimFee: maxDepositClaimFee ?? this.maxDepositClaimFee, lnurlDomain: lnurlDomain ?? this.lnurlDomain, preferSparkOverLightning: preferSparkOverLightning ?? this.preferSparkOverLightning, - sparkscanApiUrl: sparkscanApiUrl ?? this.sparkscanApiUrl, ); } } From 8daae7dd93956159d911fd9d85534bf2158f478e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 17:01:59 +0100 Subject: [PATCH 19/22] Rollback payment status filtering in storage --- crates/breez-sdk/core/src/persist/mod.rs | 17 ++--------- crates/breez-sdk/core/src/persist/sqlite.rs | 29 +++++-------------- crates/breez-sdk/core/src/sdk.rs | 2 +- .../breez-sdk/wasm/js/node-storage/index.cjs | 26 ++++++----------- .../wasm/js/node-storage/migrations.cjs | 1 - crates/breez-sdk/wasm/js/web-storage/index.js | 21 +++++++------- crates/breez-sdk/wasm/src/persist/mod.rs | 6 ++-- 7 files changed, 33 insertions(+), 69 deletions(-) diff --git a/crates/breez-sdk/core/src/persist/mod.rs b/crates/breez-sdk/core/src/persist/mod.rs index 30161af0..67e15f8e 100644 --- a/crates/breez-sdk/core/src/persist/mod.rs +++ b/crates/breez-sdk/core/src/persist/mod.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::{ - DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, PaymentStatus, - TokenBalance, models::Payment, + DepositClaimError, DepositInfo, LightningAddressInfo, LnurlPayInfo, TokenBalance, + models::Payment, }; const ACCOUNT_INFO_KEY: &str = "account_info"; @@ -79,7 +79,6 @@ pub trait Storage: Send + Sync { &self, offset: Option, limit: Option, - status: Option, ) -> Result, StorageError>; /// Inserts a payment into storage @@ -498,10 +497,7 @@ pub mod tests { .unwrap(); // List all payments - let payments = storage - .list_payments(Some(0), Some(10), None) - .await - .unwrap(); + let payments = storage.list_payments(Some(0), Some(10)).await.unwrap(); assert_eq!(payments.len(), 7); // Test each payment type individually @@ -641,13 +637,6 @@ pub mod tests { .filter(|p| p.method == PaymentMethod::Lightning) .count(); assert_eq!(lightning_count, 2); // lightning and lightning_minimal - - // Test listing pending payments - let pending_payments = storage - .list_payments(Some(0), Some(10), Some(PaymentStatus::Pending)) - .await - .unwrap(); - assert_eq!(pending_payments.len(), 2); // token and no_details } pub async fn test_unclaimed_deposits_crud(storage: Box) { diff --git a/crates/breez-sdk/core/src/persist/sqlite.rs b/crates/breez-sdk/core/src/persist/sqlite.rs index fb6dc746..00282361 100644 --- a/crates/breez-sdk/core/src/persist/sqlite.rs +++ b/crates/breez-sdk/core/src/persist/sqlite.rs @@ -1,15 +1,12 @@ -use std::fmt::Write; use std::path::{Path, PathBuf}; use macros::async_trait; -use rusqlite::params_from_iter; use rusqlite::{ Connection, Row, ToSql, params, types::{FromSql, FromSqlError, FromSqlResult, ToSqlOutput, ValueRef}, }; use rusqlite_migration::{M, Migrations, SchemaVersion}; -use crate::PaymentStatus; use crate::{ DepositInfo, LnurlPayInfo, PaymentDetails, PaymentMethod, error::DepositClaimError, @@ -194,11 +191,10 @@ impl Storage for SqliteStorage { &self, offset: Option, limit: Option, - status: Option, ) -> Result, StorageError> { let connection = self.get_connection()?; - let mut params: Vec> = Vec::new(); - let mut query = String::from( + + let query = format!( "SELECT p.id , p.payment_type , p.status @@ -220,25 +216,16 @@ impl Storage for SqliteStorage { FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id LEFT JOIN payment_details_token t ON p.id = t.payment_id - LEFT JOIN payment_metadata pm ON p.id = pm.payment_id", - ); - - if let Some(status) = status { - query.push_str(" WHERE p.status = ?"); - params.push(Box::new(status.to_string())); - } - - write!( - query, - " ORDER BY p.timestamp DESC LIMIT {} OFFSET {}", + LEFT JOIN payment_metadata pm ON p.id = pm.payment_id + ORDER BY p.timestamp DESC + LIMIT {} OFFSET {}", limit.unwrap_or(u32::MAX), offset.unwrap_or(0) - ) - .map_err(|e| StorageError::Implementation(e.to_string()))?; + ); let mut stmt = connection.prepare(&query)?; let payments = stmt - .query_map(params_from_iter(params), map_payment)? + .query_map(params![], map_payment)? .collect::, _>>()?; Ok(payments) } @@ -544,7 +531,7 @@ fn map_payment(row: &Row<'_>) -> Result { (_, _, _, Some(_), _) => Some(PaymentDetails::Spark), (_, _, _, _, Some(metadata)) => Some(PaymentDetails::Token { metadata: serde_json::from_str(&metadata).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, e.into()) + rusqlite::Error::FromSqlConversionFailure(16, rusqlite::types::Type::Text, e.into()) })?, tx_hash: row.get(17)?, }), diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 61ad79d7..70dd2934 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -1165,7 +1165,7 @@ impl BreezSdk { ) -> Result { let payments = self .storage - .list_payments(request.offset, request.limit, None) + .list_payments(request.offset, request.limit) .await?; Ok(ListPaymentsResponse { payments }) } diff --git a/crates/breez-sdk/wasm/js/node-storage/index.cjs b/crates/breez-sdk/wasm/js/node-storage/index.cjs index a9f61de7..377511d9 100644 --- a/crates/breez-sdk/wasm/js/node-storage/index.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/index.cjs @@ -119,14 +119,14 @@ class SqliteStorage { // ===== Payment Operations ===== - listPayments(offset = null, limit = null, status = null) { + listPayments(offset = null, limit = null) { try { // Handle null values by using default values const actualOffset = offset !== null ? offset : 0; const actualLimit = limit !== null ? limit : 4294967295; // u32::MAX - let query = ` - SELECT p.id + const stmt = this.db.prepare(` + SELECT p.id , p.payment_type , p.status , p.amount @@ -147,25 +147,17 @@ class SqliteStorage { FROM payments p LEFT JOIN payment_details_lightning l ON p.id = l.payment_id LEFT JOIN payment_details_token t ON p.id = t.payment_id - LEFT JOIN payment_metadata pm ON p.id = pm.payment_id`; - - const queryParams = []; - - if (status !== null) { - query += ` WHERE p.status = ?`; - queryParams.push(status); - } - - query += ` ORDER BY p.timestamp DESC LIMIT ? OFFSET ?`; - queryParams.push(actualLimit, actualOffset); + LEFT JOIN payment_metadata pm ON p.id = pm.payment_id + ORDER BY p.timestamp DESC + LIMIT ? OFFSET ? + `); - const stmt = this.db.prepare(query); - const rows = stmt.all(...queryParams); + const rows = stmt.all(actualLimit, actualOffset); return Promise.resolve(rows.map(this._rowToPayment.bind(this))); } catch (error) { return Promise.reject( new StorageError( - `Failed to list payments (offset: ${offset}, limit: ${limit}, status: ${status}): ${error.message}`, + `Failed to list payments (offset: ${offset}, limit: ${limit}): ${error.message}`, error ) ); diff --git a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs index 1b4bf07e..f05463d5 100644 --- a/crates/breez-sdk/wasm/js/node-storage/migrations.cjs +++ b/crates/breez-sdk/wasm/js/node-storage/migrations.cjs @@ -186,7 +186,6 @@ class MigrationManager { tx_hash TEXT, FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE )`, - `CREATE INDEX IF NOT EXISTS idx_payment_details_token_payment_id ON payment_details_token(payment_id)`, ], }, ]; diff --git a/crates/breez-sdk/wasm/js/web-storage/index.js b/crates/breez-sdk/wasm/js/web-storage/index.js index 81ccc9e3..61987188 100644 --- a/crates/breez-sdk/wasm/js/web-storage/index.js +++ b/crates/breez-sdk/wasm/js/web-storage/index.js @@ -109,8 +109,8 @@ class MigrationManager { unique: false, }); } - } - } + }, + }, ]; } } @@ -292,7 +292,7 @@ class IndexedDBStorage { // ===== Payment Operations ===== - async listPayments(offset = null, limit = null, status = null) { + async listPayments(offset = null, limit = null) { if (!this.db) { throw new StorageError("Database not initialized"); } @@ -332,12 +332,6 @@ class IndexedDBStorage { const payment = cursor.value; - // Filter by status if provided - if (status !== null && payment.status !== status) { - cursor.continue(); - return; - } - // Get metadata for this payment const metadataRequest = metadataStore.get(payment.id); metadataRequest.onsuccess = () => { @@ -361,7 +355,7 @@ class IndexedDBStorage { request.onerror = () => { reject( new StorageError( - `Failed to list payments (offset: ${offset}, limit: ${limit}, status: ${status}): ${ + `Failed to list payments (offset: ${offset}, limit: ${limit}): ${ request.error?.message || "Unknown error" }`, request.error @@ -730,7 +724,12 @@ class IndexedDBStorage { } // If this is a Lightning payment and we have lnurl_pay_info, add it to details - if (metadata && metadata.lnurlPayInfo && details && details.type == 'lightning') { + if ( + metadata && + metadata.lnurlPayInfo && + details && + details.type == "lightning" + ) { try { details.lnurlPayInfo = JSON.parse(metadata.lnurlPayInfo); if (metadata.lnurlDescription && !details.description) { diff --git a/crates/breez-sdk/wasm/src/persist/mod.rs b/crates/breez-sdk/wasm/src/persist/mod.rs index 84b9762e..eaf85bf1 100644 --- a/crates/breez-sdk/wasm/src/persist/mod.rs +++ b/crates/breez-sdk/wasm/src/persist/mod.rs @@ -6,7 +6,7 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::js_sys::Promise; -use crate::models::{DepositInfo, Payment, PaymentMetadata, PaymentStatus, UpdateDepositPayload}; +use crate::models::{DepositInfo, Payment, PaymentMetadata, UpdateDepositPayload}; pub struct WasmStorage { pub storage: Storage, @@ -72,11 +72,10 @@ impl breez_sdk_spark::Storage for WasmStorage { &self, offset: Option, limit: Option, - status: Option, ) -> Result, breez_sdk_spark::StorageError> { let promise = self .storage - .list_payments(offset, limit, status.map(|s| s.into())) + .list_payments(offset, limit) .map_err(js_error_to_storage_error)?; let future = JsFuture::from(promise); let result = future.await.map_err(js_error_to_storage_error)?; @@ -241,7 +240,6 @@ extern "C" { this: &Storage, offset: Option, limit: Option, - status: Option, ) -> Result; #[wasm_bindgen(structural, method, js_name = insertPayment, catch)] From bb6f26fac2b7d432c603e272ed4d64754d9a546b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 17:08:22 +0100 Subject: [PATCH 20/22] Rollback legacy hrp support --- crates/spark/src/address/mod.rs | 28 ++++------------------------ 1 file changed, 4 insertions(+), 24 deletions(-) diff --git a/crates/spark/src/address/mod.rs b/crates/spark/src/address/mod.rs index 14dad799..c8e3e350 100644 --- a/crates/spark/src/address/mod.rs +++ b/crates/spark/src/address/mod.rs @@ -221,15 +221,6 @@ impl SparkAddress { } } - fn network_to_hrp_legacy(network: &Network) -> Hrp { - match network { - Network::Mainnet => HRP_LEGACY_MAINNET, - Network::Testnet => HRP_LEGACY_TESTNET, - Network::Regtest => HRP_LEGACY_REGTEST, - Network::Signet => HRP_LEGACY_SIGNET, - } - } - fn hrp_to_network(hrp: &Hrp) -> Result { match hrp { hrp if hrp == &HRP_MAINNET || hrp == &HRP_LEGACY_MAINNET => Ok(Network::Mainnet), @@ -243,15 +234,6 @@ impl SparkAddress { impl Display for SparkAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let hrp = Self::network_to_hrp(&self.network); - - let address = self.to_string_with_hrp(hrp); - write!(f, "{address}") - } -} - -impl SparkAddress { - fn to_string_with_hrp(&self, hrp: Hrp) -> String { let spark_invoice_fields: Option = self.spark_invoice_fields.clone().map(|f| f.into()); @@ -263,13 +245,11 @@ impl SparkAddress { let payload_bytes = proto_address.encode_to_vec(); - // This is safe to unwrap, because we are using a valid HRP and payload - bech32::encode::(hrp, &payload_bytes).unwrap() - } + let hrp = Self::network_to_hrp(&self.network); - pub fn to_string_with_hrp_legacy(&self) -> String { - let hrp = Self::network_to_hrp_legacy(&self.network); - self.to_string_with_hrp(hrp) + // This is safe to unwrap, because we are using a valid HRP and payload + let address = bech32::encode::(hrp, &payload_bytes).unwrap(); + write!(f, "{address}") } } From cb23f13ff8a81c6e534dc1e59716b12e9f5f87b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 17:16:00 +0100 Subject: [PATCH 21/22] Rollback querying specific transfers --- crates/breez-sdk/core/src/sdk.rs | 1 - crates/breez-sdk/core/src/sync.rs | 13 +++++-------- crates/internal/src/command/transfer.rs | 2 +- crates/spark-wallet/src/wallet.rs | 6 +----- crates/spark/src/services/transfer.rs | 13 ++----------- packages/flutter/Makefile | 2 +- 6 files changed, 10 insertions(+), 27 deletions(-) diff --git a/crates/breez-sdk/core/src/sdk.rs b/crates/breez-sdk/core/src/sdk.rs index 70dd2934..537f19b3 100644 --- a/crates/breez-sdk/core/src/sdk.rs +++ b/crates/breez-sdk/core/src/sdk.rs @@ -905,7 +905,6 @@ impl BreezSdk { self.send_payment_internal(request, false).await } - #[allow(clippy::too_many_lines)] async fn send_payment_internal( &self, request: SendPaymentRequest, diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs index 874f53f6..4564139b 100644 --- a/crates/breez-sdk/core/src/sync.rs +++ b/crates/breez-sdk/core/src/sync.rs @@ -54,14 +54,11 @@ impl SparkSyncService { // Get batch of transfers starting from current offset let transfers_response = self .spark_wallet - .list_transfers( - Some(PagingFilter::new( - Some(next_offset), - Some(PAYMENT_SYNC_BATCH_SIZE), - Some(Order::Ascending), - )), - None, - ) + .list_transfers(Some(PagingFilter::new( + Some(next_offset), + Some(PAYMENT_SYNC_BATCH_SIZE), + Some(Order::Ascending), + ))) .await? .items; diff --git a/crates/internal/src/command/transfer.rs b/crates/internal/src/command/transfer.rs index 45a648d9..3fdd8c66 100644 --- a/crates/internal/src/command/transfer.rs +++ b/crates/internal/src/command/transfer.rs @@ -58,7 +58,7 @@ pub async fn handle_command( None }; - let transfers = wallet.list_transfers(paging, None).await?; + let transfers = wallet.list_transfers(paging).await?; println!( "Transfers: {}", serde_json::to_string_pretty(&transfers.items)? diff --git a/crates/spark-wallet/src/wallet.rs b/crates/spark-wallet/src/wallet.rs index 3ba82e43..ace667ef 100644 --- a/crates/spark-wallet/src/wallet.rs +++ b/crates/spark-wallet/src/wallet.rs @@ -555,13 +555,9 @@ impl SparkWallet { pub async fn list_transfers( &self, paging: Option, - transfer_ids: Option>, ) -> Result, SparkWalletError> { let our_pubkey = self.identity_public_key; - let transfers = self - .transfer_service - .query_transfers(paging, transfer_ids) - .await?; + let transfers = self.transfer_service.query_transfers(paging).await?; create_transfers(transfers, &self.ssp_client, our_pubkey).await } diff --git a/crates/spark/src/services/transfer.rs b/crates/spark/src/services/transfer.rs index 539c9f80..236b60bb 100644 --- a/crates/spark/src/services/transfer.rs +++ b/crates/spark/src/services/transfer.rs @@ -1045,7 +1045,6 @@ impl TransferService { async fn query_transfers_inner( &self, paging: PagingFilter, - transfer_ids: Option>, ) -> Result, ServiceError> { trace!( "Querying transfers with limit: {:?}, offset: {:?}", @@ -1070,7 +1069,6 @@ impl TransferService { operator_rpc::spark::TransferType::CooperativeExit.into(), operator_rpc::spark::TransferType::UtxoSwap.into(), ], - transfer_ids: transfer_ids.unwrap_or_default(), ..Default::default() }) .await?; @@ -1089,17 +1087,10 @@ impl TransferService { pub async fn query_transfers( &self, paging: Option, - transfer_ids: Option>, ) -> Result, ServiceError> { let transfers = match paging { - Some(paging) => self.query_transfers_inner(paging, transfer_ids).await?, - None => { - pager( - |f| self.query_transfers_inner(f, transfer_ids.clone()), - PagingFilter::default(), - ) - .await? - } + Some(paging) => self.query_transfers_inner(paging).await?, + None => pager(|f| self.query_transfers_inner(f), PagingFilter::default()).await?, }; Ok(transfers) } diff --git a/packages/flutter/Makefile b/packages/flutter/Makefile index 9712cb20..e063fe20 100644 --- a/packages/flutter/Makefile +++ b/packages/flutter/Makefile @@ -1,4 +1,4 @@ -maUNAME := $(shell uname) +UNAME := $(shell uname) TARGET_DIR := rust/target/ RELEASE_DIR := $(TARGET_DIR)release INSTALL_PREFIX := CARGO_TARGET_DIR="$(TARGET_DIR)" From 2d78b8a938d0a08be077024f3a2ccb72b54487c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Granh=C3=A3o?= Date: Mon, 6 Oct 2025 17:32:32 +0100 Subject: [PATCH 22/22] Rollback unwanted bitcoin sync changes --- crates/breez-sdk/core/src/sync.rs | 43 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/crates/breez-sdk/core/src/sync.rs b/crates/breez-sdk/core/src/sync.rs index 4564139b..2532c522 100644 --- a/crates/breez-sdk/core/src/sync.rs +++ b/crates/breez-sdk/core/src/sync.rs @@ -45,55 +45,58 @@ impl SparkSyncService { let last_synced_token_payment_id = cached_sync_info.last_synced_token_payment_id; // We'll keep querying in batches until we have all transfers - let mut next_offset = current_offset; - let mut has_more = true; - info!("Syncing payments to storage, offset = {next_offset}"); + let mut next_filter = Some(PagingFilter { + offset: current_offset, + limit: PAYMENT_SYNC_BATCH_SIZE, + order: Order::Ascending, + }); + info!("Syncing payments to storage, offset = {}", current_offset); let mut pending_payments: u64 = 0; - while has_more { - info!("Fetching transfers, offset = {next_offset}"); + while let Some(filter) = next_filter { // Get batch of transfers starting from current offset let transfers_response = self .spark_wallet - .list_transfers(Some(PagingFilter::new( - Some(next_offset), - Some(PAYMENT_SYNC_BATCH_SIZE), - Some(Order::Ascending), - ))) - .await? - .items; + .list_transfers(Some(filter.clone())) + .await?; info!( - "Syncing bitcoin payments to storage, offset = {next_offset}, transfers = {}", + "Syncing payments to storage, offset = {}, transfers = {}", + filter.offset, transfers_response.len() ); // Process transfers in this batch - for transfer in &transfers_response { + for transfer in &transfers_response.items { // Create a payment record let payment: Payment = transfer.clone().try_into()?; // Insert payment into storage if let Err(err) = self.storage.insert_payment(payment.clone()).await { - error!("Failed to insert bitcoin payment: {err:?}"); + error!("Failed to insert payment: {err:?}"); } if payment.status == PaymentStatus::Pending { pending_payments = pending_payments.saturating_add(1); } - info!("Inserted bitcoin payment: {payment:?}"); + info!("Inserted payment: {payment:?}"); } // Check if we have more transfers to fetch - next_offset = next_offset.saturating_add(u64::try_from(transfers_response.len())?); + let cache_offset = filter + .offset + .saturating_add(u64::try_from(transfers_response.len())?); + // Update our last processed offset in the storage. We should remove pending payments // from the offset as they might be removed from the list later. let save_res = object_repository .save_sync_info(&CachedSyncInfo { - offset: next_offset.saturating_sub(pending_payments), + offset: cache_offset.saturating_sub(pending_payments), last_synced_token_payment_id: last_synced_token_payment_id.clone(), }) .await; + if let Err(err) = save_res { - error!("Failed to update last sync bitcoin offset: {err:?}"); + error!("Failed to update last sync offset: {err:?}"); } - has_more = transfers_response.len() as u64 == PAYMENT_SYNC_BATCH_SIZE; + + next_filter = transfers_response.next; } Ok(())