diff --git a/lib/providers/wallet_provider.dart b/lib/providers/wallet_provider.dart index 6669264..4da1e19 100644 --- a/lib/providers/wallet_provider.dart +++ b/lib/providers/wallet_provider.dart @@ -979,45 +979,27 @@ class WalletProvider extends ChangeNotifier { Future confirmSend(PreparedSend prepared, String? memo) async { final wallet = await getActiveWallet(); - final token = await wallet.send( + final result = await wallet.send( send: prepared, memo: memo, includeMemo: memo != null && memo.isNotEmpty, ); - // Guardar token en storage local para mostrar en detalles del historial. - // Usamos hash del token como key temporal; después buscaremos la transacción. - await _saveTokenForRecentTransaction(token.encoded); + // Save token metadata using the deterministic transaction ID returned by CDK + // (SHA-256 of sorted proof Y values — no racy listTransactions needed) + if (result.transactionId.isNotEmpty) { + await _txMetaStorage.save( + result.transactionId, + TransactionMeta( + type: TransactionType.cashu, + token: result.token.encoded, + ), + ); + debugPrint('Token guardado para tx ${result.transactionId}'); + } notifyListeners(); - return token.encoded; - } - - /// Guarda el token para la transacción más reciente de tipo send. - Future _saveTokenForRecentTransaction(String tokenEncoded) async { - try { - // Obtener transacciones outgoing más recientes - final wallet = await getActiveWallet(); - final txs = await wallet.listTransactions( - direction: TransactionDirection.outgoing, - ); - - if (txs.isNotEmpty) { - // La más reciente debería ser la que acabamos de crear - final recentTx = txs.first; - - await _txMetaStorage.save( - recentTx.id, - TransactionMeta( - type: TransactionType.cashu, - token: tokenEncoded, - ), - ); - debugPrint('Token guardado para tx ${recentTx.id}'); - } - } catch (e) { - debugPrint('Error guardando token metadata: $e'); - } + return result.token.encoded; } /// Cancela un envío preparado (libera proofs reservados). @@ -1151,7 +1133,7 @@ class WalletProvider extends ChangeNotifier { // Cuando se completa, guardar metadata, confetti, limpiar pending if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) { - _saveMintMetadata(wallet, invoiceBolt11!); + _saveMintMetadata(wallet, invoiceBolt11!, quote.transactionId); _removePendingMintInvoice(quote.id); } @@ -1386,26 +1368,23 @@ class WalletProvider extends ChangeNotifier { } /// Guarda metadata para una transacción de mint (Lightning deposit). - Future _saveMintMetadata(Wallet wallet, String invoice) async { + /// Uses the deterministic transaction ID from CDK when available. + Future _saveMintMetadata(Wallet wallet, String invoice, String? transactionId) async { try { - final txs = await wallet.listTransactions( - direction: TransactionDirection.incoming, - ); - - if (txs.isNotEmpty) { - final recentTx = txs.first; - + if (transactionId != null && transactionId.isNotEmpty) { await _txMetaStorage.save( - recentTx.id, + transactionId, TransactionMeta( type: TransactionType.lightning, invoice: invoice, ), ); - debugPrint('Mint metadata guardada para tx ${recentTx.id}'); - confettiController.fire(); - notifyListeners(); + debugPrint('Mint metadata guardada para tx $transactionId'); + } else { + debugPrint('Mint metadata: no transaction ID available'); } + confettiController.fire(); + notifyListeners(); } catch (e) { debugPrint('Error guardando mint metadata: $e'); } @@ -1735,7 +1714,7 @@ class WalletProvider extends ChangeNotifier { // Enviar todo el balance final prepared = await tempWallet.prepareSend(amount: tempBalance); - final token = await tempWallet.send( + final result = await tempWallet.send( send: prepared, memo: 'Recuperación El Caju', includeMemo: true, @@ -1743,7 +1722,7 @@ class WalletProvider extends ChangeNotifier { // Reclamar en nuestro wallet final ourWallet = await getWallet(mintUrl, unit); - final received = await ourWallet.receive(token: token); + final received = await ourWallet.receive(token: result.token); totalRecovered += received; } } catch (e) { diff --git a/lib/screens/12_request/request_screen.dart b/lib/screens/12_request/request_screen.dart index 8ce0f04..0966b0d 100644 --- a/lib/screens/12_request/request_screen.dart +++ b/lib/screens/12_request/request_screen.dart @@ -769,10 +769,13 @@ class _RequestScreenState extends State { // .waitForNostrPayment(handle: request.listenerHandle) // .listen(_onNostrEvent); - // Lightning invoice generation via CDK - _mintSubscription = wallet - .mint(amount: _amount, description: description) - .listen(_onMintEvent); + // Lightning invoice generation via wallet provider + // (walletProvider.mintTokens handles pending invoice persistence and metadata) + final mintStream = await walletProvider.mintTokens( + _amount, + description, + ); + _mintSubscription = mintStream.listen(_onMintEvent); } catch (e) { setState(() { _status = RequestStatus.error; diff --git a/lib/src/rust/api/wallet.dart b/lib/src/rust/api/wallet.dart index 97764f6..d5850e7 100644 --- a/lib/src/rust/api/wallet.dart +++ b/lib/src/rust/api/wallet.dart @@ -109,7 +109,7 @@ abstract class Wallet implements RustOpaqueInterface { Future restore(); - Future send({ + Future send({ required PreparedSend send, String? memo, bool? includeMemo, @@ -182,6 +182,9 @@ class MintQuote { final Token? token; final String? error; + /// Deterministic transaction ID (set when state == Issued) + final String? transactionId; + const MintQuote({ required this.id, required this.request, @@ -190,6 +193,7 @@ class MintQuote { required this.state, this.token, this.error, + this.transactionId, }); @override @@ -200,7 +204,8 @@ class MintQuote { expiry.hashCode ^ state.hashCode ^ token.hashCode ^ - error.hashCode; + error.hashCode ^ + transactionId.hashCode; @override bool operator ==(Object other) => @@ -213,7 +218,8 @@ class MintQuote { expiry == other.expiry && state == other.state && token == other.token && - error == other.error; + error == other.error && + transactionId == other.transactionId; } enum MintQuoteState { unpaid, paid, issued, error } @@ -260,6 +266,27 @@ class SendOptions { includeFee == other.includeFee; } +/// Result of a confirmed send, carrying both the ecash token and the +/// deterministic transaction ID so Dart can save metadata without a racy +/// listTransactions lookup. +class SendResult { + final Token token; + final String transactionId; + + const SendResult({required this.token, required this.transactionId}); + + @override + int get hashCode => token.hashCode ^ transactionId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SendResult && + runtimeType == other.runtimeType && + token == other.token && + transactionId == other.transactionId; +} + class Transaction { final String id; final String mintUrl; diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index c207e9c..6d3dada 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -280,7 +280,7 @@ abstract class RustLibApi extends BaseApi { Future crateApiWalletWalletRestore({required Wallet that}); - Future crateApiWalletWalletSend({ + Future crateApiWalletWalletSend({ required Wallet that, required PreparedSend send, String? memo, @@ -2005,7 +2005,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: "Wallet_restore", argNames: ["that"]); @override - Future crateApiWalletWalletSend({ + Future crateApiWalletWalletSend({ required Wallet that, required PreparedSend send, String? memo, @@ -2033,7 +2033,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); }, codec: SseCodec( - decodeSuccessData: sse_decode_token, + decodeSuccessData: sse_decode_send_result, decodeErrorData: sse_decode_error, ), constMeta: kCrateApiWalletWalletSendConstMeta, @@ -3044,8 +3044,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { MintQuote dco_decode_mint_quote(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 7) - throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); + if (arr.length != 8) + throw Exception('unexpected arr length: expect 8 but see ${arr.length}'); return MintQuote( id: dco_decode_String(arr[0]), request: dco_decode_String(arr[1]), @@ -3054,6 +3054,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { state: dco_decode_mint_quote_state(arr[4]), token: dco_decode_opt_box_autoadd_token(arr[5]), error: dco_decode_opt_String(arr[6]), + transactionId: dco_decode_opt_String(arr[7]), ); } @@ -3289,6 +3290,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); } + @protected + SendResult dco_decode_send_result(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) + throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + return SendResult( + token: dco_decode_token(arr[0]), + transactionId: dco_decode_String(arr[1]), + ); + } + @protected SupportedSettings dco_decode_supported_settings(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -4056,6 +4069,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_state = sse_decode_mint_quote_state(deserializer); var var_token = sse_decode_opt_box_autoadd_token(deserializer); var var_error = sse_decode_opt_String(deserializer); + var var_transactionId = sse_decode_opt_String(deserializer); return MintQuote( id: var_id, request: var_request, @@ -4064,6 +4078,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { state: var_state, token: var_token, error: var_error, + transactionId: var_transactionId, ); } @@ -4379,6 +4394,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return SendOptions(pubkey: var_pubkey, includeFee: var_includeFee); } + @protected + SendResult sse_decode_send_result(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_token = sse_decode_token(deserializer); + var var_transactionId = sse_decode_String(deserializer); + return SendResult(token: var_token, transactionId: var_transactionId); + } + @protected SupportedSettings sse_decode_supported_settings( SseDeserializer deserializer, @@ -5177,6 +5200,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_mint_quote_state(self.state, serializer); sse_encode_opt_box_autoadd_token(self.token, serializer); sse_encode_opt_String(self.error, serializer); + sse_encode_opt_String(self.transactionId, serializer); } @protected @@ -5465,6 +5489,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_opt_box_autoadd_bool(self.includeFee, serializer); } + @protected + void sse_encode_send_result(SendResult self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_token(self.token, serializer); + sse_encode_String(self.transactionId, serializer); + } + @protected void sse_encode_supported_settings( SupportedSettings self, @@ -5881,7 +5912,7 @@ class WalletImpl extends RustOpaque implements Wallet { Future restore() => RustLib.instance.api.crateApiWalletWalletRestore(that: this); - Future send({ + Future send({ required PreparedSend send, String? memo, bool? includeMemo, diff --git a/lib/src/rust/frb_generated.io.dart b/lib/src/rust/frb_generated.io.dart index 69c7446..f21a941 100644 --- a/lib/src/rust/frb_generated.io.dart +++ b/lib/src/rust/frb_generated.io.dart @@ -378,6 +378,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SendOptions dco_decode_send_options(dynamic raw); + @protected + SendResult dco_decode_send_result(dynamic raw); + @protected SupportedSettings dco_decode_supported_settings(dynamic raw); @@ -781,6 +784,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SendOptions sse_decode_send_options(SseDeserializer deserializer); + @protected + SendResult sse_decode_send_result(SseDeserializer deserializer); + @protected SupportedSettings sse_decode_supported_settings(SseDeserializer deserializer); @@ -1273,6 +1279,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_send_options(SendOptions self, SseSerializer serializer); + @protected + void sse_encode_send_result(SendResult self, SseSerializer serializer); + @protected void sse_encode_supported_settings( SupportedSettings self, diff --git a/lib/src/rust/frb_generated.web.dart b/lib/src/rust/frb_generated.web.dart index 4772a54..235a73f 100644 --- a/lib/src/rust/frb_generated.web.dart +++ b/lib/src/rust/frb_generated.web.dart @@ -380,6 +380,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SendOptions dco_decode_send_options(dynamic raw); + @protected + SendResult dco_decode_send_result(dynamic raw); + @protected SupportedSettings dco_decode_supported_settings(dynamic raw); @@ -783,6 +786,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected SendOptions sse_decode_send_options(SseDeserializer deserializer); + @protected + SendResult sse_decode_send_result(SseDeserializer deserializer); + @protected SupportedSettings sse_decode_supported_settings(SseDeserializer deserializer); @@ -1275,6 +1281,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_send_options(SendOptions self, SseSerializer serializer); + @protected + void sse_encode_send_result(SendResult self, SseSerializer serializer); + @protected void sse_encode_supported_settings( SupportedSettings self, diff --git a/rust/src/api/wallet.rs b/rust/src/api/wallet.rs index 85b7ccb..41cd7fc 100644 --- a/rust/src/api/wallet.rs +++ b/rust/src/api/wallet.rs @@ -19,6 +19,7 @@ use cdk_common::{ util::unix_time, wallet::{ Transaction as CdkTransaction, TransactionDirection as CdkTransactionDirection, + TransactionId, }, NotificationPayload, }; @@ -132,12 +133,12 @@ impl Wallet { send: PreparedSend, memo: Option, include_memo: Option, - ) -> Result { + ) -> Result { let send_memo = memo.map(|m| SendMemo { memo: m, include_memo: include_memo.unwrap_or_default(), }); - let token = self + let cdk_token = self .inner .confirm_send( send.operation_id, @@ -149,10 +150,23 @@ impl Wallet { send.cdk_send_fee, send_memo, ) - .await? - .to_string(); + .await?; + + // Compute the deterministic transaction ID from the token's proofs + // (SHA-256 of sorted Y values — same as CDK uses internally) + let keysets = self.inner.get_mint_keysets().await?; + let proofs = cdk_token.proofs(&keysets)?; + let tx_id = TransactionId::try_from(proofs) + .map(|id| id.to_string()) + .unwrap_or_default(); + + let token_str = cdk_token.to_string(); self.update_balance_streams().await; - Ok(Token::from_str(&token)?) + + Ok(SendResult { + token: Token::from_str(&token_str)?, + transaction_id: tx_id, + }) } pub async fn cancel_send(&self, send: PreparedSend) -> Result<(), Error> { @@ -225,6 +239,7 @@ impl Wallet { state: CdkMintQuoteState::Paid.into(), token: None, error: None, + transaction_id: None, }); // Mint the ecash tokens @@ -234,6 +249,12 @@ impl Wallet { .await { Ok(mint_proofs) => { + let tx_id = TransactionId::try_from( + mint_proofs.clone(), + ) + .map(|id| id.to_string()) + .ok(); + let mint_amount = mint_proofs.total_amount().unwrap_or_default(); let _ = sink.add(MintQuote { @@ -250,6 +271,7 @@ impl Wallet { )) .ok(), error: None, + transaction_id: tx_id, }); _self.update_balance_streams().await; } @@ -262,6 +284,7 @@ impl Wallet { state: MintQuoteState::Error, token: None, error: Some(e.to_string()), + transaction_id: None, }); } } @@ -280,6 +303,7 @@ impl Wallet { state: CdkMintQuoteState::Issued.into(), token: None, error: None, + transaction_id: None, }); _self.update_balance_streams().await; return; @@ -300,6 +324,7 @@ impl Wallet { state: MintQuoteState::Error, token: None, error: Some("Quote expired".to_string()), + transaction_id: None, }); } }); @@ -470,6 +495,14 @@ impl Wallet { // Types // ======================================================================== +/// Result of a confirmed send, carrying both the ecash token and the +/// deterministic transaction ID so Dart can save metadata without a racy +/// listTransactions lookup. +pub struct SendResult { + pub token: Token, + pub transaction_id: String, +} + pub struct MintQuote { pub id: String, pub request: String, @@ -478,6 +511,8 @@ pub struct MintQuote { pub state: MintQuoteState, pub token: Option, pub error: Option, + /// Deterministic transaction ID (set when state == Issued) + pub transaction_id: Option, } impl From for MintQuote { @@ -490,6 +525,7 @@ impl From for MintQuote { state: quote.state.into(), token: None, error: None, + transaction_id: None, } } } diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index a59cb00..24c5f39 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -3597,6 +3597,7 @@ impl SseDecode for crate::api::wallet::MintQuote { let mut var_state = ::sse_decode(deserializer); let mut var_token = >::sse_decode(deserializer); let mut var_error = >::sse_decode(deserializer); + let mut var_transactionId = >::sse_decode(deserializer); return crate::api::wallet::MintQuote { id: var_id, request: var_request, @@ -3605,6 +3606,7 @@ impl SseDecode for crate::api::wallet::MintQuote { state: var_state, token: var_token, error: var_error, + transaction_id: var_transactionId, }; } } @@ -3948,6 +3950,18 @@ impl SseDecode for crate::api::wallet::SendOptions { } } +impl SseDecode for crate::api::wallet::SendResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_token = ::sse_decode(deserializer); + let mut var_transactionId = ::sse_decode(deserializer); + return crate::api::wallet::SendResult { + token: var_token, + transaction_id: var_transactionId, + }; + } +} + impl SseDecode for crate::api::mint_info::SupportedSettings { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -4526,6 +4540,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::wallet::MintQuote { self.state.into_into_dart().into_dart(), self.token.into_into_dart().into_dart(), self.error.into_into_dart().into_dart(), + self.transaction_id.into_into_dart().into_dart(), ] .into_dart() } @@ -4790,6 +4805,27 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::wallet::SendResult { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + [ + self.token.into_into_dart().into_dart(), + self.transaction_id.into_into_dart().into_dart(), + ] + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::wallet::SendResult +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::wallet::SendResult +{ + fn into_into_dart(self) -> crate::api::wallet::SendResult { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::mint_info::SupportedSettings { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [self.supported.into_into_dart().into_dart()].into_dart() @@ -5300,6 +5336,7 @@ impl SseEncode for crate::api::wallet::MintQuote { ::sse_encode(self.state, serializer); >::sse_encode(self.token, serializer); >::sse_encode(self.error, serializer); + >::sse_encode(self.transaction_id, serializer); } } @@ -5568,6 +5605,14 @@ impl SseEncode for crate::api::wallet::SendOptions { } } +impl SseEncode for crate::api::wallet::SendResult { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.token, serializer); + ::sse_encode(self.transaction_id, serializer); + } +} + impl SseEncode for crate::api::mint_info::SupportedSettings { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {