Skip to content

Commit fda549f

Browse files
committed
Support ERC20 Open CryptoPay payments
1 parent 6187a48 commit fda549f

11 files changed

Lines changed: 457 additions & 38 deletions

File tree

lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart

Lines changed: 169 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ import 'package:flutter/material.dart';
55
import 'package:flutter_riverpod/flutter_riverpod.dart';
66
import 'package:tuple/tuple.dart';
77

8+
import '../../models/isar/models/ethereum/eth_contract.dart';
89
import '../../models/send_view_auto_fill_data.dart';
910
import '../../notifications/show_flush_bar.dart';
11+
import '../../providers/db/main_db_provider.dart';
12+
import '../../providers/providers.dart';
13+
import '../../services/open_crypto_pay/evm_uri.dart';
1014
import '../../services/open_crypto_pay/method_support.dart';
1115
import '../../services/open_crypto_pay/models.dart';
1216
import '../../services/open_crypto_pay/open_crypto_pay_api.dart';
@@ -15,12 +19,18 @@ import '../../utilities/address_utils.dart';
1519
import '../../utilities/logger.dart';
1620
import '../../utilities/text_styles.dart';
1721
import '../../wallets/crypto_currency/crypto_currency.dart';
22+
import '../../wallets/isar/providers/eth/current_token_wallet_provider.dart';
23+
import '../../wallets/isar/providers/wallet_info_provider.dart';
24+
import '../../wallets/wallet/impl/ethereum_wallet.dart';
25+
import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart';
26+
import '../../wallets/wallet/wallet.dart';
1827
import '../../widgets/background.dart';
1928
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
2029
import '../../widgets/desktop/primary_button.dart';
2130
import '../../widgets/loading_indicator.dart';
2231
import '../../widgets/rounded_white_container.dart';
2332
import '../send_view/send_view.dart';
33+
import '../send_view/token_send_view.dart';
2434

2535
enum OpenCryptoPayConfirmResult { quoteExpired }
2636

@@ -102,6 +112,18 @@ class _OpenCryptoPayConfirmViewState
102112
/// attached to the address.
103113
({String? address, Decimal? amount, int? chainId, String? scheme})
104114
_parseTransactionUri(String uri) {
115+
final evmUri = OpenCryptoPayEvmUri.tryParse(uri);
116+
if (evmUri != null && !evmUri.isTokenTransfer) {
117+
return (
118+
address: evmUri.targetAddress,
119+
amount: evmUri.isNativeTransfer
120+
? evmUri.amount(fractionDigits: widget.coin.fractionDigits)
121+
: Decimal.tryParse(widget.selectedAsset.amount),
122+
chainId: evmUri.chainId,
123+
scheme: evmUri.scheme,
124+
);
125+
}
126+
105127
final parsedUri = Uri.tryParse(uri);
106128
final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance);
107129
var address = data?.address ?? parsedUri?.path;
@@ -125,6 +147,37 @@ class _OpenCryptoPayConfirmViewState
125147
);
126148
}
127149

