From a0f9ca738b9a01844997d18096d7749d29bd2df0 Mon Sep 17 00:00:00 2001 From: Forte11Cuba Date: Sat, 18 Apr 2026 02:09:50 -0600 Subject: [PATCH] fix: observe-only reconcile + settled state for offline sends --- lib/data/pending_send.dart | 57 +++- lib/data/pending_send_storage.dart | 76 ++++- lib/l10n/app_de.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_localizations.dart | 12 + lib/l10n/app_localizations_de.dart | 6 + lib/l10n/app_localizations_en.dart | 6 + lib/l10n/app_localizations_es.dart | 6 + lib/l10n/app_localizations_fr.dart | 6 + lib/l10n/app_localizations_it.dart | 6 + lib/l10n/app_localizations_ja.dart | 6 + lib/l10n/app_localizations_ko.dart | 6 + lib/l10n/app_localizations_pt.dart | 6 + lib/l10n/app_localizations_ru.dart | 6 + lib/l10n/app_localizations_sw.dart | 6 + lib/l10n/app_localizations_zh.dart | 6 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_sw.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/providers/wallet_provider.dart | 105 +++++- lib/screens/1_splash/splash_screen.dart | 7 +- lib/screens/9_history/history_screen.dart | 397 ++++++++++++++++++---- lib/src/rust/api/wallet.dart | 66 +++- lib/src/rust/frb_generated.dart | 165 ++++++--- rust/src/api/wallet.rs | 215 +++++++++--- rust/src/frb_generated.rs | 144 ++++++-- 32 files changed, 1123 insertions(+), 209 deletions(-) diff --git a/lib/data/pending_send.dart b/lib/data/pending_send.dart index d89feae..02185cb 100644 --- a/lib/data/pending_send.dart +++ b/lib/data/pending_send.dart @@ -1,4 +1,30 @@ -/// Modelo para rastrear envíos offline pendientes. +/// Estado del envío dentro del storage propio. +/// +/// - [active] : el receptor aún no reclamó; los proofs están PendingSpent +/// localmente. Mostrar con acción de cancelar/reclamar. +/// - [settled] : la reconciliación con el mint confirmó que los proofs +/// fueron gastados (receptor reclamó) o que una reconciliación +/// previa ya los procesó. Mostrar como tx saliente histórica, +/// sin acción de cancel. +enum PendingSendStatus { + active, + settled; + + String get wireName => name; + + static PendingSendStatus fromWire(String? raw) { + switch (raw) { + case 'settled': + return PendingSendStatus.settled; + case 'active': + case null: + default: + return PendingSendStatus.active; + } + } +} + +/// Modelo para rastrear envíos offline. /// /// Los envíos offline no crean una Transaction en CDK (porque se hacen con /// selección manual de proofs + createOfflineToken), por lo que necesitamos @@ -32,6 +58,13 @@ class PendingSend { /// Memo opcional final String? memo; + /// Estado del envío. Ver [PendingSendStatus]. + final PendingSendStatus status; + + /// Timestamp de cuando el envío pasó a [PendingSendStatus.settled]. + /// Null mientras esté activo. Se usa para ordenar en el historial. + final DateTime? settledAt; + PendingSend({ required this.id, required this.encoded, @@ -41,8 +74,17 @@ class PendingSend { required List proofYs, required this.createdAt, this.memo, + this.status = PendingSendStatus.active, + this.settledAt, }) : proofYs = List.unmodifiable(proofYs); + bool get isActive => status == PendingSendStatus.active; + bool get isSettled => status == PendingSendStatus.settled; + + /// Para ordenar en el historial: si está settled, usar settledAt; si no, + /// usar createdAt. + DateTime get effectiveTimestamp => settledAt ?? createdAt; + PendingSend copyWith({ String? id, String? encoded, @@ -52,6 +94,8 @@ class PendingSend { List? proofYs, DateTime? createdAt, String? memo, + PendingSendStatus? status, + DateTime? settledAt, }) { return PendingSend( id: id ?? this.id, @@ -62,6 +106,8 @@ class PendingSend { proofYs: proofYs ?? this.proofYs, createdAt: createdAt ?? this.createdAt, memo: memo ?? this.memo, + status: status ?? this.status, + settledAt: settledAt ?? this.settledAt, ); } @@ -75,6 +121,8 @@ class PendingSend { 'proof_ys': proofYs.join(','), 'created_at': createdAt.millisecondsSinceEpoch, 'memo': memo, + 'status': status.wireName, + 'settled_at': settledAt?.millisecondsSinceEpoch, }; } @@ -83,6 +131,7 @@ class PendingSend { final ys = ysRaw.isEmpty ? [] : ysRaw.split(',').where((s) => s.isNotEmpty).toList(); + final settledAtRaw = map['settled_at'] as int?; return PendingSend( id: map['id'] as String, encoded: map['encoded'] as String, @@ -92,10 +141,14 @@ class PendingSend { proofYs: ys, createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int), memo: map['memo'] as String?, + status: PendingSendStatus.fromWire(map['status'] as String?), + settledAt: settledAtRaw == null + ? null + : DateTime.fromMillisecondsSinceEpoch(settledAtRaw), ); } @override String toString() => - 'PendingSend(id: $id, amount: $amount $unit, mintUrl: $mintUrl, ys: ${proofYs.length})'; + 'PendingSend(id: $id, amount: $amount $unit, mintUrl: $mintUrl, ys: ${proofYs.length}, status: ${status.name})'; } diff --git a/lib/data/pending_send_storage.dart b/lib/data/pending_send_storage.dart index eeb380a..a6982bb 100644 --- a/lib/data/pending_send_storage.dart +++ b/lib/data/pending_send_storage.dart @@ -5,17 +5,19 @@ import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'pending_send.dart'; -/// Storage persistente para envíos offline pendientes. +/// Storage persistente para envíos offline. /// /// Los envíos offline no crean una Transaction en CDK, así que necesitamos /// rastrearlos nosotros para poder: -/// - Mostrarlos en el historial como "pendientes de reclamo por el receptor". +/// - Mostrarlos en el historial como activos (cancelables) o como +/// liquidados (settled, display-only). /// - Reclamar las proofs por transacción si el receptor no reclamó. /// /// Mirrors PendingTokenStorage (singleton + cache + SQLite + change stream). class PendingSendStorage { static const _dbName = 'pending_sends.db'; static const _tableName = 'pending_sends'; + static const _schemaVersion = 2; Database? _db; final Map _cache = {}; @@ -33,6 +35,9 @@ class PendingSendStorage { bool get isInitialized => _isInitialized; int get count => _cache.length; bool get hasPendingSends => _cache.isNotEmpty; + int get activeCount => + _cache.values.where((s) => s.isActive).length; + bool get hasActivePendingSends => activeCount > 0; Future init() async { if (_isInitialized) return; @@ -47,7 +52,7 @@ class PendingSendStorage { _db = await openDatabase( dbPath, - version: 1, + version: _schemaVersion, onCreate: (db, version) async { await db.execute(''' CREATE TABLE $_tableName ( @@ -58,7 +63,9 @@ class PendingSendStorage { unit TEXT NOT NULL, proof_ys TEXT NOT NULL, created_at INTEGER NOT NULL, - memo TEXT + memo TEXT, + status TEXT NOT NULL DEFAULT 'active', + settled_at INTEGER ) '''); await db.execute( @@ -67,12 +74,32 @@ class PendingSendStorage { await db.execute( 'CREATE INDEX idx_ps_created_at ON $_tableName(created_at)', ); + await db.execute( + 'CREATE INDEX idx_ps_status ON $_tableName(status)', + ); + }, + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + // v2: status + settled_at. Los records existentes quedan 'active'. + await db.execute( + "ALTER TABLE $_tableName ADD COLUMN status TEXT NOT NULL DEFAULT 'active'", + ); + await db.execute( + 'ALTER TABLE $_tableName ADD COLUMN settled_at INTEGER', + ); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_ps_status ON $_tableName(status)', + ); + } }, ); await _loadFromDb(); _isInitialized = true; - debugPrint('PendingSendStorage inicializado con ${_cache.length} envíos'); + debugPrint( + 'PendingSendStorage inicializado: ${_cache.length} envíos ' + '($activeCount activos)', + ); } Future _loadFromDb() async { @@ -103,6 +130,7 @@ class PendingSendStorage { proofYs: proofYs, createdAt: DateTime.now(), memo: memo, + // status default = active ); _cache[send.id] = send; await _db?.insert(_tableName, send.toMap()); @@ -110,6 +138,30 @@ class PendingSendStorage { return send; } + /// Marca un envío como liquidado (settled). Se usa cuando la reconciliación + /// confirmó que el receptor reclamó o el envío ya no es actionable. + /// Si no existe el record, no hace nada. + Future markSettled(String id) async { + final current = _cache[id]; + if (current == null) return; + if (current.isSettled) return; // idempotente + final updated = current.copyWith( + status: PendingSendStatus.settled, + settledAt: DateTime.now(), + ); + _cache[id] = updated; + await _db?.update( + _tableName, + updated.toMap(), + where: 'id = ?', + whereArgs: [id], + ); + _changesController.add(null); + } + + /// Borrado permanente. Reservado para "dismiss manual" (no implementado + /// hoy en UI) o cleanup explícito del wallet (`clear`). Para el caso + /// "receptor reclamó", usar `markSettled` en vez de este. Future remove(String id) async { _cache.remove(id); await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]); @@ -122,6 +174,20 @@ class PendingSendStorage { return list; } + /// Envíos activos (el receptor aún no reclamó). Ordenados por creación + /// descendente. Renderizables en el historial con botón cancel. + List listActive() { + return _cache.values.where((s) => s.isActive).toList() + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + + /// Envíos liquidados (el receptor ya reclamó). Ordenados por `settledAt` + /// descendente — representa la línea temporal de finalización. + List listSettled() { + return _cache.values.where((s) => s.isSettled).toList() + ..sort((a, b) => b.effectiveTimestamp.compareTo(a.effectiveTimestamp)); + } + PendingSend? get(String id) => _cache[id]; /// Filtra por mint+unit. Útil para mostrar pendientes específicos del diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index df2e627..ed9dfc4 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -243,6 +243,8 @@ "pendingStatus": "Ausstehend", "receivedStatus": "Empfangen", "sentStatus": "Gesendet", + "receiving": "Wird empfangen", + "sending": "Wird gesendet", "now": "Jetzt", "agoMinutes": "Vor {minutes} Min", "@agoMinutes": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9a002aa..dcd1908 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -209,6 +209,8 @@ "pendingStatus": "Pending", "receivedStatus": "Received", "sentStatus": "Sent", + "receiving": "Receiving", + "sending": "Sending", "now": "Now", "agoMinutes": "{minutes} min ago", "agoHours": "{hours} h ago", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c0466ec..827ed3f 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -251,6 +251,8 @@ "pendingStatus": "Pendiente", "receivedStatus": "Recibido", "sentStatus": "Enviado", + "receiving": "Recibiendo", + "sending": "Enviando", "now": "Ahora", "agoMinutes": "Hace {minutes} min", "@agoMinutes": { diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 81cb851..fa1db88 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -243,6 +243,8 @@ "pendingStatus": "En attente", "receivedStatus": "Reçu", "sentStatus": "Envoyé", + "receiving": "En réception", + "sending": "En envoi", "now": "Maintenant", "agoMinutes": "Il y a {minutes} min", "@agoMinutes": { diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ddd6248..a6a880c 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -243,6 +243,8 @@ "pendingStatus": "In attesa", "receivedStatus": "Ricevuto", "sentStatus": "Inviato", + "receiving": "In ricezione", + "sending": "In invio", "now": "Adesso", "agoMinutes": "{minutes} min fa", "@agoMinutes": { diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index f41435d..2a4ffc7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -243,6 +243,8 @@ "pendingStatus": "保留中", "receivedStatus": "受取済み", "sentStatus": "送金済み", + "receiving": "受取中", + "sending": "送金中", "now": "たった今", "agoMinutes": "{minutes}分前", "@agoMinutes": { diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 7f19b9d..0c8618c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -243,6 +243,8 @@ "pendingStatus": "대기 중", "receivedStatus": "받음", "sentStatus": "보냄", + "receiving": "받는 중", + "sending": "보내는 중", "now": "방금", "agoMinutes": "{minutes}분 전", "@agoMinutes": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 438b560..8431e55 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1135,6 +1135,18 @@ abstract class L10n { /// **'Enviado'** String get sentStatus; + /// No description provided for @receiving. + /// + /// In es, this message translates to: + /// **'Recibiendo'** + String get receiving; + + /// No description provided for @sending. + /// + /// In es, this message translates to: + /// **'Enviando'** + String get sending; + /// No description provided for @now. /// /// In es, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index e349d0e..ce7f777 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -558,6 +558,12 @@ class L10nDe extends L10n { @override String get sentStatus => 'Gesendet'; + @override + String get receiving => 'Wird empfangen'; + + @override + String get sending => 'Wird gesendet'; + @override String get now => 'Jetzt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 93aa8ca..d330eca 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -551,6 +551,12 @@ class L10nEn extends L10n { @override String get sentStatus => 'Sent'; + @override + String get receiving => 'Receiving'; + + @override + String get sending => 'Sending'; + @override String get now => 'Now'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d56c030..dc9ee68 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -551,6 +551,12 @@ class L10nEs extends L10n { @override String get sentStatus => 'Enviado'; + @override + String get receiving => 'Recibiendo'; + + @override + String get sending => 'Enviando'; + @override String get now => 'Ahora'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 0e4990f..327d54d 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -558,6 +558,12 @@ class L10nFr extends L10n { @override String get sentStatus => 'Envoyé'; + @override + String get receiving => 'En réception'; + + @override + String get sending => 'En envoi'; + @override String get now => 'Maintenant'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 990a971..8e242a8 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -553,6 +553,12 @@ class L10nIt extends L10n { @override String get sentStatus => 'Inviato'; + @override + String get receiving => 'In ricezione'; + + @override + String get sending => 'In invio'; + @override String get now => 'Adesso'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 93f0ec3..17d0254 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -544,6 +544,12 @@ class L10nJa extends L10n { @override String get sentStatus => '送金済み'; + @override + String get receiving => '受取中'; + + @override + String get sending => '送金中'; + @override String get now => 'たった今'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 638dbb1..0ac4997 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -546,6 +546,12 @@ class L10nKo extends L10n { @override String get sentStatus => '보냄'; + @override + String get receiving => '받는 중'; + + @override + String get sending => '보내는 중'; + @override String get now => '방금'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 0658a04..fc7b289 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -554,6 +554,12 @@ class L10nPt extends L10n { @override String get sentStatus => 'Enviado'; + @override + String get receiving => 'Recebendo'; + + @override + String get sending => 'Enviando'; + @override String get now => 'Agora'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index ae4015e..1bda627 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -552,6 +552,12 @@ class L10nRu extends L10n { @override String get sentStatus => 'Отправлено'; + @override + String get receiving => 'Получение'; + + @override + String get sending => 'Отправка'; + @override String get now => 'Сейчас'; diff --git a/lib/l10n/app_localizations_sw.dart b/lib/l10n/app_localizations_sw.dart index 44faa19..4b39e80 100644 --- a/lib/l10n/app_localizations_sw.dart +++ b/lib/l10n/app_localizations_sw.dart @@ -551,6 +551,12 @@ class L10nSw extends L10n { @override String get sentStatus => 'Imetumwa'; + @override + String get receiving => 'Inapokea'; + + @override + String get sending => 'Inatumwa'; + @override String get now => 'Sasa'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 313879b..01769a5 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -542,6 +542,12 @@ class L10nZh extends L10n { @override String get sentStatus => '已发送'; + @override + String get receiving => '接收中'; + + @override + String get sending => '发送中'; + @override String get now => '刚刚'; diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6a89396..76db2bc 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -243,6 +243,8 @@ "pendingStatus": "Pendente", "receivedStatus": "Recebido", "sentStatus": "Enviado", + "receiving": "Recebendo", + "sending": "Enviando", "now": "Agora", "agoMinutes": "Há {minutes} min", "@agoMinutes": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1dd26c6..cbfed63 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -243,6 +243,8 @@ "pendingStatus": "Ожидание", "receivedStatus": "Получено", "sentStatus": "Отправлено", + "receiving": "Получение", + "sending": "Отправка", "now": "Сейчас", "agoMinutes": "{minutes} мин назад", "@agoMinutes": { diff --git a/lib/l10n/app_sw.arb b/lib/l10n/app_sw.arb index 630f4f8..ad47b8c 100644 --- a/lib/l10n/app_sw.arb +++ b/lib/l10n/app_sw.arb @@ -243,6 +243,8 @@ "pendingStatus": "Inasubiri", "receivedStatus": "Imepokelewa", "sentStatus": "Imetumwa", + "receiving": "Inapokea", + "sending": "Inatumwa", "now": "Sasa", "agoMinutes": "Dakika {minutes} zilizopita", "@agoMinutes": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fdd0d1c..016a4c0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -243,6 +243,8 @@ "pendingStatus": "待处理", "receivedStatus": "已收到", "sentStatus": "已发送", + "receiving": "接收中", + "sending": "发送中", "now": "刚刚", "agoMinutes": "{minutes} 分钟前", "@agoMinutes": { diff --git a/lib/providers/wallet_provider.dart b/lib/providers/wallet_provider.dart index f136e6e..99e4cd7 100644 --- a/lib/providers/wallet_provider.dart +++ b/lib/providers/wallet_provider.dart @@ -135,11 +135,23 @@ class WalletProvider extends ChangeNotifier { // ============================================================ int get pendingSendCount => _pendingSendStorage.count; + int get activePendingSendCount => _pendingSendStorage.activeCount; bool get hasPendingSends => _pendingSendStorage.hasPendingSends; + bool get hasActivePendingSends => _pendingSendStorage.hasActivePendingSends; Stream get pendingSendChanges => _pendingSendStorage.changes; List listPendingSends() => _pendingSendStorage.listAll(); + /// Envíos activos (receptor aún no reclamó). Render: tile warning con + /// botón cancel. + List listActivePendingSends() => + _pendingSendStorage.listActive(); + + /// Envíos liquidados (receptor reclamó). Render: tile outgoing settled, + /// sin botón cancel. + List listSettledPendingSends() => + _pendingSendStorage.listSettled(); + List listPendingSendsByMintUnit(String mintUrl, String unit) => _pendingSendStorage.listByMintUnit(mintUrl, unit); @@ -176,27 +188,88 @@ class WalletProvider extends ChangeNotifier { } /// Reclama las proofs de un envío offline pendiente. Verifica con el mint: - /// - Si están UNSPENT → revierte a Unspent local y borra el PendingSend. - /// - Si están SPENT (receptor ya reclamó) → borra el PendingSend igual - /// (el envío se completó). - /// - Si algunas PENDING en el mint → deja el PendingSend para reintento. + /// - Si algunas están UNSPENT → revierte localmente y borra el PendingSend. + /// - Si ninguna UNSPENT y ninguna PENDING (todas SPENT o ya reconciliadas) + /// → el receptor reclamó: borra el PendingSend. + /// - Si hay proofs PENDING en el mint → conserva el PendingSend para retry. Future reclaimPendingSend(PendingSend send) async { final wallet = await getWallet(send.mintUrl, send.unit); final result = await wallet.reclaimProofsByYs(ys: send.proofYs); - // Solo removemos el registro si el mint confirmó que había proofs - // UNSPENT y las revertimos. Un count=0 es ambiguo: puede significar - // "receptor ya reclamó (SPENT)" o "receptor en medio del swap - // (PENDING)". En el caso PENDING, necesitamos conservar el record - // para poder reintentar después — borrar perdería el retry handle. - // Esto puede dejar records "fantasma" tras un SPENT real; se puede - // agregar dismissal manual como mejora futura. - if (result.count > BigInt.zero) { - await _pendingSendStorage.remove(send.id); - } + await _settlePendingSendOutcome(send, result); notifyListeners(); return result; } + /// Reconcilia todos los PendingSend contra el mint. Análogo a + /// `checkPendingTransactions` pero para el storage propio de envíos + /// offline (que CDK no conoce). Pensado para correr en pull-to-refresh, + /// history mount y startup. + /// + /// **Importante:** usa `checkProofsByYs` (observe-only), no + /// `reclaimProofsByYs`. La reconciliación automática NO debe revertir + /// proofs Unspent — eso cancelaría envíos legítimos que el receptor + /// todavía no reclamó, abriendo una ventana de doble-gasto. + /// + /// Sólo marca `settled` cuando el mint confirma que todos los proofs + /// están gastados. Cualquier otro caso (pending mid-swap o unspent + /// esperando al receptor) mantiene el record activo. + /// + /// Tolera errores por-record (un mint offline no rompe el resto). + /// Devuelve cuántos records pasaron a settled. + Future reconcilePendingSends() async { + // Sólo activos: los settled ya están resueltos. + final sends = _pendingSendStorage.listActive(); + if (sends.isEmpty) return 0; + + var settled = 0; + for (final send in sends) { + try { + final wallet = await getWallet(send.mintUrl, send.unit); + final result = await wallet.checkProofsByYs(ys: send.proofYs); + final total = BigInt.from(send.proofYs.length); + final allSpent = result.pendingCount == BigInt.zero && + result.spentCount >= total; + if (allSpent) { + await _pendingSendStorage.markSettled(send.id); + settled++; + } + } catch (e) { + debugPrint('reconcilePendingSends: ${send.id} failed: $e'); + } + } + if (settled > 0) notifyListeners(); + return settled; + } + + /// Decisión central sobre qué hacer con un PendingSend tras consultar + /// el mint. Devuelve `true` si el record dejó de estar activo. + /// + /// - `count > 0` → recuperamos nosotros: el envío se + /// abortó, no hay ledger que mostrar. REMOVE el record. + /// - `count == 0 && pending == 0` → receptor reclamó (o reconciliación + /// previa ya lo procesó): el envío se completó. SETTLE el record + /// para que aparezca en histórico como outgoing liquidado. + /// - `pending_count > 0` → receptor mid-swap: conservar activo + /// para retry. + Future _settlePendingSendOutcome( + PendingSend send, + ReclaimResult result, + ) async { + if (result.count > BigInt.zero) { + // Recuperamos las proofs; el envío se canceló desde nuestro lado. + await _pendingSendStorage.remove(send.id); + return true; + } + if (result.pendingCount == BigInt.zero) { + // Ninguna unspent, ninguna pending → todas consumidas por el receptor. + // Pasa a histórico como "outgoing liquidado". + await _pendingSendStorage.markSettled(send.id); + return true; + } + // pending > 0 → todavía hay proofs mid-swap; conservamos activo. + return false; + } + Future removePendingSend(String id) async { await _pendingSendStorage.remove(id); notifyListeners(); @@ -1611,6 +1684,10 @@ class WalletProvider extends ChangeNotifier { perUnit[unit] = ReclaimResult( count: (prev?.count ?? BigInt.zero) + result.count, amount: (prev?.amount ?? BigInt.zero) + result.amount, + pendingCount: + (prev?.pendingCount ?? BigInt.zero) + result.pendingCount, + spentCount: + (prev?.spentCount ?? BigInt.zero) + result.spentCount, ); } } catch (e) { diff --git a/lib/screens/1_splash/splash_screen.dart b/lib/screens/1_splash/splash_screen.dart index f8e5670..e726599 100644 --- a/lib/screens/1_splash/splash_screen.dart +++ b/lib/screens/1_splash/splash_screen.dart @@ -166,7 +166,12 @@ class _SplashScreenState extends State /// Se ejecuta sin bloquear la navegación. Future _checkPendingTransactions(WalletProvider wallet) async { try { - await wallet.checkPendingTransactions(); + // CDK reconcilia online; reconcilePendingSends reconcilia los envíos + // offline (storage propio). + await Future.wait([ + wallet.checkPendingTransactions(), + wallet.reconcilePendingSends(), + ]); debugPrint('Verificación de proofs completada'); } catch (e) { debugPrint('Error verificando proofs: $e'); diff --git a/lib/screens/9_history/history_screen.dart b/lib/screens/9_history/history_screen.dart index 2a8a05c..aaec04e 100644 --- a/lib/screens/9_history/history_screen.dart +++ b/lib/screens/9_history/history_screen.dart @@ -44,6 +44,17 @@ class _HistoryScreenState extends State { HistoryFilter _currentFilter = HistoryFilter.all; bool _isRefreshing = false; + @override + void initState() { + super.initState(); + // Reconciliar al entrar para capturar envíos que el receptor reclamó + // desde la última vez. Evita que el usuario tenga que pull-to-refresh + // manualmente para que un pending offline pase a histórico. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _refreshTransactions(); + }); + } + @override Widget build(BuildContext context) { return GradientBackground( @@ -161,12 +172,21 @@ class _HistoryScreenState extends State { return _buildPendingTokensList(walletProvider); } - // Envíos offline pendientes (no tienen Transaction en CDK). - // Solo mostrar en filtros "all" y "pending". - final pendingSends = + // Envíos offline activos (receptor aún no reclamó) → tile warning arriba + // con botón de cancel. Solo visibles en filtros "all" y "pending". + final activeSends = (_currentFilter == HistoryFilter.all || _currentFilter == HistoryFilter.pending) - ? walletProvider.listPendingSends() + ? walletProvider.listActivePendingSends() + : []; + + // Envíos offline liquidados (receptor reclamó) → se mezclan con las + // transactions de CDK por timestamp, renderizados como outgoing settled. + // Visibles en "all" y "cashu" (son cashu por definición). + final settledSends = + (_currentFilter == HistoryFilter.all || + _currentFilter == HistoryFilter.cashu) + ? walletProvider.listSettledPendingSends() : []; return FutureBuilder>( @@ -184,11 +204,32 @@ class _HistoryScreenState extends State { final filteredTransactions = _applyFilter(allTransactions, walletProvider); - if (filteredTransactions.isEmpty && pendingSends.isEmpty) { + if (filteredTransactions.isEmpty && + activeSends.isEmpty && + settledSends.isEmpty) { return _buildEmptyState(); } - final totalCount = pendingSends.length + filteredTransactions.length; + // Merge settled sends + transactions en una sola lista ordenada por + // timestamp descendente. Cada item es Transaction o PendingSend. + final merged = [ + ...filteredTransactions, + ...settledSends, + ]..sort((a, b) { + final ta = a is Transaction + ? a.timestamp.toInt() * 1000 + : (a as PendingSend) + .effectiveTimestamp + .millisecondsSinceEpoch; + final tb = b is Transaction + ? b.timestamp.toInt() * 1000 + : (b as PendingSend) + .effectiveTimestamp + .millisecondsSinceEpoch; + return tb.compareTo(ta); + }); + + final totalCount = activeSends.length + merged.length; return RefreshIndicator( onRefresh: _refreshTransactions, @@ -197,17 +238,22 @@ class _HistoryScreenState extends State { padding: const EdgeInsets.all(AppDimensions.paddingMedium), itemCount: totalCount, itemBuilder: (context, index) { - if (index < pendingSends.length) { - final send = pendingSends[index]; + // Activos primero (pin al tope). + if (index < activeSends.length) { return _PendingSendTile( - send: send, + send: activeSends[index], walletProvider: walletProvider, ); } - final tx = filteredTransactions[index - pendingSends.length]; - return _HistoryTransactionTile( - transaction: tx, - walletProvider: walletProvider, + final item = merged[index - activeSends.length]; + if (item is Transaction) { + return _HistoryTransactionTile( + transaction: item, + walletProvider: walletProvider, + ); + } + return _SettledPendingSendTile( + send: item as PendingSend, ); }, ), @@ -386,7 +432,13 @@ class _HistoryScreenState extends State { try { final walletProvider = context.read(); - await walletProvider.checkPendingTransactions(); + // CDK reconcilia sus Transaction pendientes (online); reconcilePendingSends + // hace lo mismo para el storage de envíos offline. Paralelos e + // independientes. + await Future.wait([ + walletProvider.checkPendingTransactions(), + walletProvider.reconcilePendingSends(), + ]); } finally { if (mounted) { setState(() => _isRefreshing = false); @@ -575,7 +627,13 @@ class _HistoryTransactionTile extends StatelessWidget { ), const SizedBox(width: 4), Text( - isIncoming ? L10n.of(context)!.receivedStatus : L10n.of(context)!.sentStatus, + isPending + ? (isIncoming + ? L10n.of(context)!.receiving + : L10n.of(context)!.sending) + : (isIncoming + ? L10n.of(context)!.receivedStatus + : L10n.of(context)!.sentStatus), style: TextStyle( fontFamily: 'Inter', fontSize: 12, @@ -1675,6 +1733,10 @@ class _MinimalDetailRow extends StatelessWidget { /// Tile para mostrar un envío offline pendiente en el historial. /// Se renderiza arriba de las Transactions de CDK. +/// Tile para un envío offline activo (receptor aún no reclamó). +/// Visualmente idéntico a una Transaction saliente pending (ecash online): +/// fondo blanco 5%, ícono coins, título "Ecash", badge Pendiente y fila +/// "↗ Enviando • timestamp". Al tap abre el detail screen con QR + cancel. class _PendingSendTile extends StatelessWidget { final PendingSend send; final WalletProvider walletProvider; @@ -1689,7 +1751,7 @@ class _PendingSendTile extends StatelessWidget { final l10n = L10n.of(context)!; final formattedAmount = UnitFormatter.formatBalance(send.amount, send.unit); final unitLabel = UnitFormatter.getUnitLabel(send.unit); - final mintDisplay = UnitFormatter.getMintDisplayName(send.mintUrl); + final dateStr = _formatRelativeDate(send.createdAt, context); return GestureDetector( onTap: () { @@ -1704,10 +1766,10 @@ class _PendingSendTile extends StatelessWidget { margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall), padding: const EdgeInsets.all(AppDimensions.paddingMedium), decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.08), + color: Colors.white.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(12), border: Border.all( - color: AppColors.warning.withValues(alpha: 0.3), + color: Colors.white.withValues(alpha: 0.1), width: 1, ), ), @@ -1717,12 +1779,12 @@ class _PendingSendTile extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.2), + color: AppColors.primaryAction.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), - child: Icon( - LucideIcons.clock, - color: AppColors.warning, + child: const Icon( + LucideIcons.coins, + color: AppColors.primaryAction, size: 22, ), ), @@ -1733,11 +1795,11 @@ class _PendingSendTile extends StatelessWidget { children: [ Row( children: [ - Text( - l10n.pendingOfflineSend, - style: const TextStyle( + const Text( + 'Ecash', + style: TextStyle( fontFamily: 'Inter', - fontSize: 15, + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white, ), @@ -1765,14 +1827,47 @@ class _PendingSendTile extends StatelessWidget { ], ), const SizedBox(height: 4), - Text( - mintDisplay, - style: TextStyle( - fontFamily: 'Inter', - fontSize: 12, - color: AppColors.textSecondary.withValues(alpha: 0.8), - ), + Row( + children: [ + const Icon( + LucideIcons.arrowUpRight, + size: 12, + color: AppColors.primaryAction, + ), + const SizedBox(width: 4), + Text( + l10n.sending, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: AppColors.primaryAction, + ), + ), + const SizedBox(width: 8), + Text( + '• $dateStr', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: AppColors.textSecondary.withValues(alpha: 0.6), + ), + ), + ], ), + if (send.memo != null && send.memo!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + send.memo!, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ], ), ), @@ -1784,20 +1879,26 @@ class _PendingSendTile extends StatelessWidget { style: const TextStyle( fontFamily: 'Inter', fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, + fontWeight: FontWeight.bold, + color: AppColors.primaryAction, ), ), Text( unitLabel, style: TextStyle( fontFamily: 'Inter', - fontSize: 11, - color: AppColors.textSecondary.withValues(alpha: 0.7), + fontSize: 12, + color: AppColors.textSecondary.withValues(alpha: 0.6), ), ), ], ), + const SizedBox(width: 8), + Icon( + LucideIcons.chevronRight, + color: Colors.white.withValues(alpha: 0.3), + size: 20, + ), ], ), ), @@ -1805,8 +1906,170 @@ class _PendingSendTile extends StatelessWidget { } } -/// Pantalla de detalle para un envío offline pendiente. -/// Muestra QR del token, monto, y botón para cancelar/reclamar. +/// Tile para un envío offline liquidado (receptor ya reclamó). Visualmente +/// equivalente a una Transaction saliente Cashu settled, para que el +/// historial sea uniforme entre online y offline. +class _SettledPendingSendTile extends StatelessWidget { + final PendingSend send; + + const _SettledPendingSendTile({required this.send}); + + @override + Widget build(BuildContext context) { + final l10n = L10n.of(context)!; + final formattedAmount = + UnitFormatter.formatBalance(send.amount, send.unit); + final unitLabel = UnitFormatter.getUnitLabel(send.unit); + final dateStr = _formatRelativeDate( + send.effectiveTimestamp, + context, + ); + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _PendingSendDetailScreen(send: send), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: AppDimensions.paddingSmall), + padding: const EdgeInsets.all(AppDimensions.paddingMedium), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.primaryAction.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + LucideIcons.coins, + color: AppColors.primaryAction, + size: 22, + ), + ), + const SizedBox(width: AppDimensions.paddingMedium), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ecash', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + LucideIcons.arrowUpRight, + size: 12, + color: AppColors.primaryAction, + ), + const SizedBox(width: 4), + Text( + l10n.sentStatus, + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: AppColors.primaryAction, + ), + ), + const SizedBox(width: 8), + Text( + '• $dateStr', + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: AppColors.textSecondary.withValues(alpha: 0.6), + ), + ), + ], + ), + if (send.memo != null && send.memo!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + send.memo!, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppColors.textSecondary.withValues(alpha: 0.5), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '-$formattedAmount', + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primaryAction, + ), + ), + Text( + unitLabel, + style: TextStyle( + fontFamily: 'Inter', + fontSize: 12, + color: AppColors.textSecondary.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(width: 8), + Icon( + LucideIcons.chevronRight, + color: Colors.white.withValues(alpha: 0.3), + size: 20, + ), + ], + ), + ), + ); + } +} + +/// Formateo relativo de fecha. Compartido por los tiles del historial. +String _formatRelativeDate(DateTime date, BuildContext context) { + final l10n = L10n.of(context)!; + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inMinutes < 1) return l10n.now; + if (diff.inHours < 1) return l10n.agoMinutes(diff.inMinutes); + if (diff.inDays < 1) return l10n.agoHours(diff.inHours); + if (diff.inDays < 7) return l10n.agoDays(diff.inDays); + final locale = Localizations.localeOf(context).toString(); + return DateFormat.yMd(locale).format(date); +} + +/// Pantalla de detalle para un envío offline. Si el envío está activo, +/// muestra QR + botón cancel. Si está liquidado (receptor reclamó), muestra +/// solo el audit trail. class _PendingSendDetailScreen extends StatefulWidget { final PendingSend send; @@ -1833,7 +2096,9 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { @override void initState() { super.initState(); - _encodeTokenToUR(); + // Para settled el QR ya no sirve (receptor reclamó), no desperdiciamos + // ciclos codificándolo. + if (widget.send.isActive) _encodeTokenToUR(); } @override @@ -2058,6 +2323,7 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { final unitLabel = UnitFormatter.getUnitLabel(widget.send.unit); final mintDisplay = UnitFormatter.getMintDisplayName(widget.send.mintUrl); + final isActive = widget.send.isActive; final qrData = _urFragments.isNotEmpty ? _urFragments[_currentFragment] : widget.send.encoded; @@ -2090,26 +2356,29 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { ), ), const SizedBox(height: 24), - // 2. QR - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), + // 2. QR — solo cuando el envío está activo (receptor aún no + // reclamó). Cuando está settled, el token ya es obsoleto. + if (isActive) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: QrImageView( + data: qrData, + version: QrVersions.auto, + size: 240, + backgroundColor: Colors.white, + errorCorrectionLevel: QrErrorCorrectLevel.L, + padding: EdgeInsets.zero, + ), ), - child: QrImageView( - data: qrData, - version: QrVersions.auto, - size: 240, - backgroundColor: Colors.white, - errorCorrectionLevel: QrErrorCorrectLevel.L, - padding: EdgeInsets.zero, - ), - ), - // 3. Controles del QR (si es animado) - if (_urFragments.length > 1) ...[ - const SizedBox(height: 12), - _buildQRControls(), + // 3. Controles del QR (si es animado) + if (_urFragments.length > 1) ...[ + const SizedBox(height: 12), + _buildQRControls(), + ], ], const SizedBox(height: 32), // 4. Monto grande @@ -2137,10 +2406,11 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { value: mintDisplay, ), _MinimalDetailRow( - icon: LucideIcons.clock, + icon: isActive ? LucideIcons.clock : LucideIcons.check, label: l10n.status, - value: l10n.pending, - valueColor: AppColors.warning, + value: isActive ? l10n.pending : l10n.sentStatus, + valueColor: + isActive ? AppColors.warning : AppColors.success, ), if (widget.send.memo != null && widget.send.memo!.isNotEmpty) @@ -2186,6 +2456,8 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { ), ), ), + // Botón cancel sólo cuando el envío está activo. + if (isActive) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -2223,6 +2495,7 @@ class _PendingSendDetailScreenState extends State<_PendingSendDetailScreen> { ), ), ), + ], const SizedBox(height: 40), ], ), diff --git a/lib/src/rust/api/wallet.dart b/lib/src/rust/api/wallet.dart index 0fc4036..9e16c8c 100644 --- a/lib/src/rust/api/wallet.dart +++ b/lib/src/rust/api/wallet.dart @@ -10,7 +10,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'payment_request.dart'; import 'token.dart'; -// These functions are ignored because they are not marked as `pub`: `mint_url`, `unit`, `update_balance_streams` +// These functions are ignored because they are not marked as `pub`: `empty`, `from_states`, `into_result`, `mint_url`, `unit`, `update_balance_streams` +// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `StateBuckets` // These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `assert_receiver_is_total_eq`, `clone`, `clone`, `clone`, `clone`, `cmp`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `into`, `partial_cmp`, `try_into`, `try_into` // Rust type: RustOpaqueMoi> @@ -71,6 +72,24 @@ abstract class Wallet implements RustOpaqueInterface { Future checkPendingTransactions(); + /// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint + /// for the state of the target Ys but **does NOT** revert Unspent proofs + /// to the local Unspent state. + /// + /// Use this for periodic reconciliation where we want to auto-settle + /// sends the receiver already claimed, without accidentally cancelling + /// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys` + /// would do by calling `unreserve_proofs`). + /// + /// Return semantics: + /// - `count` / `amount` always 0 (nothing is recovered). + /// - `pending_count` / `spent_count` reflect the mint's report. + /// - `spent_count` also includes target Ys that are no longer present + /// in local PendingSpent (already reconciled or externally cleaned). + /// The caller can infer "all resolved as spent" from + /// `spent_count == target_ys.len() && pending_count == 0`. + Future checkProofsByYs({required List ys}); + Future confirmMelt({required PreparedMelt melt}); /// Create a NUT-18 payment request with Nostr transport. @@ -125,13 +144,19 @@ abstract class Wallet implements RustOpaqueInterface { Future receive({required Token token, ReceiveOptions? opts}); /// Check pending-spent proofs with the mint and revert unspent ones. - /// Returns count and total amount of proofs recovered. + /// Returns per-state buckets (unspent reverted, still pending, spent). Future reclaimPendingProofs(); - /// Reclaim proofs belonging to a specific transaction identified by its Y values. - /// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches + /// Reclaim proofs belonging to a specific send identified by its Y values. + /// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches /// the input list are checked with the mint and reverted if Unspent. - /// Use tx.ys from a pending outgoing transaction to cancel that specific send. + /// + /// Empty returns: + /// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`. + /// - None of the target Ys are in local pending anymore (a previous + /// reconciliation already processed them) → `spent_count` is set to + /// the number of targets, signalling to callers that the send is + /// definitively finished and any local record can be removed. Future reclaimProofsByYs({required List ys}); Future recoverIncompleteSagas(); @@ -280,13 +305,36 @@ class ReceiveOptions { } class ReclaimResult { + /// Proofs reverted from PendingSpent to Unspent. Also the number of + /// recovered proofs reflected in `amount`. final BigInt count; + + /// Total value of the recovered proofs. final BigInt amount; - const ReclaimResult({required this.count, required this.amount}); + /// Proofs the mint reports as still Pending (receiver mid-swap). The + /// caller should keep the record for retry — a later reconciliation + /// will resolve them. + final BigInt pendingCount; + + /// Proofs the mint reports as Spent (receiver already claimed). When + /// `count == 0 && pending_count == 0`, the send is definitively + /// finished and the caller can delete its local record. + final BigInt spentCount; + + const ReclaimResult({ + required this.count, + required this.amount, + required this.pendingCount, + required this.spentCount, + }); @override - int get hashCode => count.hashCode ^ amount.hashCode; + int get hashCode => + count.hashCode ^ + amount.hashCode ^ + pendingCount.hashCode ^ + spentCount.hashCode; @override bool operator ==(Object other) => @@ -294,7 +342,9 @@ class ReclaimResult { other is ReclaimResult && runtimeType == other.runtimeType && count == other.count && - amount == other.amount; + amount == other.amount && + pendingCount == other.pendingCount && + spentCount == other.spentCount; } class SendOptions { diff --git a/lib/src/rust/frb_generated.dart b/lib/src/rust/frb_generated.dart index 89b1989..cc43a9e 100644 --- a/lib/src/rust/frb_generated.dart +++ b/lib/src/rust/frb_generated.dart @@ -69,7 +69,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.11.1'; @override - int get rustContentHash => -740347571; + int get rustContentHash => 1593022460; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -250,6 +250,11 @@ abstract class RustLibApi extends BaseApi { required Wallet that, }); + Future crateApiWalletWalletCheckProofsByYs({ + required Wallet that, + required List ys, + }); + Future crateApiWalletWalletConfirmMelt({ required Wallet that, required PreparedMelt melt, @@ -1803,6 +1808,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { argNames: ["that"], ); + @override + Future crateApiWalletWalletCheckProofsByYs({ + required Wallet that, + required List ys, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerWallet( + that, + serializer, + ); + sse_encode_list_String(ys, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 42, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_reclaim_result, + decodeErrorData: sse_decode_error, + ), + constMeta: kCrateApiWalletWalletCheckProofsByYsConstMeta, + argValues: [that, ys], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiWalletWalletCheckProofsByYsConstMeta => + const TaskConstMeta( + debugName: "Wallet_check_proofs_by_ys", + argNames: ["that", "ys"], + ); + @override Future crateApiWalletWalletConfirmMelt({ required Wallet that, @@ -1823,7 +1866,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 42, + funcId: 43, port: port_, ); }, @@ -1861,7 +1904,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 43, + funcId: 44, port: port_, ); }, @@ -1898,7 +1941,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 44, + funcId: 45, port: port_, ); }, @@ -1932,7 +1975,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 45, + funcId: 46, port: port_, ); }, @@ -1967,7 +2010,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 46, + funcId: 47, port: port_, ); }, @@ -2008,7 +2051,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 47, + funcId: 48, port: port_, ); }, @@ -2046,7 +2089,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 48, + funcId: 49, port: port_, ); }, @@ -2089,7 +2132,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 49, + funcId: 50, port: port_, ); }, @@ -2131,7 +2174,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { db, serializer, ); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 50)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 51)!; }, codec: SseCodec( decodeSuccessData: @@ -2169,7 +2212,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 51, + funcId: 52, port: port_, ); }, @@ -2207,7 +2250,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 52, + funcId: 53, port: port_, ); }, @@ -2248,7 +2291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 53, + funcId: 54, port: port_, ); }, @@ -2289,7 +2332,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 54, + funcId: 55, port: port_, ); }, @@ -2325,7 +2368,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 55, + funcId: 56, port: port_, ); }, @@ -2363,7 +2406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 56, + funcId: 57, port: port_, ); }, @@ -2399,7 +2442,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 57, + funcId: 58, port: port_, ); }, @@ -2433,7 +2476,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 58, + funcId: 59, port: port_, ); }, @@ -2467,7 +2510,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 59, + funcId: 60, port: port_, ); }, @@ -2509,7 +2552,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 60, + funcId: 61, port: port_, ); }, @@ -2545,7 +2588,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 61, + funcId: 62, port: port_, ); }, @@ -2591,7 +2634,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 62, + funcId: 63, port: port_, ); }, @@ -2629,7 +2672,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: 63)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 64)!; }, codec: SseCodec( decodeSuccessData: sse_decode_token, @@ -2659,7 +2702,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: 64)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 65)!; }, codec: SseCodec( decodeSuccessData: sse_decode_list_String, @@ -2689,7 +2732,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 65, + funcId: 66, port: port_, ); }, @@ -2713,7 +2756,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 66)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 67)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2739,7 +2782,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 67, + funcId: 68, port: port_, ); }, @@ -2764,7 +2807,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(secret, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 68)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -2787,7 +2830,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonic, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 69)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; }, codec: SseCodec( decodeSuccessData: sse_decode_list_prim_u_8_strict, @@ -2814,7 +2857,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(encoded, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 70)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 71)!; }, codec: SseCodec( decodeSuccessData: sse_decode_payment_request_info, @@ -2843,7 +2886,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 71, + funcId: 72, port: port_, ); }, @@ -2870,7 +2913,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 72, + funcId: 73, port: port_, ); }, @@ -2897,7 +2940,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 73, + funcId: 74, port: port_, ); }, @@ -2922,7 +2965,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: 74)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 75)!; }, codec: SseCodec( decodeSuccessData: sse_decode_token, @@ -2945,7 +2988,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(encoded, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 75)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 76)!; }, codec: SseCodec( decodeSuccessData: sse_decode_token, @@ -3797,11 +3840,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ReclaimResult dco_decode_reclaim_result(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 2) - throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + if (arr.length != 4) + throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); return ReclaimResult( count: dco_decode_u_64(arr[0]), amount: dco_decode_u_64(arr[1]), + pendingCount: dco_decode_u_64(arr[2]), + spentCount: dco_decode_u_64(arr[3]), ); } @@ -4966,7 +5011,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs var var_count = sse_decode_u_64(deserializer); var var_amount = sse_decode_u_64(deserializer); - return ReclaimResult(count: var_count, amount: var_amount); + var var_pendingCount = sse_decode_u_64(deserializer); + var var_spentCount = sse_decode_u_64(deserializer); + return ReclaimResult( + count: var_count, + amount: var_amount, + pendingCount: var_pendingCount, + spentCount: var_spentCount, + ); } @protected @@ -6122,6 +6174,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { // Codec=Sse (Serialization based), see doc to use other codecs sse_encode_u_64(self.count, serializer); sse_encode_u_64(self.amount, serializer); + sse_encode_u_64(self.pendingCount, serializer); + sse_encode_u_64(self.spentCount, serializer); } @protected @@ -6541,6 +6595,27 @@ class WalletImpl extends RustOpaque implements Wallet { Future checkPendingTransactions() => RustLib.instance.api .crateApiWalletWalletCheckPendingTransactions(that: this); + /// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint + /// for the state of the target Ys but **does NOT** revert Unspent proofs + /// to the local Unspent state. + /// + /// Use this for periodic reconciliation where we want to auto-settle + /// sends the receiver already claimed, without accidentally cancelling + /// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys` + /// would do by calling `unreserve_proofs`). + /// + /// Return semantics: + /// - `count` / `amount` always 0 (nothing is recovered). + /// - `pending_count` / `spent_count` reflect the mint's report. + /// - `spent_count` also includes target Ys that are no longer present + /// in local PendingSpent (already reconciled or externally cleaned). + /// The caller can infer "all resolved as spent" from + /// `spent_count == target_ys.len() && pending_count == 0`. + Future checkProofsByYs({required List ys}) => RustLib + .instance + .api + .crateApiWalletWalletCheckProofsByYs(that: this, ys: ys); + Future confirmMelt({required PreparedMelt melt}) => RustLib .instance .api @@ -6620,14 +6695,20 @@ class WalletImpl extends RustOpaque implements Wallet { ); /// Check pending-spent proofs with the mint and revert unspent ones. - /// Returns count and total amount of proofs recovered. + /// Returns per-state buckets (unspent reverted, still pending, spent). Future reclaimPendingProofs() => RustLib.instance.api.crateApiWalletWalletReclaimPendingProofs(that: this); - /// Reclaim proofs belonging to a specific transaction identified by its Y values. - /// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches + /// Reclaim proofs belonging to a specific send identified by its Y values. + /// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches /// the input list are checked with the mint and reverted if Unspent. - /// Use tx.ys from a pending outgoing transaction to cancel that specific send. + /// + /// Empty returns: + /// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`. + /// - None of the target Ys are in local pending anymore (a previous + /// reconciliation already processed them) → `spent_count` is set to + /// the number of targets, signalling to callers that the send is + /// definitively finished and any local record can be removed. Future reclaimProofsByYs({required List ys}) => RustLib .instance .api diff --git a/rust/src/api/wallet.rs b/rust/src/api/wallet.rs index fb390c0..6def8d4 100644 --- a/rust/src/api/wallet.rs +++ b/rust/src/api/wallet.rs @@ -531,11 +531,11 @@ impl Wallet { // === Reclaim orphaned proofs === /// Check pending-spent proofs with the mint and revert unspent ones. - /// Returns count and total amount of proofs recovered. + /// Returns per-state buckets (unspent reverted, still pending, spent). pub async fn reclaim_pending_proofs(&self) -> Result { let pending = self.inner.get_pending_spent_proofs().await?; if pending.is_empty() { - return Ok(ReclaimResult { count: 0, amount: 0 }); + return Ok(ReclaimResult::empty()); } // Build a map of Y → amount for later lookup @@ -548,56 +548,52 @@ impl Wallet { // check_proofs_spent: queries mint AND removes spent proofs from local DB let states = self.inner.check_proofs_spent(pending).await?; + let buckets = StateBuckets::from_states(states, &amount_by_y); - // 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 = states - .into_iter() - .filter(|s| s.state == ProofState::Unspent) - .map(|s| s.y) - .collect(); - - let count = unspent_ys.len() as u64; - let amount: u64 = unspent_ys.iter() - .filter_map(|y| amount_by_y.get(y)) - .sum(); - - if count > 0 { + if !buckets.unspent_ys.is_empty() { // Revert from PendingSpent to Unspent - self.inner.unreserve_proofs(unspent_ys).await?; + self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?; } // Always refresh: check_proofs_spent may have removed spent proofs self.update_balance_streams().await; - Ok(ReclaimResult { count, amount }) + Ok(buckets.into_result()) } - /// Reclaim proofs belonging to a specific transaction identified by its Y values. - /// Works like reclaim_pending_proofs but scoped: only proofs whose Y matches + /// Reclaim proofs belonging to a specific send identified by its Y values. + /// Scoped variant of `reclaim_pending_proofs`: only proofs whose Y matches /// the input list are checked with the mint and reverted if Unspent. - /// Use tx.ys from a pending outgoing transaction to cancel that specific send. + /// + /// Empty returns: + /// - Input `ys` empty or all unparseable → `ReclaimResult::empty()`. + /// - None of the target Ys are in local pending anymore (a previous + /// reconciliation already processed them) → `spent_count` is set to + /// the number of targets, signalling to callers that the send is + /// definitively finished and any local record can be removed. pub async fn reclaim_proofs_by_ys(&self, ys: Vec) -> Result { let target_ys: std::collections::HashSet = ys .iter() .filter_map(|y| PublicKey::from_hex(y).ok()) .collect(); if target_ys.is_empty() { - return Ok(ReclaimResult { count: 0, amount: 0 }); + return Ok(ReclaimResult::empty()); } let pending = self.inner.get_pending_spent_proofs().await?; - if pending.is_empty() { - return Ok(ReclaimResult { count: 0, amount: 0 }); - } - // Filter to only proofs with a Y in our target set. let relevant: Vec<_> = pending .into_iter() .filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false)) .collect(); if relevant.is_empty() { - return Ok(ReclaimResult { count: 0, amount: 0 }); + // Nothing left locally for this send → already reconciled. + // Caller can safely drop the record. + return Ok(ReclaimResult { + count: 0, + amount: 0, + pending_count: 0, + spent_count: target_ys.len() as u64, + }); } let mut amount_by_y: HashMap = HashMap::new(); @@ -608,22 +604,83 @@ impl Wallet { } let states = self.inner.check_proofs_spent(relevant).await?; + let buckets = StateBuckets::from_states(states, &amount_by_y); - let unspent_ys: Vec = states - .into_iter() - .filter(|s| s.state == ProofState::Unspent) - .map(|s| s.y) - .collect(); - - let count = unspent_ys.len() as u64; - let amount: u64 = unspent_ys.iter().filter_map(|y| amount_by_y.get(y)).sum(); - - if count > 0 { - self.inner.unreserve_proofs(unspent_ys).await?; + if !buckets.unspent_ys.is_empty() { + self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?; } self.update_balance_streams().await; - Ok(ReclaimResult { count, amount }) + Ok(buckets.into_result()) + } + + /// Observe-only counterpart of `reclaim_proofs_by_ys`. Queries the mint + /// for the state of the target Ys but **does NOT** revert Unspent proofs + /// to the local Unspent state. + /// + /// Use this for periodic reconciliation where we want to auto-settle + /// sends the receiver already claimed, without accidentally cancelling + /// sends the receiver has not claimed yet (which `reclaim_proofs_by_ys` + /// would do by calling `unreserve_proofs`). + /// + /// Return semantics: + /// - `count` / `amount` always 0 (nothing is recovered). + /// - `pending_count` / `spent_count` reflect the mint's report. + /// - `spent_count` also includes target Ys that are no longer present + /// in local PendingSpent (already reconciled or externally cleaned). + /// The caller can infer "all resolved as spent" from + /// `spent_count == target_ys.len() && pending_count == 0`. + pub async fn check_proofs_by_ys(&self, ys: Vec) -> Result { + let target_ys: std::collections::HashSet = ys + .iter() + .filter_map(|y| PublicKey::from_hex(y).ok()) + .collect(); + if target_ys.is_empty() { + return Ok(ReclaimResult::empty()); + } + let total = target_ys.len() as u64; + + let pending = self.inner.get_pending_spent_proofs().await?; + let relevant: Vec<_> = pending + .into_iter() + .filter(|proof| proof.y().map(|y| target_ys.contains(&y)).unwrap_or(false)) + .collect(); + + // Ys we expected to be in local PendingSpent but aren't anymore. + // Safe assumption: they were reconciled out earlier because the + // mint confirmed them spent (check_proofs_spent removed them). + let resolved_out_of_pending = total.saturating_sub(relevant.len() as u64); + + if relevant.is_empty() { + return Ok(ReclaimResult { + count: 0, + amount: 0, + pending_count: 0, + spent_count: total, + }); + } + + let mut amount_by_y: HashMap = HashMap::new(); + for proof in &relevant { + if let Ok(y) = proof.y() { + amount_by_y.insert(y, u64::from(proof.amount)); + } + } + + // check_proofs_spent queries the mint AND removes locally-spent + // proofs from the DB. That cleanup is fine — it doesn't cancel + // an active send, it just advances state once the mint confirms + // SPENT. The part we deliberately skip is `unreserve_proofs`. + let states = self.inner.check_proofs_spent(relevant).await?; + let buckets = StateBuckets::from_states(states, &amount_by_y); + + self.update_balance_streams().await; + Ok(ReclaimResult { + count: 0, + amount: 0, + pending_count: buckets.pending_count, + spent_count: buckets.spent_count + resolved_out_of_pending, + }) } // === Utility === @@ -787,8 +844,86 @@ impl<'a> From> for PreparedSend { } pub struct ReclaimResult { + /// Proofs reverted from PendingSpent to Unspent. Also the number of + /// recovered proofs reflected in `amount`. pub count: u64, + /// Total value of the recovered proofs. pub amount: u64, + /// Proofs the mint reports as still Pending (receiver mid-swap). The + /// caller should keep the record for retry — a later reconciliation + /// will resolve them. + pub pending_count: u64, + /// Proofs the mint reports as Spent (receiver already claimed). When + /// `count == 0 && pending_count == 0`, the send is definitively + /// finished and the caller can delete its local record. + pub spent_count: u64, +} + +impl ReclaimResult { + #[frb(ignore)] + fn empty() -> Self { + Self { + count: 0, + amount: 0, + pending_count: 0, + spent_count: 0, + } + } +} + +/// Internal helper: partition the response of `check_proofs_spent` into +/// the three buckets we care about (Unspent / Pending / Spent) and carry +/// the Ys/amount needed to build a `ReclaimResult`. +#[frb(ignore)] +struct StateBuckets { + unspent_ys: Vec, + unspent_amount: u64, + pending_count: u64, + spent_count: u64, +} + +impl StateBuckets { + fn from_states( + states: Vec, + amount_by_y: &HashMap, + ) -> Self { + let mut unspent_ys = Vec::new(); + let mut unspent_amount: u64 = 0; + let mut pending_count: u64 = 0; + let mut spent_count: u64 = 0; + + for s in states { + match s.state { + ProofState::Unspent => { + if let Some(&a) = amount_by_y.get(&s.y) { + unspent_amount = unspent_amount.saturating_add(a); + } + unspent_ys.push(s.y); + } + ProofState::Pending => pending_count += 1, + ProofState::Spent => spent_count += 1, + // Reserved / other states: not expected for pending-spent + // queries; ignore so we don't miscount. + _ => {} + } + } + + Self { + unspent_ys, + unspent_amount, + pending_count, + spent_count, + } + } + + fn into_result(self) -> ReclaimResult { + ReclaimResult { + count: self.unspent_ys.len() as u64, + amount: self.unspent_amount, + pending_count: self.pending_count, + spent_count: self.spent_count, + } + } } pub struct PreparedMelt { diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index 8c973fe..24bf71e 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -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 = -740347571; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1593022460; // Section: executor @@ -2043,6 +2043,66 @@ fn wire__crate__api__wallet__Wallet_check_pending_transactions_impl( }, ) } +fn wire__crate__api__wallet__Wallet_check_proofs_by_ys_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::TaskInfo { + debug_name: "Wallet_check_proofs_by_ys", + 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 = , + >>::sse_decode(&mut deserializer); + let api_ys = >::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::check_proofs_by_ys( + &*api_that_guard, + api_ys, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} fn wire__crate__api__wallet__Wallet_confirm_melt_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -4579,9 +4639,13 @@ impl SseDecode for crate::api::wallet::ReclaimResult { fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut var_count = ::sse_decode(deserializer); let mut var_amount = ::sse_decode(deserializer); + let mut var_pendingCount = ::sse_decode(deserializer); + let mut var_spentCount = ::sse_decode(deserializer); return crate::api::wallet::ReclaimResult { count: var_count, amount: var_amount, + pending_count: var_pendingCount, + spent_count: var_spentCount, }; } } @@ -4775,82 +4839,88 @@ fn pde_ffi_dispatcher_primary_impl( rust_vec_len, data_len, ), - 42 => wire__crate__api__wallet__Wallet_confirm_melt_impl(port, ptr, rust_vec_len, data_len), - 43 => wire__crate__api__wallet__Wallet_create_payment_request_impl( + 42 => wire__crate__api__wallet__Wallet_check_proofs_by_ys_impl( port, ptr, rust_vec_len, data_len, ), - 44 => wire__crate__api__wallet__Wallet_finalize_pending_melts_impl( + 43 => wire__crate__api__wallet__Wallet_confirm_melt_impl(port, ptr, rust_vec_len, data_len), + 44 => wire__crate__api__wallet__Wallet_create_payment_request_impl( port, ptr, rust_vec_len, data_len, ), - 45 => wire__crate__api__wallet__Wallet_get_mint_impl(port, ptr, rust_vec_len, data_len), - 46 => { + 45 => wire__crate__api__wallet__Wallet_finalize_pending_melts_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 46 => wire__crate__api__wallet__Wallet_get_mint_impl(port, ptr, rust_vec_len, data_len), + 47 => { wire__crate__api__wallet__Wallet_is_token_spent_impl(port, ptr, rust_vec_len, data_len) } - 47 => wire__crate__api__wallet__Wallet_list_transactions_impl( + 48 => wire__crate__api__wallet__Wallet_list_transactions_impl( port, ptr, rust_vec_len, data_len, ), - 48 => wire__crate__api__wallet__Wallet_melt_quote_impl(port, ptr, rust_vec_len, data_len), - 49 => wire__crate__api__wallet__Wallet_mint_impl(port, ptr, rust_vec_len, data_len), - 51 => wire__crate__api__wallet__Wallet_pay_payment_request_impl( + 49 => wire__crate__api__wallet__Wallet_melt_quote_impl(port, ptr, rust_vec_len, data_len), + 50 => wire__crate__api__wallet__Wallet_mint_impl(port, ptr, rust_vec_len, data_len), + 52 => wire__crate__api__wallet__Wallet_pay_payment_request_impl( port, ptr, rust_vec_len, data_len, ), - 52 => wire__crate__api__wallet__Wallet_prepare_melt_impl(port, ptr, rust_vec_len, data_len), - 53 => wire__crate__api__wallet__Wallet_prepare_send_impl(port, ptr, rust_vec_len, data_len), - 54 => wire__crate__api__wallet__Wallet_receive_impl(port, ptr, rust_vec_len, data_len), - 55 => wire__crate__api__wallet__Wallet_reclaim_pending_proofs_impl( + 53 => wire__crate__api__wallet__Wallet_prepare_melt_impl(port, ptr, rust_vec_len, data_len), + 54 => wire__crate__api__wallet__Wallet_prepare_send_impl(port, ptr, rust_vec_len, data_len), + 55 => wire__crate__api__wallet__Wallet_receive_impl(port, ptr, rust_vec_len, data_len), + 56 => wire__crate__api__wallet__Wallet_reclaim_pending_proofs_impl( port, ptr, rust_vec_len, data_len, ), - 56 => wire__crate__api__wallet__Wallet_reclaim_proofs_by_ys_impl( + 57 => wire__crate__api__wallet__Wallet_reclaim_proofs_by_ys_impl( port, ptr, rust_vec_len, data_len, ), - 57 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl( + 58 => wire__crate__api__wallet__Wallet_recover_incomplete_sagas_impl( port, ptr, rust_vec_len, data_len, ), - 58 => { + 59 => { wire__crate__api__wallet__Wallet_refresh_balance_impl(port, ptr, rust_vec_len, data_len) } - 59 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len), - 60 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len), - 61 => { + 60 => wire__crate__api__wallet__Wallet_restore_impl(port, ptr, rust_vec_len, data_len), + 61 => wire__crate__api__wallet__Wallet_send_impl(port, ptr, rust_vec_len, data_len), + 62 => { wire__crate__api__wallet__Wallet_stream_balance_impl(port, ptr, rust_vec_len, data_len) } - 62 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl( + 63 => wire__crate__api__wallet__Wallet_wait_for_nostr_payment_impl( port, ptr, rust_vec_len, data_len, ), - 65 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len), - 67 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len), - 71 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len), - 72 => wire__crate__api__wallet__receive_options_default_impl( + 66 => wire__crate__api__mint_info__fetch_keysets_impl(port, ptr, rust_vec_len, data_len), + 68 => wire__crate__api__mint_info__get_mint_info_impl(port, ptr, rust_vec_len, data_len), + 72 => wire__crate__api__mint_info__ping_mint_impl(port, ptr, rust_vec_len, data_len), + 73 => wire__crate__api__wallet__receive_options_default_impl( port, ptr, rust_vec_len, data_len, ), - 73 => { + 74 => { wire__crate__api__wallet__send_options_default_impl(port, ptr, rust_vec_len, data_len) } _ => unreachable!(), @@ -4899,15 +4969,15 @@ fn pde_ffi_dispatcher_sync_impl( 34 => wire__crate__api__wallet__Wallet_auto_accessor_get_unit_impl(ptr, rust_vec_len, data_len), 35 => wire__crate__api__wallet__Wallet_auto_accessor_set_mint_url_impl(ptr, rust_vec_len, data_len), 36 => wire__crate__api__wallet__Wallet_auto_accessor_set_unit_impl(ptr, rust_vec_len, data_len), -50 => wire__crate__api__wallet__Wallet_new_impl(ptr, rust_vec_len, data_len), -63 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len), -64 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len), -66 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len), -68 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len), -69 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len), -70 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len), -74 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len), -75 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len), +51 => wire__crate__api__wallet__Wallet_new_impl(ptr, rust_vec_len, data_len), +64 => wire__crate__api__token__create_offline_token_impl(ptr, rust_vec_len, data_len), +65 => wire__crate__api__token__encode_qr_token_impl(ptr, rust_vec_len, data_len), +67 => wire__crate__api__keys__generate_mnemonic_impl(ptr, rust_vec_len, data_len), +69 => wire__crate__api__keys__get_pub_key_impl(ptr, rust_vec_len, data_len), +70 => wire__crate__api__keys__mnemonic_to_seed_impl(ptr, rust_vec_len, data_len), +71 => wire__crate__api__payment_request__payment_request_info_parse_impl(ptr, rust_vec_len, data_len), +75 => wire__crate__api__token__token_from_raw_bytes_impl(ptr, rust_vec_len, data_len), +76 => wire__crate__api__token__token_parse_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -5480,6 +5550,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::wallet::ReclaimResult { [ self.count.into_into_dart().into_dart(), self.amount.into_into_dart().into_dart(), + self.pending_count.into_into_dart().into_dart(), + self.spent_count.into_into_dart().into_dart(), ] .into_dart() } @@ -6324,6 +6396,8 @@ impl SseEncode for crate::api::wallet::ReclaimResult { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { ::sse_encode(self.count, serializer); ::sse_encode(self.amount, serializer); + ::sse_encode(self.pending_count, serializer); + ::sse_encode(self.spent_count, serializer); } }