Skip to content

Commit 25fdf86

Browse files
committed
feat(open_crypto_pay): support enabled Ethereum tokens
Add EIP-681 parsing and route Ethereum mainnet ERC-20 OCP payments through the existing token send flow, using enabled token contract addresses as the authority.
1 parent 4ddccb7 commit 25fdf86

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
@@ -54,6 +54,7 @@ import '../../wallets/wallet/impl/ethereum_wallet.dart';
5454
import '../../wallets/wallet/impl/firo_wallet.dart';
5555
import '../../wallets/wallet/impl/mimblewimblecoin_wallet.dart';
5656
import '../../wallets/wallet/impl/solana_wallet.dart';
57+
import '../../wallets/wallet/impl/sub_wallets/eth_token_wallet.dart';
5758
import '../../wallets/wallet/wallet_mixin_interfaces/ordinals_interface.dart';
5859
import '../../wallets/wallet/wallet_mixin_interfaces/paynym_interface.dart';
5960
import '../../wallets/wallet/wallet.dart';
@@ -472,8 +473,11 @@ class _ConfirmTransactionViewState
472473
try {
473474
if (openCryptoPayCommit?.submissionFlow ==
474475
OpenCryptoPaySubmissionFlow.rawHexToProvider) {
476+
final submitWallet = widget.isTokenTx
477+
? ref.read(pCurrentTokenWallet)!
478+
: wallet;
475479
txDataFuture = _submitOpenCryptoPayRawHex(
476-
wallet,
480+
submitWallet,
477481
widget.txData,
478482
openCryptoPayCommit!,
479483
);
@@ -904,6 +908,9 @@ class _ConfirmTransactionViewState
904908
);
905909
if (transactionError != null) return transactionError;
906910

911+
final tokenError = _validateOpenCryptoPayToken(commit);
912+
if (tokenError != null) return tokenError;
913+
907914
switch (commit.submissionFlow) {
908915
case OpenCryptoPaySubmissionFlow.txIdAfterLocalBroadcast:
909916
return null;
@@ -950,6 +957,32 @@ class _ConfirmTransactionViewState
950957
return null;
951958
}
952959

960+
String? _validateOpenCryptoPayToken(OpenCryptoPayCommit commit) {
961+
final tokenContractAddress = commit.tokenContractAddress;
962+
if (tokenContractAddress == null) return null;
963+
964+
if (!widget.isTokenTx || commit.method != 'Ethereum') {
965+
return "Open CryptoPay token payment is not supported here";
966+
}
967+
968+
final tokenWallet = ref.read(pCurrentTokenWallet);
969+
if (tokenWallet == null) {
970+
return "Could not verify Open CryptoPay token wallet";
971+
}
972+
973+
if (tokenWallet.tokenContract.address.toLowerCase() !=
974+
tokenContractAddress.toLowerCase()) {
975+
return "Open CryptoPay token contract changed. Please scan again.";
976+
}
977+
978+
if (tokenWallet.tokenContract.symbol.toUpperCase() !=
979+
commit.asset.toUpperCase()) {
980+
return "Open CryptoPay token asset changed. Please scan again.";
981+
}
982+
983+
return null;
984+
}
985+
953986
String? _validateOpenCryptoPayMinFee(
954987
Wallet wallet,
955988
OpenCryptoPayCommit commit,
@@ -1113,6 +1146,9 @@ class _ConfirmTransactionViewState
11131146
Wallet wallet,
11141147
TxData txData,
11151148
) async {
1149+
if (wallet is EthTokenWallet) {
1150+
return await wallet.signSendWithoutBroadcast(txData: txData);
1151+
}
11161152
if (wallet is EthereumWallet) {
11171153
return await wallet.signSendWithoutBroadcast(txData: txData);
11181154
}

0 commit comments

Comments
 (0)