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:
Konstantin Ullrich 2025-04-10 04:03:06 +02:00 committed by GitHub
parent 2b020c0c1b
commit c1e9668b1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 458 additions and 69 deletions

View 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";
}

View 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;
}

View 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;
}

View 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);
}