mirror of
https://github.com/cake-tech/cake_wallet.git
synced 2025-06-28 20:39:51 +00:00
CW-969-Add-DFX-s-Open-CryptoPay (#2122)
* feat: Add Open CryptoPay for sending * bug: Fix sending Open CryptoPay for eth * feat: add transaction dismissal * style: remove print statements from OCP * feat: add support for Monero over OCP * bug: Hide "Add to Address Book" on OCP transactions * style: revert code style in send_view_model.dart * style: revert code style in send_view_model.dart * feat: set failure state if it fails to create an OCP transaction * Update lib/view_model/send/send_view_model.dart --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
parent
2b020c0c1b
commit
c1e9668b1e
11 changed files with 458 additions and 69 deletions
18
lib/core/open_crypto_pay/exceptions.dart
Normal file
18
lib/core/open_crypto_pay/exceptions.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
class OpenCryptoPayException implements Exception {
|
||||
final String message;
|
||||
|
||||
OpenCryptoPayException([this.message = '']);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'OpenCryptoPayException${message.isNotEmpty ? ': $message' : ''}';
|
||||
}
|
||||
|
||||
class OpenCryptoPayNotSupportedException extends OpenCryptoPayException {
|
||||
final String provider;
|
||||
|
||||
OpenCryptoPayNotSupportedException(this.provider);
|
||||
|
||||
@override
|
||||
String get message => "$provider does not support Open CryptoPay";
|
||||
}
|
78
lib/core/open_crypto_pay/lnurl.dart
Normal file
78
lib/core/open_crypto_pay/lnurl.dart
Normal file
|
@ -0,0 +1,78 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:bech32/bech32.dart';
|
||||
|
||||
Uri decodeLNURL(String encodedUrl) {
|
||||
Uri decodedUri;
|
||||
|
||||
/// The URL doesn't have to be encoded at all as per LUD-17: Protocol schemes and raw (non bech32-encoded) URLs.
|
||||
/// https://github.com/lnurl/luds/blob/luds/17.md
|
||||
/// Handle non bech32-encoded LNURL
|
||||
final lud17prefixes = ['lnurlw', 'lnurlc', 'lnurlp', 'keyauth'];
|
||||
decodedUri = Uri.parse(encodedUrl);
|
||||
for (final prefix in lud17prefixes) {
|
||||
if (decodedUri.scheme.contains(prefix)) {
|
||||
decodedUri = decodedUri.replace(scheme: prefix);
|
||||
}
|
||||
}
|
||||
if (lud17prefixes.contains(decodedUri.scheme)) {
|
||||
/// If the non-bech32 LNURL is a Tor address, the port has to be http instead of https for the clearnet LNURL so check if the host ends with '.onion' or '.onion.'
|
||||
decodedUri = decodedUri.replace(
|
||||
scheme: decodedUri.host.endsWith('onion') ||
|
||||
decodedUri.host.endsWith('onion.')
|
||||
? 'http'
|
||||
: 'https');
|
||||
} else {
|
||||
/// Try to parse the input as a lnUrl. Will throw an error if it fails.
|
||||
final lnUrl = _findLnUrl(encodedUrl);
|
||||
|
||||
/// Decode the lnurl using bech32
|
||||
final bech32 = const Bech32Codec().decode(lnUrl, lnUrl.length);
|
||||
decodedUri = Uri.parse(utf8.decode(_convert(bech32.data, 5, 8, false)));
|
||||
}
|
||||
return decodedUri;
|
||||
}
|
||||
|
||||
/// Parse and return a given lnurl string if it's valid. Will remove
|
||||
/// `lightning:` from the beginning of it if present.
|
||||
String _findLnUrl(String input) {
|
||||
final res = RegExp(
|
||||
r',*?((lnurl)([0-9]+[a-z0-9]+))',
|
||||
).allMatches(input.toLowerCase());
|
||||
|
||||
if (res.length == 1) {
|
||||
return res.first.group(0)!;
|
||||
} else {
|
||||
throw ArgumentError('Not a valid lnurl string');
|
||||
}
|
||||
}
|
||||
|
||||
/// Taken from bech32 (bitcoinjs): https://github.com/bitcoinjs/bech32
|
||||
List<int> _convert(List<int> data, int inBits, int outBits, bool pad) {
|
||||
var value = 0;
|
||||
var bits = 0;
|
||||
final maxV = (1 << outBits) - 1;
|
||||
|
||||
final result = <int>[];
|
||||
for (final dataValue in data) {
|
||||
value = (value << inBits) | dataValue;
|
||||
bits += inBits;
|
||||
|
||||
while (bits >= outBits) {
|
||||
bits -= outBits;
|
||||
result.add((value >> bits) & maxV);
|
||||
}
|
||||
}
|
||||
|
||||
if (pad) {
|
||||
if (bits > 0) result.add((value << (outBits - bits)) & maxV);
|
||||
} else {
|
||||
if (bits >= inBits) throw Exception('Excess padding');
|
||||
|
||||
if ((value << (outBits - bits)) & maxV > 0) {
|
||||
throw Exception('Non-zero padding');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
26
lib/core/open_crypto_pay/models.dart
Normal file
26
lib/core/open_crypto_pay/models.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
class OpenCryptoPayRequest {
|
||||
final String receiverName;
|
||||
final int expiry;
|
||||
final String callbackUrl;
|
||||
final String quote;
|
||||
final Map<String, List<OpenCryptoPayQuoteAsset>> methods;
|
||||
|
||||
OpenCryptoPayRequest({
|
||||
required this.receiverName,
|
||||
required this.expiry,
|
||||
required this.callbackUrl,
|
||||
required this.quote,
|
||||
required this.methods,
|
||||
});
|
||||
}
|
||||
|
||||
class OpenCryptoPayQuoteAsset {
|
||||
final String symbol;
|
||||
final String amount;
|
||||
|
||||
const OpenCryptoPayQuoteAsset(this.symbol, this.amount);
|
||||
|
||||
OpenCryptoPayQuoteAsset.fromJson(Map<String, dynamic> json)
|
||||
: symbol = json['asset'] as String,
|
||||
amount = json['amount'] as String;
|
||||
}
|
184
lib/core/open_crypto_pay/open_cryptopay_service.dart
Normal file
184
lib/core/open_crypto_pay/open_cryptopay_service.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:cake_wallet/core/open_crypto_pay/exceptions.dart';
|
||||
import 'package:cake_wallet/core/open_crypto_pay/lnurl.dart';
|
||||
import 'package:cake_wallet/core/open_crypto_pay/models.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:http/http.dart';
|
||||
|
||||
class OpenCryptoPayService {
|
||||
static bool isOpenCryptoPayQR(String value) =>
|
||||
value.toLowerCase().contains("lightning=lnurl") ||
|
||||
value.toLowerCase().startsWith("lnurl");
|
||||
|
||||
final Client _httpClient = Client();
|
||||
|
||||
Future<String> commitOpenCryptoPayRequest(
|
||||
String txHex, {
|
||||
required String txId,
|
||||
required OpenCryptoPayRequest request,
|
||||
required CryptoCurrency asset,
|
||||
}) async {
|
||||
final uri = Uri.parse(request.callbackUrl.replaceAll("/cb/", "/tx/"));
|
||||
|
||||
final queryParams = Map.of(uri.queryParameters);
|
||||
|
||||
queryParams['quote'] = request.quote;
|
||||
queryParams['asset'] = asset.title;
|
||||
queryParams['method'] = _getMethod(asset);
|
||||
queryParams['hex'] = txHex;
|
||||
queryParams['tx'] = txId;
|
||||
|
||||
final response =
|
||||
await _httpClient.get(Uri.https(uri.authority, uri.path, queryParams));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final body = jsonDecode(response.body) as Map;
|
||||
|
||||
if (body.keys.contains("txId")) return body["txId"] as String;
|
||||
throw OpenCryptoPayException(body.toString());
|
||||
}
|
||||
throw OpenCryptoPayException(
|
||||
"Unexpected status code ${response.statusCode} ${response.body}");
|
||||
}
|
||||
|
||||
Future<void> cancelOpenCryptoPayRequest(OpenCryptoPayRequest request) async {
|
||||
final uri = Uri.parse(request.callbackUrl.replaceAll("/cb/", "/cancel/"));
|
||||
|
||||
await _httpClient.delete(uri);
|
||||
}
|
||||
|
||||
Future<OpenCryptoPayRequest> getOpenCryptoPayInvoice(String lnUrl) async {
|
||||
if (lnUrl.toLowerCase().startsWith("http")) {
|
||||
final uri = Uri.parse(lnUrl);
|
||||
final params = uri.queryParameters;
|
||||
if (!params.containsKey("lightning")) {
|
||||
throw OpenCryptoPayNotSupportedException(uri.authority);
|
||||
}
|
||||
|
||||
lnUrl = params["lightning"] as String;
|
||||
}
|
||||
final url = decodeLNURL(lnUrl);
|
||||
final params = await _getOpenCryptoPayParams(url);
|
||||
|
||||
return OpenCryptoPayRequest(
|
||||
receiverName: params.$1.displayName ?? "Unknown",
|
||||
expiry: params.$1.expiration.difference(DateTime.now()).inSeconds,
|
||||
callbackUrl: params.$1.callbackUrl,
|
||||
quote: params.$1.id,
|
||||
methods: params.$2,
|
||||
);
|
||||
}
|
||||
|
||||
Future<(_OpenCryptoPayQuote, Map<String, List<OpenCryptoPayQuoteAsset>>)>
|
||||
_getOpenCryptoPayParams(Uri uri) async {
|
||||
final response = await _httpClient.get(uri);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseBody = jsonDecode(response.body) as Map;
|
||||
|
||||
for (final key in ['callback', 'transferAmounts', 'quote']) {
|
||||
if (!responseBody.keys.contains(key)) {
|
||||
throw OpenCryptoPayNotSupportedException(uri.authority);
|
||||
}
|
||||
}
|
||||
|
||||
final methods = <String, List<OpenCryptoPayQuoteAsset>>{};
|
||||
for (final transferAmountRaw in responseBody['transferAmounts'] as List) {
|
||||
final transferAmount = transferAmountRaw as Map;
|
||||
final method = transferAmount['method'] as String;
|
||||
methods[method] = [];
|
||||
for (final assetJson in transferAmount['assets'] as List) {
|
||||
final asset = OpenCryptoPayQuoteAsset.fromJson(
|
||||
assetJson as Map<String, dynamic>);
|
||||
methods[method]?.add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
log(responseBody.toString());
|
||||
|
||||
final quote = _OpenCryptoPayQuote.fromJson(
|
||||
responseBody['callback'] as String,
|
||||
responseBody['displayName'] as String?,
|
||||
responseBody['quote'] as Map<String, dynamic>);
|
||||
|
||||
return (quote, methods);
|
||||
} else {
|
||||
throw OpenCryptoPayException(
|
||||
'Failed to get Open CryptoPay Request. Status: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uri> getOpenCryptoPayAddress(
|
||||
OpenCryptoPayRequest request, CryptoCurrency asset) async {
|
||||
final uri = Uri.parse(request.callbackUrl);
|
||||
final queryParams = Map.of(uri.queryParameters);
|
||||
|
||||
queryParams['quote'] = request.quote;
|
||||
queryParams['asset'] = asset.title;
|
||||
queryParams['method'] = _getMethod(asset);
|
||||
|
||||
final response =
|
||||
await _httpClient.get(Uri.https(uri.authority, uri.path, queryParams));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final responseBody = jsonDecode(response.body) as Map;
|
||||
|
||||
for (final key in ['expiryDate', 'uri']) {
|
||||
if (!responseBody.keys.contains(key)) {
|
||||
throw OpenCryptoPayNotSupportedException(uri.authority);
|
||||
}
|
||||
}
|
||||
|
||||
final result = Uri.parse(responseBody['uri'] as String);
|
||||
|
||||
if (result.queryParameters['amount'] != null) return result;
|
||||
|
||||
final newQueryParameters =
|
||||
Map<String, dynamic>.from(result.queryParameters);
|
||||
|
||||
newQueryParameters['amount'] = _getAmountByAsset(request, asset);
|
||||
return Uri(
|
||||
scheme: result.scheme,
|
||||
path: result.path.split("@").first,
|
||||
queryParameters: newQueryParameters);
|
||||
} else {
|
||||
throw OpenCryptoPayException(
|
||||
'Failed to create Open CryptoPay Request. Status: ${response.statusCode} ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
String _getAmountByAsset(OpenCryptoPayRequest request, CryptoCurrency asset) {
|
||||
final method = _getMethod(asset);
|
||||
return request.methods[method]!
|
||||
.firstWhere((e) => e.symbol == asset.title)
|
||||
.amount;
|
||||
}
|
||||
|
||||
String _getMethod(CryptoCurrency asset) {
|
||||
switch (asset.tag) {
|
||||
case "ETH":
|
||||
return "Ethereum";
|
||||
case "POL":
|
||||
return "Polygon";
|
||||
default:
|
||||
return asset.fullName!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _OpenCryptoPayQuote {
|
||||
final String callbackUrl;
|
||||
final String? displayName;
|
||||
final String id;
|
||||
final DateTime expiration;
|
||||
|
||||
_OpenCryptoPayQuote(
|
||||
this.callbackUrl, this.displayName, this.id, this.expiration);
|
||||
|
||||
_OpenCryptoPayQuote.fromJson(
|
||||
this.callbackUrl, this.displayName, Map<String, dynamic> json)
|
||||
: id = json['id'] as String,
|
||||
expiration = DateTime.parse(json['expiration'] as String);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue