Compare commits

...
Author SHA1 Message Date
Forte11Cuba b56aa5ce62 fix: clear balance stream cache in deleteDatabase() and reset() 2026-04-03 15:25:11 -06:00
Forte11Cuba c69de2b41b fix: cache balance stream and use sync StreamController 2026-04-03 15:05:22 -06:00
Forte11andGitHub e5285058a8 Merge pull request #91 from Forte11Cuba/fix/send-token-metadata-race-condition
fix: use deterministic transaction ID for send/mint metadata
2026-04-02 01:58:26 -06:00
Forte11Cuba b01b658b93 fix: add mounted guards after async mintTokens call 2026-04-02 01:52:53 -06:00
Forte11Cuba 6596aa963a refactor: log mint tx ID computation failure instead of swallowing 2026-04-02 01:52:14 -06:00
Forte11Cuba ed309d18e8 refactor: add debug logs for tx ID computation failures 2026-04-02 01:08:36 -06:00
Forte11Cuba 1b30d9836b fix: make transaction ID computation best-effort after committed send 2026-04-01 22:54:05 -06:00
Forte11Cuba 588a127f9a refactor: use Option<String> for SendResult.transaction_id 2026-04-01 20:14:11 -06:00
Forte11Cuba 362e45e40f fix: use deterministic transaction ID for send/mint metadata 2026-04-01 19:42:17 -06:00
Forte11andGitHub fae57cc46e Merge pull request #90 from Forte11Cuba/fix/disable-nostr-payment-request-nip17-bug
fix: disable Cashu/Nostr payment request until CDK fixes NIP-17 sender pubkey
2026-04-01 16:45:30 -06:00
Forte11Cuba 80ab0fd9e1 refactor: remove dead code paths while Nostr is disabled 2026-04-01 16:38:02 -06:00
Forte11Cuba 29895f4079 fix: treat Lightning mint error as terminal without Nostr fallback 2026-04-01 14:45:40 -06:00
Forte11Cuba 41fa32d1b4 refactor: hide mode toggle and simplify QR content while Nostr is disabled 2026-04-01 14:33:58 -06:00
Forte11Cuba 63ce12d745 fix: defer QR display until Lightning invoice is ready 2026-04-01 14:10:39 -06:00
Forte11Cuba 7245dbb669 fix: disable Cashu/Nostr payment request until CDK fixes NIP-17 sender pubkey 2026-04-01 13:59:04 -06:00
Forte11andGitHub e124e0839a Merge pull request #89 from Forte11Cuba/feat/mint-websocket-subscription
feat: replace 3s polling with CDK WebSocket subscription (NUT-17) for mint quote
2026-04-01 12:42:25 -06:00
Forte11Cuba 1a31b47c6b fix: handle expired-quote timeout and emit Issued on recovery path 2026-04-01 12:39:17 -06:00
Forte11Cuba a5cd50af13 feat: replace 3s polling with CDK WebSocket subscription (NUT-17) for mint quote 2026-04-01 12:24:12 -06:00
Forte11andGitHub 66119f6b56 Merge pull request #88 from Forte11Cuba/fix/receive-already-signed-retry
fix: handle 'already signed' error in receive with counter resync
2026-03-31 03:42:05 -06:00
9 changed files with 491 additions and 275 deletions
+64 -47
View File
@@ -68,6 +68,10 @@ class WalletProvider extends ChangeNotifier {
/// Caché de MintInfo por URL (nombre, logo, contactos, etc.)
final Map<String, MintInfo> _mintInfoCache = {};
/// Caché del balance stream para evitar recrear StreamSink en cada build().
Stream<BigInt>? _cachedBalanceStream;
String? _cachedBalanceKey;
/// Mint activo actualmente
String? _activeMintUrl;
@@ -602,8 +606,21 @@ class WalletProvider extends ChangeNotifier {
// ============================================================
/// Stream de balance del wallet activo (reactivo).
/// Cacheado para que StreamBuilder reciba el mismo objeto entre rebuilds
/// y no mate el StreamSink de Rust.
Stream<BigInt>? streamBalance() {
return activeWallet?.streamBalance();
final wallet = activeWallet;
if (wallet == null) {
_cachedBalanceStream = null;
_cachedBalanceKey = null;
return null;
}
final key = '$_activeMintUrl:$_activeUnit';
if (_cachedBalanceKey != key) {
_cachedBalanceStream = wallet.streamBalance().asBroadcastStream();
_cachedBalanceKey = key;
}
return _cachedBalanceStream;
}
/// Obtiene el balance del wallet activo.
@@ -979,45 +996,33 @@ 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);
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;
// Save token metadata using the deterministic transaction ID returned by CDK
// (SHA-256 of sorted proof Y values — no racy listTransactions needed)
// Best-effort: don't fail the send if metadata persistence fails
final txId = result.transactionId;
if (txId != null && txId.isNotEmpty) {
try {
await _txMetaStorage.save(
recentTx.id,
txId,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
token: result.token.encoded,
),
);
debugPrint('Token guardado para tx ${recentTx.id}');
debugPrint('Token guardado para tx $txId');
} catch (e) {
debugPrint('Error guardando send metadata: $e');
}
} catch (e) {
debugPrint('Error guardando token metadata: $e');
}
notifyListeners();
return result.token.encoded;
}
/// Cancela un envío preparado (libera proofs reservados).
@@ -1129,12 +1134,14 @@ class WalletProvider extends ChangeNotifier {
_activeMintController!.close();
}
// StreamController que la UI puede escuchar y cancelar libremente
final controller = StreamController<MintQuote>();
// StreamController que la UI puede escuchar y cancelar libremente.
// sync: true para no perder eventos rápidos (paid → issued en µs).
final controller = StreamController<MintQuote>(sync: true);
_activeMintController = controller;
// Cancelar suscripción anterior si existe (await evita race de callbacks)
await _activeMintSubscription?.cancel();
// Cancelar suscripción anterior sin await: el task de Rust (polling + WS)
// puede tardar en cerrar y bloquearía la creación de una nueva factura.
_activeMintSubscription?.cancel();
_activeMintSubscription = null;
// Suscribirse al stream del CDK desde el provider (persiste sin UI)
@@ -1142,7 +1149,7 @@ class WalletProvider extends ChangeNotifier {
amount: amount,
description: description,
).listen(
(quote) {
(quote) async {
// Guardar invoice temprano en SharedPreferences
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
@@ -1151,8 +1158,10 @@ class WalletProvider extends ChangeNotifier {
// Cuando se completa, guardar metadata, confetti, limpiar pending
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
_saveMintMetadata(wallet, invoiceBolt11!);
_removePendingMintInvoice(quote.id);
final saved = await _saveMintMetadata(wallet, invoiceBolt11!, quote.transactionId);
// Only remove pending invoice if metadata was saved;
// otherwise _matchPendingMintInvoices can recover it on next startup
if (saved) _removePendingMintInvoice(quote.id);
}
// Reenviar a la UI (si sigue escuchando)
@@ -1386,28 +1395,30 @@ class WalletProvider extends ChangeNotifier {
}
/// Guarda metadata para una transacción de mint (Lightning deposit).
Future<void> _saveMintMetadata(Wallet wallet, String invoice) async {
/// Returns true if metadata was actually saved.
Future<bool> _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}');
debugPrint('Mint metadata guardada para tx $transactionId');
confettiController.fire();
notifyListeners();
return true;
} else {
debugPrint('Mint metadata: no transaction ID available');
confettiController.fire();
notifyListeners();
return false;
}
} catch (e) {
debugPrint('Error guardando mint metadata: $e');
return false;
}
}
@@ -1735,7 +1746,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 +1754,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) {
@@ -1782,6 +1793,8 @@ class WalletProvider extends ChangeNotifier {
_activeUnit = 'sat';
_mnemonic = null;
_db = null;
_cachedBalanceStream = null;
_cachedBalanceKey = null;
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
@@ -1810,6 +1823,8 @@ class WalletProvider extends ChangeNotifier {
_activeUnit = 'sat';
_mnemonic = null;
_db = null;
_cachedBalanceStream = null;
_cachedBalanceKey = null;
notifyListeners();
}
@@ -1985,6 +2000,8 @@ class WalletProvider extends ChangeNotifier {
if (_activeMintController != null && !_activeMintController!.isClosed) {
_activeMintController!.close();
}
_cachedBalanceStream = null;
_cachedBalanceKey = null;
confettiController.dispose();
super.dispose();
}
+166 -152
View File
@@ -11,7 +11,8 @@ import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../core/utils/bip321_builder.dart';
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// import '../../core/utils/bip321_builder.dart';
import '../../core/services/nfc_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
@@ -45,7 +46,9 @@ class _RequestScreenState extends State<RequestScreen> {
// Payment data
String? _creqB;
String? _bolt11;
QrMode _activeMode = QrMode.cashu;
// TODO: re-enable QrMode.cashu / universal once CDK fixes NIP-17 sender pubkey bug
// See: https://github.com/cashubtc/cdk/issues/1807
// QrMode _activeMode = QrMode.lightning;
bool _paymentHandled = false;
// Listeners
@@ -72,10 +75,11 @@ class _RequestScreenState extends State<RequestScreen> {
@override
void dispose() {
_descriptionController.dispose();
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// No-op while Nostr is disabled (_nostrSubscription is always null, _creqB is always null)
_nostrSubscription?.cancel();
_mintSubscription?.cancel();
if (_nfcEmulating) NfcService.stopEmulating();
// Clear persisted request if user abandoned without receiving payment
if (!_paymentHandled && _creqB != null) {
_walletProvider.removePendingNostrRequest();
}
@@ -349,42 +353,37 @@ class _RequestScreenState extends State<RequestScreen> {
version: QrVersions.auto,
size: 260,
backgroundColor: Colors.white,
errorCorrectionLevel: _activeMode == QrMode.universal
? QrErrorCorrectLevel.M
: QrErrorCorrectLevel.H,
// TODO: use QrErrorCorrectLevel.M for universal mode once CDK fixes NIP-17 (cashubtc/cdk#1807)
errorCorrectionLevel: QrErrorCorrectLevel.H,
),
if (_activeMode == QrMode.cashu)
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 4),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/img/cashu.png',
fit: BoxFit.contain,
),
),
// TODO: re-enable Cashu logo overlay once CDK fixes NIP-17 (cashubtc/cdk#1807)
// if (_activeMode == QrMode.cashu)
// Container(
// width: 48, height: 48,
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(10),
// border: Border.all(color: Colors.white, width: 4),
// ),
// child: ClipRRect(
// borderRadius: BorderRadius.circular(6),
// child: Image.asset('assets/img/cashu.png', fit: BoxFit.contain),
// ),
// ),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.bitcoinOrange,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 4),
),
if (_activeMode == QrMode.lightning)
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.bitcoinOrange,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 4),
),
child: const Icon(
LucideIcons.zap,
color: Colors.white,
size: 28,
),
child: const Icon(
LucideIcons.zap,
color: Colors.white,
size: 28,
),
),
],
),
),
@@ -437,73 +436,66 @@ class _RequestScreenState extends State<RequestScreen> {
}
Widget _buildModeToggle() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_buildToggleButton(
label: L10n.of(context)!.universal,
mode: QrMode.universal,
enabled: _bolt11 != null,
),
_buildToggleButton(
label: 'Cashu',
mode: QrMode.cashu,
enabled: true,
),
_buildToggleButton(
label: 'Lightning',
mode: QrMode.lightning,
enabled: _bolt11 != null,
),
],
),
// TODO: re-enable Universal/Cashu tabs once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Hidden while only Lightning is available (single button toggle is a no-op)
return const SizedBox.shrink(
// child: Container(
// padding: const EdgeInsets.all(4),
// decoration: BoxDecoration(
// color: Colors.white.withValues(alpha: 0.1),
// borderRadius: BorderRadius.circular(12),
// ),
// child: Row(
// children: [
// _buildToggleButton(label: L10n.of(context)!.universal, mode: QrMode.universal, enabled: _bolt11 != null),
// _buildToggleButton(label: 'Cashu', mode: QrMode.cashu, enabled: true),
// _buildToggleButton(label: 'Lightning', mode: QrMode.lightning, enabled: _bolt11 != null),
// ],
// ),
// ),
);
}
Widget _buildToggleButton({
required String label,
required QrMode mode,
required bool enabled,
}) {
final isActive = _activeMode == mode;
return Expanded(
child: GestureDetector(
onTap: enabled
? () {
setState(() => _activeMode = mode);
_updateNfcPayload();
}
: null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
gradient: isActive
? const LinearGradient(colors: AppColors.buttonGradient)
: null,
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 13,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
color: enabled
? (isActive ? Colors.white : AppColors.textSecondary)
: Colors.white.withValues(alpha: 0.3),
),
),
),
),
),
);
}
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Widget _buildToggleButton({
// required String label,
// required QrMode mode,
// required bool enabled,
// }) {
// final isActive = _activeMode == mode;
// return Expanded(
// child: GestureDetector(
// onTap: enabled
// ? () {
// setState(() => _activeMode = mode);
// _updateNfcPayload();
// }
// : null,
// child: Container(
// padding: const EdgeInsets.symmetric(vertical: 10),
// decoration: BoxDecoration(
// gradient: isActive
// ? const LinearGradient(colors: AppColors.buttonGradient)
// : null,
// borderRadius: BorderRadius.circular(10),
// ),
// child: Center(
// child: Text(
// label,
// style: TextStyle(
// fontFamily: 'Inter',
// fontSize: 13,
// fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
// color: enabled
// ? (isActive ? Colors.white : AppColors.textSecondary)
// : Colors.white.withValues(alpha: 0.3),
// ),
// ),
// ),
// ),
// ),
// );
// }
Widget _buildActionButtons() {
return Row(
@@ -712,11 +704,12 @@ class _RequestScreenState extends State<RequestScreen> {
// ─── Logic ───
static const List<String> _defaultNostrRelays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://nos.lol',
];
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// static const List<String> _defaultNostrRelays = [
// 'wss://relay.damus.io',
// 'wss://relay.primal.net',
// 'wss://nos.lol',
// ];
Future<void> _generateRequest() async {
final walletProvider = context.read<WalletProvider>();
@@ -734,7 +727,9 @@ class _RequestScreenState extends State<RequestScreen> {
_paymentHandled = false;
_creqB = null;
_bolt11 = null;
_activeMode = QrMode.cashu;
// TODO: re-enable Cashu/Nostr payment request once CDK fixes NIP-17 sender pubkey bug
// See: https://github.com/cashubtc/cdk/issues/1807
// _activeMode = QrMode.lightning;
setState(() => _status = RequestStatus.generating);
@@ -751,35 +746,39 @@ class _RequestScreenState extends State<RequestScreen> {
? _descriptionController.text
: null;
// 1. Create payment request (creqB + Nostr keys)
final request = await wallet.createPaymentRequest(
params: CreateRequestParams(
amount: _amount,
unit: _activeUnit,
description: description,
nostrRelays: _defaultNostrRelays,
),
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// // 1. Create payment request (creqB + Nostr keys)
// final request = await wallet.createPaymentRequest(
// params: CreateRequestParams(
// amount: _amount,
// unit: _activeUnit,
// description: description,
// nostrRelays: _defaultNostrRelays,
// ),
// );
//
// _creqB = request.creqB;
//
// // Persist handle for recovery if app is killed
// await walletProvider.savePendingNostrRequest(
// request.listenerHandle.toPersisted(),
// );
//
// // 2. Start Nostr listener
// _nostrSubscription = wallet
// .waitForNostrPayment(handle: request.listenerHandle)
// .listen(_onNostrEvent);
// Lightning invoice generation via wallet provider
// (walletProvider.mintTokens handles pending invoice persistence and metadata)
final mintStream = await walletProvider.mintTokens(
_amount,
description,
);
_creqB = request.creqB;
// Persist handle for recovery if app is killed
await walletProvider.savePendingNostrRequest(
request.listenerHandle.toPersisted(),
);
// 2. Start Nostr listener
_nostrSubscription = wallet
.waitForNostrPayment(handle: request.listenerHandle)
.listen(_onNostrEvent);
// 3. Start Lightning invoice generation directly via CDK
_mintSubscription = wallet
.mint(amount: _amount, description: description)
.listen(_onMintEvent);
setState(() => _status = RequestStatus.waiting);
if (!mounted) return;
_mintSubscription = mintStream.listen(_onMintEvent);
} catch (e) {
if (!mounted) return;
setState(() {
_status = RequestStatus.error;
_errorMessage = e.toString();
@@ -787,14 +786,15 @@ class _RequestScreenState extends State<RequestScreen> {
}
}
void _onNostrEvent(NostrPaymentEvent event) {
if (!mounted || _paymentHandled) return;
if (event.state == NostrPaymentState.received) {
_paymentHandled = true;
_mintSubscription?.cancel();
_onPaymentSuccess(event.amount ?? _amount);
}
}
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// void _onNostrEvent(NostrPaymentEvent event) {
// if (!mounted || _paymentHandled) return;
// if (event.state == NostrPaymentState.received) {
// _paymentHandled = true;
// _mintSubscription?.cancel();
// _onPaymentSuccess(event.amount ?? _amount);
// }
// }
void _onMintEvent(MintQuote quote) {
if (!mounted) return;
@@ -803,7 +803,9 @@ class _RequestScreenState extends State<RequestScreen> {
if (_bolt11 == null) {
setState(() {
_bolt11 = quote.request;
_activeMode = QrMode.universal;
// TODO: switch to QrMode.universal once CDK fixes NIP-17 (cashubtc/cdk#1807)
// _activeMode = QrMode.lightning;
_status = RequestStatus.waiting;
});
_updateNfcPayload();
}
@@ -816,7 +818,17 @@ class _RequestScreenState extends State<RequestScreen> {
}
break;
case MintQuoteState.error:
// Lightning failed, but Nostr listener continues
// No Nostr fallback while CDK NIP-17 bug is open (cashubtc/cdk#1807)
if (!_paymentHandled) {
if (_nfcEmulating) {
NfcService.stopEmulating();
_nfcEmulating = false;
}
setState(() {
_status = RequestStatus.error;
_errorMessage = quote.error ?? L10n.of(context)!.unknownError;
});
}
break;
default:
break;
@@ -831,20 +843,22 @@ class _RequestScreenState extends State<RequestScreen> {
});
final walletProvider = context.read<WalletProvider>();
walletProvider.confettiController.fire();
await walletProvider.removePendingNostrRequest();
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// Only remove pending Nostr request if this screen created one.
// Disabled while Nostr payment requests are disabled to avoid
// deleting a recovery record from a previous session.
// await walletProvider.removePendingNostrRequest();
}
// ─── QR Content ───
String _getActiveQrContent() {
switch (_activeMode) {
case QrMode.universal:
return buildUnifiedUri(creqB: _creqB!, bolt11: _bolt11);
case QrMode.cashu:
return _creqB!.toUpperCase();
case QrMode.lightning:
return _bolt11?.toUpperCase() ?? '';
}
// TODO: re-enable once CDK fixes NIP-17 (cashubtc/cdk#1807)
// case QrMode.universal:
// return buildUnifiedUri(creqB: _creqB!, bolt11: _bolt11);
// case QrMode.cashu:
// return _creqB!.toUpperCase();
return _bolt11?.toUpperCase() ?? '';
}
// ─── Actions ───
+32 -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,29 @@ 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;
/// Deterministic transaction ID (None if computation failed)
final String? transactionId;
const SendResult({required this.token, 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_opt_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_opt_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_opt_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,
+5 -6
View File
@@ -461,18 +461,17 @@ async fn wait_for_nostr_payment_inner(
.await
.map_err(|_| Error::Network("Relay connection timed out".to_string()))?;
// Create the broadcast receiver BEFORE subscribing so we capture historical
// events the relay sends back in response to our subscription filter.
// (broadcast::Receiver only sees messages sent after its creation)
let mut notifications = client.notifications();
// Subscribe to NIP-17 gift-wrap events (kind 1059) addressed to our ephemeral pubkey
let filter = Filter::new().pubkey(pubkey).kind(Kind::GiftWrap);
client
.subscribe(filter, None)
.await
.map_err(|e| Error::Cdk(format!("Subscription failed: {e}")))?;
// Listen for notifications with periodic cancellation check.
// Every 1s we check if the Dart stream is still alive by attempting
// a sink.add(). If it fails, the Dart side disposed the stream and
// we disconnect cleanly. Short interval minimizes "Fail to post" spam.
let mut notifications = client.notifications();
loop {
match tokio::time::timeout(Duration::from_secs(1), notifications.recv()).await {
Ok(Ok(notification)) => {
+124 -61
View File
@@ -12,19 +12,21 @@ use cdk::{
wallet::{
MeltQuote as CdkMeltQuote, MintQuote as CdkMintQuote, PreparedSend as CdkPreparedSend,
ReceiveOptions as CdkReceiveOptions, SendMemo, SendOptions as CdkSendOptions,
Wallet as CdkWallet,
Wallet as CdkWallet, WalletSubscription,
},
};
use cdk_common::{
util::unix_time,
wallet::{
Transaction as CdkTransaction, TransactionDirection as CdkTransactionDirection,
TransactionId,
},
NotificationPayload,
};
use cdk_sqlite::WalletSqliteDatabase;
use flutter_rust_bridge::frb;
use log::info;
use tokio::{sync::broadcast, time::sleep};
use tokio::sync::broadcast;
use uuid::Uuid;
use crate::frb_generated::StreamSink;
@@ -131,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,
@@ -148,10 +150,35 @@ 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)
// Best-effort: send is already committed, don't fail on ID computation
let tx_id = match self.inner.get_mint_keysets().await {
Ok(keysets) => match cdk_token.proofs(&keysets) {
Ok(proofs) => TransactionId::try_from(proofs)
.map(|id| id.to_string())
.map_err(|e| info!("Failed to compute tx ID from proofs: {e}"))
.ok(),
Err(e) => {
info!("Failed to extract proofs from token: {e}");
None
}
},
Err(e) => {
info!("Failed to fetch keysets for tx ID: {e}");
None
}
};
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> {
@@ -184,50 +211,38 @@ impl Wallet {
return Ok(());
}
// Subscribe to quote state changes via WebSocket (NUT-17) with HTTP polling fallback
let mut subscription = self
.inner
.subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote.id.clone()]))
.await
.map_err(|e| Error::Cdk(e.to_string()))?;
let _self = self.clone();
flutter_rust_bridge::spawn(async move {
loop {
sleep(Duration::from_secs(3)).await;
// Timeout: time until quote expires + 30s buffer, or 1 hour if no expiry
let remaining = quote.expiry.saturating_sub(unix_time());
let timeout_dur = if quote.expiry == 0 {
Duration::from_secs(3600)
} else {
Duration::from_secs(remaining + 30)
};
// Check if the Dart stream is still alive before polling
if sink
.add(MintQuote {
id: quote.id.clone(),
request: quote.request.clone(),
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: MintQuoteState::Unpaid,
token: None,
error: None,
})
.is_err()
{
info!("Mint polling stopped: Dart stream closed for {}", quote.id);
break;
}
// Clone for the timeout error path (originals are moved into the async block)
let expired_id = quote.id.clone();
let expired_request = quote.request.clone();
let expired_amount = quote.amount;
let expired_expiry = quote.expiry;
info!("Checking mint quote state for {}", quote.id);
match _self.inner.check_mint_quote_status(&quote.id).await {
Ok(state_res) => match state_res.state {
CdkMintQuoteState::Unpaid => {
if state_res.expiry < unix_time() {
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(state_res.expiry),
state: MintQuoteState::Error,
token: None,
error: Some("Quote expired".to_string()),
});
break;
}
continue;
}
CdkMintQuoteState::Issued => {
break;
}
CdkMintQuoteState::Paid => {
let result = tokio::time::timeout(timeout_dur, async {
while let Some(event) = subscription.recv().await {
match event.into_inner() {
NotificationPayload::MintQuoteBolt11Response(info)
if info.state == CdkMintQuoteState::Paid =>
{
info!("Mint quote {} paid via subscription", quote.id);
// Notify Dart: payment detected
let _ = sink.add(MintQuote {
id: quote.id.clone(),
request: quote.request.clone(),
@@ -236,13 +251,26 @@ impl Wallet {
state: CdkMintQuoteState::Paid.into(),
token: None,
error: None,
transaction_id: None,
});
// Mint the ecash tokens
match _self
.inner
.mint(&quote.id, SplitTarget::None, None)
.await
{
Ok(mint_proofs) => {
let tx_id = match TransactionId::try_from(
mint_proofs.clone(),
) {
Ok(id) => Some(id.to_string()),
Err(e) => {
info!("Failed to compute mint tx ID: {e}");
None
}
};
let mint_amount =
mint_proofs.total_amount().unwrap_or_default();
let _ = sink.add(MintQuote {
@@ -259,9 +287,9 @@ impl Wallet {
))
.ok(),
error: None,
transaction_id: tx_id,
});
_self.update_balance_streams().await;
break;
}
Err(e) => {
let _ = sink.add(MintQuote {
@@ -272,25 +300,48 @@ impl Wallet {
state: MintQuoteState::Error,
token: None,
error: Some(e.to_string()),
transaction_id: None,
});
break;
}
}
return;
}
},
Err(e) => {
let _ = sink.add(MintQuote {
id: quote.id,
request: quote.request,
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: MintQuoteState::Error,
token: None,
error: Some(e.to_string()),
});
break;
NotificationPayload::MintQuoteBolt11Response(info)
if info.state == CdkMintQuoteState::Issued =>
{
// Already issued (recovered from previous session) — notify Dart
// so it can clean up pending metadata and show success UI
let _ = sink.add(MintQuote {
id: quote.id.clone(),
request: quote.request.clone(),
amount: quote.amount.map(|a| a.into()),
expiry: Some(quote.expiry),
state: CdkMintQuoteState::Issued.into(),
token: None,
error: None,
transaction_id: None,
});
_self.update_balance_streams().await;
return;
}
_ => continue,
}
}
})
.await;
if result.is_err() {
// Timeout: quote expired
let _ = sink.add(MintQuote {
id: expired_id,
request: expired_request,
amount: expired_amount.map(|a| a.into()),
expiry: Some(expired_expiry),
state: MintQuoteState::Error,
token: None,
error: Some("Quote expired".to_string()),
transaction_id: None,
});
}
});
Ok(())
@@ -460,6 +511,15 @@ 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,
/// Deterministic transaction ID (None if computation failed)
pub transaction_id: Option<String>,
}
pub struct MintQuote {
pub id: String,
pub request: String,
@@ -468,6 +528,8 @@ pub struct MintQuote {
pub state: MintQuoteState,
pub token: Option<Token>,
pub error: Option<String>,
/// Deterministic transaction ID (set when proofs are available, typically on Issued)
pub transaction_id: Option<String>,
}
impl From<CdkMintQuote> for MintQuote {
@@ -480,6 +542,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 = <Option<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);
<Option<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) {