fix: use deterministic transaction ID for send/mint metadata

This commit is contained in:
Forte11Cuba
2026-04-01 19:42:17 -06:00
parent fae57cc46e
commit 362e45e40f
8 changed files with 204 additions and 65 deletions
+26 -47
View File
@@ -979,45 +979,27 @@ class WalletProvider extends ChangeNotifier {
Future<String> 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<void> _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<void> _saveMintMetadata(Wallet wallet, String invoice) async {
/// Uses the deterministic transaction ID from CDK when available.
Future<void> _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) {
+7 -4
View File
@@ -769,10 +769,13 @@ class _RequestScreenState extends State<RequestScreen> {
// .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;
+30 -3
View File
@@ -109,7 +109,7 @@ abstract class Wallet implements RustOpaqueInterface {
Future<void> restore();
Future<Token> send({
Future<SendResult> 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;
+37 -6
View File
@@ -280,7 +280,7 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiWalletWalletRestore({required Wallet that});
Future<Token> crateApiWalletWalletSend({
Future<SendResult> 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<Token> crateApiWalletWalletSend({
Future<SendResult> 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<dynamic>;
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<dynamic>;
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<void> restore() =>
RustLib.instance.api.crateApiWalletWalletRestore(that: this);
Future<Token> send({
Future<SendResult> send({
required PreparedSend send,
String? memo,
bool? includeMemo,
+9
View File
@@ -378,6 +378,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
@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,
+9
View File
@@ -380,6 +380,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@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<RustLibWire> {
@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<RustLibWire> {
@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,
+41 -5
View File
@@ -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<String>,
include_memo: Option<bool>,
) -> Result<Token, Error> {
) -> Result<SendResult, Error> {
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<Token>,
pub error: Option<String>,
/// Deterministic transaction ID (set when state == Issued)
pub transaction_id: Option<String>,
}
impl From<CdkMintQuote> for MintQuote {
@@ -490,6 +525,7 @@ impl From<CdkMintQuote> for MintQuote {
state: quote.state.into(),
token: None,
error: None,
transaction_id: None,
}
}
}
+45
View File
@@ -3597,6 +3597,7 @@ impl SseDecode for crate::api::wallet::MintQuote {
let mut var_state = <crate::api::wallet::MintQuoteState>::sse_decode(deserializer);
let mut var_token = <Option<crate::api::token::Token>>::sse_decode(deserializer);
let mut var_error = <Option<String>>::sse_decode(deserializer);
let mut var_transactionId = <Option<String>>::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 = <crate::api::token::Token>::sse_decode(deserializer);
let mut var_transactionId = <String>::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<crate::api::wallet::SendOptions>
}
}
// 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<crate::api::wallet::SendResult>
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 {
<crate::api::wallet::MintQuoteState>::sse_encode(self.state, serializer);
<Option<crate::api::token::Token>>::sse_encode(self.token, serializer);
<Option<String>>::sse_encode(self.error, serializer);
<Option<String>>::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) {
<crate::api::token::Token>::sse_encode(self.token, serializer);
<String>::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) {