import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:breez_sdk_spark_flutter/breez_sdk_spark.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/lightning/pending_lightning_transaction.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_type.dart'; bool _breezSdkSparkLibUninitialized = true; Stream? _logStream; class LightningWallet { final String mnemonic; final String? passphrase; final Uint8List? seedBytes; final String apiKey; final String lnurlDomain; final Network network; late BreezSdk sdk; String? cachedAddress; static int MAX_RETRIES = 10; LightningWallet({ required this.mnemonic, this.passphrase, this.seedBytes, required this.apiKey, required this.lnurlDomain, this.network = Network.mainnet, this.cachedAddress, }); static bool get isAvailable => Platform.isIOS || Platform.isAndroid || Platform.isMacOS; StreamSubscription? _eventSubscription; Stream? _eventStream; StreamSubscription? _logSubscription; bool get isInitialized => _eventStream != null; void _subscribeToLogStream(File logFile) { _logSubscription = _logStream?.listen((logEntry) { logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append); }, onError: (e) { logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append); }); } Future init(String appPath) async { try { if (_breezSdkSparkLibUninitialized) { await BreezSdkSparkLib.init(); _breezSdkSparkLibUninitialized = false; } final seed = seedBytes != null ? Seed.entropy(seedBytes!) : Seed.mnemonic(mnemonic: mnemonic, passphrase: passphrase); final config = defaultConfig(network: Network.mainnet).copyWith( lnurlDomain: lnurlDomain, apiKey: apiKey, privateEnabledDefault: true, maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5)) ); final connectRequest = ConnectRequest( config: config, seed: seed, storageDir: "$appPath/.breez/", ); sdk = await connect(request: connectRequest); _eventStream ??= sdk.addEventListener().asBroadcastStream(); _logStream ??= initLogging().asBroadcastStream(); try { final logFile = File("$appPath/lightning.log") ..createSync(); _subscribeToLogStream(logFile); } catch (e) { printV(e); } await sdk.syncWallet(request: SyncWalletRequest()); return true; } catch (e) { printV(e); return false; } } Future close() async { _eventSubscription?.cancel(); await sdk.disconnect(); _logSubscription?.cancel(); } Future getAddress() async { var retries = 0; while (retries < MAX_RETRIES) { try { final address = (await sdk.getLightningAddress())?.lightningAddress; if (address != null) { cachedAddress = address; return address; } } catch (_) {} // No need to log here since it should be in the lightning log retries++; await Future.delayed(Duration(milliseconds: 500)); } return cachedAddress; } Future getDepositAddress() async => (await sdk.receivePayment( request: ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress()))) .paymentRequest; Future getBalance() async { try { return (await sdk.getInfo(request: GetInfoRequest(ensureSynced: true))).balanceSats; } on SdkError_Generic catch (_) { } on SdkError_NetworkError catch (_) {} return BigInt.zero; } Future registerAddress(String username) async { return (await sdk.registerLightningAddress( request: RegisterLightningAddressRequest(username: username))) .lightningAddress; } Future getBolt11Invoice(BigInt? amount, String description) async { try { final response = await sdk.receivePayment( request: ReceivePaymentRequest( paymentMethod: ReceivePaymentMethod.bolt11Invoice( description: description, amountSats: amount, ), ), ); return response.paymentRequest; } on SdkError_NetworkError catch (_) { return null; } on SdkError_SparkError catch (e) { if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) rethrow; return null; } } Future isCompatible(String input) async { try { final inputType = await sdk.parse(input: input); return (inputType is InputType_Bolt11Invoice) || (inputType is InputType_LightningAddress) || (inputType is InputType_LnurlPay); } catch (_) { return false; } } Future createTransaction( String address, BigInt? amountSats, BitcoinTransactionPriority? priority, bool feesIncluded) async { final inputType = await sdk.parse(input: address); final feePolicy = feesIncluded ? FeePolicy.feesIncluded : FeePolicy.feesExcluded; if (inputType is InputType_Bolt11Invoice) { final request = PrepareSendPaymentRequest( paymentRequest: inputType.field0.invoice.bolt11, amount: amountSats, feePolicy: feePolicy); final prepareResponse = await sdk.prepareSendPayment(request: request); final paymentMethod = prepareResponse.paymentMethod; if (paymentMethod is SendPaymentMethod_Bolt11Invoice) { final lightningFeeSats = paymentMethod.lightningFeeSats; final sparkTransferFeeSats = paymentMethod.sparkTransferFeeSats; return PendingLightningTransaction( id: paymentMethod.invoiceDetails.paymentHash, amount: request.amount?.toInt() ?? amountSats?.toInt() ?? ((paymentMethod.invoiceDetails.amountMsat?.toInt() ?? 0) / 1000).round(), fee: lightningFeeSats.toInt() + (sparkTransferFeeSats?.toInt() ?? 0), commitOverride: () async { try { final res = await sdk.sendPayment( request: SendPaymentRequest(prepareResponse: prepareResponse)); printV(res.payment.status.name); } on SdkError_SparkError catch (e) { if (e.field0.contains("AlreadyExists")) { throw Exception("Invoice already paid"); } rethrow; } }, ); } } else if (inputType is InputType_LightningAddress || inputType is InputType_LnurlPay) { final optionalValidateSuccessActionUrl = true; PrepareLnurlPayRequest request; if (inputType is InputType_LightningAddress) { request = PrepareLnurlPayRequest( amountSats: amountSats!, payRequest: inputType.field0.payRequest, validateSuccessActionUrl: optionalValidateSuccessActionUrl, feePolicy: feePolicy, ); } else { request = PrepareLnurlPayRequest( amountSats: amountSats!, payRequest: (inputType as InputType_LnurlPay).field0, validateSuccessActionUrl: optionalValidateSuccessActionUrl, feePolicy: feePolicy, ); } final prepareResponse = await sdk.prepareLnurlPay(request: request); final feeSats = prepareResponse.feeSats; return PendingLightningTransaction( id: prepareResponse.invoiceDetails.paymentHash, amount: prepareResponse.amountSats.toInt(), fee: feeSats.toInt(), commitOverride: () async { final res = await sdk.lnurlPay(request: LnurlPayRequest(prepareResponse: prepareResponse)); printV(res.payment.status.name); }, ); } else if (inputType is InputType_BitcoinAddress) { final request = PrepareSendPaymentRequest( paymentRequest: inputType.field0.address, amount: amountSats, feePolicy: feePolicy, ); final prepareResponse = await sdk.prepareSendPayment(request: request); final paymentMethod = prepareResponse.paymentMethod; if (paymentMethod is SendPaymentMethod_BitcoinAddress) { final feeQuote = paymentMethod.feeQuote; OnchainConfirmationSpeed onchainConfirmationSpeed; int fee; switch (priority) { case BitcoinTransactionPriority.fast: fee = (feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat).toInt(); onchainConfirmationSpeed = OnchainConfirmationSpeed.fast; break; case BitcoinTransactionPriority.medium: fee = (feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat).toInt(); onchainConfirmationSpeed = OnchainConfirmationSpeed.medium; break; case BitcoinTransactionPriority.slow: default: fee = (feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat).toInt(); onchainConfirmationSpeed = OnchainConfirmationSpeed.slow; } return PendingLightningTransaction( id: "", // ToDo: Find out where to get it amount: prepareResponse.amount.toInt(), fee: fee, commitOverride: () async { final options = SendPaymentOptions.bitcoinAddress(confirmationSpeed: onchainConfirmationSpeed); await sdk.sendPayment( request: SendPaymentRequest(prepareResponse: prepareResponse, options: options)); }, ); } } // If not returned earlier throw UnimplementedError(); } Future> getTransactionHistory({DateTime? fromDate}) async { final request = ListPaymentsRequest( typeFilter: [PaymentType.send, PaymentType.receive], // statusFilter: [PaymentStatus.completed], fromTimestamp: fromDate != null ? BigInt.from((fromDate.millisecondsSinceEpoch / 1000).round()) : null, assetFilter: AssetFilter.bitcoin(), offset: 0, limit: 50, sortAscending: false, // Sort order (true = oldest first, false = newest first) ); final response = await sdk.listPayments(request: request); final payments = response.payments; final txHistory = {}; for (final payment in payments) { txHistory[payment.id] = _getElectrumTransactionInfoFromPayment(payment); } return txHistory; } /// Return a list of UnclaimedDeposits including a possible reason why they where not auto-claimed /// A unclaimed deposit is a [Map] consisting of the following datatypes /// /// | ----------------- | --------- |--------------------------------------------------- | /// | key-name | data-type | description | /// | ----------------- | --------- |--------------------------------------------------- | /// | txId | String | The txId of the deposit transaction | /// | vout | int | The output index of the deposit | /// | amount | BigInt | Amount of the deposit in sats. | /// | claimError | String? | The type of Claim error | /// | actualFee | BigInt? | The actualFee in case of a DepositClaimFeeExceeded | /// | claimErrorMessage | String? | The claimErrorMessage in case of a Generic Error | /// Future>> getUnclaimedDeposits() async { final unclaimedDeposits = >[]; final response = await sdk.listUnclaimedDeposits(request: ListUnclaimedDepositsRequest()); for (final deposit in response.deposits) { final unclaimedDeposit = { "txId": deposit.txid, "vout": deposit.vout, "amount": deposit.amountSats, }; final claimError = deposit.claimError; if (claimError is DepositClaimError_MaxDepositClaimFeeExceeded) { unclaimedDeposit["claimError"] = "DepositClaimError_MaxDepositClaimFeeExceeded"; unclaimedDeposit["actualFee"] = claimError.requiredFeeSats; } else if (claimError is DepositClaimError_MissingUtxo) { unclaimedDeposit["claimError"] = "MissingUtxo"; } else if (claimError is DepositClaimError_Generic) { unclaimedDeposit["claimError"] = "Generic"; unclaimedDeposit["claimErrorMessage"] = claimError.message; } } return unclaimedDeposits; } Future claimDeposit(String txId, int vout, BigInt newFee) async { final response = await sdk.claimDeposit( request: ClaimDepositRequest( txid: txId, vout: vout, maxFee: MaxFee.fixed(amount: newFee), ), ); return _getElectrumTransactionInfoFromPayment(response.payment); } Future refundDeposit(String txId, int vout, String destinationAddress, BigInt feeRate) async { final response = await sdk.refundDeposit(request: RefundDepositRequest( txid: txId, vout: vout, destinationAddress: destinationAddress, fee: Fee.rate(satPerVbyte: feeRate), ),); return response.txHex; } void setEventListener({ required Function(ElectrumTransactionInfo) onTransactionEvent, required Function onBalanceChangedEvent, required Function(Map) onCreateDepositTransactionEvent, required Function(List) onUpdateDepositTransactionEvent, }) { _eventSubscription = _eventStream?.listen((sdkEvent) { if (sdkEvent is SdkEvent_PaymentSucceeded) { onTransactionEvent(_getElectrumTransactionInfoFromPayment(sdkEvent.payment)); } else if (sdkEvent is SdkEvent_PaymentPending) { onTransactionEvent(_getElectrumTransactionInfoFromPayment(sdkEvent.payment)); } else if (sdkEvent is SdkEvent_ClaimedDeposits) { onBalanceChangedEvent(); onUpdateDepositTransactionEvent( sdkEvent.claimedDeposits.map(_getElectrumTransactionInfoFromDepositInfo).toList()); } else if (sdkEvent is SdkEvent_UnclaimedDeposits) { final unclaimedDeposits = {}; for (final deposit in sdkEvent.unclaimedDeposits) { unclaimedDeposits[deposit.txid] = _getElectrumTransactionInfoFromDepositInfo(deposit); } onCreateDepositTransactionEvent(unclaimedDeposits); } }); } ElectrumTransactionInfo _getElectrumTransactionInfoFromPayment(Payment payment) { TransactionDirection direction = TransactionDirection.outgoing; if (payment.paymentType == PaymentType.receive) { direction = TransactionDirection.incoming; } if (payment.method == PaymentMethod.deposit) { direction = TransactionDirection.incoming; } return ElectrumTransactionInfo( WalletType.bitcoin, id: payment.id, amount: payment.amount.toInt(), direction: direction, isPending: payment.status == PaymentStatus.pending, fee: payment.fees.toInt(), date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000), confirmations: payment.status == PaymentStatus.pending ? 0 : 10, additionalInfo: {"isLightning": true}, ); } ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) { return ElectrumTransactionInfo( WalletType.bitcoin, id: deposit.txid, amount: deposit.amountSats.toInt(), direction: TransactionDirection.incoming, isPending: true, fee: 0, date: DateTime.now(), confirmations: 0, additionalInfo: {"isLightning": true, "isSparkDeposit": true}, ); } }