mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-01-10 12:54:38 +00:00
Improve sending tx for electrum (#1790)
* Enhance the code for sending/sending-ALL for Electrum * remove print statements [skip ci] * update bitcoin base and minor reformatting
This commit is contained in:
parent
884a822cea
commit
28804b8ff2
3 changed files with 77 additions and 116 deletions
|
@ -439,8 +439,8 @@ abstract class ElectrumWalletBase
|
||||||
|
|
||||||
TxCreateUtxoDetails _createUTXOS({
|
TxCreateUtxoDetails _createUTXOS({
|
||||||
required bool sendAll,
|
required bool sendAll,
|
||||||
required int credentialsAmount,
|
|
||||||
required bool paysToSilentPayment,
|
required bool paysToSilentPayment,
|
||||||
|
int credentialsAmount = 0,
|
||||||
int? inputsCount,
|
int? inputsCount,
|
||||||
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
||||||
}) {
|
}) {
|
||||||
|
@ -574,13 +574,11 @@ abstract class ElectrumWalletBase
|
||||||
List<BitcoinOutput> outputs,
|
List<BitcoinOutput> outputs,
|
||||||
int feeRate, {
|
int feeRate, {
|
||||||
String? memo,
|
String? memo,
|
||||||
int credentialsAmount = 0,
|
|
||||||
bool hasSilentPayment = false,
|
bool hasSilentPayment = false,
|
||||||
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
||||||
}) async {
|
}) async {
|
||||||
final utxoDetails = _createUTXOS(
|
final utxoDetails = _createUTXOS(
|
||||||
sendAll: true,
|
sendAll: true,
|
||||||
credentialsAmount: credentialsAmount,
|
|
||||||
paysToSilentPayment: hasSilentPayment,
|
paysToSilentPayment: hasSilentPayment,
|
||||||
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
||||||
);
|
);
|
||||||
|
@ -603,23 +601,11 @@ abstract class ElectrumWalletBase
|
||||||
throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee);
|
throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (amount <= 0) {
|
|
||||||
throw BitcoinTransactionWrongBalanceException();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempting to send less than the dust limit
|
// Attempting to send less than the dust limit
|
||||||
if (_isBelowDust(amount)) {
|
if (_isBelowDust(amount)) {
|
||||||
throw BitcoinTransactionNoDustException();
|
throw BitcoinTransactionNoDustException();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credentialsAmount > 0) {
|
|
||||||
final amountLeftForFee = amount - credentialsAmount;
|
|
||||||
if (amountLeftForFee > 0 && _isBelowDust(amountLeftForFee)) {
|
|
||||||
amount -= amountLeftForFee;
|
|
||||||
fee += amountLeftForFee;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outputs.length == 1) {
|
if (outputs.length == 1) {
|
||||||
outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount));
|
outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount));
|
||||||
}
|
}
|
||||||
|
@ -649,6 +635,11 @@ abstract class ElectrumWalletBase
|
||||||
bool hasSilentPayment = false,
|
bool hasSilentPayment = false,
|
||||||
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Attempting to send less than the dust limit
|
||||||
|
if (_isBelowDust(credentialsAmount)) {
|
||||||
|
throw BitcoinTransactionNoDustException();
|
||||||
|
}
|
||||||
|
|
||||||
final utxoDetails = _createUTXOS(
|
final utxoDetails = _createUTXOS(
|
||||||
sendAll: false,
|
sendAll: false,
|
||||||
credentialsAmount: credentialsAmount,
|
credentialsAmount: credentialsAmount,
|
||||||
|
@ -726,7 +717,43 @@ abstract class ElectrumWalletBase
|
||||||
final lastOutput = updatedOutputs.last;
|
final lastOutput = updatedOutputs.last;
|
||||||
final amountLeftForChange = amountLeftForChangeAndFee - fee;
|
final amountLeftForChange = amountLeftForChangeAndFee - fee;
|
||||||
|
|
||||||
if (!_isBelowDust(amountLeftForChange)) {
|
if (_isBelowDust(amountLeftForChange)) {
|
||||||
|
// If has change that is lower than dust, will end up with tx rejected by network rules
|
||||||
|
// so remove the change amount
|
||||||
|
updatedOutputs.removeLast();
|
||||||
|
outputs.removeLast();
|
||||||
|
|
||||||
|
if (amountLeftForChange < 0) {
|
||||||
|
if (!spendingAllCoins) {
|
||||||
|
return estimateTxForAmount(
|
||||||
|
credentialsAmount,
|
||||||
|
outputs,
|
||||||
|
updatedOutputs,
|
||||||
|
feeRate,
|
||||||
|
inputsCount: utxoDetails.utxos.length + 1,
|
||||||
|
memo: memo,
|
||||||
|
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
|
||||||
|
hasSilentPayment: hasSilentPayment,
|
||||||
|
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw BitcoinTransactionWrongBalanceException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EstimatedTxResult(
|
||||||
|
utxos: utxoDetails.utxos,
|
||||||
|
inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos,
|
||||||
|
publicKeys: utxoDetails.publicKeys,
|
||||||
|
fee: fee,
|
||||||
|
amount: amount,
|
||||||
|
hasChange: false,
|
||||||
|
isSendAll: spendingAllCoins,
|
||||||
|
memo: memo,
|
||||||
|
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
|
||||||
|
spendsSilentPayment: utxoDetails.spendsSilentPayment,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
// Here, lastOutput already is change, return the amount left without the fee to the user's address.
|
// Here, lastOutput already is change, return the amount left without the fee to the user's address.
|
||||||
updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput(
|
updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput(
|
||||||
address: lastOutput.address,
|
address: lastOutput.address,
|
||||||
|
@ -740,75 +767,6 @@ abstract class ElectrumWalletBase
|
||||||
isSilentPayment: lastOutput.isSilentPayment,
|
isSilentPayment: lastOutput.isSilentPayment,
|
||||||
isChange: true,
|
isChange: true,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// If has change that is lower than dust, will end up with tx rejected by network rules, so estimate again without the added change
|
|
||||||
updatedOutputs.removeLast();
|
|
||||||
outputs.removeLast();
|
|
||||||
|
|
||||||
// Still has inputs to spend before failing
|
|
||||||
if (!spendingAllCoins) {
|
|
||||||
return estimateTxForAmount(
|
|
||||||
credentialsAmount,
|
|
||||||
outputs,
|
|
||||||
updatedOutputs,
|
|
||||||
feeRate,
|
|
||||||
inputsCount: utxoDetails.utxos.length + 1,
|
|
||||||
memo: memo,
|
|
||||||
hasSilentPayment: hasSilentPayment,
|
|
||||||
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
|
|
||||||
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final estimatedSendAll = await estimateSendAllTx(
|
|
||||||
updatedOutputs,
|
|
||||||
feeRate,
|
|
||||||
memo: memo,
|
|
||||||
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (estimatedSendAll.amount == credentialsAmount) {
|
|
||||||
return estimatedSendAll;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimate to user how much is needed to send to cover the fee
|
|
||||||
final maxAmountWithReturningChange = utxoDetails.allInputsAmount - _dustAmount - fee - 1;
|
|
||||||
throw BitcoinTransactionNoDustOnChangeException(
|
|
||||||
BitcoinAmountUtils.bitcoinAmountToString(amount: maxAmountWithReturningChange),
|
|
||||||
BitcoinAmountUtils.bitcoinAmountToString(amount: estimatedSendAll.amount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempting to send less than the dust limit
|
|
||||||
if (_isBelowDust(amount)) {
|
|
||||||
throw BitcoinTransactionNoDustException();
|
|
||||||
}
|
|
||||||
|
|
||||||
final totalAmount = amount + fee;
|
|
||||||
|
|
||||||
if (totalAmount > (balance[currency]!.confirmed + balance[currency]!.secondConfirmed)) {
|
|
||||||
throw BitcoinTransactionWrongBalanceException();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalAmount > utxoDetails.allInputsAmount) {
|
|
||||||
if (spendingAllCoins) {
|
|
||||||
throw BitcoinTransactionWrongBalanceException();
|
|
||||||
} else {
|
|
||||||
updatedOutputs.removeLast();
|
|
||||||
outputs.removeLast();
|
|
||||||
return estimateTxForAmount(
|
|
||||||
credentialsAmount,
|
|
||||||
outputs,
|
|
||||||
updatedOutputs,
|
|
||||||
feeRate,
|
|
||||||
inputsCount: utxoDetails.utxos.length + 1,
|
|
||||||
memo: memo,
|
|
||||||
useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins,
|
|
||||||
hasSilentPayment: hasSilentPayment,
|
|
||||||
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return EstimatedTxResult(
|
return EstimatedTxResult(
|
||||||
utxos: utxoDetails.utxos,
|
utxos: utxoDetails.utxos,
|
||||||
|
@ -817,12 +775,14 @@ abstract class ElectrumWalletBase
|
||||||
fee: fee,
|
fee: fee,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
hasChange: true,
|
hasChange: true,
|
||||||
isSendAll: false,
|
isSendAll: spendingAllCoins,
|
||||||
memo: memo,
|
memo: memo,
|
||||||
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
|
spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX,
|
||||||
spendsSilentPayment: utxoDetails.spendsSilentPayment,
|
spendsSilentPayment: utxoDetails.spendsSilentPayment,
|
||||||
|
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> calcFee({
|
Future<int> calcFee({
|
||||||
required List<UtxoWithAddress> utxos,
|
required List<UtxoWithAddress> utxos,
|
||||||
|
@ -895,15 +855,20 @@ abstract class ElectrumWalletBase
|
||||||
: feeRate(transactionCredentials.priority!);
|
: feeRate(transactionCredentials.priority!);
|
||||||
|
|
||||||
EstimatedTxResult estimatedTx;
|
EstimatedTxResult estimatedTx;
|
||||||
final updatedOutputs =
|
final updatedOutputs = outputs
|
||||||
outputs.map((e) => BitcoinOutput(address: e.address, value: e.value)).toList();
|
.map((e) => BitcoinOutput(
|
||||||
|
address: e.address,
|
||||||
|
value: e.value,
|
||||||
|
isSilentPayment: e.isSilentPayment,
|
||||||
|
isChange: e.isChange,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (sendAll) {
|
if (sendAll) {
|
||||||
estimatedTx = await estimateSendAllTx(
|
estimatedTx = await estimateSendAllTx(
|
||||||
updatedOutputs,
|
updatedOutputs,
|
||||||
feeRateInt,
|
feeRateInt,
|
||||||
memo: memo,
|
memo: memo,
|
||||||
credentialsAmount: credentialsAmount,
|
|
||||||
hasSilentPayment: hasSilentPayment,
|
hasSilentPayment: hasSilentPayment,
|
||||||
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
coinTypeToSpendFrom: coinTypeToSpendFrom,
|
||||||
);
|
);
|
||||||
|
|
|
@ -109,12 +109,7 @@ PODS:
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- permission_handler_apple (9.1.1):
|
- permission_handler_apple (9.1.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Protobuf (3.27.2)
|
|
||||||
- ReachabilitySwift (5.2.3)
|
- ReachabilitySwift (5.2.3)
|
||||||
- reactive_ble_mobile (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- Protobuf (~> 3.5)
|
|
||||||
- SwiftProtobuf (~> 1.0)
|
|
||||||
- SDWebImage (5.19.4):
|
- SDWebImage (5.19.4):
|
||||||
- SDWebImage/Core (= 5.19.4)
|
- SDWebImage/Core (= 5.19.4)
|
||||||
- SDWebImage/Core (5.19.4)
|
- SDWebImage/Core (5.19.4)
|
||||||
|
@ -132,6 +127,9 @@ PODS:
|
||||||
- Toast (4.1.1)
|
- Toast (4.1.1)
|
||||||
- uni_links (0.0.1):
|
- uni_links (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- universal_ble (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- wakelock_plus (0.0.1):
|
- wakelock_plus (0.0.1):
|
||||||
|
@ -161,12 +159,12 @@ DEPENDENCIES:
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- reactive_ble_mobile (from `.symlinks/plugins/reactive_ble_mobile/ios`)
|
|
||||||
- sensitive_clipboard (from `.symlinks/plugins/sensitive_clipboard/ios`)
|
- sensitive_clipboard (from `.symlinks/plugins/sensitive_clipboard/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sp_scanner (from `.symlinks/plugins/sp_scanner/ios`)
|
- sp_scanner (from `.symlinks/plugins/sp_scanner/ios`)
|
||||||
- uni_links (from `.symlinks/plugins/uni_links/ios`)
|
- uni_links (from `.symlinks/plugins/uni_links/ios`)
|
||||||
|
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||||
|
@ -178,7 +176,6 @@ SPEC REPOS:
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- MTBBarcodeScanner
|
- MTBBarcodeScanner
|
||||||
- OrderedSet
|
- OrderedSet
|
||||||
- Protobuf
|
|
||||||
- ReachabilitySwift
|
- ReachabilitySwift
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftProtobuf
|
- SwiftProtobuf
|
||||||
|
@ -226,8 +223,6 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
reactive_ble_mobile:
|
|
||||||
:path: ".symlinks/plugins/reactive_ble_mobile/ios"
|
|
||||||
sensitive_clipboard:
|
sensitive_clipboard:
|
||||||
:path: ".symlinks/plugins/sensitive_clipboard/ios"
|
:path: ".symlinks/plugins/sensitive_clipboard/ios"
|
||||||
share_plus:
|
share_plus:
|
||||||
|
@ -238,6 +233,8 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/sp_scanner/ios"
|
:path: ".symlinks/plugins/sp_scanner/ios"
|
||||||
uni_links:
|
uni_links:
|
||||||
:path: ".symlinks/plugins/uni_links/ios"
|
:path: ".symlinks/plugins/uni_links/ios"
|
||||||
|
universal_ble:
|
||||||
|
:path: ".symlinks/plugins/universal_ble/darwin"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
wakelock_plus:
|
wakelock_plus:
|
||||||
|
@ -271,9 +268,7 @@ SPEC CHECKSUMS:
|
||||||
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
|
||||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
||||||
Protobuf: fb2c13674723f76ff6eede14f78847a776455fa2
|
|
||||||
ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979
|
ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979
|
||||||
reactive_ble_mobile: 9ce6723d37ccf701dbffd202d487f23f5de03b4c
|
|
||||||
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d
|
||||||
sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986
|
sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986
|
||||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||||
|
@ -283,6 +278,7 @@ SPEC CHECKSUMS:
|
||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
|
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
|
||||||
uni_links: d97da20c7701486ba192624d99bffaaffcfc298a
|
uni_links: d97da20c7701486ba192624d99bffaaffcfc298a
|
||||||
|
universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6
|
||||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||||
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||||
|
|
|
@ -482,18 +482,18 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
|
||||||
nano!.updateTransactions(wallet);
|
nano!.updateTransactions(wallet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (pendingTransaction!.id.isNotEmpty) {
|
if (pendingTransaction!.id.isNotEmpty) {
|
||||||
|
|
||||||
final descriptionKey = '${pendingTransaction!.id}_${wallet.walletAddresses.primaryAddress}';
|
final descriptionKey = '${pendingTransaction!.id}_${wallet.walletAddresses.primaryAddress}';
|
||||||
_settingsStore.shouldSaveRecipientAddress
|
_settingsStore.shouldSaveRecipientAddress
|
||||||
? await transactionDescriptionBox.add(TransactionDescription(
|
? await transactionDescriptionBox.add(TransactionDescription(
|
||||||
id: descriptionKey,
|
id: descriptionKey,
|
||||||
recipientAddress: address,
|
recipientAddress: address,
|
||||||
transactionNote: note))
|
transactionNote: note,
|
||||||
|
))
|
||||||
: await transactionDescriptionBox.add(TransactionDescription(
|
: await transactionDescriptionBox.add(TransactionDescription(
|
||||||
id: descriptionKey,
|
id: descriptionKey,
|
||||||
transactionNote: note));
|
transactionNote: note,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
state = TransactionCommitted();
|
state = TransactionCommitted();
|
||||||
|
|
Loading…
Reference in a new issue