| 1 | import 'dart:async'; |
| 2 | import 'dart:io'; |
| 3 | import 'dart:typed_data'; |
| 4 | |
| 5 | import 'package:breez_sdk_spark_flutter/breez_sdk_spark.dart'; |
| 6 | import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; |
| 7 | import 'package:cw_bitcoin/electrum_transaction_info.dart'; |
| 8 | import 'package:cw_bitcoin/lightning/pending_lightning_transaction.dart'; |
| 9 | import 'package:cw_core/amount/money.dart'; |
| 10 | import 'package:cw_core/crypto_currency.dart'; |
| 11 | import 'package:cw_core/currency.dart'; |
| 12 | import 'package:cw_core/transaction_direction.dart'; |
| 13 | import 'package:cw_core/utils/print_verbose.dart'; |
| 14 | import 'package:cw_core/wallet_type.dart'; |
| 15 | |
| 16 | bool _breezSdkSparkLibUninitialized = true; |
| 17 | Stream<LogEntry>? _logStream; |
| 18 | |
| 19 | class LightningWallet { |
| 20 | LightningWallet({ |
| 21 | required this.mnemonic, |
| 22 | required this.apiKey, |
| 23 | required this.lnurlDomain, |
| 24 | this.network = Network.mainnet, |
| 25 | this.passphrase, |
| 26 | this.seedBytes, |
| 27 | this.cachedAddress, |
| 28 | }); |
| 29 | |
| 30 | final String mnemonic; |
| 31 | final String? passphrase; |
| 32 | final Uint8List? seedBytes; |
| 33 | final String apiKey; |
| 34 | final String lnurlDomain; |
| 35 | final Network network; |
| 36 | BreezSdk? _sdk; |
| 37 | |
| 38 | String? cachedAddress; |
| 39 | |
| 40 | static int MAX_RETRIES = 10; |
| 41 | |
| 42 | static bool get isAvailable => Platform.isIOS || Platform.isAndroid || Platform.isMacOS; |
| 43 | |
| 44 | Currency get currency => CryptoCurrency.btcln; |
| 45 | |
| 46 | BreezSdk get sdk => _sdk!; |
| 47 | |
| 48 | StreamSubscription<SdkEvent>? _eventSubscription; |
| 49 | Stream<SdkEvent>? _eventStream; |
| 50 | |
| 51 | StreamSubscription<LogEntry>? _logSubscription; |
| 52 | |
| 53 | bool get isInitialized => _eventStream != null; |
| 54 | |
| 55 | void _subscribeToLogStream(File logFile) { |
| 56 | _logSubscription = _logStream?.listen((logEntry) { |
| 57 | try { |
| 58 | // Check if file exists before writing (optional, but safer) |
| 59 | if (!logFile.existsSync()) { |
| 60 | logFile.createSync(recursive: true); |
| 61 | } |
| 62 | logFile.writeAsStringSync("[${logEntry.level}] ${logEntry.line}\n", mode: FileMode.append); |
| 63 | } catch (e) { |
| 64 | // Silently fail or use printV(e) so it doesn't crash the app |
| 65 | printV("Failed to write to log: $e"); |
| 66 | } |
| 67 | }, onError: (e) { |
| 68 | try { |
| 69 | if (!logFile.existsSync()) { |
| 70 | logFile.createSync(recursive: true); |
| 71 | } |
| 72 | logFile.writeAsStringSync("[ERROR] $e\n", mode: FileMode.append); |
| 73 | } catch (err) { |
| 74 | printV("Failed to write error to log: $err"); |
| 75 | } |
| 76 | }); |
| 77 | } |
| 78 | |
| 79 | Future<bool> init(String appPath) async { |
| 80 | try { |
| 81 | if (_breezSdkSparkLibUninitialized) { |
| 82 | await BreezSdkSparkLib.init(); |
| 83 | _breezSdkSparkLibUninitialized = false; |
| 84 | } |
| 85 | |
| 86 | final seed = seedBytes != null |
| 87 | ? Seed.entropy(seedBytes!) |
| 88 | : Seed.mnemonic(mnemonic: mnemonic, passphrase: passphrase); |
| 89 | final config = defaultConfig(network: Network.mainnet).copyWith( |
| 90 | lnurlDomain: lnurlDomain, |
| 91 | apiKey: apiKey, |
| 92 | privateEnabledDefault: true, |
| 93 | maxDepositClaimFee: MaxFee.rate(satPerVbyte: BigInt.from(5)), |
| 94 | ); |
| 95 | |
| 96 | final connectRequest = ConnectRequest( |
| 97 | config: config, |
| 98 | seed: seed, |
| 99 | storageDir: "$appPath/.breez/", |
| 100 | ); |
| 101 | |
| 102 | _sdk = await connect(request: connectRequest); |
| 103 | |
| 104 | _eventStream ??= sdk.addEventListener().asBroadcastStream(); |
| 105 | _logStream ??= initLogging().asBroadcastStream(); |
| 106 | |
| 107 | try { |
| 108 | final logFile = File("$appPath/lightning.log")..createSync(); |
| 109 | _subscribeToLogStream(logFile); |
| 110 | } catch (e) { |
| 111 | printV(e); |
| 112 | } |
| 113 | |
| 114 | await sdk.syncWallet(request: const SyncWalletRequest()); |
| 115 | |
| 116 | return true; |
| 117 | } catch (e) { |
| 118 | printV(e); |
| 119 | return false; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | Future<void> close() async { |
| 124 | _eventSubscription?.cancel(); |
| 125 | try { |
| 126 | await _sdk?.disconnect(); |
| 127 | } catch (_) {} |
| 128 | _logSubscription?.cancel(); |
| 129 | } |
| 130 | |
| 131 | Future<String?> getAddress() async { |
| 132 | var retries = 0; |
| 133 | while (retries < MAX_RETRIES) { |
| 134 | try { |
| 135 | final address = (await sdk.getLightningAddress())?.lightningAddress; |
| 136 | |
| 137 | if (address != null) { |
| 138 | cachedAddress = address; |
| 139 | return address; |
| 140 | } |
| 141 | } catch (_) {} // No need to log here since it should be in the lightning log |
| 142 | retries++; |
| 143 | await Future.delayed(const Duration(milliseconds: 500)); |
| 144 | } |
| 145 | |
| 146 | return cachedAddress; |
| 147 | } |
| 148 | |
| 149 | Future<String> getDepositAddress() async => (await sdk.receivePayment( |
| 150 | request: const ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress()), |
| 151 | )) |
| 152 | .paymentRequest; |
| 153 | |
| 154 | Future<Money> getBalance() async { |
| 155 | try { |
| 156 | return Money( |
| 157 | (await sdk.getInfo(request: const GetInfoRequest(ensureSynced: true))).balanceSats, |
| 158 | CryptoCurrency.btcln, |
| 159 | ); |
| 160 | } on SdkError_Generic catch (_) { |
| 161 | } on SdkError_NetworkError catch (_) {} |
| 162 | |
| 163 | return Money.zero(CryptoCurrency.btcln); |
| 164 | } |
| 165 | |
| 166 | Future<String> registerAddress(String username) async => (await sdk.registerLightningAddress( |
| 167 | request: RegisterLightningAddressRequest(username: username), |
| 168 | )) |
| 169 | .lightningAddress; |
| 170 | |
| 171 | Future<String?> getBolt11Invoice(BigInt? amount, String description) async { |
| 172 | try { |
| 173 | final response = await sdk.receivePayment( |
| 174 | request: ReceivePaymentRequest( |
| 175 | paymentMethod: ReceivePaymentMethod.bolt11Invoice( |
| 176 | description: description, |
| 177 | amountSats: amount, |
| 178 | ), |
| 179 | ), |
| 180 | ); |
| 181 | |
| 182 | return response.paymentRequest; |
| 183 | } on SdkError_NetworkError catch (_) { |
| 184 | return null; |
| 185 | } on SdkError_SparkError catch (e) { |
| 186 | if (!e.field0.contains("dns") && !e.field0.contains("TimedOut")) { |
| 187 | rethrow; |
| 188 | } |
| 189 | return null; |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | Future<bool> isCompatible(String input) async { |
| 194 | try { |
| 195 | final inputType = await sdk.parse(input: input); |
| 196 | return (inputType is InputType_Bolt11Invoice) || |
| 197 | (inputType is InputType_LightningAddress) || |
| 198 | (inputType is InputType_LnurlPay); |
| 199 | } catch (_) { |
| 200 | return false; |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | Future<PendingLightningTransaction> createTransaction( |
| 205 | String address, |
| 206 | BigInt? amountSats, |
| 207 | BitcoinTransactionPriority? priority, |
| 208 | bool feesIncluded, |
| 209 | ) async { |
| 210 | final inputType = await sdk.parse(input: address); |
| 211 | |
| 212 | final feePolicy = feesIncluded ? FeePolicy.feesIncluded : FeePolicy.feesExcluded; |
| 213 | |
| 214 | if (inputType is InputType_Bolt11Invoice) { |
| 215 | final request = PrepareSendPaymentRequest( |
| 216 | paymentRequest: PaymentRequest.input(input: inputType.field0.invoice.bolt11), |
| 217 | amount: amountSats, |
| 218 | feePolicy: feePolicy, |
| 219 | ); |
| 220 | final prepareResponse = await sdk.prepareSendPayment(request: request); |
| 221 | |
| 222 | final paymentMethod = prepareResponse.paymentMethod; |
| 223 | if (paymentMethod is SendPaymentMethod_Bolt11Invoice) { |
| 224 | final lightningFeeSats = paymentMethod.lightningFeeSats; |
| 225 | final sparkTransferFeeSats = paymentMethod.sparkTransferFeeSats; |
| 226 | |
| 227 | final baseAmount = request.amount ?? amountSats; |
| 228 | final amount = baseAmount != null |
| 229 | ? Money(baseAmount, currency) |
| 230 | : Money(paymentMethod.invoiceDetails.amountMsat ?? BigInt.zero, currency) / |
| 231 | BigInt.from(1000); |
| 232 | |
| 233 | return PendingLightningTransaction( |
| 234 | id: paymentMethod.invoiceDetails.paymentHash, |
| 235 | amount: amount, |
| 236 | fee: Money(lightningFeeSats + (sparkTransferFeeSats ?? BigInt.zero), currency), |
| 237 | commitOverride: () async { |
| 238 | try { |
| 239 | final res = await sdk.sendPayment( |
| 240 | request: SendPaymentRequest(prepareResponse: prepareResponse), |
| 241 | ); |
| 242 | printV(res.payment.status.name); |
| 243 | return res.payment.id; |
| 244 | } on SdkError_SparkError catch (e) { |
| 245 | if (e.field0.contains("AlreadyExists")) { |
| 246 | throw Exception("Invoice already paid"); |
| 247 | } |
| 248 | rethrow; |
| 249 | } |
| 250 | }, |
| 251 | ); |
| 252 | } |
| 253 | } else if (inputType is InputType_LightningAddress || inputType is InputType_LnurlPay) { |
| 254 | const optionalValidateSuccessActionUrl = true; |
| 255 | |
| 256 | PrepareLnurlPayRequest request; |
| 257 | if (inputType is InputType_LightningAddress) { |
| 258 | request = PrepareLnurlPayRequest( |
| 259 | amount: amountSats!, |
| 260 | payRequest: inputType.field0.payRequest, |
| 261 | validateSuccessActionUrl: optionalValidateSuccessActionUrl, |
| 262 | feePolicy: feePolicy, |
| 263 | ); |
| 264 | } else { |
| 265 | request = PrepareLnurlPayRequest( |
| 266 | amount: amountSats!, |
| 267 | payRequest: (inputType as InputType_LnurlPay).field0, |
| 268 | validateSuccessActionUrl: optionalValidateSuccessActionUrl, |
| 269 | feePolicy: feePolicy, |
| 270 | ); |
| 271 | } |
| 272 | |
| 273 | final prepareResponse = await sdk.prepareLnurlPay(request: request); |
| 274 | |
| 275 | return PendingLightningTransaction( |
| 276 | id: prepareResponse.invoiceDetails.paymentHash, |
| 277 | amount: Money(prepareResponse.amountSats, currency), |
| 278 | fee: Money(prepareResponse.feeSats, currency), |
| 279 | commitOverride: () async { |
| 280 | final res = |
| 281 | await sdk.lnurlPay(request: LnurlPayRequest(prepareResponse: prepareResponse)); |
| 282 | printV(res.payment.status.name); |
| 283 | return res.payment.id; |
| 284 | }, |
| 285 | ); |
| 286 | } else if (inputType is InputType_BitcoinAddress) { |
| 287 | final request = PrepareSendPaymentRequest( |
| 288 | paymentRequest: PaymentRequest.input(input: inputType.field0.address), |
| 289 | amount: amountSats, |
| 290 | feePolicy: feePolicy, |
| 291 | ); |
| 292 | final prepareResponse = await sdk.prepareSendPayment(request: request); |
| 293 | |
| 294 | final paymentMethod = prepareResponse.paymentMethod; |
| 295 | if (paymentMethod is SendPaymentMethod_BitcoinAddress) { |
| 296 | final feeQuote = paymentMethod.feeQuote; |
| 297 | |
| 298 | OnchainConfirmationSpeed onchainConfirmationSpeed; |
| 299 | BigInt fee; |
| 300 | switch (priority) { |
| 301 | case BitcoinTransactionPriority.fast: |
| 302 | fee = feeQuote.speedFast.userFeeSat + feeQuote.speedFast.l1BroadcastFeeSat; |
| 303 | onchainConfirmationSpeed = OnchainConfirmationSpeed.fast; |
| 304 | break; |
| 305 | case BitcoinTransactionPriority.medium: |
| 306 | fee = feeQuote.speedMedium.userFeeSat + feeQuote.speedMedium.l1BroadcastFeeSat; |
| 307 | onchainConfirmationSpeed = OnchainConfirmationSpeed.medium; |
| 308 | break; |
| 309 | case BitcoinTransactionPriority.slow: |
| 310 | default: |
| 311 | fee = feeQuote.speedSlow.userFeeSat + feeQuote.speedSlow.l1BroadcastFeeSat; |
| 312 | onchainConfirmationSpeed = OnchainConfirmationSpeed.slow; |
| 313 | } |
| 314 | |
| 315 | return PendingLightningTransaction( |
| 316 | id: "", // ToDo: Find out where to get it |
| 317 | amount: Money(prepareResponse.amount, currency), |
| 318 | fee: Money(fee, currency), |
| 319 | commitOverride: () async { |
| 320 | final options = |
| 321 | SendPaymentOptions.bitcoinAddress(confirmationSpeed: onchainConfirmationSpeed); |
| 322 | final res = await sdk.sendPayment( |
| 323 | request: SendPaymentRequest(prepareResponse: prepareResponse, options: options), |
| 324 | ); |
| 325 | return res.payment.id; |
| 326 | }, |
| 327 | ); |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | // If not returned earlier |
| 332 | throw UnimplementedError(); |
| 333 | } |
| 334 | |
| 335 | Future<Map<String, ElectrumTransactionInfo>> getTransactionHistory({DateTime? fromDate}) async { |
| 336 | final request = ListPaymentsRequest( |
| 337 | typeFilter: [PaymentType.send, PaymentType.receive], |
| 338 | // statusFilter: [PaymentStatus.completed], |
| 339 | fromTimestamp: |
| 340 | fromDate != null ? BigInt.from((fromDate.millisecondsSinceEpoch / 1000).round()) : null, |
| 341 | assetFilter: const AssetFilter.bitcoin(), |
| 342 | offset: 0, |
| 343 | limit: 50, |
| 344 | sortAscending: false, // Sort order (true = oldest first, false = newest first) |
| 345 | ); |
| 346 | final response = await sdk.listPayments(request: request); |
| 347 | final payments = response.payments; |
| 348 | |
| 349 | final txHistory = <String, ElectrumTransactionInfo>{}; |
| 350 | for (final payment in payments) { |
| 351 | txHistory[payment.id] = _getElectrumTransactionInfoFromPayment(payment); |
| 352 | } |
| 353 | |
| 354 | return txHistory; |
| 355 | } |
| 356 | |
| 357 | /// Return a list of UnclaimedDeposits including a possible reason why they where not auto-claimed |
| 358 | /// A unclaimed deposit is a [Map] consisting of the following datatypes |
| 359 | /// |
| 360 | /// | ----------------- | --------- |--------------------------------------------------- | |
| 361 | /// | key-name | data-type | description | |
| 362 | /// | ----------------- | --------- |--------------------------------------------------- | |
| 363 | /// | txId | String | The txId of the deposit transaction | |
| 364 | /// | vout | int | The output index of the deposit | |
| 365 | /// | amount | BigInt | Amount of the deposit in sats. | |
| 366 | /// | claimError | String? | The type of Claim error | |
| 367 | /// | actualFee | BigInt? | The actualFee in case of a DepositClaimFeeExceeded | |
| 368 | /// | claimErrorMessage | String? | The claimErrorMessage in case of a Generic Error | |
| 369 | /// |
| 370 | Future<List<Map<String, dynamic>>> getUnclaimedDeposits() async { |
| 371 | final unclaimedDeposits = <Map<String, dynamic>>[]; |
| 372 | final response = await sdk.listUnclaimedDeposits(request: const ListUnclaimedDepositsRequest()); |
| 373 | for (final deposit in response.deposits) { |
| 374 | final unclaimedDeposit = { |
| 375 | "txId": deposit.txid, |
| 376 | "vout": deposit.vout, |
| 377 | "amount": deposit.amountSats, |
| 378 | }; |
| 379 | |
| 380 | final claimError = deposit.claimError; |
| 381 | if (claimError is DepositClaimError_MaxDepositClaimFeeExceeded) { |
| 382 | unclaimedDeposit["claimError"] = "DepositClaimError_MaxDepositClaimFeeExceeded"; |
| 383 | unclaimedDeposit["actualFee"] = claimError.requiredFeeSats; |
| 384 | } else if (claimError is DepositClaimError_MissingUtxo) { |
| 385 | unclaimedDeposit["claimError"] = "MissingUtxo"; |
| 386 | } else if (claimError is DepositClaimError_Generic) { |
| 387 | unclaimedDeposit["claimError"] = "Generic"; |
| 388 | unclaimedDeposit["claimErrorMessage"] = claimError.message; |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | return unclaimedDeposits; |
| 393 | } |
| 394 | |
| 395 | Future<ElectrumTransactionInfo?> claimDeposit(String txId, int vout, BigInt newFee) async { |
| 396 | final response = await sdk.claimDeposit( |
| 397 | request: ClaimDepositRequest( |
| 398 | txid: txId, |
| 399 | vout: vout, |
| 400 | maxFee: MaxFee.fixed(amount: newFee), |
| 401 | ), |
| 402 | ); |
| 403 | |
| 404 | if (response.payment == null) { |
| 405 | return null; |
| 406 | } |
| 407 | return _getElectrumTransactionInfoFromPayment(response.payment!); |
| 408 | } |
| 409 | |
| 410 | Future<String> refundDeposit( |
| 411 | String txId, |
| 412 | int vout, |
| 413 | String destinationAddress, |
| 414 | BigInt feeRate, |
| 415 | ) async { |
| 416 | final response = await sdk.refundDeposit( |
| 417 | request: RefundDepositRequest( |
| 418 | txid: txId, |
| 419 | vout: vout, |
| 420 | destinationAddress: destinationAddress, |
| 421 | fee: Fee.rate(satPerVbyte: feeRate), |
| 422 | ), |
| 423 | ); |
| 424 | |
| 425 | return response.txHex; |
| 426 | } |
| 427 | |
| 428 | void setEventListener({ |
| 429 | required Function(ElectrumTransactionInfo) onTransactionEvent, |
| 430 | required Function onBalanceChangedEvent, |
| 431 | required Function(Map<String, ElectrumTransactionInfo>) onCreateDepositTransactionEvent, |
| 432 | required Function(List<ElectrumTransactionInfo>) onUpdateDepositTransactionEvent, |
| 433 | }) { |
| 434 | _eventSubscription = _eventStream?.listen((sdkEvent) { |
| 435 | if (sdkEvent is SdkEvent_PaymentSucceeded) { |
| 436 | onTransactionEvent(_getElectrumTransactionInfoFromPayment(sdkEvent.payment)); |
| 437 | } else if (sdkEvent is SdkEvent_PaymentPending) { |
| 438 | onTransactionEvent(_getElectrumTransactionInfoFromPayment(sdkEvent.payment)); |
| 439 | } else if (sdkEvent is SdkEvent_ClaimedDeposits) { |
| 440 | onBalanceChangedEvent(); |
| 441 | onUpdateDepositTransactionEvent( |
| 442 | sdkEvent.claimedDeposits.map(_getElectrumTransactionInfoFromDepositInfo).toList()); |
| 443 | } else if (sdkEvent is SdkEvent_UnclaimedDeposits) { |
| 444 | final unclaimedDeposits = <String, ElectrumTransactionInfo>{}; |
| 445 | |
| 446 | for (final deposit in sdkEvent.unclaimedDeposits) { |
| 447 | unclaimedDeposits[deposit.txid] = _getElectrumTransactionInfoFromDepositInfo(deposit); |
| 448 | } |
| 449 | |
| 450 | onCreateDepositTransactionEvent(unclaimedDeposits); |
| 451 | } |
| 452 | }); |
| 453 | } |
| 454 | |
| 455 | ElectrumTransactionInfo _getElectrumTransactionInfoFromPayment(Payment payment) { |
| 456 | var direction = TransactionDirection.outgoing; |
| 457 | |
| 458 | if (payment.paymentType == PaymentType.receive) { |
| 459 | direction = TransactionDirection.incoming; |
| 460 | } |
| 461 | if (payment.method == PaymentMethod.deposit) { |
| 462 | direction = TransactionDirection.incoming; |
| 463 | } |
| 464 | |
| 465 | String? preimage; |
| 466 | if (payment.details != null && payment.details is PaymentDetails_Lightning) { |
| 467 | preimage = (payment.details as PaymentDetails_Lightning).htlcDetails.preimage; |
| 468 | } |
| 469 | |
| 470 | return ElectrumTransactionInfo( |
| 471 | WalletType.bitcoin, |
| 472 | id: payment.id, |
| 473 | amount: Money(payment.amount, currency), |
| 474 | direction: direction, |
| 475 | isPending: payment.status == PaymentStatus.pending, |
| 476 | fee: Money(payment.fees, currency), |
| 477 | date: DateTime.fromMillisecondsSinceEpoch(payment.timestamp.toInt() * 1000), |
| 478 | confirmations: payment.status == PaymentStatus.pending ? 0 : 10, |
| 479 | additionalInfo: { |
| 480 | "isLightning": true, |
| 481 | if (preimage != null) "preimage": preimage, |
| 482 | }, |
| 483 | ); |
| 484 | } |
| 485 | |
| 486 | ElectrumTransactionInfo _getElectrumTransactionInfoFromDepositInfo(DepositInfo deposit) => |
| 487 | ElectrumTransactionInfo( |
| 488 | WalletType.bitcoin, |
| 489 | id: deposit.txid, |
| 490 | amount: Money(deposit.amountSats, currency), |
| 491 | direction: TransactionDirection.incoming, |
| 492 | isPending: true, |
| 493 | fee: Money.zero(currency), |
| 494 | date: DateTime.now(), |
| 495 | confirmations: 0, |
| 496 | additionalInfo: {"isLightning": true, "isSparkDeposit": true}, |
| 497 | ); |
| 498 | } |