Compare commits

...
Author SHA1 Message Date
Forte11andGitHub 7ec4e4eebc Merge pull request #122 from Forte11Cuba/chore/bump-0.3.0
Release APK / build (push) Canceled after 0s
chore: bump version to 0.3.0+4
2026-04-21 05:01:46 -06:00
Forte11Cuba 92cdf4ec2d chore: bump version to 0.3.0+4 2026-04-21 05:00:56 -06:00
Forte11andGitHub b9d57b69b0 Merge pull request #121 from Forte11Cuba/chore/settings-recover-icon-and-order
chore(settings): align recover icon and reorder wallet section
2026-04-21 04:56:07 -06:00
Forte11Cuba 4f83d9c3a1 chore(settings): align recover icon and reorder wallet section 2026-04-21 04:52:12 -06:00
Forte11andGitHub 6246d09876 Merge pull request #120 from Forte11Cuba/refactor/mint-quote-recovery-by-id
refactor: recover mint quotes by quote_id (closes #118)
2026-04-19 07:43:20 -06:00
Forte11Cuba 73a0261af6 fix: run mint-quote recovery before checkAllMintQuotes 2026-04-19 06:18:51 -06:00
Forte11Cuba dd9450c0cc refactor: recover mint quotes by quote_id (closes #118) 2026-04-19 06:03:21 -06:00
Forte11andGitHub 09565aa3ed Merge pull request #119 from Forte11Cuba/fix/mint-issued-event-race-condition
fix: deliver mint Issued event before metadata await (closes #115)
2026-04-19 05:09:08 -06:00
Forte11Cuba e3fdd46df8 fix: deliver mint Issued event before metadata await (closes #115) 2026-04-19 05:00:05 -06:00
Forte11andGitHub f4d15fd2ab Merge pull request #117 from Forte11Cuba/fix/mints-list-show-all-supported-units
fix: show all supported units on mints list, dim zero-balance pills
2026-04-18 05:15:34 -06:00
Forte11Cuba 7bc9639e89 fix: show all supported units on mints list, dim zero-balance pills 2026-04-18 03:55:18 -06:00
Forte11andGitHub e11778ff0a Merge pull request #116 from Forte11Cuba/feat/observe-only-reconcile-and-settled-state
fix: observe-only reconcile + settled state for offline sends
2026-04-18 03:44:17 -06:00
Forte11Cuba d5d4b3a6ea fix: address CodeRabbit review 2026-04-18 03:29:51 -06:00
Forte11Cuba a0f9ca738b fix: observe-only reconcile + settled state for offline sends 2026-04-18 02:09:50 -06:00
Forte11andGitHub ca63c56ecf Merge pull request #114 from Forte11Cuba/feat/reclaim-pending-send-from-history
feat: add cancel/reclaim button for pending sends (online + offline)
2026-04-18 01:05:04 -06:00
Forte11Cuba 5cdce99154 fix: address CodeRabbit review on #87 2026-04-17 07:52:38 -06:00
Forte11Cuba f734b35670 feat: add cancel/reclaim button for pending sends (online + offline) 2026-04-17 07:12:33 -06:00
Forte11andGitHub 5eef9ce130 Merge pull request #113 from Forte11Cuba/feat/paste-from-clipboard-in-scanner
feat: add paste-from-clipboard button to QR scanner
2026-04-17 06:04:53 -06:00
Forte11Cuba f2951d2bef feat: add paste-from-clipboard button to QR scanner 2026-04-17 05:07:53 -06:00
Forte11andGitHub 8015b4817b Merge pull request #112 from Forte11Cuba/fix/home-action-arrows-as-icons
fix: render home send/receive arrows as Lucide icons
2026-04-17 04:58:57 -06:00
Forte11Cuba cc1531f3d5 fix: render home send/receive arrows as Lucide icons 2026-04-17 03:55:09 -06:00
Forte11andGitHub aaef4b1cdc Merge pull request #111 from Forte11Cuba/fix/modal-bottom-sheet-safe-area
fix: bottom sheet modals overlap system navigation bar
2026-04-17 03:45:46 -06:00
Forte11Cuba dacec9ea22 fix: bottom sheet modals overlap system navigation bar 2026-04-17 03:39:41 -06:00
Forte11andGitHub 4b8edf9669 Merge pull request #110 from Forte11Cuba/fix/reclaim-per-unit-and-cancel-melt
fix: aggregate reclaimPendingProofs by unit and replace broken cancel Melt/cancelSend
2026-04-15 20:05:54 -06:00
Forte11Cuba f458cfad34 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:37:27 -06:00
Forte11Cuba 428f2c1f45 fix: aggregate reclaimPendingProofs by unit and replace broken cancelMelt/cancelSend 2026-04-15 17:19:38 -06:00
Forte11andGitHub 9a14af33a2 Merge pull request #107 from Forte11Cuba/fix/melt-proofs-lost-and-min-amount-validation
fix: recover reserved proofs on melt failure and validate minimum viable amounts
2026-04-15 06:10:08 -06:00
Forte11Cuba 770868a773 fix: add fee pre-validation to swap and log cancelMelt failures 2026-04-15 04:48:03 -06:00
Forte11Cuba dc78f8958c fix: recover reserved proofs on melt failure and validate minimum viable amounts 2026-04-15 04:20:53 -06:00
Forte11andGitHub 03ba0e532f Merge pull request #103 from Forte11Cuba/refactor/split-settings-screen
refactor: split settings_screen.dart into separate files
2026-04-12 16:34:06 -06:00
49 changed files with 6163 additions and 1051 deletions
+156
View File
@@ -0,0 +1,156 @@
/// 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
/// un storage propio para poder mostrarlos en el historial y permitir
/// cancelar/reclamar por transacción.
///
/// Los proofs se identifican por sus Y values (hex) — mismos que acepta
/// Wallet.reclaimProofsByYs() en el bridge Rust.
class PendingSend {
/// UUID único
final String id;
/// Token codificado completo (cashuA... o cashuB...)
final String encoded;
/// Monto total del envío (suma de los proofs)
final BigInt amount;
/// URL del mint
final String mintUrl;
/// Unidad (sat, usd, eur, etc.)
final String unit;
/// Y values de los proofs incluidos (hex). Se usan para reclamar.
final List<String> proofYs;
/// Fecha de creación del envío
final DateTime createdAt;
/// 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,
required this.amount,
required this.mintUrl,
required this.unit,
required List<String> 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. Condicionado en `isSettled` (no sólo en `settledAt != null`)
/// para que una corrupción futura del campo no reubique un record activo.
DateTime get effectiveTimestamp =>
isSettled ? (settledAt ?? createdAt) : createdAt;
PendingSend copyWith({
String? id,
String? encoded,
BigInt? amount,
String? mintUrl,
String? unit,
List<String>? proofYs,
DateTime? createdAt,
String? memo,
PendingSendStatus? status,
DateTime? settledAt,
}) {
return PendingSend(
id: id ?? this.id,
encoded: encoded ?? this.encoded,
amount: amount ?? this.amount,
mintUrl: mintUrl ?? this.mintUrl,
unit: unit ?? this.unit,
proofYs: proofYs ?? this.proofYs,
createdAt: createdAt ?? this.createdAt,
memo: memo ?? this.memo,
status: status ?? this.status,
settledAt: settledAt ?? this.settledAt,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'encoded': encoded,
'amount': amount.toString(),
'mint_url': mintUrl,
'unit': unit,
'proof_ys': proofYs.join(','),
'created_at': createdAt.millisecondsSinceEpoch,
'memo': memo,
'status': status.wireName,
'settled_at': settledAt?.millisecondsSinceEpoch,
};
}
factory PendingSend.fromMap(Map<String, dynamic> map) {
final ysRaw = map['proof_ys'] as String? ?? '';
final ys = ysRaw.isEmpty
? <String>[]
: 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,
amount: BigInt.parse(map['amount'] as String),
mintUrl: map['mint_url'] as String,
unit: map['unit'] as String,
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}, status: ${status.name})';
}
+214
View File
@@ -0,0 +1,214 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
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.
///
/// Los envíos offline no crean una Transaction en CDK, así que necesitamos
/// rastrearlos nosotros para poder:
/// - 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<String, PendingSend> _cache = {};
bool _isInitialized = false;
final StreamController<void> _changesController =
StreamController<void>.broadcast();
Stream<void> get changes => _changesController.stream;
static final PendingSendStorage _instance = PendingSendStorage._internal();
factory PendingSendStorage() => _instance;
PendingSendStorage._internal();
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<void> init() async {
if (_isInitialized) return;
if (Platform.isLinux || Platform.isWindows) {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;
}
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/$_dbName';
_db = await openDatabase(
dbPath,
version: _schemaVersion,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE $_tableName (
id TEXT PRIMARY KEY,
encoded TEXT NOT NULL,
amount TEXT NOT NULL,
mint_url TEXT NOT NULL,
unit TEXT NOT NULL,
proof_ys TEXT NOT NULL,
created_at INTEGER NOT NULL,
memo TEXT,
status TEXT NOT NULL DEFAULT 'active',
settled_at INTEGER
)
''');
await db.execute(
'CREATE INDEX idx_ps_mint_unit ON $_tableName(mint_url, unit)',
);
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: ${_cache.length} envíos '
'($activeCount activos)',
);
}
Future<void> _loadFromDb() async {
if (_db == null) return;
final results = await _db!.query(_tableName, orderBy: 'created_at DESC');
_cache.clear();
for (final row in results) {
final send = PendingSend.fromMap(row);
_cache[send.id] = send;
}
}
Future<PendingSend> add({
required String id,
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = PendingSend(
id: id,
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
createdAt: DateTime.now(),
memo: memo,
// status default = active
);
// Persistencia primero: si SQLite falla, la cache no queda contaminada
// con un record que el reinicio no va a encontrar.
await _db?.insert(_tableName, send.toMap());
_cache[send.id] = send;
_changesController.add(null);
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<void> 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(),
);
// Persistencia primero: si SQLite falla, la cache queda activa y un
// próximo reconcile reintenta. Con el orden inverso, la idempotencia
// (`isSettled → early return`) impediría el retry tras una falla.
await _db?.update(
_tableName,
updated.toMap(),
where: 'id = ?',
whereArgs: [id],
);
_cache[id] = updated;
_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<void> remove(String id) async {
// Persistencia primero: igual que add/markSettled, la cache no se
// adelanta al storage.
await _db?.delete(_tableName, where: 'id = ?', whereArgs: [id]);
_cache.remove(id);
_changesController.add(null);
}
List<PendingSend> listAll() {
final list = _cache.values.toList();
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
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<PendingSend> 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<PendingSend> 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
/// wallet activo.
List<PendingSend> listByMintUnit(String mintUrl, String unit) {
return _cache.values
.where((s) => s.mintUrl == mintUrl && s.unit == unit)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
Future<void> clear() async {
_cache.clear();
await _db?.delete(_tableName);
_changesController.add(null);
}
}
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Start",
"receive": "Empfangen",
"send": "Senden",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"sendAction": "Senden",
"receiveAction": "Empfangen",
"deposit": "Einzahlen",
"withdraw": "Abheben",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu empfangen",
"pasteTheCashuToken": "Füge den Cashu Token ein:",
"pasteFromClipboard": "Aus Zwischenablage einfügen",
"emptyClipboard": "Zwischenablage leer",
"cancelSend": "Senden abbrechen",
"cancelSendConfirmTitle": "Diesen Versand abbrechen?",
"cancelSendConfirmBody": "Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.",
"cancelSendSuccess": "Versand abgebrochen. {amount} {unit} zurückerhalten",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Der Empfänger hat das Token bereits eingelöst",
"pendingOfflineSend": "Ausstehender Offline-Versand",
"pendingOfflineSendsHeader": "Nicht eingelöste Offline-Versendungen",
"pendingSendDetailTitle": "Ausstehender Versand",
"validToken": "Token gültig",
"invalidToken": "Ungültiger oder fehlerhafter Token",
"amount": "Betrag:",
@@ -118,6 +133,7 @@
"confirm": "Bestätigen",
"cancel": "Abbrechen",
"insufficientBalance": "Unzureichendes Guthaben",
"feeExceedsAmount": "Die Gebühr übersteigt den Sendebetrag",
"tokenCreationError": "Fehler beim Erstellen des Tokens: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ausstehend",
"receivedStatus": "Empfangen",
"sentStatus": "Gesendet",
"receiving": "Wird empfangen",
"sending": "Wird gesendet",
"now": "Jetzt",
"agoMinutes": "Vor {minutes} Min",
"@agoMinutes": {
+20 -2
View File
@@ -53,8 +53,8 @@
"homeTitle": "Home",
"receive": "Receive",
"send": "Send",
"sendAction": "Send",
"receiveAction": "Receive",
"sendAction": "Send",
"receiveAction": "Receive",
"deposit": "Deposit",
"withdraw": "Withdraw",
"lightning": "Lightning",
@@ -76,6 +76,21 @@
"receiveCashu": "Receive Cashu",
"pasteTheCashuToken": "Paste the Cashu token:",
"pasteFromClipboard": "Paste from clipboard",
"emptyClipboard": "Clipboard is empty",
"cancelSend": "Cancel send",
"cancelSendConfirmTitle": "Cancel this send?",
"cancelSendConfirmBody": "Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.",
"cancelSendSuccess": "Send canceled. Recovered {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "The recipient already claimed the token",
"pendingOfflineSend": "Pending offline send",
"pendingOfflineSendsHeader": "Unclaimed offline sends",
"pendingSendDetailTitle": "Pending send",
"validToken": "Valid token",
"invalidToken": "Invalid or malformed token",
"amount": "Amount:",
@@ -103,6 +118,7 @@
"confirm": "Confirm",
"cancel": "Cancel",
"insufficientBalance": "Insufficient balance",
"feeExceedsAmount": "Fee exceeds the amount to send",
"tokenCreationError": "Error creating token: {error}",
"tokenCreated": "Token created",
@@ -193,6 +209,8 @@
"pendingStatus": "Pending",
"receivedStatus": "Received",
"sentStatus": "Sent",
"receiving": "Receiving",
"sending": "Sending",
"now": "Now",
"agoMinutes": "{minutes} min ago",
"agoHours": "{hours} h ago",
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Inicio",
"receive": "Recibir",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"sendAction": "Enviar",
"receiveAction": "Recibir",
"deposit": "Depositar",
"withdraw": "Retirar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recibir Cashu",
"pasteTheCashuToken": "Pega el token Cashu:",
"pasteFromClipboard": "Pegar del portapapeles",
"emptyClipboard": "Portapapeles vacío",
"cancelSend": "Cancelar envío",
"cancelSendConfirmTitle": "¿Cancelar envío?",
"cancelSendConfirmBody": "Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.",
"cancelSendSuccess": "Envío cancelado. Recuperaste {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "El destinatario ya reclamó el token",
"pendingOfflineSend": "Envío offline pendiente",
"pendingOfflineSendsHeader": "Envíos offline no reclamados",
"pendingSendDetailTitle": "Envío pendiente",
"validToken": "Token válido",
"invalidToken": "Token inválido o malformado",
"amount": "Monto:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Balance insuficiente",
"feeExceedsAmount": "La comisión supera el monto a enviar",
"tokenCreationError": "Error al crear token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -235,6 +251,8 @@
"pendingStatus": "Pendiente",
"receivedStatus": "Recibido",
"sentStatus": "Enviado",
"receiving": "Recibiendo",
"sending": "Enviando",
"now": "Ahora",
"agoMinutes": "Hace {minutes} min",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Accueil",
"receive": "Recevoir",
"send": "Envoyer",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"sendAction": "Envoyer",
"receiveAction": "Recevoir",
"deposit": "Déposer",
"withdraw": "Retirer",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Recevoir Cashu",
"pasteTheCashuToken": "Collez le token Cashu :",
"pasteFromClipboard": "Coller depuis le presse-papiers",
"emptyClipboard": "Presse-papiers vide",
"cancelSend": "Annuler l'envoi",
"cancelSendConfirmTitle": "Annuler cet envoi ?",
"cancelSendConfirmBody": "Ne fonctionne que si le destinataire n'a pas encore réclamé le token. S'il l'a déjà fait, l'envoi sera marqué comme terminé.",
"cancelSendSuccess": "Envoi annulé. Récupéré {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Le destinataire a déjà réclamé le token",
"pendingOfflineSend": "Envoi hors ligne en attente",
"pendingOfflineSendsHeader": "Envois hors ligne non réclamés",
"pendingSendDetailTitle": "Envoi en attente",
"validToken": "Token valide",
"invalidToken": "Token invalide ou malformé",
"amount": "Montant :",
@@ -118,6 +133,7 @@
"confirm": "Confirmer",
"cancel": "Annuler",
"insufficientBalance": "Solde insuffisant",
"feeExceedsAmount": "Les frais dépassent le montant à envoyer",
"tokenCreationError": "Erreur de création du token : {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,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": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Home",
"receive": "Ricevi",
"send": "Invia",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"sendAction": "Invia",
"receiveAction": "Ricevi",
"deposit": "Deposita",
"withdraw": "Preleva",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Ricevi Cashu",
"pasteTheCashuToken": "Incolla il token Cashu:",
"pasteFromClipboard": "Incolla dagli appunti",
"emptyClipboard": "Appunti vuoti",
"cancelSend": "Annulla invio",
"cancelSendConfirmTitle": "Annullare questo invio?",
"cancelSendConfirmBody": "Funziona solo se il destinatario non ha ancora riscattato il token. Se l'ha già fatto, l'invio sarà contrassegnato come completato.",
"cancelSendSuccess": "Invio annullato. Recuperati {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Il destinatario ha già riscattato il token",
"pendingOfflineSend": "Invio offline in sospeso",
"pendingOfflineSendsHeader": "Invii offline non riscattati",
"pendingSendDetailTitle": "Invio in sospeso",
"validToken": "Token valido",
"invalidToken": "Token non valido o malformato",
"amount": "Importo:",
@@ -118,6 +133,7 @@
"confirm": "Conferma",
"cancel": "Annulla",
"insufficientBalance": "Saldo insufficiente",
"feeExceedsAmount": "La commissione supera l'importo da inviare",
"tokenCreationError": "Errore creazione token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "In attesa",
"receivedStatus": "Ricevuto",
"sentStatus": "Inviato",
"receiving": "In ricezione",
"sending": "In invio",
"now": "Adesso",
"agoMinutes": "{minutes} min fa",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "ホーム",
"receive": "受取",
"send": "送金",
"sendAction": "送金",
"receiveAction": "受取",
"sendAction": "送金",
"receiveAction": "受取",
"deposit": "入金",
"withdraw": "出金",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashuを受け取る",
"pasteTheCashuToken": "Cashuトークンを貼り付け:",
"pasteFromClipboard": "クリップボードから貼り付け",
"emptyClipboard": "クリップボードは空です",
"cancelSend": "送金をキャンセル",
"cancelSendConfirmTitle": "この送金をキャンセルしますか?",
"cancelSendConfirmBody": "受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。",
"cancelSendSuccess": "送金をキャンセルしました。{amount} {unit}を回復しました",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "受取人は既にトークンを引き換えました",
"pendingOfflineSend": "保留中のオフライン送金",
"pendingOfflineSendsHeader": "未請求のオフライン送金",
"pendingSendDetailTitle": "保留中の送金",
"validToken": "有効なトークン",
"invalidToken": "無効または不正なトークン",
"amount": "金額:",
@@ -118,6 +133,7 @@
"confirm": "確認",
"cancel": "キャンセル",
"insufficientBalance": "残高不足",
"feeExceedsAmount": "手数料が送金額を超えています",
"tokenCreationError": "トークン作成エラー:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "保留中",
"receivedStatus": "受取済み",
"sentStatus": "送金済み",
"receiving": "受取中",
"sending": "送金中",
"now": "たった今",
"agoMinutes": "{minutes}分前",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "홈",
"receive": "받기",
"send": "보내기",
"sendAction": "보내기",
"receiveAction": "받기",
"sendAction": "보내기",
"receiveAction": "받기",
"deposit": "입금",
"withdraw": "출금",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Cashu 받기",
"pasteTheCashuToken": "Cashu 토큰을 붙여넣으세요:",
"pasteFromClipboard": "클립보드에서 붙여넣기",
"emptyClipboard": "클립보드가 비어 있음",
"cancelSend": "전송 취소",
"cancelSendConfirmTitle": "이 전송을 취소하시겠습니까?",
"cancelSendConfirmBody": "수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.",
"cancelSendSuccess": "전송이 취소되었습니다. {amount} {unit}를 복구했습니다",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "수신자가 이미 토큰을 받았습니다",
"pendingOfflineSend": "대기 중인 오프라인 전송",
"pendingOfflineSendsHeader": "청구되지 않은 오프라인 전송",
"pendingSendDetailTitle": "대기 중인 전송",
"validToken": "유효한 토큰",
"invalidToken": "유효하지 않거나 손상된 토큰",
"amount": "금액:",
@@ -118,6 +133,7 @@
"confirm": "확인",
"cancel": "취소",
"insufficientBalance": "잔액 부족",
"feeExceedsAmount": "수수료가 송금액을 초과합니다",
"tokenCreationError": "토큰 생성 오류: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "대기 중",
"receivedStatus": "받음",
"sentStatus": "보냄",
"receiving": "받는 중",
"sending": "보내는 중",
"now": "방금",
"agoMinutes": "{minutes}분 전",
"@agoMinutes": {
+74 -2
View File
@@ -388,13 +388,13 @@ abstract class L10n {
/// No description provided for @sendAction.
///
/// In es, this message translates to:
/// **'Enviar'**
/// **'Enviar'**
String get sendAction;
/// No description provided for @receiveAction.
///
/// In es, this message translates to:
/// **'Recibir'**
/// **'Recibir'**
String get receiveAction;
/// No description provided for @deposit.
@@ -505,6 +505,60 @@ abstract class L10n {
/// **'Pegar del portapapeles'**
String get pasteFromClipboard;
/// No description provided for @emptyClipboard.
///
/// In es, this message translates to:
/// **'Portapapeles vacío'**
String get emptyClipboard;
/// No description provided for @cancelSend.
///
/// In es, this message translates to:
/// **'Cancelar envío'**
String get cancelSend;
/// No description provided for @cancelSendConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Cancelar envío?'**
String get cancelSendConfirmTitle;
/// No description provided for @cancelSendConfirmBody.
///
/// In es, this message translates to:
/// **'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.'**
String get cancelSendConfirmBody;
/// No description provided for @cancelSendSuccess.
///
/// In es, this message translates to:
/// **'Envío cancelado. Recuperaste {amount} {unit}'**
String cancelSendSuccess(String amount, String unit);
/// No description provided for @cancelSendAlreadyClaimed.
///
/// In es, this message translates to:
/// **'El destinatario ya reclamó el token'**
String get cancelSendAlreadyClaimed;
/// No description provided for @pendingOfflineSend.
///
/// In es, this message translates to:
/// **'Envío offline pendiente'**
String get pendingOfflineSend;
/// No description provided for @pendingOfflineSendsHeader.
///
/// In es, this message translates to:
/// **'Envíos offline no reclamados'**
String get pendingOfflineSendsHeader;
/// No description provided for @pendingSendDetailTitle.
///
/// In es, this message translates to:
/// **'Envío pendiente'**
String get pendingSendDetailTitle;
/// No description provided for @validToken.
///
/// In es, this message translates to:
@@ -661,6 +715,12 @@ abstract class L10n {
/// **'Balance insuficiente'**
String get insufficientBalance;
/// No description provided for @feeExceedsAmount.
///
/// In es, this message translates to:
/// **'La comisión supera el monto a enviar'**
String get feeExceedsAmount;
/// No description provided for @tokenCreationError.
///
/// In es, this message translates to:
@@ -1075,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:
+43 -2
View File
@@ -156,10 +156,10 @@ class L10nDe extends L10n {
String get send => 'Senden';
@override
String get sendAction => 'Senden ↗';
String get sendAction => 'Senden';
@override
String get receiveAction => '↘ Empfangen';
String get receiveAction => 'Empfangen';
@override
String get deposit => 'Einzahlen';
@@ -215,6 +215,38 @@ class L10nDe extends L10n {
@override
String get pasteFromClipboard => 'Aus Zwischenablage einfügen';
@override
String get emptyClipboard => 'Zwischenablage leer';
@override
String get cancelSend => 'Senden abbrechen';
@override
String get cancelSendConfirmTitle => 'Diesen Versand abbrechen?';
@override
String get cancelSendConfirmBody =>
'Funktioniert nur, wenn der Empfänger das Token noch nicht eingelöst hat. Falls doch, wird der Versand als abgeschlossen markiert.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Versand abgebrochen. $amount $unit zurückerhalten';
}
@override
String get cancelSendAlreadyClaimed =>
'Der Empfänger hat das Token bereits eingelöst';
@override
String get pendingOfflineSend => 'Ausstehender Offline-Versand';
@override
String get pendingOfflineSendsHeader =>
'Nicht eingelöste Offline-Versendungen';
@override
String get pendingSendDetailTitle => 'Ausstehender Versand';
@override
String get validToken => 'Token gültig';
@@ -295,6 +327,9 @@ class L10nDe extends L10n {
@override
String get insufficientBalance => 'Unzureichendes Guthaben';
@override
String get feeExceedsAmount => 'Die Gebühr übersteigt den Sendebetrag';
@override
String tokenCreationError(String error) {
return 'Fehler beim Erstellen des Tokens: $error';
@@ -523,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';
+42 -2
View File
@@ -153,10 +153,10 @@ class L10nEn extends L10n {
String get send => 'Send';
@override
String get sendAction => 'Send ↗';
String get sendAction => 'Send';
@override
String get receiveAction => '↘ Receive';
String get receiveAction => 'Receive';
@override
String get deposit => 'Deposit';
@@ -212,6 +212,37 @@ class L10nEn extends L10n {
@override
String get pasteFromClipboard => 'Paste from clipboard';
@override
String get emptyClipboard => 'Clipboard is empty';
@override
String get cancelSend => 'Cancel send';
@override
String get cancelSendConfirmTitle => 'Cancel this send?';
@override
String get cancelSendConfirmBody =>
'Only works if the recipient has not claimed the token yet. If they already did, the send will be marked as completed.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Send canceled. Recovered $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'The recipient already claimed the token';
@override
String get pendingOfflineSend => 'Pending offline send';
@override
String get pendingOfflineSendsHeader => 'Unclaimed offline sends';
@override
String get pendingSendDetailTitle => 'Pending send';
@override
String get validToken => 'Valid token';
@@ -292,6 +323,9 @@ class L10nEn extends L10n {
@override
String get insufficientBalance => 'Insufficient balance';
@override
String get feeExceedsAmount => 'Fee exceeds the amount to send';
@override
String tokenCreationError(String error) {
return 'Error creating token: $error';
@@ -517,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';
+41 -2
View File
@@ -153,10 +153,10 @@ class L10nEs extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Recibir';
String get receiveAction => 'Recibir';
@override
String get deposit => 'Depositar';
@@ -212,6 +212,36 @@ class L10nEs extends L10n {
@override
String get pasteFromClipboard => 'Pegar del portapapeles';
@override
String get emptyClipboard => 'Portapapeles vacío';
@override
String get cancelSend => 'Cancelar envío';
@override
String get cancelSendConfirmTitle => '¿Cancelar envío?';
@override
String get cancelSendConfirmBody =>
'Solo funciona si el destinatario aún no reclamó el token. Si ya lo hizo, el envío se marcará como completado.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envío cancelado. Recuperaste $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'El destinatario ya reclamó el token';
@override
String get pendingOfflineSend => 'Envío offline pendiente';
@override
String get pendingOfflineSendsHeader => 'Envíos offline no reclamados';
@override
String get pendingSendDetailTitle => 'Envío pendiente';
@override
String get validToken => 'Token válido';
@@ -292,6 +322,9 @@ class L10nEs extends L10n {
@override
String get insufficientBalance => 'Balance insuficiente';
@override
String get feeExceedsAmount => 'La comisión supera el monto a enviar';
@override
String tokenCreationError(String error) {
return 'Error al crear token: $error';
@@ -518,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';
+42 -2
View File
@@ -157,10 +157,10 @@ class L10nFr extends L10n {
String get send => 'Envoyer';
@override
String get sendAction => 'Envoyer ↗';
String get sendAction => 'Envoyer';
@override
String get receiveAction => '↘ Recevoir';
String get receiveAction => 'Recevoir';
@override
String get deposit => 'Déposer';
@@ -216,6 +216,37 @@ class L10nFr extends L10n {
@override
String get pasteFromClipboard => 'Coller depuis le presse-papiers';
@override
String get emptyClipboard => 'Presse-papiers vide';
@override
String get cancelSend => 'Annuler l\'envoi';
@override
String get cancelSendConfirmTitle => 'Annuler cet envoi ?';
@override
String get cancelSendConfirmBody =>
'Ne fonctionne que si le destinataire n\'a pas encore réclamé le token. S\'il l\'a déjà fait, l\'envoi sera marqué comme terminé.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envoi annulé. Récupéré $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Le destinataire a déjà réclamé le token';
@override
String get pendingOfflineSend => 'Envoi hors ligne en attente';
@override
String get pendingOfflineSendsHeader => 'Envois hors ligne non réclamés';
@override
String get pendingSendDetailTitle => 'Envoi en attente';
@override
String get validToken => 'Token valide';
@@ -296,6 +327,9 @@ class L10nFr extends L10n {
@override
String get insufficientBalance => 'Solde insuffisant';
@override
String get feeExceedsAmount => 'Les frais dépassent le montant à envoyer';
@override
String tokenCreationError(String error) {
return 'Erreur de création du token : $error';
@@ -524,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';
+42 -2
View File
@@ -154,10 +154,10 @@ class L10nIt extends L10n {
String get send => 'Invia';
@override
String get sendAction => 'Invia ↗';
String get sendAction => 'Invia';
@override
String get receiveAction => '↘ Ricevi';
String get receiveAction => 'Ricevi';
@override
String get deposit => 'Deposita';
@@ -213,6 +213,37 @@ class L10nIt extends L10n {
@override
String get pasteFromClipboard => 'Incolla dagli appunti';
@override
String get emptyClipboard => 'Appunti vuoti';
@override
String get cancelSend => 'Annulla invio';
@override
String get cancelSendConfirmTitle => 'Annullare questo invio?';
@override
String get cancelSendConfirmBody =>
'Funziona solo se il destinatario non ha ancora riscattato il token. Se l\'ha già fatto, l\'invio sarà contrassegnato come completato.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Invio annullato. Recuperati $amount $unit';
}
@override
String get cancelSendAlreadyClaimed =>
'Il destinatario ha già riscattato il token';
@override
String get pendingOfflineSend => 'Invio offline in sospeso';
@override
String get pendingOfflineSendsHeader => 'Invii offline non riscattati';
@override
String get pendingSendDetailTitle => 'Invio in sospeso';
@override
String get validToken => 'Token valido';
@@ -293,6 +324,9 @@ class L10nIt extends L10n {
@override
String get insufficientBalance => 'Saldo insufficiente';
@override
String get feeExceedsAmount => 'La commissione supera l\'importo da inviare';
@override
String tokenCreationError(String error) {
return 'Errore creazione token: $error';
@@ -519,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';
+41 -2
View File
@@ -149,10 +149,10 @@ class L10nJa extends L10n {
String get send => '送金';
@override
String get sendAction => '送金';
String get sendAction => '送金';
@override
String get receiveAction => '受取';
String get receiveAction => '受取';
@override
String get deposit => '入金';
@@ -208,6 +208,36 @@ class L10nJa extends L10n {
@override
String get pasteFromClipboard => 'クリップボードから貼り付け';
@override
String get emptyClipboard => 'クリップボードは空です';
@override
String get cancelSend => '送金をキャンセル';
@override
String get cancelSendConfirmTitle => 'この送金をキャンセルしますか?';
@override
String get cancelSendConfirmBody =>
'受取人がまだトークンを引き換えていない場合のみ機能します。既に引き換え済みの場合、送金は完了として記録されます。';
@override
String cancelSendSuccess(String amount, String unit) {
return '送金をキャンセルしました。$amount $unitを回復しました';
}
@override
String get cancelSendAlreadyClaimed => '受取人は既にトークンを引き換えました';
@override
String get pendingOfflineSend => '保留中のオフライン送金';
@override
String get pendingOfflineSendsHeader => '未請求のオフライン送金';
@override
String get pendingSendDetailTitle => '保留中の送金';
@override
String get validToken => '有効なトークン';
@@ -288,6 +318,9 @@ class L10nJa extends L10n {
@override
String get insufficientBalance => '残高不足';
@override
String get feeExceedsAmount => '手数料が送金額を超えています';
@override
String tokenCreationError(String error) {
return 'トークン作成エラー:$error';
@@ -511,6 +544,12 @@ class L10nJa extends L10n {
@override
String get sentStatus => '送金済み';
@override
String get receiving => '受取中';
@override
String get sending => '送金中';
@override
String get now => 'たった今';
+41 -2
View File
@@ -151,10 +151,10 @@ class L10nKo extends L10n {
String get send => '보내기';
@override
String get sendAction => '보내기';
String get sendAction => '보내기';
@override
String get receiveAction => '받기';
String get receiveAction => '받기';
@override
String get deposit => '입금';
@@ -210,6 +210,36 @@ class L10nKo extends L10n {
@override
String get pasteFromClipboard => '클립보드에서 붙여넣기';
@override
String get emptyClipboard => '클립보드가 비어 있음';
@override
String get cancelSend => '전송 취소';
@override
String get cancelSendConfirmTitle => '이 전송을 취소하시겠습니까?';
@override
String get cancelSendConfirmBody =>
'수신자가 아직 토큰을 받지 않은 경우에만 작동합니다. 이미 받았다면 전송이 완료로 표시됩니다.';
@override
String cancelSendSuccess(String amount, String unit) {
return '전송이 취소되었습니다. $amount $unit를 복구했습니다';
}
@override
String get cancelSendAlreadyClaimed => '수신자가 이미 토큰을 받았습니다';
@override
String get pendingOfflineSend => '대기 중인 오프라인 전송';
@override
String get pendingOfflineSendsHeader => '청구되지 않은 오프라인 전송';
@override
String get pendingSendDetailTitle => '대기 중인 전송';
@override
String get validToken => '유효한 토큰';
@@ -290,6 +320,9 @@ class L10nKo extends L10n {
@override
String get insufficientBalance => '잔액 부족';
@override
String get feeExceedsAmount => '수수료가 송금액을 초과합니다';
@override
String tokenCreationError(String error) {
return '토큰 생성 오류: $error';
@@ -513,6 +546,12 @@ class L10nKo extends L10n {
@override
String get sentStatus => '보냄';
@override
String get receiving => '받는 중';
@override
String get sending => '보내는 중';
@override
String get now => '방금';
+41 -2
View File
@@ -154,10 +154,10 @@ class L10nPt extends L10n {
String get send => 'Enviar';
@override
String get sendAction => 'Enviar ↗';
String get sendAction => 'Enviar';
@override
String get receiveAction => '↘ Receber';
String get receiveAction => 'Receber';
@override
String get deposit => 'Depositar';
@@ -213,6 +213,36 @@ class L10nPt extends L10n {
@override
String get pasteFromClipboard => 'Colar da área de transferência';
@override
String get emptyClipboard => 'Área de transferência vazia';
@override
String get cancelSend => 'Cancelar envio';
@override
String get cancelSendConfirmTitle => 'Cancelar este envio?';
@override
String get cancelSendConfirmBody =>
'Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Envio cancelado. Recuperou $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'O destinatário já resgatou o token';
@override
String get pendingOfflineSend => 'Envio offline pendente';
@override
String get pendingOfflineSendsHeader => 'Envios offline não resgatados';
@override
String get pendingSendDetailTitle => 'Envio pendente';
@override
String get validToken => 'Token válido';
@@ -293,6 +323,9 @@ class L10nPt extends L10n {
@override
String get insufficientBalance => 'Saldo insuficiente';
@override
String get feeExceedsAmount => 'A taxa excede o valor a enviar';
@override
String tokenCreationError(String error) {
return 'Erro ao criar token: $error';
@@ -521,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';
+41 -2
View File
@@ -153,10 +153,10 @@ class L10nRu extends L10n {
String get send => 'Отправить';
@override
String get sendAction => 'Отправить';
String get sendAction => 'Отправить';
@override
String get receiveAction => 'Получить';
String get receiveAction => 'Получить';
@override
String get deposit => 'Пополнить';
@@ -212,6 +212,36 @@ class L10nRu extends L10n {
@override
String get pasteFromClipboard => 'Вставить из буфера обмена';
@override
String get emptyClipboard => 'Буфер обмена пуст';
@override
String get cancelSend => 'Отменить отправку';
@override
String get cancelSendConfirmTitle => 'Отменить эту отправку?';
@override
String get cancelSendConfirmBody =>
'Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Отправка отменена. Восстановлено $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Получатель уже получил токен';
@override
String get pendingOfflineSend => 'Ожидающая оффлайн-отправка';
@override
String get pendingOfflineSendsHeader => 'Неполученные оффлайн-отправки';
@override
String get pendingSendDetailTitle => 'Ожидающая отправка';
@override
String get validToken => 'Токен действителен';
@@ -292,6 +322,9 @@ class L10nRu extends L10n {
@override
String get insufficientBalance => 'Недостаточный баланс';
@override
String get feeExceedsAmount => 'Комиссия превышает сумму отправки';
@override
String tokenCreationError(String error) {
return 'Ошибка создания токена: $error';
@@ -519,6 +552,12 @@ class L10nRu extends L10n {
@override
String get sentStatus => 'Отправлено';
@override
String get receiving => 'Получение';
@override
String get sending => 'Отправка';
@override
String get now => 'Сейчас';
+41 -2
View File
@@ -153,10 +153,10 @@ class L10nSw extends L10n {
String get send => 'Tuma';
@override
String get sendAction => 'Tuma ↗';
String get sendAction => 'Tuma';
@override
String get receiveAction => '↘ Pokea';
String get receiveAction => 'Pokea';
@override
String get deposit => 'Weka';
@@ -212,6 +212,36 @@ class L10nSw extends L10n {
@override
String get pasteFromClipboard => 'Bandika kutoka ubao';
@override
String get emptyClipboard => 'Ubao wa kunakili ni tupu';
@override
String get cancelSend => 'Ghairi kutuma';
@override
String get cancelSendConfirmTitle => 'Ghairi kutuma huku?';
@override
String get cancelSendConfirmBody =>
'Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.';
@override
String cancelSendSuccess(String amount, String unit) {
return 'Kutuma kumeghairiwa. Umerudisha $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => 'Mpokeaji tayari alitumia tokeni';
@override
String get pendingOfflineSend => 'Kutuma nje ya mtandao kunasubiri';
@override
String get pendingOfflineSendsHeader => 'Kutuma nje ya mtandao bila kudaiwa';
@override
String get pendingSendDetailTitle => 'Kutuma kunakusubiri';
@override
String get validToken => 'Tokeni halali';
@@ -293,6 +323,9 @@ class L10nSw extends L10n {
@override
String get insufficientBalance => 'Salio halitoshi';
@override
String get feeExceedsAmount => 'Ada inazidi kiasi cha kutuma';
@override
String tokenCreationError(String error) {
return 'Hitilafu ya kuunda tokeni: $error';
@@ -518,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';
+40 -2
View File
@@ -148,10 +148,10 @@ class L10nZh extends L10n {
String get send => '付款';
@override
String get sendAction => '付款';
String get sendAction => '付款';
@override
String get receiveAction => '收款';
String get receiveAction => '收款';
@override
String get deposit => '存入';
@@ -207,6 +207,35 @@ class L10nZh extends L10n {
@override
String get pasteFromClipboard => '从剪贴板粘贴';
@override
String get emptyClipboard => '剪贴板为空';
@override
String get cancelSend => '取消发送';
@override
String get cancelSendConfirmTitle => '取消此次发送?';
@override
String get cancelSendConfirmBody => '仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。';
@override
String cancelSendSuccess(String amount, String unit) {
return '发送已取消。已恢复 $amount $unit';
}
@override
String get cancelSendAlreadyClaimed => '接收方已领取代币';
@override
String get pendingOfflineSend => '待处理的离线发送';
@override
String get pendingOfflineSendsHeader => '未领取的离线发送';
@override
String get pendingSendDetailTitle => '待处理发送';
@override
String get validToken => '有效代币';
@@ -287,6 +316,9 @@ class L10nZh extends L10n {
@override
String get insufficientBalance => '余额不足';
@override
String get feeExceedsAmount => '手续费超过发送金额';
@override
String tokenCreationError(String error) {
return '创建代币错误:$error';
@@ -510,6 +542,12 @@ class L10nZh extends L10n {
@override
String get sentStatus => '已发送';
@override
String get receiving => '接收中';
@override
String get sending => '发送中';
@override
String get now => '刚刚';
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Início",
"receive": "Receber",
"send": "Enviar",
"sendAction": "Enviar",
"receiveAction": "Receber",
"sendAction": "Enviar",
"receiveAction": "Receber",
"deposit": "Depositar",
"withdraw": "Sacar",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Receber Cashu",
"pasteTheCashuToken": "Cole o token Cashu:",
"pasteFromClipboard": "Colar da área de transferência",
"emptyClipboard": "Área de transferência vazia",
"cancelSend": "Cancelar envio",
"cancelSendConfirmTitle": "Cancelar este envio?",
"cancelSendConfirmBody": "Só funciona se o destinatário ainda não resgatou o token. Se já resgatou, o envio será marcado como concluído.",
"cancelSendSuccess": "Envio cancelado. Recuperou {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "O destinatário já resgatou o token",
"pendingOfflineSend": "Envio offline pendente",
"pendingOfflineSendsHeader": "Envios offline não resgatados",
"pendingSendDetailTitle": "Envio pendente",
"validToken": "Token válido",
"invalidToken": "Token inválido ou malformado",
"amount": "Valor:",
@@ -118,6 +133,7 @@
"confirm": "Confirmar",
"cancel": "Cancelar",
"insufficientBalance": "Saldo insuficiente",
"feeExceedsAmount": "A taxa excede o valor a enviar",
"tokenCreationError": "Erro ao criar token: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Pendente",
"receivedStatus": "Recebido",
"sentStatus": "Enviado",
"receiving": "Recebendo",
"sending": "Enviando",
"now": "Agora",
"agoMinutes": "Há {minutes} min",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Главная",
"receive": "Получить",
"send": "Отправить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"sendAction": "Отправить",
"receiveAction": "Получить",
"deposit": "Пополнить",
"withdraw": "Вывести",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Получить Cashu",
"pasteTheCashuToken": "Вставьте Cashu токен:",
"pasteFromClipboard": "Вставить из буфера обмена",
"emptyClipboard": "Буфер обмена пуст",
"cancelSend": "Отменить отправку",
"cancelSendConfirmTitle": "Отменить эту отправку?",
"cancelSendConfirmBody": "Работает только если получатель еще не получил токен. Если уже получил, отправка будет помечена как завершенная.",
"cancelSendSuccess": "Отправка отменена. Восстановлено {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Получатель уже получил токен",
"pendingOfflineSend": "Ожидающая оффлайн-отправка",
"pendingOfflineSendsHeader": "Неполученные оффлайн-отправки",
"pendingSendDetailTitle": "Ожидающая отправка",
"validToken": "Токен действителен",
"invalidToken": "Недействительный или повреждённый токен",
"amount": "Сумма:",
@@ -118,6 +133,7 @@
"confirm": "Подтвердить",
"cancel": "Отмена",
"insufficientBalance": "Недостаточный баланс",
"feeExceedsAmount": "Комиссия превышает сумму отправки",
"tokenCreationError": "Ошибка создания токена: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Ожидание",
"receivedStatus": "Получено",
"sentStatus": "Отправлено",
"receiving": "Получение",
"sending": "Отправка",
"now": "Сейчас",
"agoMinutes": "{minutes} мин назад",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "Nyumbani",
"receive": "Pokea",
"send": "Tuma",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"sendAction": "Tuma",
"receiveAction": "Pokea",
"deposit": "Weka",
"withdraw": "Toa",
"lightning": "Lightning",
@@ -86,6 +86,21 @@
"receiveCashu": "Pokea Cashu",
"pasteTheCashuToken": "Bandika tokeni ya Cashu:",
"pasteFromClipboard": "Bandika kutoka ubao",
"emptyClipboard": "Ubao wa kunakili ni tupu",
"cancelSend": "Ghairi kutuma",
"cancelSendConfirmTitle": "Ghairi kutuma huku?",
"cancelSendConfirmBody": "Hufanya kazi tu ikiwa mpokeaji hajatumia tokeni. Ikiwa tayari alitumia, kutuma kutaonyeshwa kama kumekamilika.",
"cancelSendSuccess": "Kutuma kumeghairiwa. Umerudisha {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "Mpokeaji tayari alitumia tokeni",
"pendingOfflineSend": "Kutuma nje ya mtandao kunasubiri",
"pendingOfflineSendsHeader": "Kutuma nje ya mtandao bila kudaiwa",
"pendingSendDetailTitle": "Kutuma kunakusubiri",
"validToken": "Tokeni halali",
"invalidToken": "Tokeni batili au imeharibika",
"amount": "Kiasi:",
@@ -118,6 +133,7 @@
"confirm": "Thibitisha",
"cancel": "Ghairi",
"insufficientBalance": "Salio halitoshi",
"feeExceedsAmount": "Ada inazidi kiasi cha kutuma",
"tokenCreationError": "Hitilafu ya kuunda tokeni: {error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "Inasubiri",
"receivedStatus": "Imepokelewa",
"sentStatus": "Imetumwa",
"receiving": "Inapokea",
"sending": "Inatumwa",
"now": "Sasa",
"agoMinutes": "Dakika {minutes} zilizopita",
"@agoMinutes": {
+20 -2
View File
@@ -63,8 +63,8 @@
"homeTitle": "首页",
"receive": "收款",
"send": "付款",
"sendAction": "付款",
"receiveAction": "收款",
"sendAction": "付款",
"receiveAction": "收款",
"deposit": "存入",
"withdraw": "提取",
"lightning": "闪电网络",
@@ -86,6 +86,21 @@
"receiveCashu": "接收 Cashu",
"pasteTheCashuToken": "粘贴 Cashu 代币:",
"pasteFromClipboard": "从剪贴板粘贴",
"emptyClipboard": "剪贴板为空",
"cancelSend": "取消发送",
"cancelSendConfirmTitle": "取消此次发送?",
"cancelSendConfirmBody": "仅当接收方尚未领取代币时有效。如已领取,发送将标记为已完成。",
"cancelSendSuccess": "发送已取消。已恢复 {amount} {unit}",
"@cancelSendSuccess": {
"placeholders": {
"amount": {"type": "String"},
"unit": {"type": "String"}
}
},
"cancelSendAlreadyClaimed": "接收方已领取代币",
"pendingOfflineSend": "待处理的离线发送",
"pendingOfflineSendsHeader": "未领取的离线发送",
"pendingSendDetailTitle": "待处理发送",
"validToken": "有效代币",
"invalidToken": "无效或格式错误的代币",
"amount": "金额:",
@@ -118,6 +133,7 @@
"confirm": "确认",
"cancel": "取消",
"insufficientBalance": "余额不足",
"feeExceedsAmount": "手续费超过发送金额",
"tokenCreationError": "创建代币错误:{error}",
"@tokenCreationError": {
"placeholders": {
@@ -227,6 +243,8 @@
"pendingStatus": "待处理",
"receivedStatus": "已收到",
"sentStatus": "已发送",
"receiving": "接收中",
"sending": "发送中",
"now": "刚刚",
"agoMinutes": "{minutes} 分钟前",
"@agoMinutes": {
+315 -177
View File
@@ -15,6 +15,8 @@ import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../data/pending_send.dart';
import '../data/pending_send_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../core/utils/p2pk_utils.dart';
import '../widgets/effects/cashu_confetti.dart';
@@ -47,6 +49,9 @@ class WalletProvider extends ChangeNotifier {
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Storage para envíos offline pendientes de ser reclamados por el receptor
final PendingSendStorage _pendingSendStorage = PendingSendStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
@@ -82,8 +87,8 @@ class WalletProvider extends ChangeNotifier {
static const _mintsKey = 'wallet_mints';
static const _activeMintKey = 'wallet_active_mint';
static const _activeUnitKey = 'wallet_active_unit';
static const _pendingMintInvoicesKey = 'pending_mint_invoices';
static const _pendingNostrRequestKey = 'pending_nostr_request';
static const _legacyPendingMintInvoicesKey = 'pending_mint_invoices';
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
@@ -125,6 +130,159 @@ class WalletProvider extends ChangeNotifier {
/// Stream de cambios en pending tokens
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
// ============================================================
// PENDING SENDS GETTERS (envíos offline no reclamados)
// ============================================================
int get pendingSendCount => _pendingSendStorage.count;
int get activePendingSendCount => _pendingSendStorage.activeCount;
bool get hasPendingSends => _pendingSendStorage.hasPendingSends;
bool get hasActivePendingSends => _pendingSendStorage.hasActivePendingSends;
Stream<void> get pendingSendChanges => _pendingSendStorage.changes;
List<PendingSend> listPendingSends() => _pendingSendStorage.listAll();
/// Envíos activos (receptor aún no reclamó). Render: tile warning con
/// botón cancel.
List<PendingSend> listActivePendingSends() =>
_pendingSendStorage.listActive();
/// Envíos liquidados (receptor reclamó). Render: tile outgoing settled,
/// sin botón cancel.
List<PendingSend> listSettledPendingSends() =>
_pendingSendStorage.listSettled();
List<PendingSend> listPendingSendsByMintUnit(String mintUrl, String unit) =>
_pendingSendStorage.listByMintUnit(mintUrl, unit);
/// Persiste un envío offline pendiente para poder reclamarlo después.
/// Llamar desde el flujo de offline_send_screen después de crear el token.
Future<PendingSend> addPendingSend({
required String encoded,
required BigInt amount,
required String mintUrl,
required String unit,
required List<String> proofYs,
String? memo,
}) async {
final send = await _pendingSendStorage.add(
id: _uuid.v4(),
encoded: encoded,
amount: amount,
mintUrl: mintUrl,
unit: unit,
proofYs: proofYs,
memo: memo,
);
// El flujo offline modifica el DB directamente (markProofsPendingSpent),
// así que CDK no sabe que cambió. Forzamos refresh del stream de balance
// para que el home se actualice al instante.
try {
final wallet = await getWallet(mintUrl, unit);
await wallet.refreshBalance();
} catch (e) {
debugPrint('refreshBalance tras addPendingSend falló: $e');
}
notifyListeners();
return send;
}
/// Reclama las proofs de un envío offline pendiente. Verifica con el mint:
/// - 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<ReclaimResult> reclaimPendingSend(PendingSend send) async {
final wallet = await getWallet(send.mintUrl, send.unit);
final result = await wallet.reclaimProofsByYs(ys: send.proofYs);
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<int> 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.
/// - `pending == 0 && spent >= proofYs.length` → mint confirmó todos
/// spent: receptor reclamó. SETTLE para histórico.
/// - cualquier otro caso (pending > 0, o cobertura parcial de spent)
/// → conservar activo; próxima reconciliación reintenta.
Future<bool> _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;
}
// Defensa en profundidad: aunque `pending_count == 0` suele bastar para
// decir "todas consumidas", exigimos además que `spent_count` cubra
// todos los proofs del send. Un resultado parcial o malformado podría
// dejar `pending == 0` sin haber observado spent para todos los ys.
final expected = BigInt.from(send.proofYs.length);
final allAccountedSpent = expected > BigInt.zero &&
result.pendingCount == BigInt.zero &&
result.spentCount >= expected;
if (allAccountedSpent) {
// Ninguna unspent, ninguna pending, todas spent → receptor reclamó.
// Pasa a histórico como "outgoing liquidado".
await _pendingSendStorage.markSettled(send.id);
return true;
}
// pending > 0 (receptor mid-swap) o cobertura parcial → conservamos
// activo para que la próxima reconciliación lo resuelva.
return false;
}
Future<void> removePendingSend(String id) async {
await _pendingSendStorage.remove(id);
notifyListeners();
}
/// Lista de mints ordenados por balance.
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
Future<List<String>> getSortedMintUrls() async {
@@ -333,6 +491,9 @@ class WalletProvider extends ChangeNotifier {
// Inicializar storage de tokens pendientes
await _pendingTokenStorage.init();
// Inicializar storage de envíos offline pendientes
await _pendingSendStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
@@ -1092,43 +1253,27 @@ class WalletProvider extends ChangeNotifier {
}
/// Método de conveniencia: prepara y confirma en un solo paso.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
/// Si confirmSend falla, los proofs quedan en PendingSpent.
/// No llamamos reclaimPendingProofs aquí porque podría revocar
/// tokens de otros envíos no reclamados. El usuario puede recuperar
/// manualmente desde Settings → Recover Tokens.
Future<String> sendTokens(BigInt amount, String? memo) async {
final prepared = await prepareSend(amount);
debugPrint('[SEND] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[SEND] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[SEND] Send completed');
return token;
}
/// Envía tokens P2PK (bloqueados a una clave pública).
/// CDK 0.15+ con includeFee: true maneja correctamente mints con ppk>0.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
Future<String> sendTokensP2pk(BigInt amount, String pubkey, String? memo) async {
debugPrint('[P2PK] Sending $amount to ${pubkey.length > 16 ? pubkey.substring(0, 16) : pubkey}...');
final prepared = await prepareSendP2pk(amount, pubkey);
debugPrint('[P2PK] prepareSend OK - fee=${prepared.fee}');
try {
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
} catch (e) {
try {
await cancelSend(prepared);
} catch (cancelErr) {
debugPrint('[P2PK] cancelSend failed: $cancelErr');
}
rethrow;
}
final token = await confirmSend(prepared, memo);
debugPrint('[P2PK] Send completed');
return token;
}
/// Verifica si hay transacciones salientes pendientes en el wallet activo.
@@ -1185,8 +1330,6 @@ class WalletProvider extends ChangeNotifier {
throw Exception('No hay wallet activo');
}
final mintUrl = _activeMintUrl!;
final unit = _activeUnit;
String? invoiceBolt11;
// Cerrar controller anterior si existe (evitar leak)
@@ -1209,25 +1352,24 @@ class WalletProvider extends ChangeNotifier {
amount: amount,
description: description,
).listen(
(quote) async {
// Guardar invoice temprano en SharedPreferences
(quote) {
if (quote.state == MintQuoteState.unpaid) {
invoiceBolt11 = quote.request;
_savePendingMintInvoice(quote.id, quote.request, mintUrl, unit, amount);
}
// Cuando se completa, guardar metadata, confetti, limpiar pending
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
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)
// Reenviar a la UI ANTES de cualquier await: Rust cierra el stream
// inmediatamente tras emitir Issued, y onDone corre durante el await
// de _saveMintMetadata, cerrando el controller antes de que el evento
// llegue al screen.
if (!controller.isClosed) {
controller.add(quote);
}
// Metadata async fire-and-forget (no bloquea el forward)
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
final invoice = invoiceBolt11!;
unawaited(_saveMintMetadata(wallet, invoice, quote.transactionId));
}
},
onError: (error) {
if (!controller.isClosed) {
@@ -1245,91 +1387,63 @@ class WalletProvider extends ChangeNotifier {
return controller.stream;
}
/// Guarda un invoice de mint pendiente para recuperarlo después.
Future<void> _savePendingMintInvoice(
String quoteId, String invoice, String mintUrl, String unit, BigInt amount,
) async {
/// Exact recovery of paid-but-unissued mint quotes using `quote_id`.
///
/// Replaces the old heuristic that matched by (mintUrl, unit, amount) and
/// could pair a freshly created invoice to an unrelated orphan transaction
/// of the same amount. For every unissued quote the CDK knows about, we
/// ask the mint for the current state and — if Paid — issue the proofs,
/// linking the resulting tx_id to its bolt11 in TransactionMeta.
Future<void> _recoverPendingMintQuotes() async {
// One-time cleanup of the legacy SharedPreferences state (no-op after
// the first run). Only writes when the key is actually present.
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map[quoteId] = {
'invoice': invoice,
'mintUrl': mintUrl,
'unit': unit,
'amount': amount.toString(),
'createdAt': DateTime.now().toIso8601String(),
};
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice guardado: $quoteId');
if (prefs.getString(_legacyPendingMintInvoicesKey) != null) {
await prefs.remove(_legacyPendingMintInvoicesKey);
debugPrint('Removed legacy $_legacyPendingMintInvoicesKey from prefs');
}
} catch (e) {
debugPrint('Error guardando pending mint invoice: $e');
debugPrint('Legacy pending-invoices cleanup failed: $e');
}
}
/// Elimina un invoice de mint pendiente (ya fue procesado).
Future<void> _removePendingMintInvoice(String quoteId) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
map.remove(quoteId);
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoice eliminado: $quoteId');
} catch (e) {
debugPrint('Error eliminando pending mint invoice: $e');
}
}
for (final entry in _mintUnits.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
final pending = await wallet.getUnissuedMintQuotes();
for (final quote in pending) {
try {
final updated = await wallet.checkMintQuoteStatus(quoteId: quote.id);
if (updated.state != MintQuoteState.paid) continue;
/// TTL para invoices de mint pendientes (24 horas).
static const _pendingMintInvoiceTtl = Duration(hours: 24);
/// Obtiene todos los invoices de mint pendientes, limpiando expirados.
Future<Map<String, dynamic>> _getPendingMintInvoices() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonStr = prefs.getString(_pendingMintInvoicesKey) ?? '{}';
final map = Map<String, dynamic>.from(jsonDecode(jsonStr));
// Filtrar expirados
final now = DateTime.now();
final expired = <String>[];
for (final entry in map.entries) {
final data = Map<String, dynamic>.from(entry.value);
final createdAt = DateTime.tryParse(data['createdAt'] ?? '');
if (createdAt == null || now.difference(createdAt) > _pendingMintInvoiceTtl) {
expired.add(entry.key);
final minted = await wallet.mintByQuoteId(quoteId: quote.id);
final txId = minted.transactionId;
if (txId != null && txId.isNotEmpty) {
await _txMetaStorage.save(
txId,
TransactionMeta(
type: TransactionType.lightning,
invoice: updated.request,
),
);
debugPrint('Recovered mint quote ${quote.id} → tx $txId');
} else {
debugPrint('Recovered mint quote ${quote.id} but no tx_id');
}
confettiController.fire();
notifyListeners();
} catch (e) {
debugPrint('Recover quote ${quote.id} failed: $e');
}
}
} catch (e) {
debugPrint('Recover quotes for ${entry.key}/$unit failed: $e');
}
}
// Limpiar expirados si hay
if (expired.isNotEmpty) {
for (final key in expired) {
map.remove(key);
}
await prefs.setString(_pendingMintInvoicesKey, jsonEncode(map));
debugPrint('Pending mint invoices expirados eliminados: ${expired.length}');
}
return map;
} catch (e) {
return {};
}
}
/// Busca un invoice pendiente que coincida con un mintUrl y unit.
/// Retorna el invoice string o null.
Future<String?> findPendingMintInvoice(String mintUrl, String unit) async {
final pending = await _getPendingMintInvoices();
for (final entry in pending.values) {
final data = Map<String, dynamic>.from(entry);
if (data['mintUrl'] == mintUrl && data['unit'] == unit) {
return data['invoice'] as String?;
}
}
return null;
}
// ============================================================
// PENDING NOSTR PAYMENT REQUESTS
// ============================================================
@@ -1498,19 +1612,82 @@ class WalletProvider extends ChangeNotifier {
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
/// FRB consume PreparedMelt por valor, así que cancelMelt no es posible.
/// Si falla, usamos recoverIncompleteSagas para reanudar solo esta operación
/// sin afectar otros proofs pendientes (como tokens enviados no reclamados).
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
final prepared = await wallet.prepareMelt(quote: quote);
try {
final totalPaid = await wallet.confirmMelt(melt: prepared);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
} catch (e) {
// PreparedMelt ya fue consumido por FRB — cancelMelt es imposible.
// recoverIncompleteSagas reanuda solo la saga interrumpida,
// sin afectar otros proofs en PendingSpent (sends no reclamados).
try {
await wallet.recoverIncompleteSagas();
} catch (recoverErr) {
debugPrint('[MELT] recoverIncompleteSagas failed: $recoverErr');
}
rethrow;
}
}
notifyListeners();
return totalPaid;
/// Recupera proofs huérfanas en PendingSpent consultando el mint.
/// Solo revierte proofs que el mint confirma como no gastadas.
/// Si [mintUrl] es null, escanea todos los mints.
/// Retorna Map<unit, ReclaimResult> con resultados agrupados por unidad.
Future<Map<String, ReclaimResult>> reclaimPendingProofs({String? mintUrl}) async {
final perUnit = <String, ReclaimResult>{};
final mints = mintUrl != null
? {mintUrl: _mintUnits[mintUrl] ?? ['sat']}
: _mintUnits;
for (final entry in mints.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
final result = await wallet.reclaimPendingProofs();
if (result.count > BigInt.zero) {
final prev = perUnit[unit];
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) {
debugPrint('Reclaim pending proofs failed for ${entry.key}:$unit: $e');
}
}
}
if (perUnit.isNotEmpty) {
notifyListeners();
}
return perUnit;
}
/// Cancela un envío pendiente reclamando sus proofs.
/// Usa tx.ys para identificar las proofs exactas de esa transacción.
/// Solo revierte las que el mint confirma como no gastadas.
Future<ReclaimResult> reclaimTransaction(Transaction tx) async {
final wallet = await getWallet(tx.mintUrl, tx.unit);
final result = await wallet.reclaimProofsByYs(ys: tx.ys);
if (result.count > BigInt.zero) {
notifyListeners();
}
return result;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
@@ -1659,6 +1836,13 @@ class WalletProvider extends ChangeNotifier {
/// Llamar en background al iniciar la app.
/// También vincula transacciones incoming sin metadata con invoices pendientes.
Future<void> checkPendingTransactions() async {
// Recover paid-but-unissued quotes by quote_id FIRST, before the
// per-mint loop runs checkAllMintQuotes. CDK's mint_unissued_quotes
// issues proofs silently (drops amount_issued > 0 into the quote,
// excluding it from the unissued list), so if it ran first our
// recovery would find nothing and bolt11 metadata would be lost.
await _recoverPendingMintQuotes();
// Iterar todos los mints y todas sus unidades, no solo los wallets
// ya instanciados. initialize() solo precarga units.first por mint,
// así que quotes/sagas/melts en otras unidades se perderían.
@@ -1668,7 +1852,9 @@ class WalletProvider extends ChangeNotifier {
final wallet = await getWallet(entry.key, unit);
try {
// Reclamar quotes pagados pero no emitidos (Lightning → proofs)
// Safety net: after our recovery pass, this should be a no-op
// for healthy cases. Still useful if _recoverPendingMintQuotes
// errored on a specific quote and CDK can retry issuance.
await wallet.checkAllMintQuotes();
} catch (e) {
debugPrint('Check mint quotes failed: $e');
@@ -1694,69 +1880,17 @@ class WalletProvider extends ChangeNotifier {
// Silencioso - puede fallar offline
debugPrint('Check pending failed: $e');
}
} catch (e) {
debugPrint('Error getting wallet ${entry.key}:$unit: $e');
}
}
}
// Vincular transacciones incoming sin metadata con pending invoices
await _matchPendingMintInvoices();
// Resume pending Nostr payment request if app was killed mid-wait
await resumePendingNostrRequest();
}
/// Busca transacciones incoming sin metadata y las vincula con
/// invoices de mint pendientes guardados en SharedPreferences.
Future<void> _matchPendingMintInvoices() async {
try {
final pending = await _getPendingMintInvoices();
if (pending.isEmpty) return;
// Obtener todas las transacciones incoming
final allTxs = await getAllTransactions(
direction: TransactionDirection.incoming,
);
for (final tx in allTxs) {
// Solo procesar transacciones sin metadata
if (_txMetaStorage.has(tx.id)) continue;
// Buscar un pending invoice que coincida con mintUrl, unit y amount
String? matchedQuoteId;
String? matchedInvoice;
for (final entry in pending.entries) {
final data = Map<String, dynamic>.from(entry.value);
if (data['mintUrl'] == tx.mintUrl && data['unit'] == tx.unit && data['amount'] == tx.amount.toString()) {
matchedQuoteId = entry.key;
matchedInvoice = data['invoice'] as String?;
break;
}
}
if (matchedQuoteId != null && matchedInvoice != null) {
await _txMetaStorage.save(
tx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: matchedInvoice,
),
);
// Limpiar el pending invoice ya vinculado
await _removePendingMintInvoice(matchedQuoteId);
pending.remove(matchedQuoteId);
debugPrint('Matched pending mint invoice → tx ${tx.id}');
confettiController.fire();
notifyListeners();
}
}
} catch (e) {
debugPrint('Error matching pending mint invoices: $e');
}
}
/// Verifica si un token específico fue gastado.
Future<bool> isTokenSpent(String encodedToken) async {
final wallet = activeWallet;
@@ -1911,6 +2045,10 @@ class WalletProvider extends ChangeNotifier {
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Limpiar envíos offline pendientes (storage separado, sobrevive al
// borrado del wallet.sqlite si no se limpia acá).
await _pendingSendStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
+89 -27
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
@@ -59,38 +60,81 @@ class _ScanScreenState extends State<ScanScreen> {
children: [
// Scanner
QrScannerWidget(
onDetect: _onCodeDetected,
showFlashControl: true,
showCameraSwitch: false,
),
// Instrucciones en la parte inferior
Positioned(
bottom: 100,
left: 24,
right: 24,
child: Text(
_getInstructionForMode(l10n),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
onDetect: _onCodeDetected,
showFlashControl: true,
showCameraSwitch: false,
),
),
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
// Instrucciones en la parte inferior
Positioned(
bottom: 170,
left: 24,
right: 24,
child: Text(
_getInstructionForMode(l10n),
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.8),
),
textAlign: TextAlign.center,
),
),
// Botón pegar del portapapeles (encima del flash)
Positioned(
bottom: 100,
left: 24,
right: 24,
child: Center(
child: Material(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(24),
child: InkWell(
onTap: _isProcessing ? null : _pasteFromClipboard,
borderRadius: BorderRadius.circular(24),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.clipboard,
color: Colors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.pasteFromClipboard,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
// Indicador de procesamiento
if (_isProcessing)
Container(
color: Colors.black54,
child: const Center(
child: CircularProgressIndicator(
color: AppColors.primaryAction,
),
),
),
],
),
),
);
@@ -118,6 +162,24 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
Future<void> _pasteFromClipboard() async {
final l10n = L10n.of(context)!;
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted) return;
if (text == null || text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.emptyClipboard),
backgroundColor: AppColors.warning,
duration: const Duration(seconds: 2),
),
);
return;
}
_onCodeDetected(text);
}
void _onCodeDetected(String rawData) async {
if (_isProcessing) return;
+1 -2
View File
@@ -841,8 +841,7 @@ class _RequestScreenState extends State<RequestScreen> {
_status = RequestStatus.received;
_receivedAmount = amount;
});
final walletProvider = context.read<WalletProvider>();
walletProvider.confettiController.fire();
// Confetti se dispara globalmente desde WalletProvider._saveMintMetadata
// 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
+41 -43
View File
@@ -14,6 +14,8 @@ import '../../src/rust/api/wallet.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
@@ -380,29 +382,11 @@ class _SwapScreenState extends State<SwapScreen>
backgroundColor: Colors.transparent,
isDismissible: false,
enableDrag: false,
builder: (ctx) => Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
builder: (ctx) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono swap
Container(
@@ -483,29 +467,13 @@ class _SwapScreenState extends State<SwapScreen>
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
child: SecondaryButton(
text: l10n.cancel,
onPressed: () {
Navigator.pop(ctx);
_mintSubscription?.cancel();
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
@@ -526,8 +494,6 @@ class _SwapScreenState extends State<SwapScreen>
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
@@ -550,9 +516,31 @@ class _SwapScreenState extends State<SwapScreen>
_swapError = null;
});
// Verificar que el fee no supere el monto (operación inviable)
if (meltQuote.amount <= meltQuote.feeReserve) {
setState(() {
_isSwapping = false;
_swapError = l10n.feeExceedsAmount;
});
return;
}
late final PreparedMelt prepared;
try {
prepared = await srcWallet.prepareMelt(quote: meltQuote);
} catch (e) {
// prepareMelt falló: no hay proofs reservadas, solo mostrar error
if (!mounted) return;
setState(() {
_isSwapping = false;
_swapError = l10n.swapErrorGeneric(e.toString());
});
return;
}
try {
// Ejecutar melt (paga el invoice Lightning)
await srcWallet.melt(quote: meltQuote);
await srcWallet.confirmMelt(melt: prepared);
// Guardar metadata del melt (lado enviado)
await walletProvider.saveSwapMeltMetadata(
@@ -592,6 +580,16 @@ class _SwapScreenState extends State<SwapScreen>
} catch (e) {
_mintSubscription?.cancel();
_mintSubscription = null;
// PreparedMelt ya fue consumido por FRB — cancelMelt es imposible.
// recoverIncompleteSagas reanuda solo la saga interrumpida,
// sin afectar otros proofs en PendingSpent (sends no reclamados).
try {
await srcWallet.recoverIncompleteSagas();
} catch (recoverErr) {
debugPrint('[SWAP] recoverIncompleteSagas failed: $recoverErr');
}
if (!mounted) return;
final errorStr = e.toString().toLowerCase();
setState(() {
+6 -1
View File
@@ -166,7 +166,12 @@ class _SplashScreenState extends State<SplashScreen>
/// Se ejecuta sin bloquear la navegación.
Future<void> _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');
+74 -80
View File
@@ -9,6 +9,7 @@ import '../../core/utils/incoming_data_parser.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/animated_action_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/p2pk_provider.dart';
@@ -356,6 +357,9 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.sendAction,
icon: LucideIcons.arrowUpRight,
showIcon: true,
iconTrailing: true,
type: ButtonType.criticalAction,
onTap: _showSendOptions,
),
@@ -368,6 +372,8 @@ class _HomeScreenState extends State<HomeScreen> {
Expanded(
child: AnimatedActionButton(
label: l10n.receiveAction,
icon: LucideIcons.arrowDownRight,
showIcon: true,
type: ButtonType.primaryAction,
onTap: _showReceiveOptions,
),
@@ -573,29 +579,11 @@ class _MethodSelectorModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Título
Text(
@@ -618,9 +606,6 @@ class _MethodSelectorModal extends StatelessWidget {
child: _MethodOptionTile(option: option),
),
),
// Espacio para la barra de navegación del sistema
SizedBox(height: MediaQuery.of(context).padding.bottom + AppDimensions.paddingSmall),
],
),
);
@@ -635,67 +620,76 @@ class _MethodOptionTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: option.onTap,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
return Semantics(
button: true,
label: option.label,
hint: option.description,
child: Material(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: option.onTap,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
// Icono
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: AppColors.buttonGradient,
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
borderRadius: BorderRadius.circular(12),
),
child: Icon(option.icon, color: Colors.white, size: 24),
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
const SizedBox(width: AppDimensions.paddingMedium),
// Texto
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
option.label,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
const SizedBox(height: 2),
Text(
option.description,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary.withValues(alpha: 0.7),
),
),
],
),
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
// Flecha
Icon(
LucideIcons.chevronRight,
color: Colors.white.withValues(alpha: 0.5),
size: 24,
),
],
),
),
),
);
@@ -1,7 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../providers/wallet_provider.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/models/proof.dart';
@@ -11,6 +13,7 @@ import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/proof/proof_selector.dart';
import '../../core/utils/keyset_debug.dart';
import 'share_token_screen.dart';
/// Pantalla para enviar tokens de forma offline seleccionando proofs manualmente.
@@ -32,6 +35,13 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
final TextEditingController _memoController = TextEditingController();
final ProofService _proofService = ProofService();
// Capturado en initState para poder persistir el PendingSend incluso si
// el widget se desmonta durante el await de markProofsPendingSpent.
// Usar `context.read` después del await es inseguro (widget puede no
// existir), y gatear con `mounted` perdería el record — los proofs
// quedan marcados PENDING_SPENT pero sin handle para reclamar.
late final WalletProvider _walletProvider;
List<LocalProof> _availableProofs = [];
Set<String> _selectedIds = {};
bool _isLoading = true;
@@ -41,6 +51,7 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
@override
void initState() {
super.initState();
_walletProvider = context.read<WalletProvider>();
_loadProofs();
}
@@ -325,6 +336,21 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
Future<void> _createOfflineToken() async {
final selectedProofs = _selectedProofs;
final selectedTotal = _proofService.calculateTotal(selectedProofs);
// Validar monto mínimo viable para el receptor
// Leer ppk del keyset local, fallback a 100 (estándar)
var ppk = await KeysetDebug.getInputFeePpk(widget.mintUrl, widget.unit);
if (ppk < 0) ppk = 100; // -1 = error leyendo DB, asumir fee estándar
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (selectedTotal <= minReceiveFee) {
if (mounted) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
}
return;
}
var proofsMarkedPending = false;
setState(() {
@@ -349,6 +375,20 @@ class _OfflineSendScreenState extends State<OfflineSendScreen> {
);
final token = cdkToken.encoded;
// Rastrear el envío offline como pendiente para poder cancelarlo
// desde el historial. Los offline sends no crean CDK Transaction.
// Usamos el provider capturado en initState (no context.read) para
// que el record se persista aunque el widget se haya desmontado
// mientras esperábamos markProofsPendingSpent.
await _walletProvider.addPendingSend(
encoded: token,
amount: selectedTotal,
mintUrl: widget.mintUrl,
unit: widget.unit,
proofYs: selectedProofs.map((p) => p.yHex).toList(),
memo: memo,
);
if (mounted) {
_isCreating = false;
Navigator.pushReplacement(
+60 -44
View File
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
@@ -7,9 +8,12 @@ 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/keyset_debug.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../core/utils/nostr_utils.dart';
@@ -572,11 +576,32 @@ class _SendScreenState extends State<SendScreen> {
});
return;
}
// Offline: verificar monto mínimo con ppk del keyset local (fallback 100)
var ppk = await KeysetDebug.getInputFeePpk(mintUrl, _activeUnit);
if (ppk < 0) ppk = 100;
final minReceiveFee = ppk > 0 ? BigInt.from((ppk + 999) ~/ 1000) : BigInt.zero;
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Offline: ir directo a selección de monedas
_goToOfflineModeWithMessage();
return;
}
// Online: verificar monto mínimo con ppk real del mint
final minReceiveFee = await _getMinReceiveFee(mintUrl);
if (!mounted) return;
if (_amount <= minReceiveFee) {
setState(() {
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// Online: mostrar modal de confirmación normal
showModalBottomSheet(
context: context,
@@ -595,6 +620,32 @@ class _SendScreenState extends State<SendScreen> {
);
}
/// Calcula el fee mínimo que el receptor pagará al reclamar el token.
/// Consulta /v1/keysets del mint para obtener el ppk real.
/// Fallback: ppk=100 (estándar) → fee mínimo = 1 sat.
Future<BigInt> _getMinReceiveFee(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/keysets'),
).timeout(const Duration(seconds: 3));
if (response.statusCode == 200) {
final data = json.decode(response.body) as Map<String, dynamic>;
final keysets = data['keysets'] as List<dynamic>? ?? [];
for (final ks in keysets) {
if (ks['active'] == true && ks['unit'] == _activeUnit) {
final ppk = (ks['input_fee_ppk'] as num?)?.toInt() ?? 0;
if (ppk > 0) {
return BigInt.from((ppk + 999) ~/ 1000); // ceil(ppk / 1000)
}
return BigInt.zero;
}
}
}
} catch (_) {}
// Fallback: ppk=100 estándar
return BigInt.one;
}
/// Verifica conectividad haciendo petición HTTP real al mint.
Future<bool> _checkConnectivity(String mintUrl) async {
try {
@@ -748,29 +799,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -790,7 +824,7 @@ class _ConfirmationModal extends StatelessWidget {
// Titulo
Text(
L10n.of(context)!.confirmSend,
l10n.confirmSend,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -831,40 +865,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.confirm,
text: l10n.confirm,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+23 -45
View File
@@ -9,6 +9,8 @@ import '../../core/services/lnurl_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/numpad_widget.dart';
import '../../providers/wallet_provider.dart';
import '../../providers/price_provider.dart';
@@ -404,6 +406,8 @@ class _AmountScreenState extends State<AmountScreen> {
Future<void> _processPayment() async {
if (!_canPay) return;
final walletProvider = context.read<WalletProvider>();
setState(() {
_isProcessing = true;
_errorMessage = null;
@@ -431,14 +435,22 @@ class _AmountScreenState extends State<AmountScreen> {
if (!mounted) return;
// 3. Obtener quote del mint (en la unidad del mint)
final walletProvider = context.read<WalletProvider>();
final quote = await walletProvider.getMeltQuote(invoiceResult.invoice);
if (!mounted) return;
final total = quote.amount + quote.feeReserve;
// 4. Verificar balance suficiente
// 4a. Verificar que el fee no supere el monto (operación inviable)
if (quote.amount <= quote.feeReserve) {
setState(() {
_isProcessing = false;
_errorMessage = L10n.of(context)!.feeExceedsAmount;
});
return;
}
// 4b. Verificar balance suficiente
if (total > _availableBalance) {
setState(() {
_isProcessing = false;
@@ -506,32 +518,16 @@ class _AmountScreenState extends State<AmountScreen> {
Future<bool> _showConfirmation(BigInt amount, BigInt fee, BigInt total) async {
final unitLabel = UnitFormatter.getUnitLabel(_activeUnit);
final l10n = L10n.of(context)!;
final result = await showModalBottomSheet<bool>(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
builder: (context) => BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -551,7 +547,7 @@ class _AmountScreenState extends State<AmountScreen> {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -596,40 +592,22 @@ class _AmountScreenState extends State<AmountScreen> {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => Navigator.pop(context, false),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: () => Navigator.pop(context, false),
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: () => Navigator.pop(context, true),
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
),
+11 -44
View File
@@ -12,6 +12,8 @@ import '../../core/services/lnurl_service.dart';
import '../../widgets/common/gradient_background.dart';
import '../../widgets/common/glass_card.dart';
import '../../widgets/common/primary_button.dart';
import '../../widgets/common/secondary_button.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../providers/wallet_provider.dart';
import '../10_scanner/scan_screen.dart';
import 'amount_screen.dart';
@@ -858,29 +860,12 @@ class _ConfirmationModal extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
final l10n = L10n.of(context)!;
return BottomSheetContainer(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
const BottomSheetHandle(),
// Icono
Container(
@@ -900,7 +885,7 @@ class _ConfirmationModal extends StatelessWidget {
// Título
Text(
L10n.of(context)!.confirmPayment,
l10n.confirmPayment,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
@@ -945,40 +930,22 @@ class _ConfirmationModal extends StatelessWidget {
Row(
children: [
Expanded(
child: GestureDetector(
onTap: onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
L10n.of(context)!.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
child: SecondaryButton(
text: l10n.cancel,
onPressed: onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: PrimaryButton(
text: L10n.of(context)!.pay,
text: l10n.pay,
onPressed: onConfirm,
height: 52,
),
),
],
),
const SizedBox(height: AppDimensions.paddingSmall),
],
),
);
+186 -195
View File
@@ -3,6 +3,8 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:elcaju/l10n/app_localizations.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../widgets/common/bottom_sheet_container.dart';
import '../../widgets/common/secondary_button.dart';
/// Modal para confirmar borrado de wallet
class DeleteWalletModal extends StatefulWidget {
@@ -33,215 +35,204 @@ class _DeleteWalletModalState extends State<DeleteWalletModal> {
Widget build(BuildContext context) {
final l10n = L10n.of(context)!;
final confirmWord = l10n.deleteConfirmWord;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
return BottomSheetContainer(
respectKeyboard: true,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono advertencia
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: AppColors.error,
size: 32,
),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
l10n.deleteWalletQuestion,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Icono advertencia
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.alertTriangle,
color: AppColors.error,
size: 32,
),
// Advertencia
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Título
Text(
l10n.deleteWalletQuestion,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: AppDimensions.paddingSmall),
// Advertencia
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
Icon(
LucideIcons.alertCircle,
color: AppColors.error,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.actionIrreversible,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
],
),
const SizedBox(height: 8),
Text(
l10n.deleteWalletWarning,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error.withValues(alpha: 0.8),
),
),
],
),
),
const SizedBox(height: AppDimensions.paddingMedium),
// Input de confirmación
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.typeDeleteToConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: confirmWord,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (value) {
setState(() {
_canDelete = value == confirmWord;
});
},
),
],
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
child: Column(
children: [
Expanded(
child: GestureDetector(
onTap: widget.onCancel,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
l10n.cancel,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
Row(
children: [
Icon(
LucideIcons.alertCircle,
color: AppColors.error,
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.actionIrreversible,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.error,
),
),
),
),
],
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: GestureDetector(
onTap: _canDelete ? widget.onConfirm : null,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: _canDelete
? AppColors.error
: AppColors.error.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
l10n.deleteWallet,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: _canDelete
? Colors.white
: Colors.white.withValues(alpha: 0.5),
),
),
),
),
const SizedBox(height: 8),
Text(
l10n.deleteWalletWarning,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: AppColors.error.withValues(alpha: 0.8),
),
),
],
),
),
const SizedBox(height: AppDimensions.paddingSmall),
],
const SizedBox(height: AppDimensions.paddingMedium),
// Input de confirmación
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.typeDeleteToConfirm,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
TextField(
controller: _controller,
style: const TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white,
),
decoration: InputDecoration(
hintText: confirmWord,
hintStyle: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
color: Colors.white.withValues(alpha: 0.3),
),
filled: true,
fillColor: Colors.white.withValues(alpha: 0.05),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (value) {
setState(() {
_canDelete = value == confirmWord;
});
},
),
],
),
),
const SizedBox(height: AppDimensions.paddingLarge),
// Botones
Row(
children: [
Expanded(
child: SecondaryButton(
text: l10n.cancel,
onPressed: widget.onCancel,
height: 52,
),
),
const SizedBox(width: AppDimensions.paddingMedium),
Expanded(
child: _DestructiveButton(
text: l10n.deleteWallet,
enabled: _canDelete,
onPressed: widget.onConfirm,
),
),
],
),
],
),
);
}
}
/// Botón destructivo con semantics accesibles.
/// Separado de los botones estándar porque la acción es irreversible.
class _DestructiveButton extends StatelessWidget {
final String text;
final bool enabled;
final VoidCallback onPressed;
const _DestructiveButton({
required this.text,
required this.enabled,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: enabled,
label: text,
child: SizedBox(
height: 52,
child: Material(
color: enabled
? AppColors.error
: AppColors.error.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(16),
child: Center(
child: Text(
text,
style: TextStyle(
fontFamily: 'Inter',
fontSize: 16,
fontWeight: FontWeight.w600,
color: enabled
? Colors.white
: Colors.white.withValues(alpha: 0.5),
),
),
),
),
),
),
);
+26 -25
View File
@@ -275,52 +275,53 @@ class _MintsScreenState extends State<MintsScreen> {
);
}
/// Badges de balance compactos (estilo cashu.me)
/// Badges de balance compactos (estilo cashu.me).
/// Muestra todas las unidades soportadas por el mint: las de saldo > 0
/// con estilo activo y las de saldo 0 atenuadas, para reflejar las
/// capacidades del mint aunque aún no se hayan usado.
Widget _buildBalanceBadges(List<String> units, Map<String, BigInt> balances) {
final nonZeroBalances = balances.entries
.where((e) => e.value > BigInt.zero)
.toList();
final entries = balances.isNotEmpty
? balances.entries.toList()
: units.map((u) => MapEntry(u, BigInt.zero)).toList();
if (nonZeroBalances.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'0 ${units.isNotEmpty ? units.first : "sat"}',
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
color: Colors.white.withValues(alpha: 0.6),
),
),
);
if (entries.isEmpty) {
return const SizedBox.shrink();
}
entries.sort((a, b) {
final aZero = a.value == BigInt.zero;
final bZero = b.value == BigInt.zero;
if (aZero == bZero) return 0;
return aZero ? 1 : -1;
});
return Wrap(
spacing: 6,
runSpacing: 4,
children: nonZeroBalances.map((entry) {
children: entries.map((entry) {
final unit = entry.key;
final balance = entry.value;
final isZero = balance == BigInt.zero;
final formatted = UnitFormatter.formatBalance(balance, unit);
final label = UnitFormatter.getUnitLabel(unit);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.primaryAction.withValues(alpha: 0.15),
color: isZero
? Colors.white.withValues(alpha: 0.08)
: AppColors.primaryAction.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$formatted $label',
style: const TextStyle(
style: TextStyle(
fontFamily: 'Inter',
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.primaryAction,
fontWeight: isZero ? FontWeight.w400 : FontWeight.w500,
color: isZero
? Colors.white.withValues(alpha: 0.5)
: AppColors.primaryAction,
),
),
);
@@ -6,6 +6,7 @@ import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
import '../../core/utils/formatters.dart';
import '../../providers/wallet_provider.dart';
import '../../widgets/common/bottom_sheet_container.dart';
/// Modal para recuperar tokens usando NUT-13
class RecoverTokensModal extends StatefulWidget {
@@ -51,34 +52,13 @@ class _RecoverTokensModalState extends State<RecoverTokensModal> {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: Container(
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Handle
Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
),
return BottomSheetContainer(
respectKeyboard: true,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const BottomSheetHandle(),
// Icono
Container(
@@ -89,7 +69,7 @@ class _RecoverTokensModalState extends State<RecoverTokensModal> {
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.refreshCw,
LucideIcons.searchCode,
color: AppColors.primaryAction,
size: 32,
),
@@ -144,7 +124,6 @@ class _RecoverTokensModalState extends State<RecoverTokensModal> {
],
),
),
),
);
}
@@ -587,7 +566,23 @@ class _RecoverTokensModalState extends State<RecoverTokensModal> {
if (hasRecovered) mintsRecovered++;
}
// Recuperar proofs pendientes (sends no reclamados, melts fallidos)
final reclaimMap = await walletProvider.reclaimPendingProofs();
if (!mounted) return;
// Agregar cada unidad reclamada por separado
if (reclaimMap.isNotEmpty) {
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
mintsRecovered++;
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
@@ -624,7 +619,20 @@ class _RecoverTokensModalState extends State<RecoverTokensModal> {
}
}
// Recuperar proofs pendientes de este mint
final reclaimMap = await walletProvider.reclaimPendingProofs(mintUrl: _selectedMintUrl);
if (!mounted) return;
// Agregar cada unidad reclamada por separado
for (final entry in reclaimMap.entries) {
final unit = entry.key;
final reclaim = entry.value;
final formatted = UnitFormatter.formatBalance(reclaim.amount, unit);
final label = UnitFormatter.getUnitLabel(unit);
recoveredDetails.add('$formatted $label (${reclaim.count} proofs)');
}
setState(() {
_isSuccess = true;
if (recoveredDetails.isNotEmpty) {
+6 -6
View File
@@ -95,12 +95,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
},
),
_buildSettingTile(
icon: LucideIcons.key,
title: l10n.backupSeedPhrase,
subtitle: l10n.viewRecoveryWords,
onTap: () => _showBackupSeed(context, settingsProvider),
),
_buildSettingTile(
icon: LucideIcons.landmark,
title: l10n.connectedMints,
@@ -127,6 +121,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
activeThumbColor: AppColors.primaryAction,
),
),
_buildSettingTile(
icon: LucideIcons.key,
title: l10n.backupSeedPhrase,
subtitle: l10n.viewRecoveryWords,
onTap: () => _showBackupSeed(context, settingsProvider),
),
_buildSettingTile(
icon: LucideIcons.searchCode,
title: l10n.recoverTokens,
File diff suppressed because it is too large Load Diff
+123 -6
View File
@@ -10,9 +10,29 @@ 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<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedMelt>>
abstract class PreparedMelt implements RustOpaqueInterface {
BigInt get amount;
BigInt get feeReserve;
BigInt get inputFee;
BigInt get swapFee;
set amount(BigInt amount);
set feeReserve(BigInt feeReserve);
set inputFee(BigInt inputFee);
set swapFee(BigInt swapFee);
}
// Rust type: RustOpaqueMoi<flutter_rust_bridge::for_generated::RustAutoOpaqueInner<PreparedSend>>
abstract class PreparedSend implements RustOpaqueInterface {
BigInt get amount;
@@ -44,12 +64,39 @@ abstract class Wallet implements RustOpaqueInterface {
Future<BigInt> balance();
Future<void> cancelMelt({required PreparedMelt melt});
Future<void> cancelSend({required PreparedSend send});
Future<void> checkAllMintQuotes();
/// Ask the mint for the current state of a single quote by id and
/// return the refreshed MintQuote. Local store is updated as a side
/// effect inside CDK.
Future<MintQuote> checkMintQuoteStatus({required String quoteId});
Future<void> 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<ReclaimResult> checkProofsByYs({required List<String> ys});
Future<BigInt> confirmMelt({required PreparedMelt melt});
/// Create a NUT-18 payment request with Nostr transport.
///
/// Builds a PaymentRequest with the wallet's mint URL and unit,
@@ -63,16 +110,24 @@ abstract class Wallet implements RustOpaqueInterface {
Future<MintInfo?> getMint();
/// Return pending (unissued) mint quotes known to the local store,
/// filtered to this wallet's mint/unit.
Future<List<MintQuote>> getUnissuedMintQuotes();
Future<bool> isTokenSpent({required Token token});
Future<List<Transaction>> listTransactions({TransactionDirection? direction});
Future<BigInt> melt({required MeltQuote quote});
Future<MeltQuote> meltQuote({required String request});
Stream<MintQuote> mint({required BigInt amount, String? description});
/// Mint proofs for a paid quote and return an Issued MintQuote with
/// `transaction_id` and `token` populated — same shape the `mint()`
/// stream emits on Issued. Use this to recover metadata for quotes
/// that were paid while the app was killed.
Future<MintQuote> mintByQuoteId({required String quoteId});
factory Wallet({
required String mintUrl,
required String unit,
@@ -97,16 +152,35 @@ abstract class Wallet implements RustOpaqueInterface {
BigInt? customAmount,
});
Future<PreparedMelt> prepareMelt({required MeltQuote quote});
Future<PreparedSend> prepareSend({required BigInt amount, SendOptions? opts});
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();
/// Returns per-state buckets (unspent reverted, still pending, spent).
Future<ReclaimResult> reclaimPendingProofs();
/// 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.
///
/// 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<ReclaimResult> reclaimProofsByYs({required List<String> ys});
Future<void> recoverIncompleteSagas();
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
Future<void> refreshBalance();
Future<void> restore();
Future<SendResult> send({
@@ -182,7 +256,7 @@ class MintQuote {
final Token? token;
final String? error;
/// Deterministic transaction ID (set when state == Issued)
/// Deterministic transaction ID (set when proofs are available, typically on Issued)
final String? transactionId;
const MintQuote({
@@ -245,6 +319,49 @@ class ReceiveOptions {
preimages == other.preimages;
}
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;
/// 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 ^
pendingCount.hashCode ^
spentCount.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ReclaimResult &&
runtimeType == other.runtimeType &&
count == other.count &&
amount == other.amount &&
pendingCount == other.pendingCount &&
spentCount == other.spentCount;
}
class SendOptions {
final String? pubkey;
final bool? includeFee;
File diff suppressed because it is too large Load Diff
+135
View File
@@ -31,6 +31,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSendPtr;
@@ -62,6 +66,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -92,6 +102,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -122,6 +138,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -161,6 +183,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -271,6 +299,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<MintMethodSettings> dco_decode_list_mint_method_settings(dynamic raw);
@protected
List<MintQuote> dco_decode_list_mint_quote(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -372,6 +403,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -426,6 +460,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -456,6 +496,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -486,6 +532,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -527,6 +579,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -651,6 +709,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
List<MintQuote> sse_decode_list_mint_quote(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -776,6 +837,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -839,6 +903,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -874,6 +945,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -909,6 +987,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -957,6 +1042,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1114,6 +1206,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_list_mint_quote(
List<MintQuote> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@@ -1270,6 +1368,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1404,6 +1505,40 @@ class RustLibWire implements BaseWire {
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandlePtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ffi.Pointer<ffi.Void> ptr,
) {
return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
}
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr =
_lookup<ffi.NativeFunction<ffi.Void Function(ffi.Pointer<ffi.Void>)>>(
'frbgen_elcaju_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt',
);
late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt =
_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMeltPtr
.asFunction<void Function(ffi.Pointer<ffi.Void>)>();
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
ffi.Pointer<ffi.Void> ptr,
+127
View File
@@ -33,6 +33,10 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
get rust_arc_decrement_strong_count_NostrListenerHandlePtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerNostrListenerHandle;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedMeltPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt;
CrossPlatformFinalizerArg
get rust_arc_decrement_strong_count_PreparedSendPtr => wire
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend;
@@ -64,6 +68,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -94,6 +104,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -124,6 +140,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -163,6 +185,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
PreparedMelt
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
dynamic raw,
);
@protected
PreparedSend
dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -273,6 +301,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
List<MintMethodSettings> dco_decode_list_mint_method_settings(dynamic raw);
@protected
List<MintQuote> dco_decode_list_mint_quote(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@@ -374,6 +405,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions dco_decode_receive_options(dynamic raw);
@protected
ReclaimResult dco_decode_reclaim_result(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@@ -428,6 +462,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -458,6 +498,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -488,6 +534,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -529,6 +581,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
PreparedMelt
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
SseDeserializer deserializer,
);
@protected
PreparedSend
sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -653,6 +711,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
List<MintQuote> sse_decode_list_mint_quote(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@@ -778,6 +839,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
ReceiveOptions sse_decode_receive_options(SseDeserializer deserializer);
@protected
ReclaimResult sse_decode_reclaim_result(SseDeserializer deserializer);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
@@ -841,6 +905,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -876,6 +947,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_RefMut_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -911,6 +989,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -959,6 +1044,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
PreparedMelt self,
SseSerializer serializer,
);
@protected
void
sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
@@ -1116,6 +1208,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_list_mint_quote(
List<MintQuote> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@@ -1272,6 +1370,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_reclaim_result(ReclaimResult self, SseSerializer serializer);
@protected
void sse_encode_record_string_string(
(String, String) self,
@@ -1361,6 +1462,22 @@ class RustLibWire implements BaseWire {
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
) => wasmModule
.rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
ptr,
);
void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
@@ -1452,6 +1569,16 @@ extension type RustLibWasmModule._(JSObject _) implements JSObject {
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedMelt(
int ptr,
);
external void
rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerPreparedSend(
int ptr,
+15 -2
View File
@@ -58,6 +58,9 @@ class AnimatedActionButton extends StatefulWidget {
/// Si mostrar el icono (default: false)
final bool showIcon;
/// Si el icono va después del texto (default: false = antes del texto)
final bool iconTrailing;
/// Ancho del botón (default: expandir al padre)
final double? width;
@@ -73,6 +76,7 @@ class AnimatedActionButton extends StatefulWidget {
this.backgroundColor,
this.icon,
this.showIcon = false,
this.iconTrailing = false,
this.width,
this.height,
});
@@ -300,8 +304,8 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icono opcional
if (widget.showIcon && widget.icon != null) ...[
// Icono leading (antes del texto)
if (widget.showIcon && widget.icon != null && !widget.iconTrailing) ...[
Icon(
widget.icon,
color: Colors.white,
@@ -319,6 +323,15 @@ class _AnimatedActionButtonState extends State<AnimatedActionButton>
color: Colors.white,
),
),
// Icono trailing (después del texto)
if (widget.showIcon && widget.icon != null && widget.iconTrailing) ...[
const SizedBox(width: AppDimensions.paddingSmall),
Icon(
widget.icon,
color: Colors.white,
size: widget.type == ButtonType.navigation ? 28 : 20,
),
],
],
),
),
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../../core/constants/colors.dart';
import '../../core/constants/dimensions.dart';
/// Frame común para modales tipo bottom sheet.
///
/// Aplica SafeArea(top: false) para que los botones internos no queden
/// tapados por la barra de navegación del sistema (gestos o 3 botones).
///
/// Usá [respectKeyboard]: true cuando el modal contenga TextField, para
/// combinar el inset de la barra con el del teclado (viewInsets.bottom).
class BottomSheetContainer extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final bool respectKeyboard;
const BottomSheetContainer({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppDimensions.paddingMedium),
this.respectKeyboard = false,
});
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
// viewPadding.bottom es el inset crudo de la nav bar del sistema
// (no se consume aunque un SafeArea padre ya lo haya absorbido).
final navBarInset = mq.viewPadding.bottom;
// Combinar el padding base con el extra del navBar abajo.
final resolvedPadding = padding.resolve(Directionality.of(context));
final effectivePadding = EdgeInsets.only(
left: resolvedPadding.left,
top: resolvedPadding.top,
right: resolvedPadding.right,
bottom: resolvedPadding.bottom + navBarInset,
);
final frame = Container(
decoration: BoxDecoration(
color: AppColors.deepVoidPurple,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimensions.radiusXLarge),
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
child: Padding(padding: effectivePadding, child: child),
);
if (respectKeyboard) {
return Padding(
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: frame,
);
}
return frame;
}
}
/// Handle visual en la parte superior del modal (la barrita).
class BottomSheetHandle extends StatelessWidget {
const BottomSheetHandle({super.key});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(2),
),
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: elcaju
description: "ElCaju - Tu wallet de ecash privado"
publish_to: 'none'
version: 0.2.0+3
version: 0.3.0+4
environment:
sdk: ^3.11.0
+350 -21
View File
@@ -411,6 +411,62 @@ impl Wallet {
Ok(())
}
/// Return pending (unissued) mint quotes known to the local store,
/// filtered to this wallet's mint/unit.
pub async fn get_unissued_mint_quotes(&self) -> Result<Vec<MintQuote>, Error> {
let quotes = self.inner.get_unissued_mint_quotes().await?;
Ok(quotes.into_iter().map(MintQuote::from).collect())
}
/// Ask the mint for the current state of a single quote by id and
/// return the refreshed MintQuote. Local store is updated as a side
/// effect inside CDK.
pub async fn check_mint_quote_status(&self, quote_id: String) -> Result<MintQuote, Error> {
let quote = self.inner.check_mint_quote_status(&quote_id).await?;
Ok(MintQuote::from(quote))
}
/// Mint proofs for a paid quote and return an Issued MintQuote with
/// `transaction_id` and `token` populated — same shape the `mint()`
/// stream emits on Issued. Use this to recover metadata for quotes
/// that were paid while the app was killed.
pub async fn mint_by_quote_id(&self, quote_id: String) -> Result<MintQuote, Error> {
let mint_url = self.mint_url()?;
let unit = self.unit();
let cdk_quote = self.inner.check_mint_quote_status(&quote_id).await?;
let expiry = cdk_quote.expiry;
let request = cdk_quote.request.clone();
let mint_proofs = self
.inner
.mint(&quote_id, SplitTarget::None, None)
.await?;
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 token = Token::try_from(CdkToken::new(mint_url, mint_proofs, None, unit)).ok();
self.update_balance_streams().await;
Ok(MintQuote {
id: quote_id,
request,
amount: Some(mint_amount.into()),
expiry: Some(expiry),
state: CdkMintQuoteState::Issued.into(),
token,
error: None,
transaction_id: tx_id,
})
}
// === Lightning Withdrawal (NUT-05) ===
pub async fn melt_quote(&self, request: String) -> Result<MeltQuote, Error> {
@@ -421,15 +477,52 @@ impl Wallet {
.into())
}
pub async fn melt(&self, quote: MeltQuote) -> Result<u64, Error> {
let melted = self
pub async fn prepare_melt(&self, quote: MeltQuote) -> Result<PreparedMelt, Error> {
let prepared = self
.inner
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
Ok(PreparedMelt {
amount: prepared.amount().into(),
fee_reserve: prepared.quote().fee_reserve.into(),
swap_fee: prepared.swap_fee().into(),
input_fee: prepared.input_fee().into(),
operation_id: prepared.operation_id(),
cdk_quote: prepared.quote().clone(),
proofs: prepared.proofs().clone(),
proofs_to_swap: prepared.proofs_to_swap().clone(),
cdk_input_fee: prepared.input_fee(),
cdk_input_fee_without_swap: prepared.input_fee_without_swap(),
})
}
pub async fn confirm_melt(&self, melt: PreparedMelt) -> Result<u64, Error> {
let finalized = self
.inner
.confirm_prepared_melt(
melt.operation_id,
melt.cdk_quote,
melt.proofs,
melt.proofs_to_swap,
melt.cdk_input_fee,
melt.cdk_input_fee_without_swap,
HashMap::new(),
)
.await?;
self.update_balance_streams().await;
Ok(melted.total_amount().into())
Ok(finalized.total_amount().into())
}
pub async fn cancel_melt(&self, melt: PreparedMelt) -> Result<(), Error> {
self.inner
.cancel_prepared_melt(
melt.operation_id,
melt.proofs,
melt.proofs_to_swap,
)
.await?;
self.update_balance_streams().await;
Ok(())
}
// === Transactions ===
@@ -494,34 +587,166 @@ impl Wallet {
// === 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> {
/// Returns per-state buckets (unspent reverted, still pending, spent).
pub async fn reclaim_pending_proofs(&self) -> Result<ReclaimResult, Error> {
let pending = self.inner.get_pending_spent_proofs().await?;
if pending.is_empty() {
return Ok(0);
return Ok(ReclaimResult::empty());
}
// Build a map of Y → amount for later lookup
let mut amount_by_y: HashMap<PublicKey, u64> = HashMap::new();
for proof in &pending {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
// 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<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 {
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(count)
Ok(buckets.into_result())
}
/// 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.
///
/// 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<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = 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?;
// 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();
// Target Ys that aren't in local PendingSpent anymore are treated as
// spent — a previous `check_proofs_spent` call removed them from the
// local DB because the mint confirmed SPENT. This matches
// `check_proofs_by_ys` and keeps `ReclaimResult`'s bucket invariant
// (`unspent + pending + spent == total`) across both paths.
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<PublicKey, u64> = HashMap::new();
for proof in &relevant {
if let Ok(y) = proof.y() {
amount_by_y.insert(y, u64::from(proof.amount));
}
}
let states = self.inner.check_proofs_spent(relevant).await?;
let buckets = StateBuckets::from_states(states, &amount_by_y);
if !buckets.unspent_ys.is_empty() {
self.inner.unreserve_proofs(buckets.unspent_ys.clone()).await?;
}
self.update_balance_streams().await;
let mut result = buckets.into_result();
result.spent_count += resolved_out_of_pending;
Ok(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<String>) -> Result<ReclaimResult, Error> {
let target_ys: std::collections::HashSet<PublicKey> = 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<PublicKey, u64> = 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 ===
@@ -552,6 +777,13 @@ impl Wallet {
CurrencyUnit::from_str(&self.unit).unwrap_or(CurrencyUnit::Custom(self.unit.clone()))
}
/// Recalcula el balance desde la DB y lo empuja al stream.
/// Llamar después de manipulaciones directas al DB (ej: offline send
/// marking proofs as PendingSpent fuera de la API de CDK).
pub async fn refresh_balance(&self) {
self.update_balance_streams().await;
}
pub(crate) async fn update_balance_streams(&self) {
let balance = self
.inner
@@ -677,6 +909,103 @@ impl<'a> From<CdkPreparedSend<'a>> 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<PublicKey>,
unspent_amount: u64,
pending_count: u64,
spent_count: u64,
}
impl StateBuckets {
fn from_states(
states: Vec<cdk::nuts::ProofState>,
amount_by_y: &HashMap<PublicKey, u64>,
) -> 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 {
pub amount: u64,
pub fee_reserve: u64,
pub swap_fee: u64,
pub input_fee: u64,
operation_id: Uuid,
cdk_quote: CdkMeltQuote,
proofs: cdk::nuts::Proofs,
proofs_to_swap: cdk::nuts::Proofs,
cdk_input_fee: Amount,
cdk_input_fee_without_swap: Amount,
}
#[derive(Default)]
pub struct ReceiveOptions {
pub signing_keys: Option<Vec<String>>,
+1170 -113
View File
File diff suppressed because it is too large Load Diff