Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/networking/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'dart:convert';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:http/io_client.dart';
import 'package:socks5_proxy/socks_client.dart';

import '../utilities/logger.dart';
Expand Down Expand Up @@ -269,3 +271,27 @@ class HTTP {
return completer.future;
}
}

/// HTTP client class that can be used with libraries that
/// accept an http.Client
class StackClient extends BaseClient {
StackClient({required this.proxyInfo});

final ({InternetAddress host, int port})? Function() proxyInfo;

@override
Future<StreamedResponse> send(BaseRequest request) async {
final httpClient = HttpClient();
final proxy = proxyInfo();
if (proxy != null) {
SocksTCPClient.assignToHttpClient(httpClient, [
ProxySettings(proxy.host, proxy.port),
]);
}
try {
return await IOClient(httpClient).send(request);
} finally {
httpClient.close();
}
}
}
280 changes: 280 additions & 0 deletions lib/pages/open_crypto_pay/open_crypto_pay_send_fee.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import 'package:decimal/decimal.dart';
import 'package:flutter/widgets.dart';

import '../../models/paymint/fee_object_model.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/enums/fee_rate_type_enum.dart';
import '../../utilities/eth_commons.dart';
import '../../utilities/logger.dart';
import '../../wallets/wallet/impl/ethereum_wallet.dart';
import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart';
import '../../wallets/wallet/wallet.dart';
import '../../wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart';
import '../../widgets/eth_fee_form.dart';

const _highFeeTitle = "High network fee";
String _highFeeMessage(String required, String fast) =>
"The payment request requires a network fee of at least $required, "
"above the current fast estimate of $fast.";
const _unmetFeeTitle = "Network fee too low";
const _unknownFeeTitle = "Network fee unknown";
const _unknownFeeMessage =
"The network fee could not be estimated, so the payment request's "
"minimum cannot be checked. Check the wallet's connection and sync.";
String _unmetFeeMessage(String required, String fastest) =>
"The payment request requires a network fee of at least $required, "
"above this wallet's fastest fee of $fastest.";
String _fixedFeeMessage(String required, String fee) =>
"The payment request requires a network fee of at least $required, "
"above this transaction's fee of $fee.";

/// Fee values for a send, with the payment request's minimum fee applied.
typedef OpenCryptoPaySendFee = ({
FeeRateType feeRateType,
int? satsPerVByte,
EthEIP1559Fee? ethFee,
});

/// Asks the user to confirm; false when they cancelled.
typedef OpenCryptoPayConfirm = Future<bool> Function(
BuildContext context,
String title,
String message,
);

/// Tells the user the payment cannot be made yet.
typedef OpenCryptoPayNotify = Future<void> Function(
BuildContext context,
String title,
String message,
);

/// The chosen fee, raised to the minimum when below it. Null when the user
/// cancelled the confirmation, no fee level reaches the minimum, or the fee
/// cannot be estimated.
Future<OpenCryptoPaySendFee?> openCryptoPaySendFee(
BuildContext context,
Wallet wallet, {
required Amount amount,
required num minFee,
required OpenCryptoPaySendFee chosen,
required OpenCryptoPayConfirm confirm,
required OpenCryptoPayNotify unmet,
}) async {
final bool isUtxo = wallet is ElectrumXInterface;
final bool isEvm = wallet is EthereumWallet || wallet is EthTokenWallet;
final FeeObject fees;
try {
fees = await wallet.fees;
} catch (e, s) {
Logging.instance.w(
"OpenCryptoPay fee estimate unavailable",
error: e,
stackTrace: s,
);
if (!context.mounted) return null;
return _feeUnknown(context, unmet);
}
if (!context.mounted) return null;
if (isUtxo) return _utxoSendFee(context, fees, minFee, chosen, confirm);
if (isEvm) {
return _evmSendFee(
context,
wallet,
fees as EthFeeObject,
minFee,
chosen,
confirm,
);
}
return _levelSendFee(context, wallet, fees, amount, minFee, chosen, unmet);
}

/// Picks the lowest fee level at or above the chosen one whose estimated fee
/// reaches the minimum.
Future<OpenCryptoPaySendFee?> _levelSendFee(
BuildContext context,
Wallet wallet,
FeeObject fees,
Amount amount,
num minFee,
OpenCryptoPaySendFee chosen,
OpenCryptoPayNotify unmet,
) async {
final required = _requiredRaw(minFee);
final levels = [
(type: FeeRateType.slow, rate: fees.slow),
(type: FeeRateType.average, rate: fees.medium),
(type: FeeRateType.fast, rate: fees.fast),
];
final start = levels.indexWhere((l) => l.type == chosen.feeRateType);
Amount? fee;
for (final level in levels.sublist(start < 0 ? 0 : start)) {
try {
fee = await wallet.estimateFeeFor(amount, level.rate);
} catch (e, s) {
Logging.instance.w(
"OpenCryptoPay fee estimate unavailable",
error: e,
stackTrace: s,
);
if (!context.mounted) return null;
return _feeUnknown(context, unmet);
}
// A zero estimate means the wallet cannot estimate yet.
if (fee.raw <= BigInt.zero) {
Logging.instance.w("OpenCryptoPay fee estimate is zero");
if (!context.mounted) return null;
return _feeUnknown(context, unmet);
}
if (fee.raw >= required) {
return level.type == chosen.feeRateType
? chosen
: (
feeRateType: level.type,
satsPerVByte: chosen.satsPerVByte,
ethFee: chosen.ethFee,
);
}
}
final fastest = fee!;
if (!context.mounted) return null;
await unmet(
context,
_unmetFeeTitle,
_unmetFeeMessage(
_coins(wallet, required, fastest.fractionDigits),
_coins(wallet, fastest.raw, fastest.fractionDigits),
),
);
return null;
}

