Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a1cb21d35 | ||
|
|
b8649348c8 | ||
|
|
d9de23b425 | ||
|
|
28d95fdae0 | ||
|
|
3b72367ff1 | ||
|
|
a8251c433c | ||
|
|
812f28d3b9 | ||
|
|
0465034a2b | ||
|
|
9abb2c03e1 | ||
|
|
f842561864 | ||
|
|
5fe1ec5c90 | ||
|
|
336f4316aa | ||
|
|
7f902f7f75 | ||
|
|
bb07a8f2da | ||
|
|
cb33924a75 | ||
|
|
390af394b9 | ||
|
|
64430191f4 | ||
|
|
0952bda73b | ||
|
|
7da8181022 | ||
|
|
13de1456c8 | ||
|
|
b1777f32a2 | ||
|
|
d9144e74d1 | ||
|
|
bfca3da6e5 | ||
|
|
235dc29b08 | ||
|
|
3840929d5e | ||
|
|
705916d42c |
@@ -0,0 +1,61 @@
|
||||
name: Release APK
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android
|
||||
|
||||
- name: Install Android NDK
|
||||
run: echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "ndk;27.0.12077973"
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Generate localizations
|
||||
run: flutter gen-l10n
|
||||
|
||||
- name: Build release APKs
|
||||
run: flutter build apk --release --split-per-abi
|
||||
|
||||
- name: Rename APKs
|
||||
run: |
|
||||
cd build/app/outputs/flutter-apk
|
||||
mv app-arm64-v8a-release.apk elcaju-${{ github.ref_name }}-arm64.apk
|
||||
mv app-armeabi-v7a-release.apk elcaju-${{ github.ref_name }}-armeabi-v7a.apk
|
||||
mv app-x86_64-release.apk elcaju-${{ github.ref_name }}-x86_64.apk
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-arm64.apk
|
||||
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-armeabi-v7a.apk
|
||||
build/app/outputs/flutter-apk/elcaju-${{ github.ref_name }}-x86_64.apk
|
||||
generate_release_notes: true
|
||||
@@ -21,7 +21,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "me.elcaju"
|
||||
minSdk flutter.minSdkVersion
|
||||
minSdk 21 // Required for FlutterSecureStorage (EncryptedSharedPreferences)
|
||||
targetSdk flutter.targetSdkVersion
|
||||
versionCode flutter.versionCode
|
||||
versionName flutter.versionName
|
||||
|
||||
@@ -2,3 +2,4 @@ arb-dir: lib/l10n
|
||||
template-arb-file: app_es.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: L10n
|
||||
synthetic-package: false
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/// Utilidad para leer datos del SQLite de CDK.
|
||||
/// Usado para:
|
||||
/// - Debug de keyset counters
|
||||
/// - Leer input_fee_ppk para cálculo de fees
|
||||
/// - Contar proofs unspent para consolidación P2PK
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class KeysetDebug {
|
||||
static Database? _db;
|
||||
|
||||
/// Abre la DB de CDK en modo solo lectura.
|
||||
static Future<Database> _getDb() async {
|
||||
if (_db != null && _db!.isOpen) return _db!;
|
||||
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dbPath = '${dir.path}/elcaju_wallet.sqlite';
|
||||
|
||||
_db = await openDatabase(dbPath, readOnly: true);
|
||||
return _db!;
|
||||
}
|
||||
|
||||
/// Lee todos los keysets con sus counters y los imprime.
|
||||
/// Retorna el counter del keyset activo (si hay uno).
|
||||
static Future<void> logCounters(String label) async {
|
||||
try {
|
||||
final db = await _getDb();
|
||||
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT id, unit, active, counter FROM keyset ORDER BY active DESC, unit ASC',
|
||||
);
|
||||
|
||||
debugPrint('[COUNTER DEBUG] ===== $label =====');
|
||||
debugPrint('[COUNTER DEBUG] Total keysets: ${rows.length}');
|
||||
|
||||
for (final row in rows) {
|
||||
final id = row['id'] as String?;
|
||||
final unit = row['unit'] as String?;
|
||||
final active = row['active'] as int?;
|
||||
final counter = row['counter'] as int?;
|
||||
final shortId = (id != null && id.length > 12) ? id.substring(0, 12) : id;
|
||||
|
||||
debugPrint(
|
||||
'[COUNTER DEBUG] keyset=$shortId unit=$unit active=$active counter=$counter',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[COUNTER DEBUG] Error leyendo counters: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Lee el input_fee_ppk del keyset activo para un mint y unidad.
|
||||
///
|
||||
/// IMPORTANTE: Lee directamente del schema interno de CDK (tabla `keyset`,
|
||||
/// columna `input_fee_ppk`). Escrito para CDK 0.13.4 (cdk-flutter actual).
|
||||
/// Si CDK cambia el schema (ej: en 0.14.x con rusqlite), este query puede
|
||||
/// fallar. En caso de error retorna -1 (asume fees > 0) para bloquear P2PK
|
||||
/// de forma segura — nunca retornar 0 en error porque permitiria P2PK en
|
||||
/// mints con fees, causando perdida de fondos.
|
||||
///
|
||||
/// TODO: Revisar este codigo al actualizar cdk-flutter a CDK 0.14.x.
|
||||
/// Idealmente cdk-flutter deberia exponer getActiveKeyset().inputFeePpk
|
||||
/// via API publica en lugar de leer SQLite directamente.
|
||||
static Future<int> getInputFeePpk(String mintUrl, String unit) async {
|
||||
try {
|
||||
final db = await _getDb();
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT input_fee_ppk FROM keyset WHERE active=1 AND unit=? AND mint_url=?',
|
||||
[unit, mintUrl],
|
||||
);
|
||||
if (rows.isEmpty) {
|
||||
debugPrint('[KEYSET DEBUG] No active keyset found for $mintUrl/$unit');
|
||||
return -1;
|
||||
}
|
||||
return (rows.first['input_fee_ppk'] as int?) ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[KEYSET DEBUG] Error leyendo input_fee_ppk: $e');
|
||||
// Fail-safe: asumir fees > 0 para bloquear P2PK ante schema desconocido
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cuenta los proofs UNSPENT de un mint y unidad.
|
||||
/// Retorna 0 si no se encuentra o hay error.
|
||||
static Future<int> getUnspentProofCount(String mintUrl, String unit) async {
|
||||
try {
|
||||
final db = await _getDb();
|
||||
final rows = await db.rawQuery(
|
||||
'SELECT COUNT(*) as count FROM proof WHERE state=? AND mint_url=? AND unit=?',
|
||||
['UNSPENT', mintUrl, unit],
|
||||
);
|
||||
if (rows.isEmpty) return 0;
|
||||
return (rows.first['count'] as int?) ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[KEYSET DEBUG] Error contando proofs: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra la DB (llamar al final si es necesario).
|
||||
static Future<void> close() async {
|
||||
await _db?.close();
|
||||
_db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/// Utilidades para conversión de claves Nostr (npub/nsec/hex)
|
||||
///
|
||||
/// Formatos soportados:
|
||||
/// - npub1... (clave pública Nostr en bech32)
|
||||
/// - nsec1... (clave privada Nostr en bech32)
|
||||
/// - hex (64 caracteres hexadecimales)
|
||||
/// - nostr:npub1... (URI de Nostr)
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'package:bech32/bech32.dart';
|
||||
|
||||
class NostrUtils {
|
||||
static const _npubHrp = 'npub';
|
||||
static const _nsecHrp = 'nsec';
|
||||
|
||||
// ============ HEX -> BECH32 ============
|
||||
|
||||
/// Convierte hex a npub
|
||||
/// Acepta 64 chars (x-only) o 66 chars (SEC1 comprimido con prefijo 02/03)
|
||||
static String hexToNpub(String hex) {
|
||||
// Si es SEC1 comprimido (66 chars con prefijo 02 o 03), quitar prefijo
|
||||
if (RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(hex)) {
|
||||
hex = hex.substring(2);
|
||||
} else if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) {
|
||||
throw ArgumentError('Expected 64 or 66-char hex string, got ${hex.length} chars');
|
||||
}
|
||||
final bytes = _hexToBytes(hex);
|
||||
final converted = _convertBits(bytes, 8, 5, true);
|
||||
final bech32Data = Bech32(_npubHrp, converted);
|
||||
return const Bech32Codec().encode(bech32Data);
|
||||
}
|
||||
|
||||
/// Convierte hex (64 chars) a nsec
|
||||
static String hexToNsec(String hex) {
|
||||
if (!RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(hex)) {
|
||||
throw ArgumentError('Expected 64-char hex string, got ${hex.length} chars');
|
||||
}
|
||||
final bytes = _hexToBytes(hex);
|
||||
final converted = _convertBits(bytes, 8, 5, true);
|
||||
final bech32Data = Bech32(_nsecHrp, converted);
|
||||
return const Bech32Codec().encode(bech32Data);
|
||||
}
|
||||
|
||||
// ============ BECH32 -> HEX ============
|
||||
|
||||
/// Convierte npub a hex
|
||||
static String? npubToHex(String npub) {
|
||||
try {
|
||||
final decoded = const Bech32Codec().decode(npub);
|
||||
if (decoded.hrp != _npubHrp) return null;
|
||||
final bytes = _convertBits(decoded.data, 5, 8, false);
|
||||
return _bytesToHex(Uint8List.fromList(bytes));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Convierte nsec a hex
|
||||
static String? nsecToHex(String nsec) {
|
||||
try {
|
||||
final decoded = const Bech32Codec().decode(nsec);
|
||||
if (decoded.hrp != _nsecHrp) return null;
|
||||
final bytes = _convertBits(decoded.data, 5, 8, false);
|
||||
return _bytesToHex(Uint8List.fromList(bytes));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ NORMALIZACIÓN ============
|
||||
|
||||
/// Normaliza input a hex (acepta npub, hex, nostr:npub)
|
||||
/// Retorna null si el input no es válido
|
||||
static String? normalizeToHex(String input) {
|
||||
input = input.trim();
|
||||
|
||||
// Remover prefijo nostr: si existe
|
||||
if (input.startsWith('nostr:')) {
|
||||
input = input.substring(6);
|
||||
}
|
||||
|
||||
// Si es npub → convertir a hex
|
||||
if (input.startsWith('npub1')) {
|
||||
return npubToHex(input);
|
||||
}
|
||||
|
||||
// Si es hex válido (64 caracteres) → usar directo
|
||||
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) {
|
||||
return input.toLowerCase();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============ P2PK (SEC1 COMPRESSED) ============
|
||||
|
||||
/// Normaliza input a clave pública comprimida SEC1 (66 hex chars)
|
||||
/// Cashu P2PK usa claves comprimidas de 33 bytes (prefijo 02 o 03)
|
||||
/// Acepta: npub, hex 64 chars (x-only), hex 66 chars (compressed)
|
||||
static String? normalizeToCompressedHex(String input) {
|
||||
input = input.trim();
|
||||
|
||||
// Remover prefijo nostr: si existe
|
||||
if (input.startsWith('nostr:')) {
|
||||
input = input.substring(6);
|
||||
}
|
||||
|
||||
// Si ya es hex comprimido (66 chars con prefijo 02 o 03)
|
||||
if (RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(input)) {
|
||||
return input.toLowerCase();
|
||||
}
|
||||
|
||||
// Si es npub → convertir a hex x-only → añadir prefijo 02
|
||||
if (input.startsWith('npub1')) {
|
||||
final xOnlyHex = npubToHex(input);
|
||||
if (xOnlyHex != null) {
|
||||
return '02$xOnlyHex';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Si es hex x-only (64 chars) → añadir prefijo 02
|
||||
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) {
|
||||
return '02${input.toLowerCase()}';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Valida si un input es válido para P2PK
|
||||
static bool isValidP2PKPubkey(String input) {
|
||||
return normalizeToCompressedHex(input) != null;
|
||||
}
|
||||
|
||||
// ============ VALIDACIÓN ============
|
||||
|
||||
/// Valida si un input es una pubkey válida (npub o hex)
|
||||
static bool isValidPubkey(String input) {
|
||||
final hex = normalizeToHex(input);
|
||||
return hex != null && hex.length == 64;
|
||||
}
|
||||
|
||||
/// Valida si es un nsec válido
|
||||
static bool isValidNsec(String input) {
|
||||
if (input.startsWith('nostr:')) input = input.substring(6);
|
||||
return input.startsWith('nsec1') && nsecToHex(input) != null;
|
||||
}
|
||||
|
||||
/// Valida si es un npub válido
|
||||
static bool isValidNpub(String input) {
|
||||
if (input.startsWith('nostr:')) input = input.substring(6);
|
||||
return input.startsWith('npub1') && npubToHex(input) != null;
|
||||
}
|
||||
|
||||
// ============ HELPERS PRIVADOS ============
|
||||
|
||||
static Uint8List _hexToBytes(String hex) {
|
||||
final result = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < hex.length; i += 2) {
|
||||
result[i ~/ 2] = int.parse(hex.substring(i, i + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Convierte bits entre bases (5 <-> 8) para bech32
|
||||
static List<int> _convertBits(
|
||||
List<int> data,
|
||||
int fromBits,
|
||||
int toBits,
|
||||
bool pad,
|
||||
) {
|
||||
var acc = 0;
|
||||
var bits = 0;
|
||||
final result = <int>[];
|
||||
final maxv = (1 << toBits) - 1;
|
||||
|
||||
for (final value in data) {
|
||||
acc = (acc << fromBits) | value;
|
||||
bits += fromBits;
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits;
|
||||
result.add((acc >> bits) & maxv);
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add((acc << (toBits - bits)) & maxv);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/// Utilidades para detección de P2PK en tokens Cashu
|
||||
///
|
||||
/// Soporta:
|
||||
/// - Tokens V3 (cashuA - base64 JSON)
|
||||
/// - Tokens V4 (cashuB - CBOR)
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:cbor/cbor.dart';
|
||||
|
||||
class P2PKUtils {
|
||||
/// Extrae la pubkey bloqueada de un token P2PK (si existe)
|
||||
/// Retorna null si el token no es P2PK o no se puede parsear
|
||||
static String? extractLockedPubkey(String encodedToken) {
|
||||
try {
|
||||
String token = encodedToken.trim();
|
||||
|
||||
// Remover prefijo cashu: si existe
|
||||
if (token.startsWith('cashu:')) {
|
||||
token = token.substring(6);
|
||||
}
|
||||
|
||||
if (token.startsWith('cashuA')) {
|
||||
return _extractFromV3(token);
|
||||
} else if (token.startsWith('cashuB')) {
|
||||
return _extractFromV4(token);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifica si un token está bloqueado con P2PK
|
||||
static bool isP2PKLocked(String encodedToken) {
|
||||
return extractLockedPubkey(encodedToken) != null;
|
||||
}
|
||||
|
||||
// ============ V3 TOKEN (cashuA - base64 JSON) ============
|
||||
|
||||
static String? _extractFromV3(String token) {
|
||||
try {
|
||||
final base64Part = token.substring(6);
|
||||
final jsonStr = utf8.decode(base64.decode(base64Part));
|
||||
final data = jsonDecode(jsonStr) as Map<String, dynamic>;
|
||||
|
||||
// Estructura: { token: [{ proofs: [...] }] }
|
||||
final tokenList = data['token'] as List?;
|
||||
if (tokenList == null || tokenList.isEmpty) return null;
|
||||
|
||||
final proofs = tokenList[0]['proofs'] as List?;
|
||||
if (proofs == null || proofs.isEmpty) return null;
|
||||
|
||||
final secret = proofs[0]['secret'];
|
||||
return _parseP2PKSecret(secret);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ V4 TOKEN (cashuB - CBOR) ============
|
||||
|
||||
static String? _extractFromV4(String token) {
|
||||
try {
|
||||
final cborPart = token.substring(6);
|
||||
// V4 usa base64url
|
||||
final bytes = base64Url.decode(_padBase64(cborPart));
|
||||
final decoded = cbor.decode(bytes);
|
||||
|
||||
if (decoded is! CborMap) return null;
|
||||
|
||||
// Estructura CBOR: { t: [{ p: [...] }] } o { p: [...] }
|
||||
final tokenData = decoded.toObject() as Map<dynamic, dynamic>;
|
||||
|
||||
List<dynamic>? proofs;
|
||||
if (tokenData.containsKey('t')) {
|
||||
final t = tokenData['t'] as List?;
|
||||
if (t != null && t.isNotEmpty) {
|
||||
final firstToken = t[0];
|
||||
if (firstToken is Map) {
|
||||
proofs = firstToken['p'] as List?;
|
||||
}
|
||||
}
|
||||
} else if (tokenData.containsKey('p')) {
|
||||
proofs = tokenData['p'] as List?;
|
||||
}
|
||||
|
||||
if (proofs == null || proofs.isEmpty) return null;
|
||||
|
||||
final firstProof = proofs[0];
|
||||
if (firstProof is! Map) return null;
|
||||
|
||||
final secret = firstProof['s'];
|
||||
return _parseP2PKSecret(secret);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ PARSER DE SECRET P2PK (NUT-10) ============
|
||||
|
||||
/// Parsea el secret de un proof para extraer la pubkey P2PK
|
||||
/// Estructura P2PK: ["P2PK", { "nonce": "...", "data": "<pubkey>", "tags": [...] }]
|
||||
static String? _parseP2PKSecret(dynamic secret) {
|
||||
if (secret == null) return null;
|
||||
|
||||
String secretStr;
|
||||
if (secret is String) {
|
||||
secretStr = secret;
|
||||
} else if (secret is List<int>) {
|
||||
// Uint8List también es List<int>, así que este branch cubre ambos
|
||||
secretStr = utf8.decode(secret);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = jsonDecode(secretStr);
|
||||
if (parsed is List && parsed.length >= 2 && parsed[0] == 'P2PK') {
|
||||
final data = parsed[1];
|
||||
if (data is Map && data.containsKey('data')) {
|
||||
final pubkey = data['data'];
|
||||
// Validar que sea hex de 64 chars (x-only) o 66 chars (SEC1 comprimido)
|
||||
if (pubkey is String &&
|
||||
(RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(pubkey) ||
|
||||
RegExp(r'^0[23][0-9a-fA-F]{64}$').hasMatch(pubkey))) {
|
||||
return pubkey.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// No es P2PK o formato inválido
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Agrega padding a base64url si es necesario
|
||||
static String _padBase64(String input) {
|
||||
final remainder = input.length % 4;
|
||||
if (remainder == 0) return input;
|
||||
return input + '=' * (4 - remainder);
|
||||
}
|
||||
}
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(du brauchst 12 oder 24)",
|
||||
"restoreScanningMint": "Mint wird nach vorhandenen Token durchsucht...",
|
||||
"restoreError": "Wiederherstellungsfehler: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Scanne eine Lightning Rechnung (lnbc...)",
|
||||
"addMintQuestion": "Diesen Mint hinzufügen?",
|
||||
"cameraPermissionDenied": "Kamera-Berechtigung verweigert",
|
||||
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt"
|
||||
"paymentRequestNotSupported": "Zahlungsanfragen werden noch nicht unterstützt",
|
||||
"p2pkTitle": "P2PK-Schlüssel",
|
||||
"p2pkSettingsDescription": "Gesperrtes ecash empfangen",
|
||||
"p2pkExperimental": "P2PK ist experimentell. Mit Vorsicht verwenden.",
|
||||
"p2pkPendingSendWarning": "Du hast einen ausstehenden P2PK-Versand. Gehe zum Verlauf und aktualisiere, nachdem der Empfänger den Token eingelöst hat.",
|
||||
"p2pkExperimentalShort": "Experimentell",
|
||||
"p2pkPrimaryKey": "Primärschlüssel",
|
||||
"p2pkDerived": "Abgeleitet",
|
||||
"p2pkImported": "Importiert",
|
||||
"p2pkImportedKeys": "Importierte Schlüssel",
|
||||
"p2pkNoImportedKeys": "Keine importierten Schlüssel",
|
||||
"p2pkShowQR": "QR zeigen",
|
||||
"p2pkCopy": "Kopieren",
|
||||
"p2pkImportNsec": "nsec importieren",
|
||||
"p2pkImport": "Importieren",
|
||||
"p2pkEnterLabel": "Name für diesen Schlüssel",
|
||||
"p2pkLockToKey": "Senden mit P2PK-Signatur",
|
||||
"p2pkLockDescription": "Nur der Empfänger kann einlösen",
|
||||
"p2pkReceiverPubkey": "npub1... oder hex (64/66 Zeichen)",
|
||||
"p2pkInvalidPubkey": "Ungültiger öffentlicher Schlüssel",
|
||||
"p2pkInvalidPrivateKey": "Ungültiger privater Schlüssel",
|
||||
"p2pkLockedToYou": "Für dich gesperrt",
|
||||
"p2pkLockedToOther": "Für anderen Schlüssel gesperrt",
|
||||
"p2pkCannotUnlock": "Du hast nicht den Schlüssel, um diesen Token zu entsperren",
|
||||
"p2pkEnterPrivateKey": "Privaten Schlüssel eingeben (nsec)",
|
||||
"p2pkDeleteTitle": "Schlüssel löschen",
|
||||
"p2pkDeleteConfirm": "Diesen Schlüssel löschen? Du kannst keine Token mehr empfangen, die daran gesperrt sind.",
|
||||
"p2pkRequiresConnection": "P2PK erfordert Verbindung zum Mint",
|
||||
"p2pkErrorMaxKeysReached": "Maximale Anzahl importierter Schlüssel erreicht (10)",
|
||||
"p2pkErrorInvalidNsec": "Ungültiger nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "Dieser Schlüssel existiert bereits",
|
||||
"p2pkErrorKeyNotFound": "Schlüssel nicht gefunden",
|
||||
"p2pkErrorCannotDeletePrimary": "Primärschlüssel kann nicht gelöscht werden",
|
||||
"p2pkSendComingSoon": "Demnächst verfügbar"
|
||||
}
|
||||
|
||||
+36
-1
@@ -47,6 +47,7 @@
|
||||
"seedPlaceholder": "word1 word2 word3 ...",
|
||||
"wordCount": "{count} words",
|
||||
"needWords": "(you need 12 or 24)",
|
||||
"restoreScanningMint": "Scanning mint for existing tokens...",
|
||||
"restoreError": "Restore error: {error}",
|
||||
|
||||
"homeTitle": "Home",
|
||||
@@ -365,5 +366,39 @@
|
||||
"scanLightningInvoiceHint": "Scan a Lightning invoice (lnbc...)",
|
||||
"addMintQuestion": "Add this mint?",
|
||||
"cameraPermissionDenied": "Camera permission denied",
|
||||
"paymentRequestNotSupported": "Payment requests are not yet supported"
|
||||
"paymentRequestNotSupported": "Payment requests are not yet supported",
|
||||
|
||||
"p2pkTitle": "P2PK Keys",
|
||||
"p2pkSettingsDescription": "Receive locked ecash",
|
||||
"p2pkExperimental": "P2PK is experimental. Use with caution.",
|
||||
"p2pkPendingSendWarning": "You have a pending P2PK send. Go to history and refresh after the recipient claims the token.",
|
||||
"p2pkExperimentalShort": "Experimental",
|
||||
"p2pkPrimaryKey": "Primary Key",
|
||||
"p2pkDerived": "Derived",
|
||||
"p2pkImported": "Imported",
|
||||
"p2pkImportedKeys": "Imported Keys",
|
||||
"p2pkNoImportedKeys": "No imported keys",
|
||||
"p2pkShowQR": "Show QR",
|
||||
"p2pkCopy": "Copy",
|
||||
"p2pkImportNsec": "Import nsec",
|
||||
"p2pkImport": "Import",
|
||||
"p2pkEnterLabel": "Name for this key",
|
||||
"p2pkLockToKey": "Send with P2PK signature",
|
||||
"p2pkLockDescription": "Only the recipient can claim",
|
||||
"p2pkReceiverPubkey": "npub1... or hex (64/66 chars)",
|
||||
"p2pkInvalidPubkey": "Invalid public key",
|
||||
"p2pkInvalidPrivateKey": "Invalid private key",
|
||||
"p2pkLockedToYou": "Locked to you",
|
||||
"p2pkLockedToOther": "Locked to another key",
|
||||
"p2pkCannotUnlock": "You don't have the key to unlock this token",
|
||||
"p2pkEnterPrivateKey": "Enter private key (nsec)",
|
||||
"p2pkDeleteTitle": "Delete key",
|
||||
"p2pkDeleteConfirm": "Delete this key? You won't be able to receive tokens locked to it.",
|
||||
"p2pkRequiresConnection": "P2PK requires connection to the mint",
|
||||
"p2pkErrorMaxKeysReached": "Maximum imported keys reached (10)",
|
||||
"p2pkErrorInvalidNsec": "Invalid nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "This key already exists",
|
||||
"p2pkErrorKeyNotFound": "Key not found",
|
||||
"p2pkErrorCannotDeletePrimary": "Cannot delete primary key",
|
||||
"p2pkSendComingSoon": "Coming soon"
|
||||
}
|
||||
|
||||
+36
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(necesitas 12 o 24)",
|
||||
"restoreScanningMint": "Escaneando mint en busca de tokens...",
|
||||
"restoreError": "Error al restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,39 @@
|
||||
"scanLightningInvoiceHint": "Escanea un invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "¿Agregar este mint?",
|
||||
"cameraPermissionDenied": "Permiso de cámara denegado",
|
||||
"paymentRequestNotSupported": "Los payment requests aún no están soportados"
|
||||
"paymentRequestNotSupported": "Los payment requests aún no están soportados",
|
||||
|
||||
"p2pkTitle": "Claves P2PK",
|
||||
"p2pkSettingsDescription": "Recibir ecash bloqueado",
|
||||
"p2pkExperimental": "P2PK es experimental. Úsala con precaución.",
|
||||
"p2pkPendingSendWarning": "Tienes un envío P2PK pendiente. Ve al historial y presiona actualizar después de que el destinatario reclame el token.",
|
||||
"p2pkExperimentalShort": "Experimental",
|
||||
"p2pkPrimaryKey": "Clave Principal",
|
||||
"p2pkDerived": "Derivada",
|
||||
"p2pkImported": "Importada",
|
||||
"p2pkImportedKeys": "Claves Importadas",
|
||||
"p2pkNoImportedKeys": "No hay claves importadas",
|
||||
"p2pkShowQR": "Mostrar QR",
|
||||
"p2pkCopy": "Copiar",
|
||||
"p2pkImportNsec": "Importar nsec",
|
||||
"p2pkImport": "Importar",
|
||||
"p2pkEnterLabel": "Nombre para esta clave",
|
||||
"p2pkLockToKey": "Envío con firma P2PK",
|
||||
"p2pkLockDescription": "Solo el destinatario podrá reclamar",
|
||||
"p2pkReceiverPubkey": "npub1... o hex (64/66 caracteres)",
|
||||
"p2pkInvalidPubkey": "Clave pública inválida",
|
||||
"p2pkInvalidPrivateKey": "Clave privada inválida",
|
||||
"p2pkLockedToYou": "Bloqueado para ti",
|
||||
"p2pkLockedToOther": "Bloqueado para otra clave",
|
||||
"p2pkCannotUnlock": "No tienes la clave para desbloquear este token",
|
||||
"p2pkEnterPrivateKey": "Ingresa la clave privada (nsec)",
|
||||
"p2pkDeleteTitle": "Eliminar clave",
|
||||
"p2pkDeleteConfirm": "¿Eliminar esta clave? No podrás recibir tokens bloqueados a ella.",
|
||||
"p2pkRequiresConnection": "P2PK requiere conexión al mint",
|
||||
"p2pkErrorMaxKeysReached": "Máximo de claves importadas alcanzado (10)",
|
||||
"p2pkErrorInvalidNsec": "nsec inválido",
|
||||
"p2pkErrorKeyAlreadyExists": "Esta clave ya existe",
|
||||
"p2pkErrorKeyNotFound": "Clave no encontrada",
|
||||
"p2pkErrorCannotDeletePrimary": "No se puede eliminar la clave principal",
|
||||
"p2pkSendComingSoon": "Disponible próximamente"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(vous avez besoin de 12 ou 24)",
|
||||
"restoreScanningMint": "Recherche de tokens sur le mint...",
|
||||
"restoreError": "Erreur de restauration : {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Scannez une facture Lightning (lnbc...)",
|
||||
"addMintQuestion": "Ajouter ce mint ?",
|
||||
"cameraPermissionDenied": "Permission de la caméra refusée",
|
||||
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge"
|
||||
"paymentRequestNotSupported": "Les demandes de paiement ne sont pas encore prises en charge",
|
||||
"p2pkTitle": "Clés P2PK",
|
||||
"p2pkSettingsDescription": "Recevoir ecash verrouillé",
|
||||
"p2pkExperimental": "P2PK est expérimental. Utiliser avec prudence.",
|
||||
"p2pkPendingSendWarning": "Vous avez un envoi P2PK en attente. Allez dans l'historique et actualisez après que le destinataire a réclamé le jeton.",
|
||||
"p2pkExperimentalShort": "Expérimental",
|
||||
"p2pkPrimaryKey": "Clé Principale",
|
||||
"p2pkDerived": "Dérivée",
|
||||
"p2pkImported": "Importée",
|
||||
"p2pkImportedKeys": "Clés Importées",
|
||||
"p2pkNoImportedKeys": "Aucune clé importée",
|
||||
"p2pkShowQR": "Afficher QR",
|
||||
"p2pkCopy": "Copier",
|
||||
"p2pkImportNsec": "Importer nsec",
|
||||
"p2pkImport": "Importer",
|
||||
"p2pkEnterLabel": "Nom pour cette clé",
|
||||
"p2pkLockToKey": "Envoi avec signature P2PK",
|
||||
"p2pkLockDescription": "Seul le destinataire peut réclamer",
|
||||
"p2pkReceiverPubkey": "npub1... ou hex (64/66 caractères)",
|
||||
"p2pkInvalidPubkey": "Clé publique invalide",
|
||||
"p2pkInvalidPrivateKey": "Clé privée invalide",
|
||||
"p2pkLockedToYou": "Verrouillé pour vous",
|
||||
"p2pkLockedToOther": "Verrouillé pour une autre clé",
|
||||
"p2pkCannotUnlock": "Vous n'avez pas la clé pour déverrouiller ce token",
|
||||
"p2pkEnterPrivateKey": "Entrer la clé privée (nsec)",
|
||||
"p2pkDeleteTitle": "Supprimer la clé",
|
||||
"p2pkDeleteConfirm": "Supprimer cette clé ? Vous ne pourrez plus recevoir de tokens verrouillés dessus.",
|
||||
"p2pkRequiresConnection": "P2PK nécessite une connexion au mint",
|
||||
"p2pkErrorMaxKeysReached": "Nombre maximum de clés importées atteint (10)",
|
||||
"p2pkErrorInvalidNsec": "nsec invalide",
|
||||
"p2pkErrorKeyAlreadyExists": "Cette clé existe déjà",
|
||||
"p2pkErrorKeyNotFound": "Clé non trouvée",
|
||||
"p2pkErrorCannotDeletePrimary": "Impossible de supprimer la clé principale",
|
||||
"p2pkSendComingSoon": "Bientôt disponible"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(servono 12 o 24)",
|
||||
"restoreScanningMint": "Scansione del mint per token esistenti...",
|
||||
"restoreError": "Errore di ripristino: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Scansiona una fattura Lightning (lnbc...)",
|
||||
"addMintQuestion": "Aggiungere questo mint?",
|
||||
"cameraPermissionDenied": "Permesso fotocamera negato",
|
||||
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate"
|
||||
"paymentRequestNotSupported": "Le richieste di pagamento non sono ancora supportate",
|
||||
"p2pkTitle": "Chiavi P2PK",
|
||||
"p2pkSettingsDescription": "Ricevi ecash bloccato",
|
||||
"p2pkExperimental": "P2PK è sperimentale. Usare con cautela.",
|
||||
"p2pkPendingSendWarning": "Hai un invio P2PK in sospeso. Vai alla cronologia e aggiorna dopo che il destinatario ha riscattato il token.",
|
||||
"p2pkExperimentalShort": "Sperimentale",
|
||||
"p2pkPrimaryKey": "Chiave Principale",
|
||||
"p2pkDerived": "Derivata",
|
||||
"p2pkImported": "Importata",
|
||||
"p2pkImportedKeys": "Chiavi Importate",
|
||||
"p2pkNoImportedKeys": "Nessuna chiave importata",
|
||||
"p2pkShowQR": "Mostra QR",
|
||||
"p2pkCopy": "Copia",
|
||||
"p2pkImportNsec": "Importa nsec",
|
||||
"p2pkImport": "Importa",
|
||||
"p2pkEnterLabel": "Nome per questa chiave",
|
||||
"p2pkLockToKey": "Invio con firma P2PK",
|
||||
"p2pkLockDescription": "Solo il destinatario può riscattare",
|
||||
"p2pkReceiverPubkey": "npub1... o hex (64/66 caratteri)",
|
||||
"p2pkInvalidPubkey": "Chiave pubblica non valida",
|
||||
"p2pkInvalidPrivateKey": "Chiave privata non valida",
|
||||
"p2pkLockedToYou": "Bloccato per te",
|
||||
"p2pkLockedToOther": "Bloccato per un'altra chiave",
|
||||
"p2pkCannotUnlock": "Non hai la chiave per sbloccare questo token",
|
||||
"p2pkEnterPrivateKey": "Inserisci chiave privata (nsec)",
|
||||
"p2pkDeleteTitle": "Elimina chiave",
|
||||
"p2pkDeleteConfirm": "Eliminare questa chiave? Non potrai ricevere token bloccati ad essa.",
|
||||
"p2pkRequiresConnection": "P2PK richiede connessione al mint",
|
||||
"p2pkErrorMaxKeysReached": "Numero massimo di chiavi importate raggiunto (10)",
|
||||
"p2pkErrorInvalidNsec": "nsec non valido",
|
||||
"p2pkErrorKeyAlreadyExists": "Questa chiave esiste già",
|
||||
"p2pkErrorKeyNotFound": "Chiave non trovata",
|
||||
"p2pkErrorCannotDeletePrimary": "Impossibile eliminare la chiave principale",
|
||||
"p2pkSendComingSoon": "Disponibile prossimamente"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(12語または24語が必要)",
|
||||
"restoreScanningMint": "ミントで既存のトークンをスキャン中...",
|
||||
"restoreError": "復元エラー:{error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Lightningインボイスをスキャン(lnbc...)",
|
||||
"addMintQuestion": "このMintを追加しますか?",
|
||||
"cameraPermissionDenied": "カメラの許可が拒否されました",
|
||||
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません"
|
||||
"paymentRequestNotSupported": "支払いリクエストはまだサポートされていません",
|
||||
"p2pkTitle": "P2PK鍵",
|
||||
"p2pkSettingsDescription": "ロックされたecashを受け取る",
|
||||
"p2pkExperimental": "P2PKは実験的機能です。注意してご使用ください。",
|
||||
"p2pkPendingSendWarning": "保留中のP2PK送信があります。受取人がトークンを受け取った後、履歴で更新してください。",
|
||||
"p2pkExperimentalShort": "実験的",
|
||||
"p2pkPrimaryKey": "プライマリ鍵",
|
||||
"p2pkDerived": "派生",
|
||||
"p2pkImported": "インポート済み",
|
||||
"p2pkImportedKeys": "インポートした鍵",
|
||||
"p2pkNoImportedKeys": "インポートした鍵はありません",
|
||||
"p2pkShowQR": "QRを表示",
|
||||
"p2pkCopy": "コピー",
|
||||
"p2pkImportNsec": "nsecをインポート",
|
||||
"p2pkImport": "インポート",
|
||||
"p2pkEnterLabel": "この鍵の名前",
|
||||
"p2pkLockToKey": "P2PK署名で送信",
|
||||
"p2pkLockDescription": "受取人のみが請求可能",
|
||||
"p2pkReceiverPubkey": "npub1... または hex(64/66文字)",
|
||||
"p2pkInvalidPubkey": "無効な公開鍵",
|
||||
"p2pkInvalidPrivateKey": "無効な秘密鍵",
|
||||
"p2pkLockedToYou": "あなた宛にロック",
|
||||
"p2pkLockedToOther": "別の鍵にロック",
|
||||
"p2pkCannotUnlock": "このトークンをアンロックする鍵がありません",
|
||||
"p2pkEnterPrivateKey": "秘密鍵を入力(nsec)",
|
||||
"p2pkDeleteTitle": "鍵を削除",
|
||||
"p2pkDeleteConfirm": "この鍵を削除しますか?この鍵にロックされたトークンを受け取れなくなります。",
|
||||
"p2pkRequiresConnection": "P2PKにはMintへの接続が必要です",
|
||||
"p2pkErrorMaxKeysReached": "インポート可能な鍵の上限に達しました(10)",
|
||||
"p2pkErrorInvalidNsec": "無効なnsec",
|
||||
"p2pkErrorKeyAlreadyExists": "この鍵は既に存在します",
|
||||
"p2pkErrorKeyNotFound": "鍵が見つかりません",
|
||||
"p2pkErrorCannotDeletePrimary": "プライマリ鍵は削除できません",
|
||||
"p2pkSendComingSoon": "近日公開"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(12개 또는 24개 필요)",
|
||||
"restoreScanningMint": "민트에서 기존 토큰 검색 중...",
|
||||
"restoreError": "복구 오류: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Lightning 인보이스 스캔 (lnbc...)",
|
||||
"addMintQuestion": "이 mint를 추가하시겠습니까?",
|
||||
"cameraPermissionDenied": "카메라 권한이 거부되었습니다",
|
||||
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다"
|
||||
"paymentRequestNotSupported": "결제 요청은 아직 지원되지 않습니다",
|
||||
"p2pkTitle": "P2PK 키",
|
||||
"p2pkSettingsDescription": "잠긴 ecash 받기",
|
||||
"p2pkExperimental": "P2PK는 실험적 기능입니다. 주의하여 사용하세요.",
|
||||
"p2pkPendingSendWarning": "대기 중인 P2PK 전송이 있습니다. 수신자가 토큰을 수령한 후 기록에서 새로고침하세요.",
|
||||
"p2pkExperimentalShort": "실험적",
|
||||
"p2pkPrimaryKey": "기본 키",
|
||||
"p2pkDerived": "파생됨",
|
||||
"p2pkImported": "가져옴",
|
||||
"p2pkImportedKeys": "가져온 키",
|
||||
"p2pkNoImportedKeys": "가져온 키가 없습니다",
|
||||
"p2pkShowQR": "QR 표시",
|
||||
"p2pkCopy": "복사",
|
||||
"p2pkImportNsec": "nsec 가져오기",
|
||||
"p2pkImport": "가져오기",
|
||||
"p2pkEnterLabel": "이 키의 이름",
|
||||
"p2pkLockToKey": "P2PK 서명으로 전송",
|
||||
"p2pkLockDescription": "수신자만 청구 가능",
|
||||
"p2pkReceiverPubkey": "npub1... 또는 hex (64/66자)",
|
||||
"p2pkInvalidPubkey": "유효하지 않은 공개 키",
|
||||
"p2pkInvalidPrivateKey": "유효하지 않은 개인 키",
|
||||
"p2pkLockedToYou": "당신에게 잠김",
|
||||
"p2pkLockedToOther": "다른 키로 잠김",
|
||||
"p2pkCannotUnlock": "이 토큰을 잠금 해제할 키가 없습니다",
|
||||
"p2pkEnterPrivateKey": "개인 키 입력 (nsec)",
|
||||
"p2pkDeleteTitle": "키 삭제",
|
||||
"p2pkDeleteConfirm": "이 키를 삭제하시겠습니까? 이 키로 잠긴 토큰을 받을 수 없게 됩니다.",
|
||||
"p2pkRequiresConnection": "P2PK는 mint 연결이 필요합니다",
|
||||
"p2pkErrorMaxKeysReached": "가져온 키의 최대 개수에 도달했습니다 (10)",
|
||||
"p2pkErrorInvalidNsec": "유효하지 않은 nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "이 키는 이미 존재합니다",
|
||||
"p2pkErrorKeyNotFound": "키를 찾을 수 없습니다",
|
||||
"p2pkErrorCannotDeletePrimary": "기본 키는 삭제할 수 없습니다",
|
||||
"p2pkSendComingSoon": "곧 출시 예정"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(você precisa de 12 ou 24)",
|
||||
"restoreScanningMint": "Escaneando mint em busca de tokens...",
|
||||
"restoreError": "Erro ao restaurar: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Escaneie um invoice Lightning (lnbc...)",
|
||||
"addMintQuestion": "Adicionar este mint?",
|
||||
"cameraPermissionDenied": "Permissão de câmera negada",
|
||||
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas"
|
||||
"paymentRequestNotSupported": "Solicitações de pagamento ainda não são suportadas",
|
||||
"p2pkTitle": "Chaves P2PK",
|
||||
"p2pkSettingsDescription": "Receber ecash bloqueado",
|
||||
"p2pkExperimental": "P2PK é experimental. Use com cautela.",
|
||||
"p2pkPendingSendWarning": "Você tem um envio P2PK pendente. Vá ao histórico e atualize após o destinatário resgatar o token.",
|
||||
"p2pkExperimentalShort": "Experimental",
|
||||
"p2pkPrimaryKey": "Chave Principal",
|
||||
"p2pkDerived": "Derivada",
|
||||
"p2pkImported": "Importada",
|
||||
"p2pkImportedKeys": "Chaves Importadas",
|
||||
"p2pkNoImportedKeys": "Nenhuma chave importada",
|
||||
"p2pkShowQR": "Mostrar QR",
|
||||
"p2pkCopy": "Copiar",
|
||||
"p2pkImportNsec": "Importar nsec",
|
||||
"p2pkImport": "Importar",
|
||||
"p2pkEnterLabel": "Nome para esta chave",
|
||||
"p2pkLockToKey": "Envio com assinatura P2PK",
|
||||
"p2pkLockDescription": "Apenas o destinatário pode resgatar",
|
||||
"p2pkReceiverPubkey": "npub1... ou hex (64/66 caracteres)",
|
||||
"p2pkInvalidPubkey": "Chave pública inválida",
|
||||
"p2pkInvalidPrivateKey": "Chave privada inválida",
|
||||
"p2pkLockedToYou": "Bloqueado para você",
|
||||
"p2pkLockedToOther": "Bloqueado para outra chave",
|
||||
"p2pkCannotUnlock": "Você não tem a chave para desbloquear este token",
|
||||
"p2pkEnterPrivateKey": "Digite a chave privada (nsec)",
|
||||
"p2pkDeleteTitle": "Excluir chave",
|
||||
"p2pkDeleteConfirm": "Excluir esta chave? Você não poderá receber tokens bloqueados para ela.",
|
||||
"p2pkRequiresConnection": "P2PK requer conexão com o mint",
|
||||
"p2pkErrorMaxKeysReached": "Número máximo de chaves importadas atingido (10)",
|
||||
"p2pkErrorInvalidNsec": "nsec inválido",
|
||||
"p2pkErrorKeyAlreadyExists": "Esta chave já existe",
|
||||
"p2pkErrorKeyNotFound": "Chave não encontrada",
|
||||
"p2pkErrorCannotDeletePrimary": "Não é possível excluir a chave principal",
|
||||
"p2pkSendComingSoon": "Em breve disponível"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(нужно 12 или 24)",
|
||||
"restoreScanningMint": "Сканирование минта на наличие токенов...",
|
||||
"restoreError": "Ошибка восстановления: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Сканируйте Lightning счёт (lnbc...)",
|
||||
"addMintQuestion": "Добавить этот mint?",
|
||||
"cameraPermissionDenied": "Доступ к камере запрещён",
|
||||
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются"
|
||||
"paymentRequestNotSupported": "Запросы на оплату пока не поддерживаются",
|
||||
"p2pkTitle": "Ключи P2PK",
|
||||
"p2pkSettingsDescription": "Получить заблокированный ecash",
|
||||
"p2pkExperimental": "P2PK экспериментальный. Используйте с осторожностью.",
|
||||
"p2pkPendingSendWarning": "У вас есть ожидающая отправка P2PK. Перейдите в историю и обновите после того, как получатель заберёт токен.",
|
||||
"p2pkExperimentalShort": "Экспериментальный",
|
||||
"p2pkPrimaryKey": "Основной ключ",
|
||||
"p2pkDerived": "Производный",
|
||||
"p2pkImported": "Импортированный",
|
||||
"p2pkImportedKeys": "Импортированные ключи",
|
||||
"p2pkNoImportedKeys": "Нет импортированных ключей",
|
||||
"p2pkShowQR": "Показать QR",
|
||||
"p2pkCopy": "Копировать",
|
||||
"p2pkImportNsec": "Импортировать nsec",
|
||||
"p2pkImport": "Импортировать",
|
||||
"p2pkEnterLabel": "Имя для этого ключа",
|
||||
"p2pkLockToKey": "Отправка с подписью P2PK",
|
||||
"p2pkLockDescription": "Только получатель может получить",
|
||||
"p2pkReceiverPubkey": "npub1... или hex (64/66 символов)",
|
||||
"p2pkInvalidPubkey": "Недействительный публичный ключ",
|
||||
"p2pkInvalidPrivateKey": "Недействительный приватный ключ",
|
||||
"p2pkLockedToYou": "Заблокировано для вас",
|
||||
"p2pkLockedToOther": "Заблокировано для другого ключа",
|
||||
"p2pkCannotUnlock": "У вас нет ключа для разблокировки этого токена",
|
||||
"p2pkEnterPrivateKey": "Введите приватный ключ (nsec)",
|
||||
"p2pkDeleteTitle": "Удалить ключ",
|
||||
"p2pkDeleteConfirm": "Удалить этот ключ? Вы не сможете получать токены заблокированные на него.",
|
||||
"p2pkRequiresConnection": "P2PK требует подключения к mint",
|
||||
"p2pkErrorMaxKeysReached": "Достигнуто максимальное количество импортированных ключей (10)",
|
||||
"p2pkErrorInvalidNsec": "Недействительный nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "Этот ключ уже существует",
|
||||
"p2pkErrorKeyNotFound": "Ключ не найден",
|
||||
"p2pkErrorCannotDeletePrimary": "Невозможно удалить основной ключ",
|
||||
"p2pkSendComingSoon": "Скоро будет доступно"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(unahitaji 12 au 24)",
|
||||
"restoreScanningMint": "Inatafuta tokeni zilizopo kwenye mint...",
|
||||
"restoreError": "Hitilafu ya kurejesha: {error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "Changanua ankara ya Lightning (lnbc...)",
|
||||
"addMintQuestion": "Ongeza mint hii?",
|
||||
"cameraPermissionDenied": "Ruhusa ya kamera imekataliwa",
|
||||
"paymentRequestNotSupported": "Maombi ya malipo bado hayatumiki"
|
||||
"paymentRequestNotSupported": "Maombi ya malipo bado hayatumiki",
|
||||
"p2pkTitle": "Funguo za P2PK",
|
||||
"p2pkSettingsDescription": "Pokea ecash iliyofungwa",
|
||||
"p2pkExperimental": "P2PK ni ya majaribio. Tumia kwa uangalifu.",
|
||||
"p2pkPendingSendWarning": "Una usafirishaji wa P2PK unaosubiri. Nenda kwenye historia na usasishe baada ya mpokeaji kudai tokeni.",
|
||||
"p2pkExperimentalShort": "Majaribio",
|
||||
"p2pkPrimaryKey": "Ufunguo Mkuu",
|
||||
"p2pkDerived": "Iliyotokana",
|
||||
"p2pkImported": "Iliyoingizwa",
|
||||
"p2pkImportedKeys": "Funguo Zilizoingizwa",
|
||||
"p2pkNoImportedKeys": "Hakuna funguo zilizoingizwa",
|
||||
"p2pkShowQR": "Onyesha QR",
|
||||
"p2pkCopy": "Nakili",
|
||||
"p2pkImportNsec": "Ingiza nsec",
|
||||
"p2pkImport": "Ingiza",
|
||||
"p2pkEnterLabel": "Jina la ufunguo huu",
|
||||
"p2pkLockToKey": "Tuma kwa saini ya P2PK",
|
||||
"p2pkLockDescription": "Mpokeaji pekee anaweza kudai",
|
||||
"p2pkReceiverPubkey": "npub1... au hex (herufi 64/66)",
|
||||
"p2pkInvalidPubkey": "Ufunguo wa umma batili",
|
||||
"p2pkInvalidPrivateKey": "Ufunguo wa siri batili",
|
||||
"p2pkLockedToYou": "Imefungwa kwako",
|
||||
"p2pkLockedToOther": "Imefungwa kwa ufunguo mwingine",
|
||||
"p2pkCannotUnlock": "Huna ufunguo wa kufungua tokeni hii",
|
||||
"p2pkEnterPrivateKey": "Ingiza ufunguo wa siri (nsec)",
|
||||
"p2pkDeleteTitle": "Futa ufunguo",
|
||||
"p2pkDeleteConfirm": "Futa ufunguo huu? Hutaweza kupokea tokeni zilizofungwa kwake.",
|
||||
"p2pkRequiresConnection": "P2PK inahitaji muunganisho kwa mint",
|
||||
"p2pkErrorMaxKeysReached": "Idadi ya juu ya funguo zilizoingizwa imefikiwa (10)",
|
||||
"p2pkErrorInvalidNsec": "nsec batili",
|
||||
"p2pkErrorKeyAlreadyExists": "Ufunguo huu tayari upo",
|
||||
"p2pkErrorKeyNotFound": "Ufunguo haujapatikana",
|
||||
"p2pkErrorCannotDeletePrimary": "Haiwezekani kufuta ufunguo mkuu",
|
||||
"p2pkSendComingSoon": "Inakuja hivi karibuni"
|
||||
}
|
||||
|
||||
+35
-1
@@ -52,6 +52,7 @@
|
||||
}
|
||||
},
|
||||
"needWords": "(需要12或24个)",
|
||||
"restoreScanningMint": "正在扫描铸造厂中的现有代币...",
|
||||
"restoreError": "恢复错误:{error}",
|
||||
"@restoreError": {
|
||||
"placeholders": {
|
||||
@@ -485,5 +486,38 @@
|
||||
"scanLightningInvoiceHint": "扫描闪电发票(lnbc...)",
|
||||
"addMintQuestion": "添加此铸造厂?",
|
||||
"cameraPermissionDenied": "相机权限被拒绝",
|
||||
"paymentRequestNotSupported": "付款请求尚不支持"
|
||||
"paymentRequestNotSupported": "付款请求尚不支持",
|
||||
"p2pkTitle": "P2PK密钥",
|
||||
"p2pkSettingsDescription": "接收锁定的ecash",
|
||||
"p2pkExperimental": "P2PK是实验性功能。请谨慎使用。",
|
||||
"p2pkPendingSendWarning": "您有一笔待处理的P2PK发送。在收款人领取代币后,请前往历史记录刷新。",
|
||||
"p2pkExperimentalShort": "实验性",
|
||||
"p2pkPrimaryKey": "主密钥",
|
||||
"p2pkDerived": "派生的",
|
||||
"p2pkImported": "已导入",
|
||||
"p2pkImportedKeys": "已导入的密钥",
|
||||
"p2pkNoImportedKeys": "没有已导入的密钥",
|
||||
"p2pkShowQR": "显示二维码",
|
||||
"p2pkCopy": "复制",
|
||||
"p2pkImportNsec": "导入nsec",
|
||||
"p2pkImport": "导入",
|
||||
"p2pkEnterLabel": "此密钥的名称",
|
||||
"p2pkLockToKey": "P2PK签名发送",
|
||||
"p2pkLockDescription": "只有接收者可以领取",
|
||||
"p2pkReceiverPubkey": "npub1... 或 hex(64/66字符)",
|
||||
"p2pkInvalidPubkey": "无效的公钥",
|
||||
"p2pkInvalidPrivateKey": "无效的私钥",
|
||||
"p2pkLockedToYou": "已锁定给您",
|
||||
"p2pkLockedToOther": "已锁定给其他密钥",
|
||||
"p2pkCannotUnlock": "您没有解锁此代币的密钥",
|
||||
"p2pkEnterPrivateKey": "输入私钥(nsec)",
|
||||
"p2pkDeleteTitle": "删除密钥",
|
||||
"p2pkDeleteConfirm": "删除此密钥?您将无法接收锁定给它的代币。",
|
||||
"p2pkRequiresConnection": "P2PK需要连接到铸造厂",
|
||||
"p2pkErrorMaxKeysReached": "已达到导入密钥的最大数量(10)",
|
||||
"p2pkErrorInvalidNsec": "无效的nsec",
|
||||
"p2pkErrorKeyAlreadyExists": "此密钥已存在",
|
||||
"p2pkErrorKeyNotFound": "未找到密钥",
|
||||
"p2pkErrorCannotDeletePrimary": "无法删除主密钥",
|
||||
"p2pkSendComingSoon": "即将推出"
|
||||
}
|
||||
|
||||
+12
-1
@@ -3,11 +3,13 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/wallet_provider.dart';
|
||||
import 'providers/settings_provider.dart';
|
||||
import 'providers/price_provider.dart';
|
||||
import 'providers/p2pk_provider.dart';
|
||||
import 'widgets/effects/cashu_confetti.dart';
|
||||
import 'screens/1_splash/splash_screen.dart';
|
||||
|
||||
void main() async {
|
||||
@@ -40,6 +42,7 @@ void main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => SettingsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => WalletProvider()),
|
||||
ChangeNotifierProvider(create: (_) => P2PKProvider()),
|
||||
ChangeNotifierProvider.value(value: priceProvider),
|
||||
],
|
||||
child: const ElCajuApp(),
|
||||
@@ -81,6 +84,14 @@ class ElCajuApp extends StatelessWidget {
|
||||
],
|
||||
locale: Locale(settingsProvider.locale),
|
||||
|
||||
builder: (context, child) {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
return CashuConfetti(
|
||||
controller: walletProvider.confettiController,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/// Modelo de clave P2PK
|
||||
///
|
||||
/// Representa una clave pública/privada para bloquear/desbloquear tokens Cashu
|
||||
library;
|
||||
|
||||
import '../core/utils/nostr_utils.dart';
|
||||
|
||||
class P2PKKey {
|
||||
/// ID único de la clave
|
||||
final String id;
|
||||
|
||||
/// Clave pública en formato hex (64 caracteres)
|
||||
final String publicKey;
|
||||
|
||||
/// Clave privada en formato hex (64 caracteres)
|
||||
final String privateKey;
|
||||
|
||||
/// true si fue derivada del mnemonic (NIP-06)
|
||||
final bool isDerived;
|
||||
|
||||
/// Etiqueta para identificar la clave (ej: "Principal", "Mi Nostr")
|
||||
final String label;
|
||||
|
||||
/// Fecha de creación
|
||||
final DateTime createdAt;
|
||||
|
||||
const P2PKKey({
|
||||
required this.id,
|
||||
required this.publicKey,
|
||||
required this.privateKey,
|
||||
required this.isDerived,
|
||||
required this.label,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
// ============ GETTERS PARA FORMATOS NOSTR ============
|
||||
|
||||
/// Clave pública en formato npub (Nostr)
|
||||
String get npub => NostrUtils.hexToNpub(publicKey);
|
||||
|
||||
/// Clave privada en formato nsec (Nostr)
|
||||
String get nsec => NostrUtils.hexToNsec(privateKey);
|
||||
|
||||
// ============ SERIALIZACIÓN ============
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'publicKey': publicKey,
|
||||
'privateKey': privateKey,
|
||||
'isDerived': isDerived,
|
||||
'label': label,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
factory P2PKKey.fromJson(Map<String, dynamic> json) => P2PKKey(
|
||||
id: json['id'] as String,
|
||||
publicKey: json['publicKey'] as String,
|
||||
privateKey: json['privateKey'] as String,
|
||||
isDerived: json['isDerived'] as bool,
|
||||
label: json['label'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
// ============ UTILIDADES ============
|
||||
|
||||
P2PKKey copyWith({String? label}) => P2PKKey(
|
||||
id: id,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
isDerived: isDerived,
|
||||
label: label ?? this.label,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => 'P2PKKey(id: $id, label: $label, isDerived: $isDerived)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is P2PKKey &&
|
||||
runtimeType == other.runtimeType &&
|
||||
publicKey == other.publicKey;
|
||||
|
||||
@override
|
||||
int get hashCode => publicKey.hashCode;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/// Provider para gestión de claves P2PK
|
||||
///
|
||||
/// Funcionalidades:
|
||||
/// - Derivar clave principal desde mnemonic (NIP-06)
|
||||
/// - Importar claves adicionales via nsec
|
||||
/// - Detectar tokens P2PK bloqueados
|
||||
/// - Verificar si podemos desbloquear un token
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:bip32/bip32.dart' as bip32;
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/p2pk_key.dart';
|
||||
import '../core/utils/nostr_utils.dart';
|
||||
import '../core/utils/p2pk_utils.dart';
|
||||
|
||||
/// Códigos de error para operaciones P2PK
|
||||
enum P2PKErrorCode {
|
||||
maxKeysReached,
|
||||
invalidNsec,
|
||||
keyAlreadyExists,
|
||||
keyNotFound,
|
||||
cannotDeletePrimaryKey,
|
||||
}
|
||||
|
||||
/// Excepción tipada para errores P2PK (traducir en UI con L10n)
|
||||
class P2PKException implements Exception {
|
||||
final P2PKErrorCode code;
|
||||
|
||||
const P2PKException(this.code);
|
||||
|
||||
@override
|
||||
String toString() => 'P2PKException: $code';
|
||||
}
|
||||
|
||||
class P2PKProvider extends ChangeNotifier {
|
||||
static const _storageKey = 'p2pk_keys';
|
||||
static const _maxImportedKeys = 10;
|
||||
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
|
||||
List<P2PKKey> _keys = [];
|
||||
P2PKKey? _primaryKey;
|
||||
bool _isInitialized = false;
|
||||
|
||||
P2PKProvider({FlutterSecureStorage? secureStorage})
|
||||
: _secureStorage = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
// ============ GETTERS ============
|
||||
|
||||
/// Todas las claves (principal + importadas)
|
||||
List<P2PKKey> get keys => List.unmodifiable(_keys);
|
||||
|
||||
/// Clave principal derivada del mnemonic
|
||||
P2PKKey? get primaryKey => _primaryKey;
|
||||
|
||||
/// Solo claves importadas (no derivadas)
|
||||
List<P2PKKey> get importedKeys =>
|
||||
_keys.where((k) => !k.isDerived).toList();
|
||||
|
||||
/// Si el provider está inicializado
|
||||
bool get isInitialized => _isInitialized;
|
||||
|
||||
/// Cantidad de claves importadas
|
||||
int get importedCount => importedKeys.length;
|
||||
|
||||
/// Si se pueden importar más claves
|
||||
bool get canImportMore => importedCount < _maxImportedKeys;
|
||||
|
||||
// ============ INICIALIZACIÓN ============
|
||||
|
||||
/// Inicializa el provider derivando la clave principal del mnemonic
|
||||
Future<void> initialize(String mnemonic) async {
|
||||
// Cargar claves guardadas
|
||||
await _loadKeys();
|
||||
|
||||
// Siempre derivar para comparar con la almacenada
|
||||
final derived = await _deriveFromMnemonic(mnemonic);
|
||||
|
||||
if (_primaryKey == null) {
|
||||
// Primera vez: guardar clave derivada
|
||||
_primaryKey = derived;
|
||||
_keys.insert(0, _primaryKey!);
|
||||
await _saveKeys();
|
||||
} else if (_primaryKey!.publicKey != derived.publicKey) {
|
||||
// Mnemonic cambió (ej: wallet restore) — reemplazar clave primaria
|
||||
_keys.removeWhere((k) => k.isDerived);
|
||||
_primaryKey = derived;
|
||||
_keys.insert(0, _primaryKey!);
|
||||
await _saveKeys();
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Inicializado con ${_keys.length} claves');
|
||||
debugPrint('[P2PKProvider] Clave principal: ${_primaryKey?.npub.substring(0, 20)}...');
|
||||
}
|
||||
|
||||
// ============ GESTIÓN DE CLAVES ============
|
||||
|
||||
/// Importa una clave desde nsec
|
||||
Future<P2PKKey> importFromNsec(String nsec, String label) async {
|
||||
if (!canImportMore) {
|
||||
throw const P2PKException(P2PKErrorCode.maxKeysReached);
|
||||
}
|
||||
|
||||
// Limpiar input
|
||||
String cleanNsec = nsec.trim();
|
||||
if (cleanNsec.startsWith('nostr:')) {
|
||||
cleanNsec = cleanNsec.substring(6);
|
||||
}
|
||||
|
||||
final privateKeyHex = NostrUtils.nsecToHex(cleanNsec);
|
||||
if (privateKeyHex == null) {
|
||||
throw const P2PKException(P2PKErrorCode.invalidNsec);
|
||||
}
|
||||
|
||||
// Obtener pubkey usando cdk-flutter
|
||||
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
|
||||
|
||||
// Verificar que no exista ya
|
||||
if (_keys.any((k) => k.publicKey == publicKeyHex)) {
|
||||
throw const P2PKException(P2PKErrorCode.keyAlreadyExists);
|
||||
}
|
||||
|
||||
final key = P2PKKey(
|
||||
id: const Uuid().v4(),
|
||||
publicKey: publicKeyHex,
|
||||
privateKey: privateKeyHex,
|
||||
isDerived: false,
|
||||
label: label.isEmpty ? 'Importada ${importedCount + 1}' : label,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
_keys.add(key);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Clave importada: ${key.npub.substring(0, 20)}...');
|
||||
return key;
|
||||
}
|
||||
|
||||
/// Elimina una clave importada (no la principal)
|
||||
Future<void> removeKey(String keyId) async {
|
||||
final key = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k?.id == keyId,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
if (key == null) {
|
||||
throw const P2PKException(P2PKErrorCode.keyNotFound);
|
||||
}
|
||||
|
||||
if (key.isDerived) {
|
||||
throw const P2PKException(P2PKErrorCode.cannotDeletePrimaryKey);
|
||||
}
|
||||
|
||||
_keys.removeWhere((k) => k.id == keyId);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
|
||||
debugPrint('[P2PKProvider] Clave eliminada: $keyId');
|
||||
}
|
||||
|
||||
/// Actualiza el label de una clave
|
||||
Future<void> updateLabel(String keyId, String newLabel) async {
|
||||
final index = _keys.indexWhere((k) => k.id == keyId);
|
||||
if (index < 0) return;
|
||||
|
||||
_keys[index] = _keys[index].copyWith(label: newLabel);
|
||||
await _saveKeys();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ============ DETECCIÓN DE TOKENS P2PK ============
|
||||
|
||||
/// Verifica si un token está bloqueado con P2PK
|
||||
bool isTokenLocked(String encodedToken) {
|
||||
return P2PKUtils.isP2PKLocked(encodedToken);
|
||||
}
|
||||
|
||||
/// Verifica si un token está bloqueado para alguna de nuestras claves
|
||||
bool isTokenLockedToUs(String encodedToken) {
|
||||
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
if (lockedPubkey == null) return false;
|
||||
|
||||
// Normalizar a x-only (64 chars) para comparación
|
||||
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
|
||||
return _keys.any((k) => _normalizeToXOnly(k.publicKey) == normalizedLocked);
|
||||
}
|
||||
|
||||
/// Obtiene la clave privada para desbloquear un token
|
||||
/// Prueba todas las claves automáticamente
|
||||
String? getPrivateKeyForToken(String encodedToken) {
|
||||
final lockedPubkey = P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
if (lockedPubkey == null) return null;
|
||||
|
||||
// Normalizar a x-only (64 chars) para comparación
|
||||
final normalizedLocked = _normalizeToXOnly(lockedPubkey);
|
||||
final key = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k != null && _normalizeToXOnly(k.publicKey) == normalizedLocked,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return key?.privateKey;
|
||||
}
|
||||
|
||||
/// Normaliza pubkey a formato x-only (64 chars) para comparación
|
||||
/// Si es SEC1 (66 chars con prefijo 02/03), quita el prefijo
|
||||
String _normalizeToXOnly(String pubkey) {
|
||||
final lower = pubkey.toLowerCase();
|
||||
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
|
||||
return lower.substring(2);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
/// Extrae la pubkey bloqueada de un token
|
||||
String? extractLockedPubkey(String encodedToken) {
|
||||
return P2PKUtils.extractLockedPubkey(encodedToken);
|
||||
}
|
||||
|
||||
// ============ VALIDACIONES ============
|
||||
|
||||
/// Valida si un input es una pubkey válida (npub o hex)
|
||||
bool isValidPubkey(String input) {
|
||||
return NostrUtils.isValidPubkey(input);
|
||||
}
|
||||
|
||||
/// Normaliza input (npub/hex/nostr:npub) a hex
|
||||
String? normalizeToHex(String input) {
|
||||
return NostrUtils.normalizeToHex(input);
|
||||
}
|
||||
|
||||
// ============ MÉTODOS PRIVADOS ============
|
||||
|
||||
/// Deriva la clave principal del mnemonic usando NIP-06
|
||||
/// Path: m/44'/1237'/0'/0/0
|
||||
Future<P2PKKey> _deriveFromMnemonic(String mnemonic) async {
|
||||
// 1. Mnemonic -> Seed (64 bytes) usando cdk-flutter
|
||||
final seed = cdk.mnemonicToSeed(mnemonic: mnemonic);
|
||||
|
||||
// 2. Seed -> BIP32 root
|
||||
final root = bip32.BIP32.fromSeed(seed);
|
||||
|
||||
// 3. Derivar con path NIP-06: m/44'/1237'/0'/0/0
|
||||
final child = root.derivePath("m/44'/1237'/0'/0/0");
|
||||
|
||||
// 4. Obtener private key hex
|
||||
final privateKeyHex = _bytesToHex(child.privateKey!);
|
||||
|
||||
// 5. Obtener public key usando cdk-flutter
|
||||
final publicKeyHex = cdk.getPubKey(secret: privateKeyHex);
|
||||
|
||||
return P2PKKey(
|
||||
id: 'primary',
|
||||
publicKey: publicKeyHex,
|
||||
privateKey: privateKeyHex,
|
||||
isDerived: true,
|
||||
label: 'Principal',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadKeys() async {
|
||||
try {
|
||||
final json = await _secureStorage.read(key: _storageKey);
|
||||
if (json != null) {
|
||||
final List<dynamic> list = jsonDecode(json);
|
||||
_keys = list.map((e) => P2PKKey.fromJson(e as Map<String, dynamic>)).toList();
|
||||
_primaryKey = _keys.cast<P2PKKey?>().firstWhere(
|
||||
(k) => k?.isDerived == true,
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[P2PKProvider] Error cargando claves: $e');
|
||||
_keys = [];
|
||||
_primaryKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveKeys() async {
|
||||
final json = jsonEncode(_keys.map((k) => k.toJson()).toList());
|
||||
await _secureStorage.write(key: _storageKey, value: json);
|
||||
}
|
||||
|
||||
String _bytesToHex(List<int> bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// Limpia todas las claves (usado al borrar wallet)
|
||||
Future<void> clear() async {
|
||||
_keys = [];
|
||||
_primaryKey = null;
|
||||
_isInitialized = false;
|
||||
await _secureStorage.delete(key: _storageKey);
|
||||
notifyListeners();
|
||||
debugPrint('[P2PKProvider] Claves eliminadas');
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -10,6 +11,8 @@ 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 {
|
||||
@@ -39,6 +42,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// 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();
|
||||
|
||||
@@ -67,6 +73,7 @@ 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';
|
||||
|
||||
/// Mint de Cuba Bitcoin - siempre aparece primero en la lista
|
||||
static const cubaBitcoinMint = 'https://mint.cubabitcoin.org';
|
||||
@@ -784,8 +791,9 @@ class WalletProvider extends ChangeNotifier {
|
||||
/// 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) async {
|
||||
Future<BigInt> receiveToken(String encodedToken, {String? p2pkPrivateKey}) async {
|
||||
// Parsear token (incluye detección de unidad)
|
||||
final tokenInfo = parseToken(encodedToken);
|
||||
if (tokenInfo == null) {
|
||||
@@ -802,13 +810,13 @@ class WalletProvider extends ChangeNotifier {
|
||||
// Intentar detectar unidad de nuevo ahora que tenemos keysets
|
||||
final detectedUnit = detectTokenUnit(encodedToken);
|
||||
if (detectedUnit != null) {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, detectedUnit, p2pkPrivateKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Si tenemos unidad detectada, usarla
|
||||
if (tokenInfo.unit != null) {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, tokenInfo.unit!, p2pkPrivateKey);
|
||||
}
|
||||
|
||||
// Fallback: intentar con cada unidad del mint hasta que funcione
|
||||
@@ -817,7 +825,7 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
for (final unit in units) {
|
||||
try {
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, unit);
|
||||
return await _receiveWithUnit(encodedToken, tokenMint, unit, p2pkPrivateKey);
|
||||
} catch (e) {
|
||||
lastError = e as Exception;
|
||||
debugPrint('Receive falló con unidad $unit: $e');
|
||||
@@ -835,19 +843,65 @@ class WalletProvider extends ChangeNotifier {
|
||||
String encodedToken,
|
||||
String mintUrl,
|
||||
String unit,
|
||||
String? p2pkPrivateKey,
|
||||
) async {
|
||||
final wallet = await getWallet(mintUrl, unit);
|
||||
final token = Token.parse(encoded: encodedToken);
|
||||
final amount = await wallet.receive(token: token);
|
||||
|
||||
// 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 {
|
||||
@@ -873,6 +927,7 @@ class WalletProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -882,20 +937,14 @@ class WalletProvider extends ChangeNotifier {
|
||||
throw Exception('Token inválido');
|
||||
}
|
||||
|
||||
// Cambiar al mint del token
|
||||
// Agregar mint si no existe
|
||||
if (!_mintUnits.containsKey(tokenInfo.mintUrl)) {
|
||||
await addMint(tokenInfo.mintUrl);
|
||||
}
|
||||
|
||||
_activeMintUrl = tokenInfo.mintUrl;
|
||||
|
||||
// Verificar si la unidad activa es soportada
|
||||
final units = _mintUnits[tokenInfo.mintUrl]!;
|
||||
if (!units.contains(_activeUnit)) {
|
||||
_activeUnit = units.first;
|
||||
}
|
||||
|
||||
final wallet = await getWallet(tokenInfo.mintUrl, _activeUnit);
|
||||
// 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(
|
||||
@@ -980,52 +1029,266 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
/// Método de conveniencia: prepara y confirma en un solo paso.
|
||||
Future<String> sendTokens(BigInt amount, String? memo) async {
|
||||
final wallet = await getActiveWallet();
|
||||
final balanceBefore = await wallet.balance();
|
||||
debugPrint('[SEND DEBUG] Normal send - balance=$balanceBefore, amount=$amount');
|
||||
await KeysetDebug.logCounters('BEFORE normal send');
|
||||
final prepared = await prepareSend(amount);
|
||||
return await confirmSend(prepared, memo);
|
||||
debugPrint('[SEND DEBUG] prepareSend OK - fee=${prepared.fee}');
|
||||
final token = await confirmSend(prepared, memo);
|
||||
final balanceAfter = await wallet.balance();
|
||||
debugPrint('[SEND DEBUG] confirmSend OK - balance after=$balanceAfter');
|
||||
await KeysetDebug.logCounters('AFTER normal send');
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Envía tokens P2PK.
|
||||
Future<String> sendTokensP2pk(
|
||||
BigInt amount,
|
||||
String pubkey,
|
||||
String? memo,
|
||||
) async {
|
||||
/// Verifica si el mint activo soporta P2PK sin el bug de fees.
|
||||
/// Bug CDK: prepare_send con P2PK (force_swap=true) no reserva proofs
|
||||
/// para el swap fee. Solo mints con ppk=0 funcionan correctamente.
|
||||
/// Ver P2PK_SEND_BUG.md para análisis completo.
|
||||
Future<void> _checkP2pkMintCompatibility() async {
|
||||
final mintUrl = _activeMintUrl!;
|
||||
final unit = _activeUnit;
|
||||
final ppk = await KeysetDebug.getInputFeePpk(mintUrl, unit);
|
||||
|
||||
debugPrint('[P2PK] mintUrl=$mintUrl, unit=$unit, ppk=$ppk');
|
||||
|
||||
if (ppk > 0) {
|
||||
throw Exception(
|
||||
'P2PK no disponible en este mint. '
|
||||
'El mint $mintUrl cobra input fees (ppk=$ppk) y CDK tiene un bug '
|
||||
'que impide P2PK sends en mints con fees. '
|
||||
'Usa un mint con ppk=0 (ej: mint.cubabitcoin.org).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Envía tokens P2PK (bloqueados a una clave pública).
|
||||
/// Solo funciona en mints con ppk=0 debido a bug en CDK core.
|
||||
/// Ver P2PK_SEND_BUG.md para detalles.
|
||||
Future<String> sendTokensP2pk(BigInt amount, String pubkey, String? memo) async {
|
||||
final wallet = await getActiveWallet();
|
||||
|
||||
// Verificar compatibilidad del mint con P2PK
|
||||
await _checkP2pkMintCompatibility();
|
||||
|
||||
// DEBUG: estado antes del P2PK send
|
||||
final balanceBefore = await wallet.balance();
|
||||
debugPrint('[P2PK DEBUG] ===== BEFORE P2PK SEND =====');
|
||||
debugPrint('[P2PK DEBUG] Balance: $balanceBefore');
|
||||
debugPrint('[P2PK DEBUG] Amount to send: $amount');
|
||||
debugPrint('[P2PK DEBUG] Pubkey: ${pubkey.length > 16 ? pubkey.substring(0, 16) : pubkey}...');
|
||||
|
||||
// DEBUG: counters antes de prepareSend
|
||||
await KeysetDebug.logCounters('BEFORE P2PK prepareSend');
|
||||
|
||||
// Paso 1: prepareSend P2PK
|
||||
debugPrint('[P2PK DEBUG] Calling prepareSendP2pk...');
|
||||
final prepared = await prepareSendP2pk(amount, pubkey);
|
||||
return await confirmSend(prepared, memo);
|
||||
debugPrint('[P2PK DEBUG] prepareSendP2pk OK - fee=${prepared.fee}');
|
||||
|
||||
final balanceAfterPrepare = await wallet.balance();
|
||||
debugPrint('[P2PK DEBUG] Balance after prepare: $balanceAfterPrepare');
|
||||
|
||||
// DEBUG: counters después de prepareSend
|
||||
await KeysetDebug.logCounters('AFTER P2PK prepareSend');
|
||||
|
||||
// Paso 2: confirmSend
|
||||
debugPrint('[P2PK DEBUG] Calling confirmSend...');
|
||||
try {
|
||||
final token = await confirmSend(prepared, memo);
|
||||
final balanceAfterSend = await wallet.balance();
|
||||
debugPrint('[P2PK DEBUG] confirmSend OK!');
|
||||
debugPrint('[P2PK DEBUG] Balance after send: $balanceAfterSend');
|
||||
|
||||
// DEBUG: counters después de send exitoso
|
||||
await KeysetDebug.logCounters('AFTER P2PK send OK');
|
||||
|
||||
return token;
|
||||
} catch (e) {
|
||||
final balanceAfterError = await wallet.balance();
|
||||
debugPrint('[P2PK DEBUG] confirmSend FAILED: $e');
|
||||
debugPrint('[P2PK DEBUG] Balance after error: $balanceAfterError');
|
||||
|
||||
// DEBUG: counters después de send fallido
|
||||
await KeysetDebug.logCounters('AFTER P2PK send FAILED');
|
||||
|
||||
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.
|
||||
/// Guarda metadata type=lightning cuando se completa.
|
||||
Stream<MintQuote> mintTokens(BigInt amount, String? description) {
|
||||
/// 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;
|
||||
|
||||
// Wrapper del stream para capturar el invoice y guardar metadata
|
||||
return wallet.mint(
|
||||
// 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,
|
||||
).map((quote) {
|
||||
// Capturar el invoice cuando está en estado unpaid
|
||||
if (quote.state == MintQuoteState.unpaid) {
|
||||
invoiceBolt11 = quote.request;
|
||||
).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);
|
||||
}
|
||||
}
|
||||
|
||||
// Cuando se completa, guardar metadata
|
||||
if (quote.state == MintQuoteState.issued && invoiceBolt11 != null) {
|
||||
_saveMintMetadata(wallet, invoiceBolt11!);
|
||||
// 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 quote;
|
||||
});
|
||||
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).
|
||||
@@ -1046,6 +1309,8 @@ class WalletProvider extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
debugPrint('Mint metadata guardada para tx ${recentTx.id}');
|
||||
confettiController.fire();
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error guardando mint metadata: $e');
|
||||
@@ -1143,8 +1408,20 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
/// 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) {
|
||||
return _txMetaStorage.getType(tx.id, tx.metadata);
|
||||
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.
|
||||
@@ -1163,6 +1440,7 @@ class WalletProvider extends ChangeNotifier {
|
||||
|
||||
/// 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 {
|
||||
for (final wallet in _wallets.values) {
|
||||
try {
|
||||
@@ -1172,6 +1450,59 @@ class WalletProvider extends ChangeNotifier {
|
||||
debugPrint('Check pending failed: $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.
|
||||
@@ -1507,4 +1838,14 @@ class WalletProvider extends ChangeNotifier {
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_activeMintSubscription?.cancel();
|
||||
if (_activeMintController != null && !_activeMintController!.isClosed) {
|
||||
_activeMintController!.close();
|
||||
}
|
||||
confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart';
|
||||
import '../../widgets/scanner/qr_scanner_widget.dart';
|
||||
|
||||
@@ -2,12 +2,13 @@ import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../2_onboarding/welcome_screen.dart';
|
||||
import '../3_home/home_screen.dart';
|
||||
|
||||
@@ -78,6 +79,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
try {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
// Inicializar settings (cargar preferencias)
|
||||
await settingsProvider.initialize();
|
||||
@@ -95,6 +97,14 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
if (mnemonic != null && mnemonic.isNotEmpty) {
|
||||
await walletProvider.initialize(mnemonic);
|
||||
|
||||
// Inicializar P2PK (derivar clave principal del mnemonic)
|
||||
// Aislado: P2PK es secundario, su fallo no debe bloquear la wallet
|
||||
try {
|
||||
await p2pkProvider.initialize(mnemonic);
|
||||
} catch (e) {
|
||||
debugPrint('[SplashScreen] Error initializing P2PK (non-fatal): $e');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Restaurar mint y unidad activa desde settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -10,6 +10,7 @@ import '../../widgets/common/secondary_button.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import 'backup_seed_screen.dart';
|
||||
import '../3_home/home_screen.dart';
|
||||
|
||||
@@ -36,6 +37,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
try {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
// Generar mnemonic real (12 palabras BIP39)
|
||||
_mnemonic = walletProvider.generateNewMnemonic();
|
||||
@@ -46,6 +48,13 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
|
||||
// Inicializar wallet con el mnemonic
|
||||
await walletProvider.initialize(_mnemonic!);
|
||||
|
||||
// Inicializar P2PK (derivar clave principal del mnemonic)
|
||||
try {
|
||||
await p2pkProvider.initialize(_mnemonic!);
|
||||
} catch (e) {
|
||||
debugPrint('[CreateWalletScreen] Error initializing P2PK (non-fatal): $e');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCreating = false;
|
||||
_walletCreated = true;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
@@ -9,6 +9,7 @@ import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../3_home/home_screen.dart';
|
||||
|
||||
/// Pantalla de restauración de wallet
|
||||
@@ -24,6 +25,7 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isRestoring = false;
|
||||
String? _errorMessage;
|
||||
String? _statusMessage;
|
||||
|
||||
int get _wordCount {
|
||||
final text = _seedController.text.trim();
|
||||
@@ -55,16 +57,40 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
});
|
||||
|
||||
try {
|
||||
final mnemonic = _seedController.text.trim().toLowerCase();
|
||||
final mnemonic =
|
||||
_seedController.text.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
// Guardar mnemonic de forma segura
|
||||
// Inicializar wallet primero (valida el mnemonic internamente).
|
||||
// Si es inválido, cdk_flutter lanzará una excepción antes de persistir.
|
||||
await walletProvider.initialize(mnemonic);
|
||||
|
||||
// Solo guardar mnemonic si initialize() pasó sin error
|
||||
await settingsProvider.saveMnemonic(mnemonic);
|
||||
|
||||
// Inicializar wallet (esto valida el mnemonic internamente)
|
||||
// Si el mnemonic es inválido, cdk_flutter lanzará una excepción
|
||||
await walletProvider.initialize(mnemonic);
|
||||
// Inicializar P2PK (derivar clave principal del mnemonic)
|
||||
try {
|
||||
await p2pkProvider.initialize(mnemonic);
|
||||
} catch (e) {
|
||||
debugPrint('[RestoreWalletScreen] Error initializing P2PK (non-fatal): $e');
|
||||
}
|
||||
|
||||
// Escanear mint activo para recuperar tokens existentes (NUT-13)
|
||||
final activeMint = walletProvider.activeMintUrl;
|
||||
if (activeMint != null && mounted) {
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
_statusMessage = l10n.restoreScanningMint;
|
||||
});
|
||||
|
||||
try {
|
||||
await walletProvider.restoreFromMint(activeMint);
|
||||
} catch (e) {
|
||||
debugPrint('[RestoreWalletScreen] Error scanning mint (non-fatal): $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushAndRemoveUntil(
|
||||
@@ -74,9 +100,13 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Revertir el guardado del mnemonic si falló la inicialización
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
await settingsProvider.deleteWallet();
|
||||
// Limpiar cualquier estado parcial si algo falló
|
||||
try {
|
||||
final settingsProvider = context.read<SettingsProvider>();
|
||||
await settingsProvider.deleteWallet();
|
||||
} catch (cleanupError) {
|
||||
debugPrint('Error during restore cleanup: $cleanupError');
|
||||
}
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
@@ -251,6 +281,22 @@ class _RestoreWalletScreenState extends State<RestoreWalletScreen> {
|
||||
? _restoreWallet
|
||||
: null,
|
||||
),
|
||||
|
||||
if (_isRestoring && _statusMessage != null) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
_statusMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -9,7 +9,6 @@ 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/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../4_receive/receive_screen.dart';
|
||||
@@ -34,9 +33,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// Estado local
|
||||
bool _isBalanceVisible = true;
|
||||
|
||||
// Controller para el efecto confeti
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -46,11 +42,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
/// Dispara confetti global desde WalletProvider
|
||||
void _fireConfetti() => context.read<WalletProvider>().confettiController.fire();
|
||||
|
||||
/// Verifica y reclama automáticamente tokens pendientes
|
||||
Future<void> _checkPendingTokens() async {
|
||||
@@ -64,9 +57,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final unit = (result['unit'] as String?) ?? walletProvider.activeUnit;
|
||||
|
||||
if (claimed > 0 && mounted) {
|
||||
// Disparar confetti
|
||||
_confettiController.fire();
|
||||
|
||||
// Confetti se dispara globalmente desde WalletProvider.receiveToken
|
||||
// Mostrar snackbar
|
||||
final l10n = L10n.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -84,9 +75,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CashuConfetti(
|
||||
controller: _confettiController,
|
||||
child: GradientBackground(
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
@@ -122,7 +111,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -161,7 +149,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
// El Caju (derecha) - toca para confeti
|
||||
GestureDetector(
|
||||
onTap: () => _confettiController.fire(),
|
||||
onTap: () => _fireConfetti(),
|
||||
child: Image.asset(
|
||||
'assets/img/elcajucubano.png',
|
||||
width: 56,
|
||||
|
||||
@@ -2,16 +2,17 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
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/incoming_data_parser.dart' hide TokenInfo;
|
||||
import '../../core/utils/nostr_utils.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../10_scanner/scan_screen.dart';
|
||||
|
||||
/// Pantalla para recibir tokens Cashu
|
||||
@@ -27,8 +28,6 @@ class ReceiveScreen extends StatefulWidget {
|
||||
|
||||
class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
final TextEditingController _tokenController = TextEditingController();
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
// Estado del token
|
||||
bool _isValidToken = false;
|
||||
bool _isProcessing = false;
|
||||
@@ -38,6 +37,15 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
TokenInfo? _tokenInfo;
|
||||
String? _errorMessage;
|
||||
|
||||
// Estado P2PK
|
||||
bool _isP2PKLocked = false;
|
||||
bool _isLockedToUs = false;
|
||||
String? _lockedToPubkeyHex;
|
||||
String? _matchingKeyLabel; // Label de nuestra clave si coincide
|
||||
final TextEditingController _manualKeyController = TextEditingController();
|
||||
bool _showManualKey = false; // Toggle para mostrar/ocultar nsec
|
||||
String? _manualKeyError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -53,15 +61,13 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_tokenController.dispose();
|
||||
_confettiController.dispose();
|
||||
_manualKeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CashuConfetti(
|
||||
controller: _confettiController,
|
||||
child: GradientBackground(
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
@@ -84,7 +90,6 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
child: _showSuccess ? _buildSuccessView() : _buildReceiveForm(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +129,12 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
// Preview del token (si es válido)
|
||||
if (_isValidToken && _tokenInfo != null) _buildTokenPreview(),
|
||||
|
||||
// Indicador P2PK (si el token está bloqueado)
|
||||
if (_isValidToken && _isP2PKLocked) ...[
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
_buildP2PKIndicator(),
|
||||
],
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
@@ -457,12 +468,312 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Widget que muestra el estado P2PK del token
|
||||
Widget _buildP2PKIndicator() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// Convertir pubkey hex a npub para mostrar
|
||||
String? npubDisplay;
|
||||
if (_lockedToPubkeyHex != null) {
|
||||
try {
|
||||
npubDisplay = NostrUtils.hexToNpub(_lockedToPubkeyHex!);
|
||||
} catch (_) {
|
||||
npubDisplay = _lockedToPubkeyHex; // Fallback a hex si falla conversión
|
||||
}
|
||||
}
|
||||
|
||||
// Truncar npub para display: npub1abc...xyz
|
||||
String truncatedNpub = '';
|
||||
if (npubDisplay != null && npubDisplay.length > 20) {
|
||||
truncatedNpub = '${npubDisplay.substring(0, 12)}...${npubDisplay.substring(npubDisplay.length - 6)}';
|
||||
} else {
|
||||
truncatedNpub = npubDisplay ?? '';
|
||||
}
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Encabezado con icono y estado
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: _isLockedToUs
|
||||
? AppColors.success.withValues(alpha: 0.2)
|
||||
: AppColors.warning.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isLockedToUs ? LucideIcons.unlock : LucideIcons.lock,
|
||||
color: _isLockedToUs ? AppColors.success : AppColors.warning,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_isLockedToUs ? l10n.p2pkLockedToYou : l10n.p2pkLockedToOther,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isLockedToUs ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
),
|
||||
if (_isLockedToUs && _matchingKeyLabel != null)
|
||||
Text(
|
||||
_matchingKeyLabel!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Badge experimental
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.flaskConical,
|
||||
size: 12,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.p2pkExperimentalShort,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Pubkey (npub truncado) - tap para copiar
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (npubDisplay != null) {
|
||||
Clipboard.setData(ClipboardData(text: npubDisplay));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.copied('npub')),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
truncatedNpub,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
size: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Campo nsec manual (solo si NO es nuestra clave)
|
||||
if (!_isLockedToUs) ...[
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Mensaje de error si no puede desbloquear
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.alertTriangle,
|
||||
size: 16,
|
||||
color: AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.p2pkCannotUnlock,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Campo para ingresar nsec manualmente
|
||||
Text(
|
||||
l10n.p2pkEnterPrivateKey,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _manualKeyError != null
|
||||
? AppColors.error.withValues(alpha: 0.5)
|
||||
: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _manualKeyController,
|
||||
obscureText: !_showManualKey,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'nsec1... o hex',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
setState(() {
|
||||
_manualKeyError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
// Botón mostrar/ocultar
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_showManualKey ? LucideIcons.eyeOff : LucideIcons.eye,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showManualKey = !_showManualKey;
|
||||
});
|
||||
},
|
||||
),
|
||||
// Botón pegar
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.clipboard,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData?.text != null) {
|
||||
_manualKeyController.text = clipboardData!.text!.trim();
|
||||
setState(() {
|
||||
_manualKeyError = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Error de clave manual
|
||||
if (_manualKeyError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_manualKeyError!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReceiveButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Botón único "Recibir" - auto-detecta conectividad
|
||||
|
||||
// Determinar si el botón debe estar habilitado
|
||||
bool canReceive = _isValidToken && !_isProcessing;
|
||||
|
||||
// Si es P2PK y NO es nuestra clave, necesita nsec manual válido
|
||||
if (_isP2PKLocked && !_isLockedToUs) {
|
||||
final manualKey = _manualKeyController.text.trim();
|
||||
canReceive = canReceive && manualKey.isNotEmpty;
|
||||
}
|
||||
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? l10n.claiming : l10n.receive,
|
||||
onPressed: _isValidToken && !_isProcessing ? _receiveToken : null,
|
||||
onPressed: canReceive ? _receiveToken : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -476,9 +787,17 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
|
||||
void _onTokenChanged(String value) {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_manualKeyError = null;
|
||||
|
||||
// Reset P2PK state
|
||||
_isP2PKLocked = false;
|
||||
_isLockedToUs = false;
|
||||
_lockedToPubkeyHex = null;
|
||||
_matchingKeyLabel = null;
|
||||
|
||||
if (value.isEmpty) {
|
||||
_isValidToken = false;
|
||||
@@ -492,6 +811,18 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
if (tokenInfo != null) {
|
||||
_isValidToken = true;
|
||||
_tokenInfo = tokenInfo;
|
||||
|
||||
// Detectar P2PK
|
||||
_isP2PKLocked = p2pkProvider.isTokenLocked(value.trim());
|
||||
if (_isP2PKLocked) {
|
||||
_lockedToPubkeyHex = p2pkProvider.extractLockedPubkey(value.trim());
|
||||
_isLockedToUs = p2pkProvider.isTokenLockedToUs(value.trim());
|
||||
|
||||
// Si es nuestra, buscar el label de la clave
|
||||
if (_isLockedToUs && _lockedToPubkeyHex != null) {
|
||||
_matchingKeyLabel = _findMatchingKeyLabel(p2pkProvider);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_isValidToken = false;
|
||||
_tokenInfo = null;
|
||||
@@ -500,6 +831,31 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Busca el label de la clave que coincide con el token P2PK
|
||||
String? _findMatchingKeyLabel(P2PKProvider p2pkProvider) {
|
||||
if (_lockedToPubkeyHex == null) return null;
|
||||
|
||||
// Normalizar a x-only para comparar
|
||||
final lockedNormalized = _normalizeToXOnly(_lockedToPubkeyHex!);
|
||||
|
||||
for (final key in p2pkProvider.keys) {
|
||||
final keyNormalized = _normalizeToXOnly(key.publicKey);
|
||||
if (keyNormalized == lockedNormalized) {
|
||||
return key.label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Normaliza pubkey a x-only (64 chars) quitando prefijo SEC1 si existe
|
||||
String _normalizeToXOnly(String pubkey) {
|
||||
final lower = pubkey.toLowerCase();
|
||||
if (lower.length == 66 && (lower.startsWith('02') || lower.startsWith('03'))) {
|
||||
return lower.substring(2);
|
||||
}
|
||||
return lower;
|
||||
}
|
||||
|
||||
/// Método principal: recibe token con detección automática de conectividad.
|
||||
/// 1. Si el mint es desconocido y no hay conexión → rechazar
|
||||
/// 2. Ping al mint (3s) → si OK → claim → si falla → guardar pendiente
|
||||
@@ -605,15 +961,56 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
/// Reclama el token directamente (cuando hay conexión).
|
||||
Future<void> _claimToken() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
final p2pkProvider = context.read<P2PKProvider>();
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// Guardar unidad detectada del token ANTES de reclamar
|
||||
final detectedUnit = _tokenInfo?.unit ?? walletProvider.activeUnit;
|
||||
final token = _tokenController.text.trim();
|
||||
|
||||
try {
|
||||
// Reclamar token (usa unidad detectada internamente)
|
||||
final amountReceived = await walletProvider.receiveToken(
|
||||
_tokenController.text.trim(),
|
||||
);
|
||||
BigInt amountReceived;
|
||||
|
||||
if (_isP2PKLocked) {
|
||||
// Token P2PK - necesita clave privada para desbloquear
|
||||
String? privateKeyHex;
|
||||
|
||||
if (_isLockedToUs) {
|
||||
// Es nuestra clave - obtener automáticamente
|
||||
privateKeyHex = p2pkProvider.getPrivateKeyForToken(token);
|
||||
if (privateKeyHex == null) {
|
||||
throw Exception(l10n.p2pkCannotUnlock);
|
||||
}
|
||||
} else {
|
||||
// No es nuestra - usar clave manual
|
||||
final manualInput = _manualKeyController.text.trim();
|
||||
|
||||
// Intentar convertir nsec a hex
|
||||
if (manualInput.startsWith('nsec1')) {
|
||||
privateKeyHex = NostrUtils.nsecToHex(manualInput);
|
||||
} else if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(manualInput)) {
|
||||
// Ya es hex de 64 caracteres
|
||||
privateKeyHex = manualInput.toLowerCase();
|
||||
}
|
||||
|
||||
if (privateKeyHex == null) {
|
||||
setState(() {
|
||||
_manualKeyError = l10n.p2pkInvalidPrivateKey;
|
||||
_isProcessing = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Reclamar token P2PK
|
||||
amountReceived = await walletProvider.receiveP2pkToken(
|
||||
token,
|
||||
[privateKeyHex],
|
||||
);
|
||||
} else {
|
||||
// Token normal - flujo existente
|
||||
amountReceived = await walletProvider.receiveToken(token);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -623,12 +1020,10 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
_isProcessing = false;
|
||||
});
|
||||
|
||||
// Disparar confetti
|
||||
_confettiController.fire();
|
||||
// Confetti se dispara globalmente desde WalletProvider.receiveToken
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l10n = L10n.of(context)!;
|
||||
// Mensajes de error más amigables
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
String errorMessage;
|
||||
@@ -636,6 +1031,8 @@ class _ReceiveScreenState extends State<ReceiveScreen> {
|
||||
errorMessage = l10n.tokenAlreadyClaimed;
|
||||
} else if (errorStr.contains('unknown mint') || errorStr.contains('mint not found')) {
|
||||
errorMessage = l10n.unknownMint;
|
||||
} else if (errorStr.contains('cannot unlock') || errorStr.contains('p2pk')) {
|
||||
errorMessage = l10n.p2pkCannotUnlock;
|
||||
} else {
|
||||
errorMessage = l10n.claimError(e.toString());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/models/proof.dart';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/common/primary_button.dart';
|
||||
import '../../widgets/common/numpad_widget.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
import '../../core/utils/nostr_utils.dart';
|
||||
import 'share_token_screen.dart';
|
||||
import 'offline_send_screen.dart';
|
||||
|
||||
@@ -24,6 +26,7 @@ class SendScreen extends StatefulWidget {
|
||||
|
||||
class _SendScreenState extends State<SendScreen> {
|
||||
final TextEditingController _memoController = TextEditingController();
|
||||
final TextEditingController _pubkeyController = TextEditingController();
|
||||
|
||||
String _amountValue = '';
|
||||
bool _isProcessing = false;
|
||||
@@ -31,6 +34,10 @@ class _SendScreenState extends State<SendScreen> {
|
||||
BigInt _availableBalance = BigInt.zero;
|
||||
late String _activeUnit;
|
||||
|
||||
// P2PK
|
||||
bool _useP2PK = false;
|
||||
String? _pubkeyError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -51,6 +58,7 @@ class _SendScreenState extends State<SendScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_memoController.dispose();
|
||||
_pubkeyController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -111,6 +119,11 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// P2PK (bloquear a clave pública)
|
||||
_buildP2PKSection(),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Mensaje de error (si hay)
|
||||
if (_errorMessage != null) _buildErrorMessage(),
|
||||
],
|
||||
@@ -260,6 +273,83 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildP2PKSection() {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
// P2PK Send deshabilitado temporalmente por bug en CDK (cdk-flutter usa CDK 0.13.4).
|
||||
// Se habilitará cuando cdk-flutter actualice a CDK 0.14.x con el fix de include_fee.
|
||||
const bool p2pkSendEnabled = false;
|
||||
|
||||
return Opacity(
|
||||
opacity: p2pkSendEnabled ? 1.0 : 0.5,
|
||||
child: GlassCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.lock,
|
||||
color: AppColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.p2pkLockToKey,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: p2pkSendEnabled ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.p2pkSendComingSoon,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: false,
|
||||
onChanged: null,
|
||||
activeColor: AppColors.primaryAction,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _validatePubkey(String value) {
|
||||
if (value.isEmpty) {
|
||||
setState(() => _pubkeyError = null);
|
||||
return;
|
||||
}
|
||||
|
||||
final isValid = NostrUtils.isValidP2PKPubkey(value);
|
||||
setState(() {
|
||||
_pubkeyError = isValid ? null : L10n.of(context)!.p2pkInvalidPubkey;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pastePubkey() async {
|
||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
if (clipboardData?.text != null) {
|
||||
_pubkeyController.text = clipboardData!.text!;
|
||||
_validatePubkey(clipboardData.text!);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: AppDimensions.paddingMedium),
|
||||
@@ -297,9 +387,13 @@ class _SendScreenState extends State<SendScreen> {
|
||||
|
||||
Widget _buildCreateButton() {
|
||||
final l10n = L10n.of(context)!;
|
||||
// Si P2PK está habilitado, también debe haber una pubkey válida
|
||||
final isP2PKValid = !_useP2PK ||
|
||||
(_pubkeyController.text.isNotEmpty && _pubkeyError == null);
|
||||
final canCreate = _isValidAmount && !_isProcessing && isP2PKValid;
|
||||
return PrimaryButton(
|
||||
text: _isProcessing ? l10n.creatingToken : l10n.createToken,
|
||||
onPressed: _isValidAmount && !_isProcessing ? _showConfirmation : null,
|
||||
onPressed: canCreate ? _showConfirmation : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -359,6 +453,13 @@ class _SendScreenState extends State<SendScreen> {
|
||||
});
|
||||
|
||||
if (!isOnline) {
|
||||
// P2PK requiere conexión al mint
|
||||
if (_useP2PK) {
|
||||
setState(() {
|
||||
_errorMessage = L10n.of(context)!.p2pkRequiresConnection;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Offline: ir directo a selección de monedas
|
||||
_goToOfflineModeWithMessage();
|
||||
return;
|
||||
@@ -394,6 +495,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
}
|
||||
|
||||
Future<void> _createToken() async {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
_errorMessage = null;
|
||||
@@ -405,7 +508,21 @@ class _SendScreenState extends State<SendScreen> {
|
||||
// Crear token real con cdk-flutter
|
||||
final memo = _memoController.text.isNotEmpty ? _memoController.text : null;
|
||||
final amount = _amount;
|
||||
final token = await walletProvider.sendTokens(amount, memo);
|
||||
|
||||
String token;
|
||||
|
||||
// P2PK: bloquear a clave pública si está habilitado
|
||||
if (_useP2PK && _pubkeyController.text.isNotEmpty) {
|
||||
// Usar normalizeToCompressedHex para obtener formato SEC1 (66 chars)
|
||||
final pubkeyHex = NostrUtils.normalizeToCompressedHex(_pubkeyController.text);
|
||||
if (pubkeyHex == null) {
|
||||
throw Exception(l10n.p2pkInvalidPubkey);
|
||||
}
|
||||
token = await walletProvider.sendTokensP2pk(amount, pubkeyHex, memo);
|
||||
} else {
|
||||
debugPrint('[SendScreen] Sending normal token, amount: $amount');
|
||||
token = await walletProvider.sendTokens(amount, memo);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pushReplacement(
|
||||
@@ -420,7 +537,9 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SendScreen] ERROR creating token: $e');
|
||||
debugPrint('[SendScreen] Stack trace: $stackTrace');
|
||||
final errorStr = e.toString().toLowerCase();
|
||||
|
||||
// Detectar errores de red y redirigir a modo offline
|
||||
@@ -434,14 +553,15 @@ class _SendScreenState extends State<SendScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = L10n.of(context)!;
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = l10n.insufficientBalance;
|
||||
} else {
|
||||
_errorMessage = l10n.tokenCreationError(e.toString());
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (errorStr.contains('insufficient') || errorStr.contains('not enough')) {
|
||||
_errorMessage = l10n.insufficientBalance;
|
||||
} else {
|
||||
_errorMessage = l10n.tokenCreationError(e.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
@@ -5,13 +5,12 @@ import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
import '../../widgets/effects/cashu_confetti.dart';
|
||||
import '../../providers/wallet_provider.dart';
|
||||
|
||||
/// Estados del proceso de mint
|
||||
@@ -39,7 +38,6 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
String? _invoice;
|
||||
String? _errorMessage;
|
||||
StreamSubscription<MintQuote>? _mintSubscription;
|
||||
final CashuConfettiController _confettiController = CashuConfettiController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -50,15 +48,14 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_mintSubscription?.cancel();
|
||||
_confettiController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startMintProcess() {
|
||||
Future<void> _startMintProcess() async {
|
||||
final walletProvider = context.read<WalletProvider>();
|
||||
|
||||
try {
|
||||
final mintStream = walletProvider.mintTokens(
|
||||
final mintStream = await walletProvider.mintTokens(
|
||||
widget.amount,
|
||||
widget.description,
|
||||
);
|
||||
@@ -107,8 +104,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
}
|
||||
|
||||
void _onMintCompleted() {
|
||||
// Disparar confetti inmediatamente
|
||||
_confettiController.fire();
|
||||
// Confetti se dispara globalmente desde WalletProvider._saveMintMetadata
|
||||
|
||||
// Esperar a que termine el confetti antes de navegar
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
@@ -133,25 +129,19 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CashuConfetti(
|
||||
controller: _confettiController,
|
||||
child: GradientBackground(
|
||||
child: PopScope(
|
||||
canPop: _status == MintStatus.issued || _status == MintStatus.error,
|
||||
child: Scaffold(
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: (_status == MintStatus.issued || _status == MintStatus.error)
|
||||
? IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
)
|
||||
: null,
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.arrowLeft,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
L10n.of(context)!.payInvoiceTitle,
|
||||
style: const TextStyle(
|
||||
@@ -181,9 +171,7 @@ class _InvoiceScreenState extends State<InvoiceScreen> {
|
||||
child: _buildContent(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' hide WalletProvider;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo, ContactInfo;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show MintInfo;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/nostr_utils.dart';
|
||||
import '../../providers/p2pk_provider.dart';
|
||||
import '../../models/p2pk_key.dart';
|
||||
import '../../widgets/common/gradient_background.dart';
|
||||
import '../../widgets/common/glass_card.dart';
|
||||
|
||||
/// Pantalla para gestionar claves P2PK
|
||||
class P2PKKeysScreen extends StatefulWidget {
|
||||
const P2PKKeysScreen({super.key});
|
||||
|
||||
@override
|
||||
State<P2PKKeysScreen> createState() => _P2PKKeysScreenState();
|
||||
}
|
||||
|
||||
class _P2PKKeysScreenState extends State<P2PKKeysScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = L10n.of(context)!;
|
||||
|
||||
return GradientBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.arrowLeft, color: Colors.white),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
title: Text(
|
||||
l10n.p2pkTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Consumer<P2PKProvider>(
|
||||
builder: (context, p2pkProvider, child) {
|
||||
if (!p2pkProvider.isInitialized) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Contenido scrolleable
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Banner experimental
|
||||
_buildExperimentalBanner(l10n),
|
||||
const SizedBox(height: AppDimensions.paddingMedium),
|
||||
|
||||
// Clave principal
|
||||
if (p2pkProvider.primaryKey != null) ...[
|
||||
_buildSectionHeader(l10n.p2pkPrimaryKey),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
_buildKeyCard(p2pkProvider.primaryKey!, p2pkProvider, l10n),
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
],
|
||||
|
||||
// Claves importadas
|
||||
_buildSectionHeader(l10n.p2pkImportedKeys),
|
||||
const SizedBox(height: AppDimensions.paddingSmall),
|
||||
|
||||
if (p2pkProvider.importedKeys.isEmpty)
|
||||
_buildEmptyImportedState(l10n)
|
||||
else
|
||||
...p2pkProvider.importedKeys.map(
|
||||
(key) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: AppDimensions.paddingSmall,
|
||||
),
|
||||
child: _buildKeyCard(key, p2pkProvider, l10n),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Botón importar (fijo abajo)
|
||||
if (p2pkProvider.canImportMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: _buildImportButton(p2pkProvider, l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExperimentalBanner(L10n l10n) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warning.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.warning.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.flaskConical,
|
||||
color: AppColors.warning,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.p2pkExperimental,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.5,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyCard(P2PKKey key, P2PKProvider provider, L10n l10n) {
|
||||
String truncatedNpub;
|
||||
try {
|
||||
final npub = NostrUtils.hexToNpub(key.publicKey);
|
||||
truncatedNpub = '${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||
} catch (_) {
|
||||
truncatedNpub = key.publicKey.length > 16
|
||||
? '${key.publicKey.substring(0, 16)}...'
|
||||
: key.publicKey;
|
||||
}
|
||||
|
||||
return GlassCard(
|
||||
padding: const EdgeInsets.all(AppDimensions.paddingMedium),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header con label y badge
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
key.label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: key.isDerived
|
||||
? AppColors.primaryAction.withValues(alpha: 0.2)
|
||||
: AppColors.secondaryAction.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
key.isDerived ? l10n.p2pkDerived : l10n.p2pkImported,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: key.isDerived
|
||||
? AppColors.primaryAction
|
||||
: AppColors.secondaryAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// npub (tap to copy)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
final fullNpub = _safeNpub(key.publicKey);
|
||||
Clipboard.setData(ClipboardData(text: fullNpub));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.copied('npub')),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.key,
|
||||
color: AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
truncatedNpub,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Acciones
|
||||
Row(
|
||||
children: [
|
||||
// Mostrar QR
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showQRDialog(key, l10n),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.qrCode,
|
||||
color: AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.p2pkShowQR,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Eliminar (solo para importadas)
|
||||
if (!key.isDerived) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () => _showDeleteDialog(key, provider, l10n),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.trash2,
|
||||
color: AppColors.error,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyImportedState(L10n l10n) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.03),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.keyRound,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.4),
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.p2pkNoImportedKeys,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImportButton(P2PKProvider provider, L10n l10n) {
|
||||
return GestureDetector(
|
||||
onTap: () => _showImportDialog(provider, l10n),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.primaryAction, Color(0xFFFF9100)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(LucideIcons.plus, color: Colors.white, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.p2pkImportNsec,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============ DIÁLOGOS ============
|
||||
|
||||
void _showQRDialog(P2PKKey key, L10n l10n) {
|
||||
final npub = _safeNpub(key.publicKey);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
content: SizedBox(
|
||||
width: 280,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
key.label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: npub,
|
||||
version: QrVersions.auto,
|
||||
size: 200,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: npub));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.copied('npub')),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.copy,
|
||||
color: AppColors.textSecondary,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.p2pkCopy,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
l10n.close,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showImportDialog(P2PKProvider provider, L10n l10n) {
|
||||
final nsecController = TextEditingController();
|
||||
final labelController = TextEditingController();
|
||||
bool obscureNsec = true;
|
||||
String? errorMessage;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
final nsecBorder = OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: errorMessage != null
|
||||
? const BorderSide(color: AppColors.error)
|
||||
: BorderSide.none,
|
||||
);
|
||||
return AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
title: Text(
|
||||
l10n.p2pkImportNsec,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 300,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Label
|
||||
TextField(
|
||||
controller: labelController,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.p2pkEnterLabel,
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
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,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.tag,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// nsec input
|
||||
TextField(
|
||||
controller: nsecController,
|
||||
obscureText: obscureNsec,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'nsec1...',
|
||||
hintStyle: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.white.withValues(alpha: 0.05),
|
||||
border: nsecBorder,
|
||||
enabledBorder: nsecBorder,
|
||||
focusedBorder: nsecBorder,
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.keyRound,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
obscureNsec ? LucideIcons.eye : LucideIcons.eyeOff,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
setDialogState(() => obscureNsec = !obscureNsec);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
LucideIcons.clipboard,
|
||||
color: AppColors.textSecondary,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () async {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
if (data?.text != null) {
|
||||
nsecController.text = data!.text!;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Error message
|
||||
if (errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
errorMessage!,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 12,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await provider.importFromNsec(
|
||||
nsecController.text.trim(),
|
||||
labelController.text.trim(),
|
||||
);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.success),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on P2PKException catch (e) {
|
||||
setDialogState(() {
|
||||
errorMessage = _getErrorMessage(e.code, l10n);
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
l10n.p2pkImport,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteDialog(P2PKKey key, P2PKProvider provider, L10n l10n) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppColors.deepVoidPurple,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
title: Text(
|
||||
l10n.p2pkDeleteTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
content: Text(
|
||||
l10n.p2pkDeleteConfirm,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
try {
|
||||
await provider.removeKey(key.id);
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.error),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
l10n.delete,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convierte hex a npub de forma segura, retornando el hex como fallback.
|
||||
String _safeNpub(String hex) {
|
||||
try {
|
||||
return NostrUtils.hexToNpub(hex);
|
||||
} catch (_) {
|
||||
return hex;
|
||||
}
|
||||
}
|
||||
|
||||
String _getErrorMessage(P2PKErrorCode code, L10n l10n) {
|
||||
switch (code) {
|
||||
case P2PKErrorCode.maxKeysReached:
|
||||
return l10n.p2pkErrorMaxKeysReached;
|
||||
case P2PKErrorCode.invalidNsec:
|
||||
return l10n.p2pkErrorInvalidNsec;
|
||||
case P2PKErrorCode.keyAlreadyExists:
|
||||
return l10n.p2pkErrorKeyAlreadyExists;
|
||||
case P2PKErrorCode.keyNotFound:
|
||||
return l10n.p2pkErrorKeyNotFound;
|
||||
case P2PKErrorCode.cannotDeletePrimaryKey:
|
||||
return l10n.p2pkErrorCannotDeletePrimary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -13,6 +13,7 @@ import '../../widgets/common/glass_card.dart';
|
||||
import '../2_onboarding/backup_seed_screen.dart';
|
||||
import 'mints_screen.dart';
|
||||
import 'language_screen.dart';
|
||||
import 'p2pk_keys_screen.dart';
|
||||
|
||||
/// Pantalla de configuración
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
@@ -94,6 +95,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
subtitle: l10n.scanMintsWithSeed,
|
||||
onTap: () => _showRecoverTokensDialog(context, settingsProvider),
|
||||
),
|
||||
_buildP2PKTile(l10n),
|
||||
|
||||
const SizedBox(height: AppDimensions.paddingLarge),
|
||||
|
||||
@@ -225,6 +227,100 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildP2PKTile(L10n l10n) {
|
||||
return GlassCard(
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const P2PKKeysScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDimensions.paddingMedium,
|
||||
vertical: AppDimensions.paddingMedium,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(LucideIcons.keyRound, color: AppColors.textSecondary, size: 20),
|
||||
),
|
||||
const SizedBox(width: AppDimensions.paddingMedium),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Título con badge experimental
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n.p2pkTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.flaskConical,
|
||||
color: AppColors.error,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
l10n.p2pkExperimentalShort,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Descripción normal
|
||||
Text(
|
||||
l10n.p2pkSettingsDescription,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
color: AppColors.textSecondary.withValues(alpha: 0.5),
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' as cdk;
|
||||
import 'package:cdk_flutter/cdk_flutter.dart' show Transaction, TransactionDirection, TransactionStatus;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/constants/dimensions.dart';
|
||||
import '../../core/utils/formatters.dart';
|
||||
@@ -704,6 +704,11 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
final meta = widget.walletProvider.getTransactionMeta(widget.transaction.id);
|
||||
_tokenOrInvoice = meta?.token ?? meta?.invoice;
|
||||
|
||||
// Si es Lightning incoming sin invoice, buscar en pending mint invoices
|
||||
if (_tokenOrInvoice == null && _isLightning && _isIncoming) {
|
||||
_loadPendingMintInvoice();
|
||||
}
|
||||
|
||||
// Mostrar QR para todo EXCEPTO Lightning saliente
|
||||
_shouldShowQR = !_isLightning || _isIncoming;
|
||||
|
||||
@@ -713,6 +718,19 @@ class _TransactionDetailScreenState extends State<_TransactionDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Busca el invoice en pending mint invoices si no hay metadata.
|
||||
Future<void> _loadPendingMintInvoice() async {
|
||||
final invoice = await widget.walletProvider.findPendingMintInvoice(
|
||||
widget.transaction.mintUrl,
|
||||
widget.transaction.unit,
|
||||
);
|
||||
if (invoice != null && mounted) {
|
||||
setState(() {
|
||||
_tokenOrInvoice = invoice;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationTimer?.cancel();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:elcaju/l10n/app_localizations.dart';
|
||||
import 'package:cdk_flutter/cdk_flutter.dart';
|
||||
import '../../core/constants/colors.dart';
|
||||
import '../../core/utils/incoming_data_parser.dart';
|
||||
|
||||
@@ -41,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
bip32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: bip32
|
||||
sha256: "54787cd7a111e9d37394aabbf53d1fc5e2e0e0af2cd01c459147a97c0e3f8a97"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -49,6 +57,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
bs58check:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bs58check
|
||||
sha256: c4a164d42b25c2f6bc88a8beccb9fc7d01440f3c60ba23663a20a70faf484ea9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
build_cli_annotations:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -542,6 +558,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
pointycastle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.1"
|
||||
posix:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -56,6 +56,9 @@ dependencies:
|
||||
# UUID generator for pending tokens
|
||||
uuid: ^4.3.3
|
||||
|
||||
# BIP32 for NIP-06 key derivation (P2PK)
|
||||
bip32: ^2.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user