mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-18 05:51:34 +02:00
440 lines
16 KiB
Dart
440 lines
16 KiB
Dart
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<LogEntry>? _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<SdkEvent>? _eventSubscription;
|
|
Stream<SdkEvent>? _eventStream;
|
|
|
|
StreamSubscription<LogEntry>? _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<bool> 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<void> close() async {
|
|
_eventSubscription?.cancel();
|
|
await sdk.disconnect();
|
|
_logSubscription?.cancel();
|
|
}
|
|
|
|
Future<String?> 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<String> getDepositAddress() async => (await sdk.receivePayment(
|
|
request: ReceivePaymentRequest(paymentMethod: ReceivePaymentMethod.bitcoinAddress())))
|
|
.paymentRequest;
|
|
|
|
Future<BigInt> getBalance() async {
|
|
try {
|
|
return (await sdk.getInfo(request: GetInfoRequest(ensureSynced: true))).balanceSats;
|
|
} on SdkError_Generic catch (_) {
|
|
} on SdkError_NetworkError catch (_) {}
|
|
|
|
return BigInt.zero;
|
|
}
|
|
|
|
Future<String> registerAddress(String username) async {
|
|
return (await sdk.registerLightningAddress(
|
|
request: RegisterLightningAddressRequest(username: username)))
|
|
.lightningAddress;
|
|
}
|
|
|
|
Future<String?> 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<bool> 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<PendingLightningTransaction> 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<Map<String, ElectrumTransactionInfo>> 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 = <String, ElectrumTransactionInfo>{};
|
|
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<List<Map<String, dynamic>>> getUnclaimedDeposits() async {
|
|
final unclaimedDeposits = <Map<String, dynamic>>[];
|
|
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<ElectrumTransactionInfo> 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<String> 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<String, ElectrumTransactionInfo>) onCreateDepositTransactionEvent,
|
|
required Function(List<ElectrumTransactionInfo>) 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 = <String, ElectrumTransactionInfo>{};
|
|
|
|
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},
|
|
);
|
|
}
|
|
}
|