Skip to content
Draft
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
52 changes: 52 additions & 0 deletions lib/networking/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import 'dart:convert';
import 'dart:io';

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

import '../app_config.dart';
import '../services/tor_service.dart';
import '../utilities/logger.dart';
import '../utilities/prefs.dart';

// WIP wrapper layer

Expand Down Expand Up @@ -163,3 +167,51 @@ class HTTP {
return completer.future;
}
}

/// HTTP client class that can be used with librairies that
/// accept an http.Client
class StackClient extends BaseClient {
StackClient({HTTP? client, this.timeout = const Duration(seconds: 30)})
: _client = client ?? const HTTP();

final HTTP _client;
final Duration timeout;

static ({InternetAddress host, int port})? _proxyInfo() {
if (AppConfig.hasFeature(AppFeature.tor) && Prefs.instance.useTor) {
return TorService.sharedInstance.getProxyInfo();
} else {
return null;
}
}

@override
Future<StreamedResponse> send(BaseRequest request) async {
final proxyInfo = _proxyInfo();

final Response response;
switch (request.method) {
case 'GET':
response = await _client
.get(
url: request.url,
headers: request.headers,
proxyInfo: proxyInfo,
connectionTimeout: timeout,
)
.timeout(timeout);
default:
throw ClientException(
'Unsupported HTTP method ${request.method}',
request.url,
);
}

return StreamedResponse(
ByteStream.fromBytes(response.bodyBytes),
response.code,
contentLength: response.bodyBytes.length,
request: request,
);
}
}
224 changes: 224 additions & 0 deletions lib/pages/open_crypto_pay/open_crypto_pay_send_handler.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/*
* This file is part of Stack Wallet.
*
* Copyright (c) 2023 Cypher Stack
* All Rights Reserved.
* The code is distributed under GPLv3 license, see LICENSE file for details.
* Generated by Cypher Stack on 2023-05-26
*
*/

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:opencryptopay/opencryptopay.dart';

import '../../networking/http.dart';
import '../../notifications/show_flush_bar.dart';
import '../../providers/ui/preview_tx_button_state_provider.dart';
import '../../utilities/amount/amount.dart';
import '../../utilities/amount/amount_formatter.dart';
import '../../utilities/logger.dart';
import '../../utilities/show_loading.dart';
import '../../utilities/util.dart';
import '../../wallets/crypto_currency/crypto_currency.dart';
import '../../widgets/stack_dialog.dart';

/// Map a wallet [CryptoCurrency] (plus optional token symbol) to the
/// library's [CryptoCoin] descriptor.
CryptoCoin cryptoCoinFor(CryptoCurrency currency, {String? tokenSymbol}) =>
CryptoCoin(
ticker: tokenSymbol ?? currency.ticker,
prettyName: currency.prettyName,
displayName: tokenSymbol ?? currency.prettyName,
);