150+
EthContract? _enabledErc20Token(String contractAddress) {
151+
final normalized = contractAddress.toLowerCase();
152+
final mainDB = ref.read(mainDBProvider);
153+
for (final address in ref.read(pWalletTokenAddresses(widget.walletId))) {
154+
final contract = mainDB.getEthContractSync(address);
155+
if (contract == null || contract.type != EthContractType.erc20) {
156+
continue;
157+
}
158+
if (contract.address.toLowerCase() == normalized) {
159+
return contract;
160+
}
161+
}
162+
return null;
163+
}
164+
165+
Future<EthTokenWallet> _loadTokenWallet(EthContract contract) async {
166+
final wallet = ref.read(pWallets).getWallet(widget.walletId);
167+
if (wallet is! EthereumWallet) {
168+
throw Exception("Ethereum wallet not loaded");
169+
}
170+
171+
final old = ref.read(tokenServiceStateProvider);
172+
final tokenWallet =
173+
Wallet.loadTokenWallet(ethWallet: wallet, contract: contract)
174+
as EthTokenWallet;
175+
await tokenWallet.init();
176+
unawaited(old?.exit());
177+
ref.read(tokenServiceStateProvider.state).state = tokenWallet;
178+
return tokenWallet;
179+
}
180+
128181
Future<void> _proceedToSend() async {
129182
if (_isExpired) {
130183
_warn("Quote expired, refreshing...");
@@ -140,32 +193,11 @@ class _OpenCryptoPayConfirmViewState
140193
return;
141194
}
142195

143-
final parsed = _parseTransactionUri(uri);
144-
if (parsed.address == null) {
145-
_warn("Could not parse payment address");
146-
return;
147-
}
148-
if (parsed.amount == null) {
149-
_warn("Could not parse payment amount");
150-
return;
151-
}
152-
if (parsed.scheme != null &&
153-
parsed.scheme!.isNotEmpty &&
154-
parsed.scheme != widget.coin.uriScheme) {
155-
_warn("Payment URI does not match this wallet");
156-
return;
157-
}
158196
if (_txDetails?.blockchain != null &&
159197
_txDetails!.blockchain != widget.selectedMethod.method) {
160198
_warn("Payment details do not match the selected method");
161199
return;
162200
}
163-
if (widget.selectedMethod.method == 'Ethereum' &&
164-
parsed.chainId != null &&
165-
parsed.chainId != 1) {
166-
_warn("Payment URI is for a different Ethereum network");
167-
return;
168-
}
169201

170202
final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor(
171203
widget.selectedMethod.method,
@@ -187,6 +219,63 @@ class _OpenCryptoPayConfirmViewState
187219
widget.paymentDetails.displayName ??
188220
"OpenCryptoPay";
189221

222+
final evmUri = widget.selectedMethod.method == 'Ethereum'
223+
? OpenCryptoPayEvmUri.tryParse(uri)
224+
: null;
225+
if (widget.selectedMethod.method == 'Ethereum') {
226+
if (evmUri == null) {
227+
_warn("Could not parse Ethereum payment details");
228+
return;
229+
}
230+
if (evmUri.chainId != null && evmUri.chainId != 1) {
231+
_warn("Payment URI is for a different Ethereum network");
232+
return;
233+
}
234+
if (evmUri.functionName != null && !evmUri.isTokenTransfer) {
235+
_warn("Unsupported Ethereum payment request");
236+
return;
237+
}
238+
if (evmUri.isTokenTransfer) {
239+
if (evmUri.chainId != 1) {
240+
_warn("Payment URI is for a different Ethereum network");
241+
return;
242+
}
243+
if (widget.selectedAsset.asset.toUpperCase() ==
244+
widget.coin.ticker.toUpperCase()) {
245+
_warn("Payment token details are invalid");
246+
return;
247+
}
248+
await _proceedToTokenSend(
249+
evmUri: evmUri,
250+
expiresAt: expiresAt,
251+
recipient: recipient,
252+
submissionFlow: submissionFlow,
253+
);
254+
return;
255+
}
256+
if (widget.selectedAsset.asset.toUpperCase() !=
257+
widget.coin.ticker.toUpperCase()) {
258+
_warn("Payment token details are invalid");
259+
return;
260+
}
261+
}
262+
263+
final parsed = _parseTransactionUri(uri);
264+
if (parsed.address == null) {
265+
_warn("Could not parse payment address");
266+
return;
267+
}
268+
if (parsed.amount == null) {
269+
_warn("Could not parse payment amount");
270+
return;
271+
}
272+
if (parsed.scheme != null &&
273+
parsed.scheme!.isNotEmpty &&
274+
parsed.scheme != widget.coin.uriScheme) {
275+
_warn("Payment URI does not match this wallet");
276+
return;
277+
}
278+
190279
if (!mounted) return;
191280
await Navigator.of(context).pushNamed(
192281
SendView.routeName,
@@ -214,6 +303,65 @@ class _OpenCryptoPayConfirmViewState
214303
);
215304
}
216305

306+
Future<void> _proceedToTokenSend({
307+
required OpenCryptoPayEvmUri evmUri,
308+
required DateTime expiresAt,
309+
required String recipient,
310+
required OpenCryptoPaySubmissionFlow submissionFlow,
311+
}) async {
312+
final contract = _enabledErc20Token(evmUri.targetAddress);
313+
if (contract == null) {
314+
_warn("This token is not enabled in this wallet");
315+
return;
316+
}
317+
if (contract.symbol.toUpperCase() !=
318+
widget.selectedAsset.asset.toUpperCase()) {
319+
_warn("Payment token does not match the selected asset");
320+
return;
321+
}
322+
323+
try {
324+
await _loadTokenWallet(contract);
325+
} catch (e, s) {
326+
Logging.instance.e(
327+
"OpenCryptoPay token wallet load failed",
328+
error: e,
329+
stackTrace: s,
330+
);
331+
_warn("Could not load token wallet");
332+
return;
333+
}
334+
335+
final amount = evmUri.amount(fractionDigits: contract.decimals);
336+
if (!mounted) return;
337+
await Navigator.of(context).pushNamed(
338+
TokenSendView.routeName,
339+
arguments: Tuple4(
340+
widget.walletId,
341+
widget.coin,
342+
contract,
343+
SendViewAutoFillData(
344+
address: evmUri.recipientAddress!,
345+
contactLabel: recipient,
346+
amount: amount,
347+
note: "OpenCryptoPay: $recipient",
348+
openCryptoPayCommit: OpenCryptoPayCommit(
349+
callbackUrl: widget.paymentDetails.callback,
350+
quoteId: widget.paymentDetails.quote!.id,
351+
method: widget.selectedMethod.method,
352+
asset: widget.selectedAsset.asset,
353+
expiresAt: expiresAt,
354+
submissionFlow: submissionFlow,
355+
minFee: widget.selectedMethod.minFee,
356+
recipientAddress: evmUri.recipientAddress!,
357+
amount: amount,
358+
tokenContractAddress: contract.address,
359+
),
360+
),
361+
),
362+
);
363+
}
364+
217365
void _warn(String message) {
218366
unawaited(
219367
showFloatingFlushBar(

lib/pages/open_crypto_pay/open_crypto_pay_view.dart

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import 'dart:async';
33
import 'package:flutter/material.dart';
44
import 'package:flutter_riverpod/flutter_riverpod.dart';
55

6+
import '../../models/isar/models/ethereum/eth_contract.dart';
67
import '../../notifications/show_flush_bar.dart';
8+
import '../../providers/db/main_db_provider.dart';
79
import '../../services/open_crypto_pay/method_support.dart';
810
import '../../services/open_crypto_pay/models.dart';
911
import '../../services/open_crypto_pay/open_crypto_pay_api.dart';
1012
import '../../themes/stack_colors.dart';
1113
import '../../utilities/logger.dart';
1214
import '../../utilities/text_styles.dart';
1315
import '../../wallets/crypto_currency/crypto_currency.dart';
16+
import '../../wallets/isar/providers/wallet_info_provider.dart';
1417
import '../../widgets/background.dart';
1518
import '../../widgets/custom_buttons/app_bar_icon_button.dart';
1619
import '../../widgets/desktop/primary_button.dart';
@@ -75,11 +78,26 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
7578
bool _isSupportedOption(
7679
OpenCryptoPayTransferMethod method,
7780
OpenCryptoPayAsset asset,
78-
) => OpenCryptoPayMethodSupport.isSupportedWalletOption(
79-
coin: widget.coin,
80-
method: method,
81-
asset: asset,
82-
);
81+
Iterable<EthContract> enabledErc20Tokens,
82+
) {
83+
return OpenCryptoPayMethodSupport.isSupportedWalletOption(
84+
coin: widget.coin,
85+
method: method,
86+
asset: asset,
87+
enabledErc20Symbols: enabledErc20Tokens.map((e) => e.symbol),
88+
);
89+
}
90+
91+
List<EthContract> _enabledErc20Tokens() {
92+
if (widget.coin is! Ethereum) return const [];
93+
final mainDB = ref.watch(mainDBProvider);
94+
return ref
95+
.watch(pWalletTokenAddresses(widget.walletId))
96+
.map(mainDB.getEthContractSync)
97+
.whereType<EthContract>()
98+
.where((e) => e.type == EthContractType.erc20)
99+
.toList();
100+
}
83101

84102
Future<void> _onSelected(
85103
OpenCryptoPayTransferMethod method,
@@ -164,11 +182,14 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
164182
return const Center(child: Text("No payment data"));
165183
}
166184

185+
final enabledErc20Tokens = _enabledErc20Tokens();
186+
167187
// Flatten into (method, asset) pairs that this wallet can safely settle.
168188
final options = [
169189
for (final m in details.availableMethods)
170190
for (final a in m.assets)
171-
if (_isSupportedOption(m, a)) (method: m, asset: a),
191+
if (_isSupportedOption(m, a, enabledErc20Tokens))
192+
(method: m, asset: a),
172193
];
173194

174195
return SingleChildScrollView(

lib/pages/send_view/confirm_transaction_view.dart

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import '../../wallets/wallet/impl/ethereum_wallet.dart';
5050
import '../../wallets/wallet/impl/firo_wallet.dart';
5151
import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart';
5252
import '../../wallets/wallet/impl/solana_wallet.dart';
53+
import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart';
5354
import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
5455
import '../../wallets/wallet/wallet.dart';
5556
import '../../widgets/background.dart';
@@ -338,8 +339,11 @@ class _ConfirmTransactionViewState
338339
try {
339340
if (openCryptoPayCommit?.submissionFlow ==
340341
OpenCryptoPaySubmissionFlow.rawHexToProvider) {
342+
final submitWallet = widget.isTokenTx
343+
? ref.read(pCurrentTokenWallet)!
344+
: wallet;
341345
txDataFuture = _submitOpenCryptoPayRawHex(
342-
wallet,
346+
submitWallet,
343347
widget.txData,
344348
openCryptoPayCommit!,
345349
);
@@ -591,6 +595,9 @@ class _ConfirmTransactionViewState
591595
);
592596
if (transactionError != null) return transactionError;
593597

598+
final tokenError = _validateOpenCryptoPayToken(commit);
599+
if (tokenError != null) return tokenError;
600+
594601
switch (commit.submissionFlow) {
595602
case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast:
596603
return null;
@@ -637,6 +644,32 @@ class _ConfirmTransactionViewState
637644
return null;
638645
}
639646

647+
String? _validateOpenCryptoPayToken(OpenCryptoPayCommit commit) {
648+
final tokenContractAddress = commit.tokenContractAddress;
649+
if (tokenContractAddress == null) return null;
650+
651+
if (!widget.isTokenTx || commit.method != 'Ethereum') {
652+
return "Open CryptoPay token payment is not supported here";
653+
}
654+
655+
final tokenWallet = ref.read(pCurrentTokenWallet);
656+
if (tokenWallet == null) {
657+
return "Could not verify Open CryptoPay token wallet";
658+
}
659+
660+
if (tokenWallet.tokenContract.address.toLowerCase() !=
661+
tokenContractAddress.toLowerCase()) {
662+
return "Open CryptoPay token contract changed. Please scan again.";
663+
}
664+
665+
if (tokenWallet.tokenContract.symbol.toUpperCase() !=
666+
commit.asset.toUpperCase()) {
667+
return "Open CryptoPay token asset changed. Please scan again.";
668+
}
669+
670+
return null;
671+
}
672+
640673
String? _validateOpenCryptoPayMinFee(
641674
Wallet wallet,
642675
OpenCryptoPayCommit commit,
@@ -800,6 +833,9 @@ class _ConfirmTransactionViewState
800833
Wallet wallet,
801834
TxData txData,
802835
) async {
836+
if (wallet is EthTokenWallet) {
837+
return await wallet.signSendWithoutBroadcast(txData: txData);
838+
}
803839
if (wallet is EthereumWallet) {
804840
return await wallet.signSendWithoutBroadcast(txData: txData);
805841
}

0 commit comments

Comments
 (0)