Files
me.elcaju/lib/providers/wallet_provider.dart
T

1835 lines
58 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:cbor/cbor.dart';
import 'package:cdk_flutter/cdk_flutter.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../data/transaction_meta_storage.dart';
import '../data/pending_token.dart';
import '../data/pending_token_storage.dart';
import '../core/utils/keyset_debug.dart';
import '../widgets/effects/cashu_confetti.dart';
/// Helper class para info de token parseado
class TokenInfo {
final BigInt amount;
final String mintUrl;
final String encoded;
final String? unit; // Unidad detectada del token (sat, usd, eur, etc.)
TokenInfo({
required this.amount,
required this.mintUrl,
required this.encoded,
this.unit,
});
}
/// Provider que gestiona todas las operaciones del wallet Cashu.
/// Soporta múltiples unidades (sat, usd, eur) por mint.
/// Usa múltiples instancias de Wallet compartiendo una sola DB.
class WalletProvider extends ChangeNotifier {
WalletDatabase? _db;
String? _mnemonic;
/// Storage para metadata de transacciones (tipo, token, invoice)
final TransactionMetaStorage _txMetaStorage = TransactionMetaStorage();
/// Storage para tokens pendientes de reclamar (Receive Later)
final PendingTokenStorage _pendingTokenStorage = PendingTokenStorage();
/// Controller global de confetti para celebrar recepciones
final CashuConfettiController confettiController = CashuConfettiController();
/// Generador de UUIDs
static const _uuid = Uuid();
/// Mints conocidos con sus unidades soportadas.
/// Ejemplo: {'mint.cubabitcoin.org': ['sat', 'usd']}
final Map<String, List<String>> _mintUnits = {};
/// Wallets por mint:unidad (lazy loading).
/// Clave: 'mintUrl:unit', Ejemplo: 'https://mint.cubabitcoin.org:sat'
final Map<String, Wallet> _wallets = {};
/// Caché de keyset_id → unit para detectar unidad de tokens.
/// Ejemplo: {'00abc123': 'sat', '00def456': 'usd'}
final Map<String, String> _keysetUnits = {};
/// Caché de MintInfo por URL (nombre, logo, contactos, etc.)
final Map<String, MintInfo> _mintInfoCache = {};
/// Mint activo actualmente
String? _activeMintUrl;
/// Unidad activa actualmente
String _activeUnit = 'sat';
/// Claves para persistencia en SharedPreferences
static const _mintsKey = 'wallet_mints';
static const _activeMintKey = 'wallet_active_mint';
static const _activeUnitKey = 'wallet_active_unit';
static const _pendingMintInvoicesKey = 'pending_mint_invoices';
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
// ============================================================
// GETTERS
// ============================================================
bool get isInitialized => _db != null && _mnemonic != null;
String? get activeMintUrl => _activeMintUrl;
String get activeUnit => _activeUnit;
/// Unidades soportadas por el mint activo
List<String> get activeUnits => _mintUnits[_activeMintUrl] ?? ['sat'];
/// Wallet activo (mint + unidad actuales)
Wallet? get activeWallet {
if (_activeMintUrl == null) return null;
final key = '$_activeMintUrl:$_activeUnit';
return _wallets[key];
}
/// Todos los wallets creados (para verificación de proofs)
Iterable<Wallet> get allWallets => _wallets.values;
/// Lista de URLs de mints conocidos (orden de inserción)
List<String> get mintUrls => _mintUnits.keys.toList();
// ============================================================
// PENDING TOKENS GETTERS
// ============================================================
/// Cantidad de tokens pendientes de reclamar
int get pendingTokenCount => _pendingTokenStorage.count;
/// Verifica si hay tokens pendientes
bool get hasPendingTokens => _pendingTokenStorage.hasPendingTokens;
/// Stream de cambios en pending tokens
Stream<void> get pendingTokenChanges => _pendingTokenStorage.changes;
/// Lista de mints ordenados por balance.
/// Cuba Bitcoin siempre primero, luego ordenados por: sats → usd → eur → otros.
Future<List<String>> getSortedMintUrls() async {
final mints = _mintUnits.keys.toList();
if (mints.length <= 1) return mints;
// Obtener balances de cada mint
final mintBalances = <String, Map<String, BigInt>>{};
for (final mint in mints) {
mintBalances[mint] = await getBalancesForMint(mint);
}
// Orden de prioridad de unidades
const unitPriority = ['sat', 'usd', 'eur'];
// Ordenar mints (excepto Cuba Bitcoin)
final otherMints = mints.where((m) => m != cubaBitcoinMint).toList();
otherMints.sort((a, b) {
final balancesA = mintBalances[a] ?? {};
final balancesB = mintBalances[b] ?? {};
// Comparar por cada unidad en orden de prioridad
for (final unit in unitPriority) {
final balA = balancesA[unit] ?? BigInt.zero;
final balB = balancesB[unit] ?? BigInt.zero;
if (balA != balB) {
return balB.compareTo(balA); // Mayor primero
}
}
// Si empatan en las prioritarias, comparar otras unidades alfabéticamente
final otherUnitsA = balancesA.keys.where((u) => !unitPriority.contains(u)).toList()..sort();
final otherUnitsB = balancesB.keys.where((u) => !unitPriority.contains(u)).toList()..sort();
for (final unit in {...otherUnitsA, ...otherUnitsB}) {
final balA = balancesA[unit] ?? BigInt.zero;
final balB = balancesB[unit] ?? BigInt.zero;
if (balA != balB) {
return balB.compareTo(balA);
}
}
return 0;
});
// Cuba Bitcoin siempre primero
final result = <String>[];
if (mints.contains(cubaBitcoinMint)) {
result.add(cubaBitcoinMint);
}
result.addAll(otherMints);
return result;
}
// ============================================================
// CONNECTIVITY
// ============================================================
/// Verifica si podemos alcanzar un mint específico.
/// Hace ping HTTP GET a {mintUrl}/v1/info con timeout de 3 segundos.
/// Retorna true si responde 200, false en cualquier otro caso.
Future<bool> canReachMint(String mintUrl) async {
try {
final uri = Uri.parse('$mintUrl/v1/info');
final response = await http.get(uri).timeout(
const Duration(seconds: 3),
);
return response.statusCode == 200;
} catch (e) {
debugPrint('Ping to mint failed: $e');
return false;
}
}
// ============================================================
// MINT INFO
// ============================================================
/// Obtiene la info de un mint (nombre, logo, contactos, etc.)
/// Usa caché para evitar llamadas repetidas.
Future<MintInfo?> fetchMintInfo(String mintUrl) async {
// Revisar caché primero
if (_mintInfoCache.containsKey(mintUrl)) {
return _mintInfoCache[mintUrl];
}
try {
final info = await getMintInfo(mintUrl: mintUrl);
_mintInfoCache[mintUrl] = info;
return info;
} catch (e) {
debugPrint('Error fetching mint info for $mintUrl: $e');
return null;
}
}
/// Obtiene MintInfo del caché (sin fetch)
MintInfo? getCachedMintInfo(String mintUrl) {
return _mintInfoCache[mintUrl];
}
/// Obtiene el nombre del mint (del caché o display name)
String getMintName(String mintUrl) {
final cached = _mintInfoCache[mintUrl];
if (cached?.name != null && cached!.name!.isNotEmpty) {
return cached.name!;
}
// Fallback: extraer nombre del URL
return _getMintDisplayName(mintUrl);
}
/// Obtiene el URL del icono del mint (del caché)
String? getMintIconUrl(String mintUrl) {
return _mintInfoCache[mintUrl]?.iconUrl;
}
/// Helper: extrae nombre legible del URL del mint
String _getMintDisplayName(String mintUrl) {
try {
final uri = Uri.parse(mintUrl);
var host = uri.host;
host = host.replaceFirst('mint.', '');
host = host.replaceFirst('www.', '');
return host;
} catch (e) {
return mintUrl;
}
}
// ============================================================
// PERSISTENCIA DE MINTS
// ============================================================
/// Guarda la lista de mints y sus unidades en SharedPreferences.
Future<void> _saveMints() async {
final prefs = await SharedPreferences.getInstance();
// Guardar mints como JSON: {"url": ["sat", "usd"], ...}
final mintsJson = jsonEncode(_mintUnits);
await prefs.setString(_mintsKey, mintsJson);
// Guardar mint y unidad activos
if (_activeMintUrl != null) {
await prefs.setString(_activeMintKey, _activeMintUrl!);
}
await prefs.setString(_activeUnitKey, _activeUnit);
debugPrint('Mints guardados: ${_mintUnits.keys.length}');
}
/// Carga la lista de mints desde SharedPreferences.
/// Retorna true si había mints guardados.
Future<bool> _loadMints() async {
final prefs = await SharedPreferences.getInstance();
final mintsJson = prefs.getString(_mintsKey);
if (mintsJson == null || mintsJson.isEmpty) {
return false;
}
try {
final decoded = jsonDecode(mintsJson) as Map<String, dynamic>;
_mintUnits.clear();
for (final entry in decoded.entries) {
final units = (entry.value as List<dynamic>).cast<String>();
_mintUnits[entry.key] = units;
}
// Cargar mint y unidad activos
_activeMintUrl = prefs.getString(_activeMintKey);
_activeUnit = prefs.getString(_activeUnitKey) ?? 'sat';
// Verificar que el mint activo existe
if (_activeMintUrl != null && !_mintUnits.containsKey(_activeMintUrl)) {
_activeMintUrl = _mintUnits.keys.firstOrNull;
}
// Verificar que la unidad activa es válida para el mint
if (_activeMintUrl != null) {
final units = _mintUnits[_activeMintUrl]!;
if (!units.contains(_activeUnit)) {
_activeUnit = units.first;
}
}
debugPrint('Mints cargados: ${_mintUnits.keys.length}');
return _mintUnits.isNotEmpty;
} catch (e) {
debugPrint('Error cargando mints: $e');
return false;
}
}
// ============================================================
// INICIALIZACION
// ============================================================
/// Inicializa el provider con un mnemonic.
/// Carga mints y unidades desde SharedPreferences.
Future<void> initialize(String mnemonic) async {
_mnemonic = mnemonic;
// Inicializar storage de metadata de transacciones
await _txMetaStorage.init();
// Inicializar storage de tokens pendientes
await _pendingTokenStorage.init();
// Obtener directorio de documentos (path absoluto requerido)
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
// Crear base de datos (se crea automáticamente si no existe)
_db = await WalletDatabase.newInstance(path: dbPath);
// Intentar cargar mints desde SharedPreferences
final hasSavedMints = await _loadMints();
if (hasSavedMints) {
// Cargar keysets para cada mint guardado
for (final mintUrl in _mintUnits.keys) {
try {
await _fetchKeysets(mintUrl);
// Crear wallet para la primera unidad de cada mint
final units = _mintUnits[mintUrl]!;
await getWallet(mintUrl, units.first);
} catch (e) {
debugPrint('Error cargando mint $mintUrl: $e');
}
}
debugPrint('Mints restaurados: ${_mintUnits.keys.length}');
} else {
// Primera vez - agregar mint por defecto
await addMint('https://mint.cubabitcoin.org');
}
notifyListeners();
}
/// Detecta si es primera vez (sin mints conocidos).
bool isFirstTime() {
return _mintUnits.isEmpty;
}
/// Genera un nuevo mnemonic de 12 palabras BIP39.
String generateNewMnemonic() {
return generateMnemonic();
}
// ============================================================
// WALLET MANAGEMENT (Lazy Loading)
// ============================================================
/// Obtiene o crea un wallet para un mint y unidad específicos.
/// Implementa lazy loading - solo crea el wallet cuando se necesita.
Future<Wallet> getWallet(String mintUrl, String unit) async {
if (_db == null || _mnemonic == null) {
throw Exception('WalletProvider no inicializado');
}
final key = '$mintUrl:$unit';
if (!_wallets.containsKey(key)) {
_wallets[key] = Wallet(
mintUrl: mintUrl,
unit: unit,
mnemonic: _mnemonic!,
db: _db!,
);
}
return _wallets[key]!;
}
/// Obtiene el wallet activo, creándolo si no existe.
Future<Wallet> getActiveWallet() async {
if (_activeMintUrl == null) {
throw Exception('No hay mint activo');
}
return await getWallet(_activeMintUrl!, _activeUnit);
}
// ============================================================
// MULTI-MINT OPERATIONS
// ============================================================
/// Detecta las unidades soportadas por un mint y las registra.
/// Usa mintInfo.nuts.nut04 (MintMethodSettings) para obtener las unidades.
Future<List<String>> _detectAndAddMint(String mintUrl) async {
try {
// Obtener info del mint (función global de cdk_flutter)
final mintInfo = await getMintInfo(mintUrl: mintUrl);
// Extraer unidades de nut04 (mint methods)
final units = <String>{};
// nut04 contiene los métodos de mint con sus unidades
final nut04 = mintInfo.nuts.nut04;
for (final method in nut04.methods) {
units.add(method.unit);
}
// Si no hay unidades detectadas, usar 'sat' por defecto
final unitList = units.isEmpty ? ['sat'] : units.toList();
// Ordenar: sat primero, luego alfabético
unitList.sort((a, b) {
if (a == 'sat') return -1;
if (b == 'sat') return 1;
return a.compareTo(b);
});
_mintUnits[mintUrl] = unitList;
// Obtener keysets para mapear keyset_id → unit
await _fetchKeysets(mintUrl);
return unitList;
} catch (e) {
debugPrint('Error detectando unidades de $mintUrl: $e');
// Fallback a sat
_mintUnits[mintUrl] = ['sat'];
return ['sat'];
}
}
/// Obtiene los keysets de un mint y los agrega al caché _keysetUnits.
/// Llama a GET {mintUrl}/v1/keysets para obtener el mapping keyset_id → unit.
Future<void> _fetchKeysets(String mintUrl) async {
try {
final response = await http.get(
Uri.parse('$mintUrl/v1/keysets'),
headers: {'Content-Type': 'application/json'},
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
final keysets = data['keysets'] as List<dynamic>?;
if (keysets != null) {
for (final ks in keysets) {
final id = ks['id'] as String?;
final unit = ks['unit'] as String?;
if (id != null && unit != null) {
_keysetUnits[id] = unit;
}
}
debugPrint('Keysets cargados para $mintUrl: ${keysets.length}');
}
} else {
debugPrint('Error HTTP ${response.statusCode} al obtener keysets de $mintUrl');
}
} catch (e) {
debugPrint('Error obteniendo keysets de $mintUrl: $e');
// No lanzamos excepción, el caché simplemente no tendrá estos keysets
}
}
/// Agrega un mint, detectando automáticamente sus unidades.
Future<void> addMint(String mintUrl) async {
if (_db == null || _mnemonic == null) {
throw Exception('WalletProvider no inicializado');
}
// Detectar unidades
final units = await _detectAndAddMint(mintUrl);
// Crear wallet para la primera unidad
await getWallet(mintUrl, units.first);
// Activar si es el primer mint
_activeMintUrl ??= mintUrl;
if (_activeMintUrl == mintUrl) {
_activeUnit = units.first;
}
// Persistir cambios
await _saveMints();
notifyListeners();
}
/// Refresca la información de un mint (keysets, unidades).
/// Útil para detectar nuevas unidades agregadas por el mint.
Future<List<String>> refreshMint(String mintUrl) async {
final units = await _detectAndAddMint(mintUrl);
notifyListeners();
return units;
}
/// Obtiene las unidades soportadas por un mint.
List<String> getUnitsForMint(String mintUrl) {
return _mintUnits[mintUrl] ?? ['sat'];
}
/// Lista los mints conocidos con sus unidades.
Map<String, List<String>> listMintsWithUnits() {
return Map.unmodifiable(_mintUnits);
}
/// Remueve un mint y todos sus wallets asociados.
/// Nota: Si hay balance, el usuario puede recuperarlo después con restore (NUT-13).
Future<void> removeMint(String mintUrl) async {
// Eliminar wallets de este mint
final keysToRemove = _wallets.keys
.where((key) => key.startsWith('$mintUrl:'))
.toList();
for (final key in keysToRemove) {
_wallets.remove(key);
}
// Eliminar de mints conocidos
_mintUnits.remove(mintUrl);
// Si era el mint activo, cambiar a otro
if (_activeMintUrl == mintUrl) {
if (_mintUnits.isNotEmpty) {
final newMint = _mintUnits.keys.first;
_activeMintUrl = newMint;
_activeUnit = _mintUnits[newMint]!.first;
} else {
_activeMintUrl = null;
_activeUnit = 'sat';
}
}
// Persistir cambios
await _saveMints();
notifyListeners();
}
// ============================================================
// ACTIVE MINT/UNIT SELECTION
// ============================================================
/// Cambia el mint activo.
Future<void> setActiveMint(String mintUrl) async {
if (!_mintUnits.containsKey(mintUrl)) {
throw Exception('Mint no conocido: $mintUrl');
}
_activeMintUrl = mintUrl;
// Si la unidad activa no está soportada por este mint, cambiar a la primera
final units = _mintUnits[mintUrl]!;
if (!units.contains(_activeUnit)) {
_activeUnit = units.first;
}
// Asegurar que el wallet existe
await getWallet(mintUrl, _activeUnit);
// Persistir cambio
await _saveMints();
notifyListeners();
}
/// Cambia la unidad activa.
Future<void> setActiveUnit(String unit) async {
if (_activeMintUrl == null) return;
final units = _mintUnits[_activeMintUrl]!;
if (!units.contains(unit)) {
throw Exception('Unidad $unit no soportada por el mint activo');
}
_activeUnit = unit;
// Asegurar que el wallet existe
await getWallet(_activeMintUrl!, unit);
// Persistir cambio
await _saveMints();
notifyListeners();
}
/// Cicla a la siguiente unidad del mint activo.
/// Orden: sat → usd → eur → sat ...
Future<void> cycleUnit() async {
if (_activeMintUrl == null) return;
final units = activeUnits;
if (units.length <= 1) return;
final currentIndex = units.indexOf(_activeUnit);
final nextIndex = (currentIndex + 1) % units.length;
await setActiveUnit(units[nextIndex]);
}
// ============================================================
// BALANCE
// ============================================================
/// Stream de balance del wallet activo (reactivo).
Stream<BigInt>? streamBalance() {
return activeWallet?.streamBalance();
}
/// Obtiene el balance del wallet activo.
Future<BigInt> getBalance() async {
final wallet = activeWallet;
if (wallet == null) return BigInt.zero;
return await wallet.balance();
}
/// Obtiene el balance de un mint en una unidad específica.
Future<BigInt> getBalanceForMintUnit(String mintUrl, String unit) async {
final key = '$mintUrl:$unit';
final wallet = _wallets[key];
if (wallet == null) {
// Crear wallet si no existe
final newWallet = await getWallet(mintUrl, unit);
return await newWallet.balance();
}
return await wallet.balance();
}
/// Obtiene el balance total de un mint (todas las unidades).
/// Retorna Map con unit como clave y balance como valor.
Future<Map<String, BigInt>> getBalancesForMint(String mintUrl) async {
final units = _mintUnits[mintUrl] ?? [];
final balances = <String, BigInt>{};
for (final unit in units) {
balances[unit] = await getBalanceForMintUnit(mintUrl, unit);
}
return balances;
}
// ============================================================
// RECEIVE (Recibir tokens Cashu)
// ============================================================
/// Parsea un token sin reclamarlo. Retorna null si inválido.
/// Incluye detección de unidad si es posible.
TokenInfo? parseToken(String encodedToken) {
try {
final token = Token.parse(encoded: encodedToken);
final unit = detectTokenUnit(encodedToken);
return TokenInfo(
amount: token.amount,
mintUrl: token.mintUrl,
encoded: token.encoded,
unit: unit,
);
} catch (e) {
return null;
}
}
/// Detecta la unidad de un token decodificando su keyset_id.
/// Soporta tokens V3 (cashuA) y V4 (cashuB con fallback).
/// Retorna null si no puede detectar la unidad.
String? detectTokenUnit(String encodedToken) {
try {
// Limpiar prefijo "cashu:" si existe
String token = encodedToken.trim();
if (token.startsWith('cashu:')) {
token = token.substring(6);
}
// Detectar versión por prefijo
if (token.startsWith('cashuA')) {
// Token V3: base64 JSON
return _detectUnitFromV3(token);
} else if (token.startsWith('cashuB')) {
// Token V4: CBOR - intentar fallback
return _detectUnitFromV4(token);
}
return null;
} catch (e) {
debugPrint('Error detectando unidad del token: $e');
return null;
}
}
/// Extrae keyset_id de un token V3 (cashuA + base64 JSON).
String? _detectUnitFromV3(String token) {
try {
// Remover prefijo "cashuA"
final base64Part = token.substring(6);
// Decodificar base64
final decoded = utf8.decode(base64.decode(base64Part));
final json = jsonDecode(decoded) as Map<String, dynamic>;
// Estructura V3: {"token": [{"mint": "...", "proofs": [{"id": "...", ...}]}]}
final tokenList = json['token'] as List<dynamic>?;
if (tokenList == null || tokenList.isEmpty) return null;
final firstToken = tokenList[0] as Map<String, dynamic>?;
if (firstToken == null) return null;
final proofs = firstToken['proofs'] as List<dynamic>?;
if (proofs == null || proofs.isEmpty) return null;
final firstProof = proofs[0] as Map<String, dynamic>?;
final keysetId = firstProof?['id'] as String?;
if (keysetId != null) {
// Lookup en caché
final unit = _keysetUnits[keysetId];
if (unit != null) {
debugPrint('Token V3 detectado: keyset=$keysetId, unit=$unit');
return unit;
}
debugPrint('Keyset $keysetId no encontrado en caché');
}
return null;
} catch (e) {
debugPrint('Error decodificando token V3: $e');
return null;
}
}
/// Intenta detectar unidad de un token V4 (cashuB + CBOR).
/// Por ahora hace fallback a probar las unidades conocidas del mint.
/// Detecta unidad de un token V4 (cashuB + base64url CBOR).
/// V4 incluye la unidad directamente en el campo 'u'.
String? _detectUnitFromV4(String token) {
try {
// Remover prefijo "cashuB"
final base64Part = token.substring(6);
// Decodificar base64url (V4 usa base64url, no base64 estándar)
final normalized = base64Part
.replaceAll('-', '+')
.replaceAll('_', '/');
// Agregar padding si es necesario
final padded = normalized.padRight(
(normalized.length + 3) & ~3,
'=',
);
final bytes = base64.decode(padded);
// Decodificar CBOR usando la API simple
final decoded = cbor.decode(bytes);
// V4 format: {m: mint_url, u: unit, t: [...]}
if (decoded is CborMap) {
// Buscar el campo 'u' (unit)
for (final entry in decoded.entries) {
final key = entry.key;
if (key is CborString && key.toString() == 'u') {
final value = entry.value;
if (value is CborString) {
final unit = value.toString();
debugPrint('Token V4 detectado: unit=$unit');
return unit;
}
}
}
}
debugPrint('Token V4: no se encontró campo "u"');
return null;
} catch (e) {
debugPrint('Error decodificando token V4: $e');
return null;
}
}
/// Reclama un token Cashu detectando automáticamente su unidad.
/// Si el mint del token es diferente al activo, lo agrega.
/// NO cambia la unidad activa (comportamiento similar a cashu.me).
/// Si el token es P2PK, pasar la clave privada en p2pkPrivateKey.
/// Retorna monto recibido.
Future<BigInt> receiveToken(String encodedToken, {String? p2pkPrivateKey}) async {
// Parsear token (incluye detección de unidad)
final tokenInfo = parseToken(encodedToken);
if (tokenInfo == null) {
throw Exception('Token inválido');
}
final tokenMint = tokenInfo.mintUrl;
// Verificar si conocemos este mint
if (!_mintUnits.containsKey(tokenMint)) {
// Agregar el mint automáticamente (esto también carga keysets)
await addMint(tokenMint);
// Intentar detectar unidad de nuevo ahora que tenemos keysets
final detectedUnit = detectTokenUnit(encodedToken);
if (detectedUnit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit, p2pkPrivateKey);
}
}
// Si tenemos unidad detectada, usarla
if (tokenInfo.unit != null) {
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!, p2pkPrivateKey);
}
// Fallback: intentar con cada unidad del mint hasta que funcione
final units = _mintUnits[tokenMint] ?? ['sat'];
Exception? lastError;
for (final unit in units) {
try {
return await _receiveWithUnit(encodedToken, tokenMint, unit, p2pkPrivateKey);
} catch (e) {
lastError = e as Exception;
debugPrint('Receive falló con unidad $unit: $e');
continue;
}
}
// Si ninguna unidad funcionó, lanzar el último error
throw lastError ?? Exception('No se pudo reclamar el token');
}
/// Recibe un token con una unidad específica.
/// Guarda metadata type=cashu para identificar en historial.
Future<BigInt> _receiveWithUnit(
String encodedToken,
String mintUrl,
String unit,
String? p2pkPrivateKey,
) async {
final wallet = await getWallet(mintUrl, unit);
final token = Token.parse(encoded: encodedToken);
// Si hay clave P2PK, usarla para desbloquear el token
final opts = p2pkPrivateKey != null
? ReceiveOptions(signingKeys: [p2pkPrivateKey])
: null;
// DEBUG: counters antes de receive
await KeysetDebug.logCounters('BEFORE receive ($unit)');
// DEBUG: transacciones ANTES del receive
await _debugLogTransactions(wallet, 'BEFORE receive');
final amount = await wallet.receive(token: token, opts: opts);
// DEBUG: counters después de receive
await KeysetDebug.logCounters('AFTER receive ($unit)');
// DEBUG: transacciones DESPUÉS del receive (antes de checkPending)
await _debugLogTransactions(wallet, 'AFTER receive (before checkPending)');
// Guardar metadata para la transacción recién creada
await _saveMetaForRecentReceive(wallet, encodedToken);
// Verificar transacciones pendientes para actualizar outgoing pending → settled
try {
await wallet.checkPendingTransactions();
} catch (e) {
debugPrint('Check pending after receive failed: $e');
}
// DEBUG: transacciones DESPUÉS de checkPending
await _debugLogTransactions(wallet, 'AFTER checkPending');
debugPrint('Token recibido: $amount $unit en $mintUrl');
confettiController.fire();
notifyListeners();
return amount;
}
/// DEBUG: Lista todas las transacciones de un wallet para diagnóstico.
Future<void> _debugLogTransactions(Wallet wallet, String label) async {
try {
final allTxs = await wallet.listTransactions();
debugPrint('[TX DEBUG] ===== $label =====');
debugPrint('[TX DEBUG] Total transacciones: ${allTxs.length}');
for (final tx in allTxs) {
final dir = tx.direction == TransactionDirection.incoming ? 'IN' : 'OUT';
final status = tx.status == TransactionStatus.pending ? 'PENDING' : 'SETTLED';
debugPrint('[TX DEBUG] $dir ${tx.amount} ${tx.unit} [$status] fee=${tx.fee} id=${tx.id.substring(0, 16)}...');
}
} catch (e) {
debugPrint('[TX DEBUG] Error listando transacciones: $e');
}
}
/// Guarda metadata para la transacción de receive más reciente.
Future<void> _saveMetaForRecentReceive(Wallet wallet, String tokenEncoded) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Receive metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando receive metadata: $e');
}
}
/// Reclama un token P2PK (bloqueado a una clave pública).
/// Usa la unidad del token automáticamente (no requiere cambiar unidad activa).
Future<BigInt> receiveP2pkToken(
String encodedToken,
List<String> signingKeys,
) async {
final tokenInfo = parseToken(encodedToken);
if (tokenInfo == null) {
throw Exception('Token inválido');
}
// Agregar mint si no existe
if (!_mintUnits.containsKey(tokenInfo.mintUrl)) {
await addMint(tokenInfo.mintUrl);
}
// Usar la unidad del token (fallback a 'sat' si no especificada)
final tokenUnit = tokenInfo.unit ?? 'sat';
final wallet = await getWallet(tokenInfo.mintUrl, tokenUnit);
final token = Token.parse(encoded: encodedToken);
final amount = await wallet.receive(
token: token,
opts: ReceiveOptions(signingKeys: signingKeys),
);
notifyListeners();
return amount;
}
// ============================================================
// SEND (Enviar tokens Cashu)
// ============================================================
/// Prepara un envío (reserva proofs, calcula fees).
Future<PreparedSend> prepareSend(BigInt amount) async {
final wallet = await getActiveWallet();
return await wallet.prepareSend(amount: amount);
}
/// Prepara un envío P2PK (bloqueado a una clave pública).
/// includeFee: true asegura que se reserven proofs para cubrir el swap fee (CDK 0.15+).
Future<PreparedSend> prepareSendP2pk(BigInt amount, String pubkey) async {
final wallet = await getActiveWallet();
return await wallet.prepareSend(
amount: amount,
opts: SendOptions(pubkey: pubkey, includeFee: true),
);
}
/// Confirma un envío preparado y retorna el token encoded.
/// Guarda metadata type=cashu para identificar en historial.
Future<String> confirmSend(PreparedSend prepared, String? memo) async {
final wallet = await getActiveWallet();
final token = await wallet.send(
send: prepared,
memo: memo,
includeMemo: memo != null && memo.isNotEmpty,
);
// Guardar token en storage local para mostrar en detalles del historial.
// Usamos hash del token como key temporal; después buscaremos la transacción.
await _saveTokenForRecentTransaction(token.encoded);
notifyListeners();
return token.encoded;
}
/// Guarda el token para la transacción más reciente de tipo send.
Future<void> _saveTokenForRecentTransaction(String tokenEncoded) async {
try {
// Obtener transacciones outgoing más recientes
final wallet = await getActiveWallet();
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
// La más reciente debería ser la que acabamos de crear
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.cashu,
token: tokenEncoded,
),
);
debugPrint('Token guardado para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando token metadata: $e');
}
}
/// Cancela un envío preparado (libera proofs reservados).
Future<void> cancelSend(PreparedSend prepared) async {
final wallet = await getActiveWallet();
await wallet.cancelSend(send: prepared);
}
/// Método de conveniencia: prepara y confirma en un solo paso.
/// Si confirmSend falla, libera proofs reservados con cancelSend.
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;
}
}
/// 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;
}
}
/// Verifica si hay transacciones salientes pendientes en el wallet activo.
Future<bool> hasPendingOutgoingTransactions() async {
try {
final wallet = await getActiveWallet();
final txs = await wallet.listTransactions();
return txs.any((tx) =>
tx.direction == TransactionDirection.outgoing &&
tx.status == TransactionStatus.pending);
} catch (_) {
return false;
}
}
// ============================================================
// MINT (Depositar via Lightning)
// ============================================================
/// Suscripción activa al stream de mint (vive en el provider, no en la UI).
StreamSubscription<MintQuote>? _activeMintSubscription;
/// Controller del stream actual de mint (para cerrar al iniciar uno nuevo).
StreamController<MintQuote>? _activeMintController;
/// Inicia un depósito via Lightning.
/// Retorna Stream con estados: unpaid -> paid -> issued.
/// La suscripción al CDK vive en el provider para que los side effects
/// (guardar metadata, confetti) ocurran aunque la UI se cierre.
Future<Stream<MintQuote>> mintTokens(BigInt amount, String? description) async {
final wallet = activeWallet;
if (wallet == null) {
throw Exception('No hay wallet activo');
}
final mintUrl = _activeMintUrl!;
final unit = _activeUnit;
String? invoiceBolt11;
// Cerrar controller anterior si existe (evitar leak)
if (_activeMintController != null && !_activeMintController!.isClosed) {
_activeMintController!.close();
}
// StreamController que la UI puede escuchar y cancelar libremente
final controller = StreamController<MintQuote>();
_activeMintController = controller;
// Cancelar suscripción anterior si existe (await evita race de callbacks)
await _activeMintSubscription?.cancel();
_activeMintSubscription = null;
// Suscribirse al stream del CDK desde el provider (persiste sin UI)
_activeMintSubscription = wallet.mint(
amount: amount,
description: description,
).listen(
(quote) {
// Guardar invoice temprano en SharedPreferences
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) {
_saveMintMetadata(wallet, invoiceBolt11!);
_removePendingMintInvoice(quote.id);
}
// Reenviar a la UI (si sigue escuchando)
if (!controller.isClosed) {
controller.add(quote);
}
},
onError: (error) {
if (!controller.isClosed) {
controller.addError(error);
}
},
onDone: () {
_activeMintSubscription = null;
if (!controller.isClosed) {
controller.close();
}
},
);
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 {
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');
} catch (e) {
debugPrint('Error guardando pending mint invoice: $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');
}
}
/// 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);
}
}
// 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;
}
/// Guarda metadata para una transacción de mint (Lightning deposit).
Future<void> _saveMintMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.incoming,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
confettiController.fire();
notifyListeners();
}
} catch (e) {
debugPrint('Error guardando mint metadata: $e');
}
}
// ============================================================
// MELT (Retirar a Lightning)
// ============================================================
/// Invoice temporal para guardar metadata después de melt
String? _pendingMeltInvoice;
/// Obtiene quote para pagar un invoice BOLT11.
/// Guarda el invoice temporalmente para asociarlo a la transacción después.
Future<MeltQuote> getMeltQuote(String bolt11Invoice) async {
_pendingMeltInvoice = bolt11Invoice;
final wallet = await getActiveWallet();
return await wallet.meltQuote(request: bolt11Invoice);
}
/// Ejecuta el pago del invoice.
/// Guarda metadata type=lightning para identificar en historial.
Future<BigInt> melt(MeltQuote quote) async {
final wallet = await getActiveWallet();
final totalPaid = await wallet.melt(quote: quote);
// Guardar metadata con el invoice
if (_pendingMeltInvoice != null) {
await _saveMeltMetadata(wallet, _pendingMeltInvoice!);
_pendingMeltInvoice = null;
}
notifyListeners();
return totalPaid;
}
/// Guarda metadata para una transacción de melt (Lightning withdrawal).
Future<void> _saveMeltMetadata(Wallet wallet, String invoice) async {
try {
final txs = await wallet.listTransactions(
direction: TransactionDirection.outgoing,
);
if (txs.isNotEmpty) {
final recentTx = txs.first;
await _txMetaStorage.save(
recentTx.id,
TransactionMeta(
type: TransactionType.lightning,
invoice: invoice,
),
);
debugPrint('Melt metadata guardada para tx ${recentTx.id}');
}
} catch (e) {
debugPrint('Error guardando melt metadata: $e');
}
}
// ============================================================
// HISTORIAL
// ============================================================
/// Obtiene transacciones del wallet activo.
Future<List<Transaction>> getTransactions({
TransactionDirection? direction,
}) async {
final wallet = activeWallet;
if (wallet == null) return [];
return await wallet.listTransactions(direction: direction);
}
/// Obtiene transacciones de todos los wallets.
Future<List<Transaction>> getAllTransactions({
TransactionDirection? direction,
}) async {
final allTransactions = <Transaction>[];
for (final wallet in _wallets.values) {
try {
final txs = await wallet.listTransactions(direction: direction);
allTransactions.addAll(txs);
} catch (e) {
debugPrint('Error obteniendo transacciones: $e');
}
}
// Ordenar por timestamp (más reciente primero)
allTransactions.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return allTransactions;
}
/// Obtiene el tipo de una transacción (cashu o lightning).
/// Busca primero en metadata del CDK, luego en storage local.
/// Si no hay metadata y es incoming, asume Lightning (los receives Cashu
/// siempre guardan metadata inmediatamente).
/// Limitación conocida: transacciones anteriores al sistema de metadata
/// o donde el guardado falló silenciosamente serían clasificadas como Lightning.
TransactionType getTransactionType(Transaction tx) {
final type = _txMetaStorage.getType(tx.id, tx.metadata);
// Si el storage devuelve cashu por defecto pero no tiene metadata real,
// y la transacción es incoming → probablemente es Lightning
if (!_txMetaStorage.has(tx.id) &&
tx.metadata['type'] == null &&
tx.direction == TransactionDirection.incoming) {
return TransactionType.lightning;
}
return type;
}
/// Obtiene metadata adicional de una transacción.
TransactionMeta? getTransactionMeta(String transactionId) {
return _txMetaStorage.get(transactionId);
}
/// Verifica si una transacción tiene metadata guardada.
bool hasTransactionMeta(String transactionId) {
return _txMetaStorage.has(transactionId);
}
// ============================================================
// VERIFICACION DE PROOFS
// ============================================================
/// Verifica proofs pendientes en todos los wallets.
/// Llamar en background al iniciar la app.
/// También vincula transacciones incoming sin metadata con invoices pendientes.
Future<void> checkPendingTransactions() async {
// 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.
for (final entry in _mintUnits.entries) {
for (final unit in entry.value) {
try {
final wallet = await getWallet(entry.key, unit);
try {
// Reclamar quotes pagados pero no emitidos (Lightning → proofs)
await wallet.checkAllMintQuotes();
} catch (e) {
debugPrint('Check mint quotes failed: $e');
}
try {
// Recuperar sagas incompletas (swaps/mints/melts interrumpidos)
await wallet.recoverIncompleteSagas();
} catch (e) {
debugPrint('Recover incomplete sagas failed: $e');
}
try {
// Finalizar retiros Lightning que quedaron a medias
await wallet.finalizePendingMelts();
} catch (e) {
debugPrint('Finalize pending melts failed: $e');
}
try {
await wallet.checkPendingTransactions();
} catch (e) {
// 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();
}
/// 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;
if (wallet == null) return false;
final token = Token.parse(encoded: encodedToken);
return await wallet.isTokenSpent(token: token);
}
// ============================================================
// RESTORE (NUT-13 + NUT-09)
// ============================================================
/// Escanea un mint para todas sus unidades usando NUT-13.
/// Retorna Map con unit como clave y balance recuperado como valor.
Future<Map<String, BigInt>> restoreFromMint(String mintUrl) async {
if (_db == null || _mnemonic == null) {
throw Exception('WalletProvider no inicializado');
}
final results = <String, BigInt>{};
final units = _mintUnits[mintUrl] ?? ['sat'];
for (final unit in units) {
try {
final wallet = await getWallet(mintUrl, unit);
final balanceBefore = await wallet.balance();
await wallet.restore();
final balanceAfter = await wallet.balance();
final recovered = balanceAfter - balanceBefore;
results[unit] = recovered;
} catch (e) {
debugPrint('Error restaurando $mintUrl:$unit: $e');
results[unit] = BigInt.from(-1); // Indica error
}
}
notifyListeners();
return results;
}
/// Escanea todos los mints conocidos para recuperar tokens.
Future<Map<String, Map<String, BigInt>>> restoreAllMints() async {
final results = <String, Map<String, BigInt>>{};
for (final mintUrl in _mintUnits.keys) {
results[mintUrl] = await restoreFromMint(mintUrl);
}
return results;
}
/// Restaura tokens usando otro mnemonic y los transfiere al wallet actual.
Future<BigInt> restoreWithMnemonic(
String mnemonic,
List<String> mintUrls,
) async {
if (_db == null || _mnemonic == null) {
throw Exception('WalletProvider no inicializado');
}
BigInt totalRecovered = BigInt.zero;
// Normalizar mnemonic
final normalizedMnemonic =
mnemonic.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
// Crear DB temporal
final dir = await getApplicationDocumentsDirectory();
final tempDbPath =
'${dir.path}/temp_restore_${DateTime.now().millisecondsSinceEpoch}.sqlite';
WalletDatabase? tempDb;
try {
tempDb = await WalletDatabase.newInstance(path: tempDbPath);
for (final mintUrl in mintUrls) {
// Obtener unidades del mint
final units = _mintUnits[mintUrl] ?? ['sat'];
for (final unit in units) {
try {
// Crear wallet temporal
final tempWallet = Wallet(
mintUrl: mintUrl,
unit: unit,
mnemonic: normalizedMnemonic,
db: tempDb,
);
// Escanear
await tempWallet.restore();
final tempBalance = await tempWallet.balance();
if (tempBalance > BigInt.zero) {
// Enviar todo el balance
final prepared =
await tempWallet.prepareSend(amount: tempBalance);
final token = await tempWallet.send(
send: prepared,
memo: 'Recuperación El Caju',
includeMemo: true,
);
// Reclamar en nuestro wallet
final ourWallet = await getWallet(mintUrl, unit);
final received = await ourWallet.receive(token: token);
totalRecovered += received;
}
} catch (e) {
debugPrint('Error restaurando $mintUrl:$unit: $e');
}
}
}
} finally {
// Limpiar DB temporal
tempDb = null;
try {
final tempFile = File(tempDbPath);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
debugPrint('Error limpiando DB temporal: $e');
}
}
notifyListeners();
return totalRecovered;
}
// ============================================================
// BORRAR WALLET
// ============================================================
/// Borra la base de datos SQLite del wallet.
Future<bool> deleteDatabase() async {
try {
// Limpiar referencias
_wallets.clear();
_mintUnits.clear();
_activeMintUrl = null;
_activeUnit = 'sat';
_mnemonic = null;
_db = null;
// Limpiar metadata de transacciones
await _txMetaStorage.clear();
// Borrar archivo
final dir = await getApplicationDocumentsDirectory();
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
final dbFile = File(dbPath);
if (await dbFile.exists()) {
await dbFile.delete();
}
notifyListeners();
return true;
} catch (e) {
debugPrint('Error al borrar database: $e');
return false;
}
}
/// Resetea el estado del provider (sin borrar la DB).
void reset() {
_wallets.clear();
_mintUnits.clear();
_activeMintUrl = null;
_activeUnit = 'sat';
_mnemonic = null;
_db = null;
notifyListeners();
}
// ============================================================
// PENDING TOKENS (Receive Later)
// ============================================================
/// Guarda un token para reclamar después.
/// Retorna el PendingToken creado o null si hay error/límite alcanzado.
Future<PendingToken?> addPendingToken(String encodedToken) async {
// Parsear token para validar y extraer info
final tokenInfo = parseToken(encodedToken);
if (tokenInfo == null) {
debugPrint('Token inválido para pending');
return null;
}
// Generar ID único
final id = _uuid.v4();
// Guardar en storage
final pending = await _pendingTokenStorage.add(
id: id,
encoded: encodedToken,
amount: tokenInfo.amount,
mintUrl: tokenInfo.mintUrl,
unit: tokenInfo.unit,
);
if (pending != null) {
notifyListeners();
}
return pending;
}
/// Lista todos los tokens pendientes
List<PendingToken> listPendingTokens() {
return _pendingTokenStorage.listAll();
}
/// Lista solo tokens pendientes válidos (no expirados)
List<PendingToken> listValidPendingTokens() {
return _pendingTokenStorage.listValid();
}
/// Elimina un token pendiente por ID
Future<void> removePendingToken(String id) async {
await _pendingTokenStorage.remove(id);
notifyListeners();
}
/// Intenta reclamar un token pendiente.
/// Retorna el monto recibido si tiene éxito, o lanza excepción.
/// Si el token está gastado o es inválido, lo elimina automáticamente.
/// Verifica conectividad al mint antes de intentar reclamar.
Future<BigInt> claimPendingToken(String id) async {
final pending = _pendingTokenStorage.get(id);
if (pending == null) {
throw Exception('Token pendiente no encontrado');
}
// Verificar conectividad al mint primero (evita esperas largas sin conexión)
final canReach = await canReachMint(pending.mintUrl);
if (!canReach) {
throw Exception('No connection to mint');
}
try {
// Intentar reclamar usando el método existente
final amount = await receiveToken(pending.encoded);
// Éxito: eliminar de pending
await _pendingTokenStorage.remove(id);
notifyListeners();
debugPrint('Token pendiente reclamado: $id, monto: $amount');
return amount;
} catch (e) {
final errorStr = e.toString().toLowerCase();
// Si el token ya fue gastado o es inválido, eliminarlo
if (errorStr.contains('already spent') ||
errorStr.contains('token already') ||
errorStr.contains('invalid')) {
await _pendingTokenStorage.remove(id);
notifyListeners();
debugPrint('Token pendiente eliminado (gastado/inválido): $id');
} else {
// Otro error: registrar intento fallido
await _pendingTokenStorage.recordFailedAttempt(id, e.toString());
}
rethrow;
}
}
/// Verifica y reclama automáticamente tokens pendientes.
/// Retorna un mapa con estadísticas: claimed, failed, removed, totalClaimed, unit.
Future<Map<String, dynamic>> checkPendingTokens() async {
final tokens = _pendingTokenStorage.listValid();
if (tokens.isEmpty) {
return {'claimed': 0, 'failed': 0, 'removed': 0, 'totalClaimed': BigInt.zero, 'unit': _activeUnit};
}
int claimed = 0;
int failed = 0;
int removed = 0;
BigInt totalClaimed = BigInt.zero;
String? claimedUnit;
for (final token in tokens) {
try {
final amount = await claimPendingToken(token.id);
claimed++;
totalClaimed += amount;
claimedUnit ??= token.unit; // Usar la unidad del primer token reclamado
debugPrint('Auto-claim exitoso: ${token.id}');
} catch (e) {
final errorStr = e.toString().toLowerCase();
if (errorStr.contains('already spent') ||
errorStr.contains('token already') ||
errorStr.contains('invalid')) {
removed++;
debugPrint('Auto-claim: token eliminado (gastado): ${token.id}');
} else {
failed++;
debugPrint('Auto-claim fallido: ${token.id} - $e');
}
}
}
// Limpiar expirados también
final expired = await _pendingTokenStorage.cleanExpired();
removed += expired;
if (claimed > 0 || removed > 0) {
notifyListeners();
}
return {
'claimed': claimed,
'failed': failed,
'removed': removed,
'totalClaimed': totalClaimed,
'unit': claimedUnit ?? _activeUnit,
};
}
/// Limpia tokens pendientes expirados
Future<int> cleanExpiredPendingTokens() async {
final cleaned = await _pendingTokenStorage.cleanExpired();
if (cleaned > 0) {
notifyListeners();
}
return cleaned;
}
@override
void dispose() {
_activeMintSubscription?.cancel();
if (_activeMintController != null && !_activeMintController!.isClosed) {
_activeMintController!.close();
}
confettiController.dispose();
super.dispose();
}
}