/// Whether a prepared transaction's [fee] reaches the minimum, for sends that
/// build their own fee.
Future<bool> openCryptoPayPreparedFeeMeetsMinimum(
BuildContext context,
Wallet wallet, {
required num minFee,
required Amount? fee,
required OpenCryptoPayNotify unmet,
}) async {
if (fee == null || fee.raw <= BigInt.zero) {
await _feeUnknown(context, unmet);
return false;
}
final required = _requiredRaw(minFee);
if (fee.raw >= required) return true;
await unmet(
context,
_unmetFeeTitle,
_fixedFeeMessage(
_coins(wallet, required, fee.fractionDigits),
_coins(wallet, fee.raw, fee.fractionDigits),
),
);
return false;
}

BigInt _requiredRaw(num minFee) => BigInt.from(minFee.ceil());

String _coins(Wallet wallet, BigInt raw, int fractionDigits) {
final amount = Amount(rawValue: raw, fractionDigits: fractionDigits);
return "${amount.decimal} ${wallet.cryptoCurrency.ticker}";
}

/// Stops the send when the fee cannot be checked against the minimum.
Future<OpenCryptoPaySendFee?> _feeUnknown(
BuildContext context,
OpenCryptoPayNotify unmet,
) async {
await unmet(context, _unknownFeeTitle, _unknownFeeMessage);
return null;
}

Future<OpenCryptoPaySendFee?> _utxoSendFee(
BuildContext context,
FeeObject fees,
num minFee,
OpenCryptoPaySendFee chosen,
OpenCryptoPayConfirm confirm,
) async {
final requiredPerKb = BigInt.from((minFee * 1000).ceil());
final current = switch (chosen.feeRateType) {
FeeRateType.fast => fees.fast,
FeeRateType.average => fees.medium,
FeeRateType.slow => fees.slow,
FeeRateType.custom => BigInt.from((chosen.satsPerVByte ?? 0) * 1000),
};
if (current >= requiredPerKb) return chosen;
final required = minFee.ceil();
String perVByte(BigInt perKb) =>
"${Decimal.fromBigInt(perKb).shift(-3).toStringAsFixed(2)} sats/vByte";
if (requiredPerKb > fees.fast &&
!await confirm(
context,
_highFeeTitle,
_highFeeMessage("$required sats/vByte", perVByte(fees.fast)),
)) {
return null;
}
return (
feeRateType: FeeRateType.custom,
satsPerVByte: required,
ethFee: chosen.ethFee,
);
}

Future<OpenCryptoPaySendFee?> _evmSendFee(
BuildContext context,
Wallet wallet,
EthFeeObject fees,
num minFee,
OpenCryptoPaySendFee chosen,
OpenCryptoPayConfirm confirm,
) async {
final minWei = _requiredRaw(minFee);
BigInt customPrice(EthEIP1559Fee? fee) {
if (fee == null) return BigInt.zero;
final price = fees.suggestBaseFee + fee.maxPriorityFeePerGasWei;
return price < fee.maxFeePerGasWei ? price : fee.maxFeePerGasWei;
}

final current = switch (chosen.feeRateType) {
FeeRateType.fast => fees.fast,
FeeRateType.average => fees.medium,
FeeRateType.slow => fees.slow,
FeeRateType.custom => customPrice(chosen.ethFee),
};
if (current >= minWei) return chosen;
Decimal gwei(BigInt wei) => Decimal.fromBigInt(wei).shift(-9);
if (minWei > fees.fast &&
!await confirm(
context,
_highFeeTitle,
_highFeeMessage(
"${gwei(minWei).toStringAsFixed(2)} gwei",
"${gwei(fees.fast).toStringAsFixed(2)} gwei",
),
)) {
return null;
}
// The priority fee tops the base fee up to the minimum gas price.
final caps = resolveEip1559FeeCaps(
baseFee: fees.suggestBaseFee,
priorityFeePerGas: minWei - fees.suggestBaseFee,
);
return (
feeRateType: FeeRateType.custom,
satsPerVByte: chosen.satsPerVByte,
ethFee: EthEIP1559Fee(
maxFeePerGasGwei: gwei(caps.maxFeePerGas),
maxPriorityFeePerGasGwei: gwei(caps.maxPriorityFeePerGas),
gasLimit:
chosen.ethFee?.gasLimit ??
(wallet is EthTokenWallet
? kEthereumTokenMinGasLimit
: kEthereumMinGasLimit),
),
);
}
Loading
Loading