Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
426b63456d | ||
|
|
4d2ad39323 | ||
|
|
060c6acd4e | ||
|
|
14da243b89 | ||
|
|
4f91a2be1c | ||
|
|
ab13e8f6c2 | ||
|
|
f431f825c5 | ||
|
|
b56aa5ce62 | ||
|
|
c69de2b41b | ||
|
|
e5285058a8 | ||
|
|
b01b658b93 | ||
|
|
6596aa963a | ||
|
|
ed309d18e8 | ||
|
|
1b30d9836b | ||
|
|
588a127f9a | ||
|
|
362e45e40f | ||
|
|
fae57cc46e | ||
|
|
80ab0fd9e1 | ||
|
|
29895f4079 | ||
|
|
41fa32d1b4 | ||
|
|
63ce12d745 | ||
|
|
7245dbb669 | ||
|
|
e124e0839a | ||
|
|
1a31b47c6b | ||
|
|
a5cd50af13 | ||
|
|
66119f6b56 | ||
|
|
484f73a33b | ||
|
|
67204a12f8 | ||
|
|
b4a62fe9e5 |
@@ -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;
|
||||
|
||||
@@ -352,6 +356,10 @@ class WalletProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
debugPrint('Mints restaurados: ${_mintUnits.keys.length}');
|
||||
|
||||
// Re-detectar unidades en background (sin bloquear arranque).
|
||||
// Si el mint ahora soporta más unidades (ej: usd), se actualizan.
|
||||
_refreshAllMintUnits();
|
||||
} else {
|
||||
// Primera vez - agregar mint por defecto
|
||||
await addMint('https://mint.cubabitcoin.org');
|
||||
@@ -403,6 +411,62 @@ class WalletProvider extends ChangeNotifier {
|
||||
return await getWallet(_activeMintUrl!, _activeUnit);
|
||||
}
|
||||
|
||||
/// Re-detecta unidades de todos los mints conocidos via NUT-04.
|
||||
/// Corre en background sin bloquear el arranque. Solo notifica si hay cambios.
|
||||
void _refreshAllMintUnits() async {
|
||||
bool changed = false;
|
||||
|
||||
for (final mintUrl in _mintUnits.keys.toList()) {
|
||||
try {
|
||||
final mintInfo = await getMintInfo(mintUrl: mintUrl);
|
||||
final units = <String>{};
|
||||
for (final method in mintInfo.nuts.nut04.methods) {
|
||||
units.add(method.unit);
|
||||
}
|
||||
final unitList = units.isEmpty ? ['sat'] : units.toList();
|
||||
unitList.sort((a, b) {
|
||||
if (a == 'sat') return -1;
|
||||
if (b == 'sat') return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
||||
final previous = _mintUnits[mintUrl];
|
||||
if (!_listEquals(previous, unitList)) {
|
||||
_mintUnits[mintUrl] = unitList;
|
||||
changed = true;
|
||||
debugPrint('Unidades actualizadas para $mintUrl: $unitList');
|
||||
|
||||
// Validar _activeUnit si este es el mint activo
|
||||
if (mintUrl == _activeMintUrl && !unitList.contains(_activeUnit)) {
|
||||
_activeUnit = unitList.first;
|
||||
debugPrint('Active unit reset to ${unitList.first} for $mintUrl');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error refrescando unidades de $mintUrl: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
try {
|
||||
await _saveMints();
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando mints actualizados: $e');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compara dos listas de strings por valor.
|
||||
bool _listEquals(List<String>? a, List<String>? b) {
|
||||
if (a == null || b == null) return a == b;
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// MULTI-MINT OPERATIONS
|
||||
// ============================================================
|
||||
@@ -602,8 +666,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.
|
||||
@@ -844,7 +921,20 @@ class WalletProvider extends ChangeNotifier {
|
||||
// DEBUG: transacciones ANTES del receive
|
||||
await _debugLogTransactions(wallet, 'BEFORE receive');
|
||||
|
||||
final amount = await wallet.receive(token: token, opts: opts);
|
||||
BigInt amount;
|
||||
try {
|
||||
amount = await wallet.receive(token: token, opts: opts);
|
||||
} catch (e) {
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
if (errorStr.contains('already signed')) {
|
||||
debugPrint('[RECEIVE] Already signed error, restoring wallet counters...');
|
||||
await wallet.restore();
|
||||
// Retry once after counter resync
|
||||
amount = await wallet.receive(token: token, opts: opts);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG: counters después de receive
|
||||
await KeysetDebug.logCounters('AFTER receive ($unit)');
|
||||
@@ -966,45 +1056,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).
|
||||
@@ -1116,12 +1194,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)
|
||||
@@ -1129,7 +1209,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;
|
||||
@@ -1138,8 +1218,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)
|
||||
@@ -1373,28 +1455,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1722,7 +1806,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,
|
||||
@@ -1730,7 +1814,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) {
|
||||
@@ -1769,6 +1853,8 @@ class WalletProvider extends ChangeNotifier {
|
||||
_activeUnit = 'sat';
|
||||
_mnemonic = null;
|
||||
_db = null;
|
||||
_cachedBalanceStream = null;
|
||||
_cachedBalanceKey = null;
|
||||
|
||||
// Limpiar metadata de transacciones
|
||||
await _txMetaStorage.clear();
|
||||
@@ -1797,6 +1883,8 @@ class WalletProvider extends ChangeNotifier {
|
||||
_activeUnit = 'sat';
|
||||
_mnemonic = null;
|
||||
_db = null;
|
||||
_cachedBalanceStream = null;
|
||||
_cachedBalanceKey = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -1972,6 +2060,8 @@ class WalletProvider extends ChangeNotifier {
|
||||
if (_activeMintController != null && !_activeMintController!.isClosed) {
|
||||
_activeMintController!.close();
|
||||
}
|
||||
_cachedBalanceStream = null;
|
||||
_cachedBalanceKey = null;
|
||||
confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -101,11 +101,15 @@ abstract class Wallet implements RustOpaqueInterface {
|
||||
|
||||
Future<BigInt> receive({required Token token, ReceiveOptions? opts});
|
||||
|
||||
/// Check pending-spent proofs with the mint and revert unspent ones.
|
||||
/// Returns the number of proofs recovered.
|
||||
Future<BigInt> reclaimPendingProofs();
|
||||
|
||||
Future<void> recoverIncompleteSagas();
|
||||
|
||||
Future<void> restore();
|
||||
|
||||
Future<Token> send({
|
||||
Future<SendResult> send({
|
||||
required PreparedSend send,
|
||||
String? memo,
|
||||
bool? includeMemo,
|
||||
@@ -178,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,
|
||||
@@ -186,6 +193,7 @@ class MintQuote {
|
||||
required this.state,
|
||||
this.token,
|
||||
this.error,
|
||||
this.transactionId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -196,7 +204,8 @@ class MintQuote {
|
||||
expiry.hashCode ^
|
||||
state.hashCode ^
|
||||
token.hashCode ^
|
||||
error.hashCode;
|
||||
error.hashCode ^
|
||||
transactionId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@@ -209,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 }
|
||||
@@ -256,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;
|
||||
|
||||
+101
-25
@@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.11.1';
|
||||
|
||||
@override
|
||||
int get rustContentHash => -413939836;
|
||||
int get rustContentHash => -1802776128;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -270,13 +270,17 @@ abstract class RustLibApi extends BaseApi {
|
||||
ReceiveOptions? opts,
|
||||
});
|
||||
|
||||
Future<BigInt> crateApiWalletWalletReclaimPendingProofs({
|
||||
required Wallet that,
|
||||
});
|
||||
|
||||
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
|
||||
required Wallet that,
|
||||
});
|
||||
|
||||
Future<void> crateApiWalletWalletRestore({required Wallet that});
|
||||
|
||||
Future<Token> crateApiWalletWalletSend({
|
||||
Future<SendResult> crateApiWalletWalletSend({
|
||||
required Wallet that,
|
||||
required PreparedSend send,
|
||||
String? memo,
|
||||
@@ -1898,7 +1902,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
|
||||
Future<BigInt> crateApiWalletWalletReclaimPendingProofs({
|
||||
required Wallet that,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
@@ -1916,6 +1920,42 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_u_64,
|
||||
decodeErrorData: sse_decode_error,
|
||||
),
|
||||
constMeta: kCrateApiWalletWalletReclaimPendingProofsConstMeta,
|
||||
argValues: [that],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiWalletWalletReclaimPendingProofsConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "Wallet_reclaim_pending_proofs",
|
||||
argNames: ["that"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
|
||||
required Wallet that,
|
||||
}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerWallet(
|
||||
that,
|
||||
serializer,
|
||||
);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_error,
|
||||
@@ -1946,7 +1986,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 46,
|
||||
funcId: 47,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1965,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,
|
||||
@@ -1988,12 +2028,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 47,
|
||||
funcId: 48,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_token,
|
||||
decodeSuccessData: sse_decode_send_result,
|
||||
decodeErrorData: sse_decode_error,
|
||||
),
|
||||
constMeta: kCrateApiWalletWalletSendConstMeta,
|
||||
@@ -2024,7 +2064,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 48,
|
||||
funcId: 49,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2070,7 +2110,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 49,
|
||||
funcId: 50,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2108,7 +2148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
sse_encode_String(proofsJson, serializer);
|
||||
sse_encode_opt_String(memo, serializer);
|
||||
sse_encode_opt_String(unit, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 50)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_token,
|
||||
@@ -2138,7 +2178,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_box_autoadd_token(token, serializer);
|
||||
sse_encode_opt_box_autoadd_usize(maxFragmentLength, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_list_String,
|
||||
@@ -2168,7 +2208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 52,
|
||||
funcId: 53,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2192,7 +2232,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 53)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -2218,7 +2258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 54,
|
||||
funcId: 55,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2243,7 +2283,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(secret, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -2266,7 +2306,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(mnemonic, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 57)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_list_prim_u_8_strict,
|
||||
@@ -2293,7 +2333,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(encoded, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 57)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 58)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_payment_request_info,
|
||||
@@ -2322,7 +2362,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 58,
|
||||
funcId: 59,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2349,7 +2389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 59,
|
||||
funcId: 60,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2376,7 +2416,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 60,
|
||||
funcId: 61,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -2401,7 +2441,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_list_prim_u_8_loose(raw, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_token,
|
||||
@@ -2424,7 +2464,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(encoded, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 63)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_token,
|
||||
@@ -3004,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]),
|
||||
@@ -3014,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]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3249,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
|
||||
@@ -4016,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,
|
||||
@@ -4024,6 +4078,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
state: var_state,
|
||||
token: var_token,
|
||||
error: var_error,
|
||||
transactionId: var_transactionId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4339,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,
|
||||
@@ -5137,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
|
||||
@@ -5425,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,
|
||||
@@ -5830,13 +5901,18 @@ class WalletImpl extends RustOpaque implements Wallet {
|
||||
opts: opts,
|
||||
);
|
||||
|
||||
/// Check pending-spent proofs with the mint and revert unspent ones.
|
||||
/// Returns the number of proofs recovered.
|
||||
Future<BigInt> reclaimPendingProofs() =>
|
||||
RustLib.instance.api.crateApiWalletWalletReclaimPendingProofs(that: this);
|
||||
|
||||
Future<void> recoverIncompleteSagas() => RustLib.instance.api
|
||||
.crateApiWalletWalletRecoverIncompleteSagas(that: this);
|
||||
|
||||
Future<void> restore() =>
|
||||
RustLib.instance.api.crateApiWalletWalletRestore(that: this);
|
||||
|
||||
Future<Token> send({
|
||||
Future<SendResult> send({
|
||||
required PreparedSend send,
|
||||
String? memo,
|
||||
bool? includeMemo,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
+248
-96
@@ -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,113 +211,193 @@ impl Wallet {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try WebSocket (NUT-17) subscription — if unavailable, HTTP polling still works
|
||||
let subscription = match self
|
||||
.inner
|
||||
.subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote.id.clone()]))
|
||||
.await
|
||||
{
|
||||
Ok(sub) => Some(sub),
|
||||
Err(e) => {
|
||||
info!(
|
||||
"Mint quote {}: WebSocket unavailable, using HTTP polling only: {e}",
|
||||
quote.id
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
// 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;
|
||||
|
||||
// Detect payment via two parallel paths:
|
||||
// - Path A: WebSocket (NUT-17) — fast when it works (sats on most mints)
|
||||
// - Path B: HTTP polling every 5s — fallback for mints that don't send
|
||||
// WebSocket notifications for all units (e.g. Nutshell 0.20.0 + USD)
|
||||
// First one to detect payment wins.
|
||||
let quote_id_for_poll = quote.id.clone();
|
||||
let poll_wallet = _self.clone();
|
||||
|
||||
// Timeout only covers detection, not minting.
|
||||
// If payment is detected near expiry, mint() must still complete.
|
||||
enum Detected {
|
||||
Paid,
|
||||
Issued,
|
||||
}
|
||||
|
||||
let mut subscription = subscription;
|
||||
let detected = tokio::time::timeout(timeout_dur, async {
|
||||
tokio::select! {
|
||||
// Path A: WebSocket subscription (skipped if unavailable)
|
||||
result = async {
|
||||
let Some(ref mut sub) = subscription else {
|
||||
return std::future::pending::<Detected>().await;
|
||||
};
|
||||
while let Some(event) = sub.recv().await {
|
||||
match event.into_inner() {
|
||||
NotificationPayload::MintQuoteBolt11Response(info)
|
||||
if info.state == CdkMintQuoteState::Paid =>
|
||||
{
|
||||
info!("Mint quote {} paid via WebSocket", quote.id);
|
||||
return Detected::Paid;
|
||||
}
|
||||
NotificationPayload::MintQuoteBolt11Response(info)
|
||||
if info.state == CdkMintQuoteState::Issued =>
|
||||
{
|
||||
return Detected::Issued;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
std::future::pending::<Detected>().await
|
||||
} => result,
|
||||
|
||||
// Path B: HTTP polling fallback
|
||||
result = async {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
match poll_wallet.inner.check_mint_quote_status("e_id_for_poll).await {
|
||||
Ok(q) if q.state == CdkMintQuoteState::Paid => {
|
||||
info!("Mint quote {} paid via HTTP polling", quote_id_for_poll);
|
||||
return Detected::Paid;
|
||||
}
|
||||
Ok(q) if q.state == CdkMintQuoteState::Issued => {
|
||||
return Detected::Issued;
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
} => result,
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Handle detection result — mint logic runs outside the timeout
|
||||
match detected {
|
||||
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(Detected::Paid) => {
|
||||
// Notify Dart: payment detected
|
||||
let _ = 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,
|
||||
state: CdkMintQuoteState::Paid.into(),
|
||||
token: None,
|
||||
error: None,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
info!("Mint polling stopped: Dart stream closed for {}", quote.id);
|
||||
break;
|
||||
}
|
||||
transaction_id: None,
|
||||
});
|
||||
|
||||
info!("Checking mint quote state for {}", quote.id);
|
||||
match _self.inner.check_mint_quote_status("e.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 => {
|
||||
// Mint the ecash tokens (outside timeout)
|
||||
match _self
|
||||
.inner
|
||||
.mint("e.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 {
|
||||
id: quote.id.clone(),
|
||||
request: quote.request.clone(),
|
||||
id: quote.id,
|
||||
request: quote.request,
|
||||
amount: Some(mint_amount.into()),
|
||||
expiry: Some(quote.expiry),
|
||||
state: CdkMintQuoteState::Issued.into(),
|
||||
token: Token::try_from(CdkToken::new(
|
||||
mint_url,
|
||||
mint_proofs,
|
||||
None,
|
||||
unit,
|
||||
))
|
||||
.ok(),
|
||||
error: None,
|
||||
transaction_id: tx_id,
|
||||
});
|
||||
_self.update_balance_streams().await;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = sink.add(MintQuote {
|
||||
id: quote.id,
|
||||
request: quote.request,
|
||||
amount: quote.amount.map(|a| a.into()),
|
||||
expiry: Some(quote.expiry),
|
||||
state: CdkMintQuoteState::Paid.into(),
|
||||
state: MintQuoteState::Error,
|
||||
token: None,
|
||||
error: None,
|
||||
error: Some(e.to_string()),
|
||||
transaction_id: None,
|
||||
});
|
||||
match _self
|
||||
.inner
|
||||
.mint("e.id, SplitTarget::None, None)
|
||||
.await
|
||||
{
|
||||
Ok(mint_proofs) => {
|
||||
let mint_amount =
|
||||
mint_proofs.total_amount().unwrap_or_default();
|
||||
let _ = sink.add(MintQuote {
|
||||
id: quote.id,
|
||||
request: quote.request,
|
||||
amount: Some(mint_amount.into()),
|
||||
expiry: Some(quote.expiry),
|
||||
state: CdkMintQuoteState::Issued.into(),
|
||||
token: Token::try_from(CdkToken::new(
|
||||
mint_url,
|
||||
mint_proofs,
|
||||
None,
|
||||
unit,
|
||||
))
|
||||
.ok(),
|
||||
error: None,
|
||||
});
|
||||
_self.update_balance_streams().await;
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
Ok(Detected::Issued) => {
|
||||
// Already issued (recovered from previous session)
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
@@ -384,6 +491,39 @@ impl Wallet {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Reclaim orphaned proofs ===
|
||||
|
||||
/// Check pending-spent proofs with the mint and revert unspent ones.
|
||||
/// Returns the number of proofs recovered.
|
||||
pub async fn reclaim_pending_proofs(&self) -> Result<u64, Error> {
|
||||
let pending = self.inner.get_pending_spent_proofs().await?;
|
||||
if pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// check_proofs_spent: queries mint AND removes spent proofs from local DB
|
||||
let states = self.inner.check_proofs_spent(pending).await?;
|
||||
|
||||
// Collect Y values of proofs the mint says are NOT spent
|
||||
// Only reclaim proofs the mint explicitly reports as Unspent.
|
||||
// Pending proofs (still being processed) must not be unreserved.
|
||||
let unspent_ys: Vec<PublicKey> = states
|
||||
.into_iter()
|
||||
.filter(|s| s.state == ProofState::Unspent)
|
||||
.map(|s| s.y)
|
||||
.collect();
|
||||
|
||||
let count = unspent_ys.len() as u64;
|
||||
if count > 0 {
|
||||
// Revert from PendingSpent to Unspent
|
||||
self.inner.unreserve_proofs(unspent_ys).await?;
|
||||
}
|
||||
|
||||
// Always refresh: check_proofs_spent may have removed spent proofs
|
||||
self.update_balance_streams().await;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// === Utility ===
|
||||
|
||||
pub async fn is_token_spent(&self, token: Token) -> Result<bool, Error> {
|
||||
@@ -427,6 +567,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,
|
||||
@@ -435,6 +584,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 {
|
||||
@@ -447,6 +598,7 @@ impl From<CdkMintQuote> for MintQuote {
|
||||
state: quote.state.into(),
|
||||
token: None,
|
||||
error: None,
|
||||
transaction_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
-19
@@ -40,7 +40,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
|
||||
default_rust_auto_opaque = RustAutoOpaqueMoi,
|
||||
);
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1";
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -413939836;
|
||||
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -1802776128;
|
||||
|
||||
// Section: executor
|
||||
|
||||
@@ -2315,6 +2315,63 @@ fn wire__crate__api__wallet__Wallet_receive_impl(
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__wallet__Wallet_reclaim_pending_proofs_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
rust_vec_len_: i32,
|
||||
data_len_: i32,
|
||||
) {
|
||||
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
|
||||
flutter_rust_bridge::for_generated::TaskInfo {
|
||||
debug_name: "Wallet_reclaim_pending_proofs",
|
||||
port: Some(port_),
|
||||
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
|
||||
},
|
||||
move || {
|
||||
let message = unsafe {
|
||||
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
|
||||
ptr_,
|
||||
rust_vec_len_,
|
||||
data_len_,
|
||||
)
|
||||
};
|
||||
let mut deserializer =
|
||||
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
|
||||
let api_that = <RustOpaqueMoi<
|
||||
flutter_rust_bridge::for_generated::RustAutoOpaqueInner<Wallet>,
|
||||
>>::sse_decode(&mut deserializer);
|
||||
deserializer.end();
|
||||
move |context| async move {
|
||||
transform_result_sse::<_, crate::api::error::Error>(
|
||||
(move || async move {
|
||||
let mut api_that_guard = None;
|
||||
let decode_indices_ =
|
||||
flutter_rust_bridge::for_generated::lockable_compute_decode_order(
|
||||
vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new(
|
||||
&api_that, 0, false,
|
||||
)],
|
||||
);
|
||||
for i in decode_indices_ {
|
||||
match i {
|
||||
0 => {
|
||||
api_that_guard =
|
||||
Some(api_that.lockable_decode_async_ref().await)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
let api_that_guard = api_that_guard.unwrap();
|
||||
let output_ok =
|
||||
crate::api::wallet::Wallet::reclaim_pending_proofs(&*api_that_guard)
|
||||
.await?;
|
||||
Ok(output_ok)
|
||||
})()
|
||||
.await,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
fn wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
|
||||
port_: flutter_rust_bridge::for_generated::MessagePort,
|
||||
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
|
||||
@@ -3540,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,
|
||||
@@ -3548,6 +3606,7 @@ impl SseDecode for crate::api::wallet::MintQuote {
|
||||
state: var_state,
|
||||
token: var_token,
|
||||
error: var_error,
|
||||
transaction_id: var_transactionId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3891,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 {
|
||||
@@ -4079,33 +4150,39 @@ fn pde_ffi_dispatcher_primary_impl(
|
||||
),
|
||||
43 => wire__crate__api__wallet__Wallet_prepare_send_impl(port, ptr, rust_vec_len, data_len),
|
||||
44 => wire__crate__api__wallet__Wallet_receive_impl(port, ptr, rust_vec_len, data_len),
|
||||
45 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
|
||||
45 => wire__crate__api__wallet__Wallet_reclaim_pending_proofs_impl(
|
||||
port,
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
46 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len),
|
||||
47 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => {
|
||||
46 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl(
|
||||
port,
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
47 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len),
|
||||
48 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len),
|
||||
49 => {
|
||||
wire__crate__api__wallet__Wallet_stream_balance_impl(port, ptr, rust_vec_len, data_len)
|
||||
}
|
||||
49 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl(
|
||||
50 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl(
|
||||
port,
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
52 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len),
|
||||
54 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len),
|
||||
58 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len),
|
||||
59 => wire__crate__api__wallet__receive_options_default_impl(
|
||||
53 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len),
|
||||
55 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len),
|
||||
59 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len),
|
||||
60 => wire__crate__api__wallet__receive_options_default_impl(
|
||||
port,
|
||||
ptr,
|
||||
rust_vec_len,
|
||||
data_len,
|
||||
),
|
||||
60 => {
|
||||
61 => {
|
||||
wire__crate__api__wallet__send_options_default_impl(port, ptr, rust_vec_len, data_len)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
@@ -4147,14 +4224,14 @@ fn pde_ffi_dispatcher_sync_impl(
|
||||
27 => wire__crate__api__wallet__Wallet_auto_accessor_set_mint_url_impl(ptr, rust_vec_len, data_len),
|
||||
28 => wire__crate__api__wallet__Wallet_auto_accessor_set_unit_impl(ptr, rust_vec_len, data_len),
|
||||
41 => wire__crate__api__wallet__Wallet_new_impl(ptr, rust_vec_len, data_len),
|
||||
50 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len),
|
||||
51 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len),
|
||||
53 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len),
|
||||
55 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len),
|
||||
56 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len),
|
||||
57 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len),
|
||||
61 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len),
|
||||
62 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len),
|
||||
51 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len),
|
||||
52 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len),
|
||||
54 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len),
|
||||
56 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len),
|
||||
57 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len),
|
||||
58 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len),
|
||||
62 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len),
|
||||
63 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
@@ -4463,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()
|
||||
}
|
||||
@@ -4727,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()
|
||||
@@ -5237,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5505,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) {
|
||||
|
||||
Reference in New Issue
Block a user