dev
dart 498 lines 17.2 KB
Raw
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 }