class OpenCryptoPaySendHandler {
OpenCryptoPaySendHandler({
required this.ref,
required this.coin,
required this.sendToController,
required this.cryptoAmountController,
required this.setValidAddress,
required this.isMounted,
this.tokenSymbol,
this.tokenDecimals,
@visibleForTesting OpenCryptoPayController? controller,
}) : _controller =
controller ??
OpenCryptoPayController(
service: OpenCryptoPayService(client: StackClient()),
);

final WidgetRef ref;
final CryptoCurrency coin;
final TextEditingController sendToController;
final TextEditingController cryptoAmountController;
final void Function(String address) setValidAddress;
final bool Function() isMounted;

/// Set for token wallets (ERC-20, SPL, ...) so the OpenCryptoPay request
/// targets the token asset (ex: "USDT") instead of the chain's native coin
/// and amounts use the token's decimals.
final String? tokenSymbol;
final int? tokenDecimals;

int get _fractionDigits => tokenDecimals ?? coin.fractionDigits;

final OpenCryptoPayController _controller;
OpenCryptoPaySession? _session;

Future<void> showQuoteExpiredError(
BuildContext context, {
bool paymentNotSent = false,
}) => _showError(
context: context,
title: OpenCryptoPayStrings.quoteExpiredTitle,
message: OpenCryptoPayStrings.quoteExpiredMessage(
paymentNotSent: paymentNotSent,
),
);

bool get requiresBroadcast => _session?.requiresBroadcast ?? true;

bool get isQuoteExpired => _session?.isQuoteExpired ?? false;

bool isActivePaymentFor(String? recipientAddress) =>
_session?.isActivePaymentFor(recipientAddress) ?? false;

Future<OpenCryptoPayResult?> _run({
required BuildContext context,
required String qrData,
required CryptoCoin coin,
required String logLabel,
}) async {
return showLoading<OpenCryptoPayResult>(
whileFuture: _controller.run(
qrData: qrData,
coin: coin,
onError: (e, s) =>
Logging.instance.w(logLabel, error: e, stackTrace: s),
),
context: context,
rootNavigator: Util.isDesktop,
message: OpenCryptoPayStrings.loading,
);
}

Future<void> _showError({
required BuildContext context,
required String title,
required String message,
}) async {
if (!isMounted()) return;
await showDialog<void>(
context: context,
builder: (_) => StackOkDialog(
title: title,
message: message,
desktopPopRootNavigator: Util.isDesktop,
maxWidth: Util.isDesktop ? 600 : null,
),
);
}

Future<OpenCryptoPaySuccess?> _showErrorOrSuccess(
BuildContext context,
OpenCryptoPayResult result,
) async {
switch (result) {
case OpenCryptoPayFailure():
await _showError(
context: context,
title: result.title,
message: result.message,
);
return null;
case OpenCryptoPaySuccess():
return result;
}
}

Future<void> handle(BuildContext context, String qrData) async {
final result = await _run(
context: context,
qrData: qrData,
coin: cryptoCoinFor(coin, tokenSymbol: tokenSymbol),
logLabel: "OpenCryptoPay flow failed",
);

if (!isMounted() || result == null) return;

// ignore: use_build_context_synchronously
final success = await _showErrorOrSuccess(context, result);
if (success == null) return;

if (success.session.isQuoteExpired) {
// ignore: use_build_context_synchronously
await showQuoteExpiredError(context);
return;
}

_applySuccess(success);
}

void _applySuccess(OpenCryptoPaySuccess result) {
_session = result.session;

// Prefill the form in place.
final address = result.address;
sendToController.text = address;

final rawAmount = result.amountInSmallestUnit(_fractionDigits);
if (rawAmount != null) {
final parsed = Amount(
rawValue: rawAmount,
fractionDigits: _fractionDigits,
);
cryptoAmountController.text = ref
.read(pAmountFormatter(coin))
.format(parsed, withUnitName: false);
ref.read(pSendAmount.notifier).state = parsed;
}

setValidAddress(address);
}

Future<bool> submitProof(BuildContext context, String txProof) async {
final session = _session;
if (session == null) return true;

final result = await session.submitProof(txProof);
switch (result) {
case OpenCryptoPayProofAccepted():
_session = null;
return true;
case OpenCryptoPayProofQuoteExpired(:final error):
Logging.instance.w(
"OpenCryptoPay proof submission failed",
error: error,
);
if (!isMounted()) return false;
// ignore: use_build_context_synchronously
await showQuoteExpiredError(context, paymentNotSent: true);
return false;
case OpenCryptoPayProofFailed(:final message, :final error):
// The session stays active so the user can retry.
Logging.instance.w(
"OpenCryptoPay proof submission failed",
error: error,
);
if (!isMounted()) return false;
unawaited(
showFloatingFlushBar(
type: FlushBarType.warning,
message: message,
// ignore: use_build_context_synchronously
context: context,
),
);
return false;
}
}
}
Loading
Loading