Compare commits

...
Author SHA1 Message Date
Forte11andGitHub 02fec140e1 Merge pull request #97 from Forte11Cuba/release/v0.2.0
Release APK / build (push) Canceled after 0s
chore: bump version to v0.2.0+3
2026-04-05 16:08:16 -06:00
Forte11Cuba 7cc92d6732 chore: bump version to v0.2.0+3 2026-04-05 15:51:01 -06:00
Forte11andGitHub 013560d25b Merge pull request #96 from Forte11Cuba/chore/rename-package-id
chore: rename package identifier from com.example.elcaju to me.elcaju
2026-04-03 18:44:20 -06:00
Forte11Cuba d19a58df84 chore: rename package identifier from com.example.elcaju to me.elcaju 2026-04-03 17:51:08 -06:00
Forte11andGitHub fd64d2f269 Merge pull request #95 from Forte11Cuba/fix/mint-http-polling-fallback
fix: add HTTP polling fallback for mint quote payment detection
2026-04-03 17:46:23 -06:00
Forte11Cuba 426b63456d fix: move mint logic outside timeout to prevent expired race 2026-04-03 17:25:38 -06:00
Forte11Cuba 4d2ad39323 fix: add HTTP polling fallback for mint quote payment detection 2026-04-03 16:56:22 -06:00
Forte11andGitHub 060c6acd4e Merge pull request #94 from Forte11Cuba/fix/refresh-mint-units-on-startup
fix: refresh mint units on startup via NUT-04
2026-04-03 16:48:24 -06:00
Forte11Cuba 14da243b89 fix: wrap _saveMints in try-catch inside async void method 2026-04-03 16:20:23 -06:00
Forte11Cuba 4f91a2be1c fix: validate activeUnit after refreshing mint units 2026-04-03 15:48:02 -06:00
Forte11Cuba ab13e8f6c2 fix: refresh mint units on startup via NUT-04 2026-04-03 15:40:51 -06:00
Forte11andGitHub f431f825c5 Merge pull request #93 from Forte11Cuba/fix/cache-balance-stream-and-sync-controller
fix: cache balance stream and use sync StreamController
2026-04-03 15:30:20 -06:00
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
Forte11Cuba 484f73a33b fix: only reclaim proofs explicitly reported as Unspent by mint 2026-03-31 03:36:16 -06:00
Forte11Cuba 67204a12f8 fix: handle 'already signed' error in receive with counter resync 2026-03-31 02:36:43 -06:00
Forte11andGitHub b4a62fe9e5 Merge pull request #86 from Forte11Cuba/fix/offline-send-token-v4
fix: use CDK to create valid V4 tokens for offline send
2026-03-31 01:49:26 -06:00
Forte11Cuba 638b288b7e fix: prevent ProofService leak and snapshot selectedTotal 2026-03-31 00:42:17 -06:00
Forte11Cuba 357cc6827f fix: prevent ProofService close during in-flight token creation 2026-03-31 00:31:11 -06:00
Forte11Cuba 682ec4ba7d fix: log rollback error instead of swallowing silently 2026-03-30 23:36:52 -06:00
Forte11Cuba 03cf1d2119 fix: rollback proofs to UNSPENT if offline token creation fails 2026-03-30 23:30:00 -06:00
Forte11Cuba 2309a2edda fix: use CDK to create valid V4 tokens for offline send 2026-03-30 23:09:40 -06:00
Forte11andGitHub ac1ac11075 Merge pull request #85 from Forte11Cuba/fix/dispose-crash-modal-overlap
fix: dispose crash and modal overlap
2026-03-30 00:20:13 -06:00
Forte11Cuba aafa9f97eb fix: dispose crash and modal overlap 2026-03-30 00:10:34 -06:00
Forte11andGitHub 7d3fe5ac58 Merge pull request #84 from Forte11Cuba/fix/nostr-listener-fail-to-post
fix: reduce Nostr listener cancellation delay from 5s to 1s
2026-03-29 23:53:06 -06:00
Forte11Cuba b9c01ce865 fix: reduce Nostr listener cancellation delay from 5s to 1s 2026-03-29 23:45:36 -06:00
Forte11andGitHub 679aa6be5f Merge pull request #83 from Forte11Cuba/feat/nostr-key-persistence
feat: persist Nostr ephemeral keys for payment request recovery
2026-03-29 23:35:54 -06:00
22 changed files with 1029 additions and 377 deletions
+6 -6
View File
@@ -368,7 +368,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -384,7 +384,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -401,7 +401,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -416,7 +416,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -547,7 +547,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -569,7 +569,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
+1 -1
View File
@@ -33,7 +33,7 @@ class ProofService {
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
_db = await openDatabase(dbPath);
_db = await openDatabase(dbPath, singleInstance: false);
return _db!;
}
+138 -48
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;
@@ -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();
}
+171 -154
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
@@ -59,22 +62,26 @@ class _RequestScreenState extends State<RequestScreen> {
// Success
BigInt _receivedAmount = BigInt.zero;
late final WalletProvider _walletProvider;
@override
void initState() {
super.initState();
_activeUnit = context.read<WalletProvider>().activeUnit;
_walletProvider = context.read<WalletProvider>();
_activeUnit = _walletProvider.activeUnit;
_checkNfc();
}
@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) {
context.read<WalletProvider>().removePendingNostrRequest();
_walletProvider.removePendingNostrRequest();
}
super.dispose();
}
@@ -346,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,
),
),
],
),
),
@@ -434,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(
@@ -709,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>();
@@ -731,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);
@@ -748,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();
@@ -784,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;
@@ -800,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();
}
@@ -813,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;
@@ -828,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 ───
+28 -8
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
@@ -5,6 +6,7 @@ import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/models/proof.dart';
import '../../core/services/proof_service.dart';
import '../../src/rust/api/token.dart' as cdk;
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
@@ -45,7 +47,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
@override
void dispose() {
_memoController.dispose();
_proofService.close();
if (!_isCreating) _proofService.close();
super.dispose();
}
@@ -321,32 +323,40 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
}
Future<void> _createOfflineToken() async {
final selectedProofs = _selectedProofs;
final selectedTotal = _proofService.calculateTotal(selectedProofs);
var proofsMarkedPending = false;
setState(() {
_isCreating = true;
});
try {
final selectedProofs = _selectedProofs;
// Marcar proofs como PENDING_SPENT
await _proofService.markProofsPendingSpent(selectedProofs);
proofsMarkedPending = true;
// Crear token V3
// Crear token via CDK (produce V4/cashuB válido)
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
final token = _proofService.createTokenV3(
final proofsJson = jsonEncode(
selectedProofs.map((p) => p.toTokenProof()).toList(),
);
final cdkToken = cdk.createOfflineToken(
mintUrl: widget.mintUrl,
proofs: selectedProofs,
proofsJson: proofsJson,
memo: memo,
unit: widget.unit,
);
final token = cdkToken.encoded;
if (mounted) {
_isCreating = false;
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ShareTokenScreen(
token: token,
amount: _selectedTotal,
amount: selectedTotal,
unit: widget.unit,
memo: memo,
),
@@ -354,7 +364,17 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
);
}
} catch (e) {
if (!mounted) return;
if (proofsMarkedPending) {
try {
await _proofService.markProofsUnspent(selectedProofs);
} catch (rollbackErr) {
debugPrint('Failed to rollback proofs to UNSPENT: $rollbackErr');
}
}
if (!mounted) {
_proofService.close();
return;
}
final errorMessage = L10n.of(context)!.creatingTokenError(e.toString());
setState(() {
_errorMessage = errorMessage;
+1
View File
@@ -581,6 +581,7 @@ class _SendScreenState extends State<SendScreen> {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _ConfirmationModal(
amount: _amount,
unit: _activeUnit,
+7 -2
View File
@@ -72,8 +72,13 @@ class _ShareTokenScreenState extends State<ShareTokenScreen> {
}
void _encodeTokenToUR() {
// Parsear el token string a objeto Token de cdk-flutter
final token = cdk.Token.parse(encoded: widget.token);
// Construir Token directamente sin Token.parse para evitar validación
// estricta de keyset IDs. encodeQrToken solo usa token.encoded.
final token = cdk.Token(
encoded: widget.token,
amount: widget.amount,
mintUrl: '',
);
// Codificar a fragmentos UR usando cdk-flutter
// maxFragmentLength por defecto es 150 bytes
+1
View File
@@ -249,6 +249,7 @@ class _MintScreenState extends State<MintScreen> {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => _ConfirmationModal(
amount: _amount,
unit: _activeUnit,
+15
View File
@@ -10,6 +10,21 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `proofs`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `fmt`, `from_str`, `try_from`, `try_into`, `try_into`
/// Create a token from raw proof data without network access.
/// Uses CDK's CdkToken::new to produce a valid V4 (cashuB) token.
/// `proofs_json` is a JSON array of proofs: [{"id":"...","amount":1,"secret":"...","C":"..."}, ...]
Token createOfflineToken({
required String mintUrl,
required String proofsJson,
String? memo,
String? unit,
}) => RustLib.instance.api.crateApiTokenCreateOfflineToken(
mintUrl: mintUrl,
proofsJson: proofsJson,
memo: memo,
unit: unit,
);
List<String> encodeQrToken({required Token token, BigInt? maxFragmentLength}) =>
RustLib.instance.api.crateApiTokenEncodeQrToken(
token: token,
+36 -3
View File
@@ -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;
+141 -24
View File
@@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.11.1';
@override
int get rustContentHash => -262065523;
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,
@@ -290,6 +294,13 @@ abstract class RustLibApi extends BaseApi {
required NostrListenerHandle handle,
});
Token crateApiTokenCreateOfflineToken({
required String mintUrl,
required String proofsJson,
String? memo,
String? unit,
});
List<String> crateApiTokenEncodeQrToken({
required Token token,
BigInt? maxFragmentLength,
@@ -1891,7 +1902,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
@override
Future<void> crateApiWalletWalletRecoverIncompleteSagas({
Future<BigInt> crateApiWalletWalletReclaimPendingProofs({
required Wallet that,
}) {
return handler.executeNormal(
@@ -1909,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,
@@ -1939,7 +1986,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 47,
port: port_,
);
},
@@ -1958,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,
@@ -1981,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,
@@ -2017,7 +2064,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 48,
funcId: 49,
port: port_,
);
},
@@ -2063,7 +2110,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 49,
funcId: 50,
port: port_,
);
},
@@ -2086,6 +2133,40 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["that", "handle", "sink"],
);
@override
Token crateApiTokenCreateOfflineToken({
required String mintUrl,
required String proofsJson,
String? memo,
String? unit,
}) {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(mintUrl, serializer);
sse_encode_String(proofsJson, serializer);
sse_encode_opt_String(memo, serializer);
sse_encode_opt_String(unit, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
decodeErrorData: sse_decode_error,
),
constMeta: kCrateApiTokenCreateOfflineTokenConstMeta,
argValues: [mintUrl, proofsJson, memo, unit],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiTokenCreateOfflineTokenConstMeta =>
const TaskConstMeta(
debugName: "create_offline_token",
argNames: ["mintUrl", "proofsJson", "memo", "unit"],
);
@override
List<String> crateApiTokenEncodeQrToken({
required Token token,
@@ -2097,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: 50)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_String,
@@ -2127,7 +2208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 51,
funcId: 53,
port: port_,
);
},
@@ -2151,7 +2232,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 52)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -2177,7 +2258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 53,
funcId: 55,
port: port_,
);
},
@@ -2202,7 +2283,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(secret, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 54)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -2225,7 +2306,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(mnemonic, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 55)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 57)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_list_prim_u_8_strict,
@@ -2252,7 +2333,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(encoded, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 56)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 58)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_payment_request_info,
@@ -2281,7 +2362,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 57,
funcId: 59,
port: port_,
);
},
@@ -2308,7 +2389,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 58,
funcId: 60,
port: port_,
);
},
@@ -2335,7 +2416,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 59,
funcId: 61,
port: port_,
);
},
@@ -2360,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: 60)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 62)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
@@ -2383,7 +2464,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(encoded, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 61)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 63)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_token,
@@ -2963,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]),
@@ -2973,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]),
);
}
@@ -3208,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
@@ -3975,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,
@@ -3983,6 +4078,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
state: var_state,
token: var_token,
error: var_error,
transactionId: var_transactionId,
);
}
@@ -4298,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,
@@ -5096,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
@@ -5384,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,
@@ -5789,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,
+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,
+1 -1
View File
@@ -7,7 +7,7 @@ project(runner LANGUAGES CXX)
set(BINARY_NAME "elcaju")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.elcaju")
set(APPLICATION_ID "me.elcaju")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+3 -3
View File
@@ -385,7 +385,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
@@ -399,7 +399,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
@@ -413,7 +413,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/elcaju.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/elcaju";
+2 -2
View File
@@ -8,7 +8,7 @@
PRODUCT_NAME = elcaju
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.example.elcaju
PRODUCT_BUNDLE_IDENTIFIER = me.elcaju
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved.
PRODUCT_COPYRIGHT = Copyright © 2026 ElCaju. All rights reserved.
+1 -1
View File
@@ -1,7 +1,7 @@
name: elcaju
description: "ElCaju - Tu wallet de ecash privado"
publish_to: 'none'
version: 0.1.0+2
version: 0.2.0+3
environment:
sdk: ^3.11.0
+19 -7
View File
@@ -461,21 +461,33 @@ 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 5s 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.
let mut notifications = client.notifications();
loop {
match tokio::time::timeout(Duration::from_secs(5), notifications.recv()).await {
match tokio::time::timeout(Duration::from_secs(1), notifications.recv()).await {
Ok(Ok(notification)) => {
// Check sink is alive before processing
if sink
.add(NostrPaymentEvent {
state: NostrPaymentState::Waiting,
amount: None,
error: None,
})
.is_err()
{
client.disconnect().await;
return Err(Error::Network("Listener cancelled by client".to_string()));
}
if let nostr_sdk::RelayPoolNotification::Event { event, .. } = notification {
// Try to unwrap NIP-17 gift-wrap
let unwrapped = match client.unwrap_gift_wrap(&event).await {
+25 -1
View File
@@ -4,7 +4,10 @@ use std::{
};
use bc_ur::{prelude::CBOR, MultipartDecoder, MultipartEncoder, UR};
use cdk::nuts::Token as CdkToken;
use cdk::{
mint_url::MintUrl,
nuts::{CurrencyUnit, Proof, Token as CdkToken},
};
use cdk_common::{KeySetInfo, Proofs};
use flutter_rust_bridge::frb;
@@ -92,6 +95,27 @@ impl TryInto<CdkToken> for &Token {
}
}
// === Offline token creation ===
/// Create a token from raw proof data without network access.
/// Uses CDK's CdkToken::new to produce a valid V4 (cashuB) token.
/// `proofs_json` is a JSON array of proofs: [{"id":"...","amount":1,"secret":"...","C":"..."}, ...]
#[frb(sync)]
pub fn create_offline_token(
mint_url: &str,
proofs_json: &str,
memo: Option<String>,
unit: Option<String>,
) -> Result<Token, Error> {
let mint_url = MintUrl::from_str(mint_url)?;
let unit = unit
.map(|u| CurrencyUnit::from_str(&u).unwrap_or(CurrencyUnit::Custom(u)))
.unwrap_or(CurrencyUnit::Sat);
let proofs: Vec<Proof> = serde_json::from_str(proofs_json)?;
let token = CdkToken::new(mint_url, proofs, memo, unit);
Token::try_from(token)
}
// === QR Multiframe (bc-ur) ===
pub struct TokenDecoder {
+248 -96
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,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(&quote_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(&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 => {
// Mint the ecash tokens (outside timeout)
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 {
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(&quote.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,
}
}
}
+165 -18
View File
@@ -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 = -262065523;
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,
@@ -2618,6 +2675,44 @@ fn wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl(
},
)
}
fn wire__crate__api__token__create_offline_token_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "create_offline_token",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
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_mint_url = <String>::sse_decode(&mut deserializer);
let api_proofs_json = <String>::sse_decode(&mut deserializer);
let api_memo = <Option<String>>::sse_decode(&mut deserializer);
let api_unit = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, crate::api::error::Error>((move || {
let output_ok = crate::api::token::create_offline_token(
&api_mint_url,
&api_proofs_json,
api_memo,
api_unit,
)?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__token__encode_qr_token_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
@@ -3502,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,
@@ -3510,6 +3606,7 @@ impl SseDecode for crate::api::wallet::MintQuote {
state: var_state,
token: var_token,
error: var_error,
transaction_id: var_transactionId,
};
}
}
@@ -3853,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 {
@@ -4041,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,
),
51 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len),
53 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len),
57 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len),
58 => 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,
),
59 => {
61 => {
wire__crate__api__wallet__send_options_default_impl(port, ptr, rust_vec_len, data_len)
}
_ => unreachable!(),
@@ -4109,13 +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__encode_qr_token_impl(ptr, rust_vec_len, data_len),
52 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len),
54 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len),
55 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len),
56 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len),
60 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len),
61 => 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!(),
}
}
@@ -4424,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()
}
@@ -4688,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()
@@ -5198,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);
}
}
@@ -5466,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) {
+2 -2
View File
@@ -89,11 +89,11 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "CompanyName", "ElCaju" "\0"
VALUE "FileDescription", "elcaju" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "elcaju" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 ElCaju. All rights reserved." "\0"
VALUE "OriginalFilename", "elcaju.exe" "\0"
VALUE "ProductName", "elcaju" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"