Skip to content

Commit 6187a48

Browse files
committed
Align Open CryptoPay flows with spec
1 parent 58284b4 commit 6187a48

9 files changed

Lines changed: 644 additions & 105 deletions

File tree

lib/models/send_view_auto_fill_data.dart

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ class SendViewAutoFillData {
1818
final Decimal? amount;
1919
final String note;
2020

21-
/// When set, ConfirmTransactionView will notify the OpenCryptoPay provider
22-
/// with the broadcast tx ID (and raw hex, where available) after a
23-
/// successful send.
21+
/// When set, ConfirmTransactionView completes the OpenCryptoPay submission
22+
/// flow for the prepared transaction.
2423
final OpenCryptoPayCommit? openCryptoPayCommit;
2524

2625
SendViewAutoFillData({

lib/pages/open_crypto_pay/open_crypto_pay_confirm_view.dart

Lines changed: 94 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'package:tuple/tuple.dart';
77

88
import '../../models/send_view_auto_fill_data.dart';
99
import '../../notifications/show_flush_bar.dart';
10+
import '../../services/open_crypto_pay/method_support.dart';
1011
import '../../services/open_crypto_pay/models.dart';
1112
import '../../services/open_crypto_pay/open_crypto_pay_api.dart';
1213
import '../../themes/stack_colors.dart';
@@ -21,6 +22,8 @@ import '../../widgets/loading_indicator.dart';
2122
import '../../widgets/rounded_white_container.dart';
2223
import '../send_view/send_view.dart';
2324

25+
enum OpenCryptoPayConfirmResult { quoteExpired }
26+
2427
/// Fetches the transaction details for the selected method/asset, shows a
2528
/// summary, then forwards to the standard [SendView] prefilled with the
2629
/// payment address and amount.
@@ -51,6 +54,14 @@ class _OpenCryptoPayConfirmViewState
5154
bool _isLoading = true;
5255
String? _errorMessage;
5356

57+
DateTime? get _expiresAt =>
58+
_txDetails?.expiryDate ?? widget.paymentDetails.quote?.expiration;
59+
60+
bool get _isExpired {
61+
final expiresAt = _expiresAt;
62+
return expiresAt != null && expiresAt.isBefore(DateTime.now());
63+
}
64+
5465
@override
5566
void initState() {
5667
super.initState();
@@ -64,37 +75,65 @@ class _OpenCryptoPayConfirmViewState
6475
});
6576

6677
try {
78+
final quote = widget.paymentDetails.quote;
79+
if (quote == null) {
80+
throw Exception("No quote provided by the payment provider");
81+
}
6782
_txDetails = await OpenCryptoPayApi.instance.getTransactionDetails(
6883
callbackUrl: widget.paymentDetails.callback,
69-
quoteId: widget.paymentDetails.quote!.id,
84+
quoteId: quote.id,
7085
method: widget.selectedMethod.method,
7186
asset: widget.selectedAsset.asset,
7287
);
7388
} catch (e, s) {
74-
Logging.instance.e("OpenCryptoPay tx fetch failed", error: e, stackTrace: s);
89+
Logging.instance.e(
90+
"OpenCryptoPay tx fetch failed",
91+
error: e,
92+
stackTrace: s,
93+
);
7594
_errorMessage = 'Failed to fetch transaction details: $e';
7695
} finally {
7796
if (mounted) setState(() => _isLoading = false);
7897
}
7998
}
8099

81-
/// Parses address and amount from the transaction URI. Strips the EVM
82-
/// `@chainId` suffix that [AddressUtils] leaves attached.
83-
({String? address, Decimal? amount}) _parseTransactionUri(String uri) {
100+
/// Parses address and amount from the transaction URI. For EVM URIs this
101+
/// also extracts the EIP-681 `@chainId` suffix that [AddressUtils] leaves
102+
/// attached to the address.
103+
({String? address, Decimal? amount, int? chainId, String? scheme})
104+
_parseTransactionUri(String uri) {
105+
final parsedUri = Uri.tryParse(uri);
84106
final data = AddressUtils.parsePaymentUri(uri, logging: Logging.instance);
85-
var address = data?.address ?? Uri.tryParse(uri)?.path;
107+
var address = data?.address ?? parsedUri?.path;
108+
int? chainId;
86109
if (address != null) {
87110
final at = address.indexOf('@');
88-
if (at != -1) address = address.substring(0, at);
111+
if (at != -1) {
112+
chainId = int.tryParse(address.substring(at + 1));
113+
address = address.substring(0, at);
114+
}
89115
if (address.isEmpty) address = null;
90116
}
91117
final amount = data?.amount != null
92118
? Decimal.tryParse(data!.amount!)
93119
: Decimal.tryParse(widget.selectedAsset.amount);
94-
return (address: address, amount: amount);
120+
return (
121+
address: address,
122+
amount: amount,
123+
chainId: chainId,
124+
scheme: data?.scheme ?? parsedUri?.scheme,
125+
);
95126
}
96127

97128
Future<void> _proceedToSend() async {
129+
if (_isExpired) {
130+
_warn("Quote expired, refreshing...");
131+
if (mounted) {
132+
Navigator.of(context).pop(OpenCryptoPayConfirmResult.quoteExpired);
133+
}
134+
return;
135+
}
136+
98137
final uri = _txDetails?.uri;
99138
if (uri == null) {
100139
_warn("No transaction URI provided by the payment provider");
@@ -106,8 +145,45 @@ class _OpenCryptoPayConfirmViewState
106145
_warn("Could not parse payment address");
107146
return;
108147
}
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+
}
158+
if (_txDetails?.blockchain != null &&
159+
_txDetails!.blockchain != widget.selectedMethod.method) {
160+
_warn("Payment details do not match the selected method");
161+
return;
162+
}
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+
}
169+
170+
final submissionFlow = OpenCryptoPayMethodSupport.submissionFlowFor(
171+
widget.selectedMethod.method,
172+
);
173+
if (submissionFlow == null ||
174+
submissionFlow == OpenCryptoPaySubmissionFlow.external) {
175+
_warn("This Open CryptoPay method is not supported yet");
176+
return;
177+
}
178+
179+
final expiresAt = _expiresAt;
180+
if (expiresAt == null) {
181+
_warn("No quote expiration provided by the payment provider");
182+
return;
183+
}
109184

110-
final recipient = widget.paymentDetails.recipient?.name ??
185+
final recipient =
186+
widget.paymentDetails.recipient?.name ??
111187
widget.paymentDetails.displayName ??
112188
"OpenCryptoPay";
113189

@@ -127,6 +203,11 @@ class _OpenCryptoPayConfirmViewState
127203
quoteId: widget.paymentDetails.quote!.id,
128204
method: widget.selectedMethod.method,
129205
asset: widget.selectedAsset.asset,
206+
expiresAt: expiresAt,
207+
submissionFlow: submissionFlow,
208+
minFee: widget.selectedMethod.minFee,
209+
recipientAddress: parsed.address!,
210+
amount: parsed.amount!,
130211
),
131212
),
132213
),
@@ -147,11 +228,11 @@ class _OpenCryptoPayConfirmViewState
147228
Widget build(BuildContext context) {
148229
return Background(
149230
child: Scaffold(
150-
backgroundColor:
151-
Theme.of(context).extension<StackColors>()!.background,
231+
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
152232
appBar: AppBar(
153-
backgroundColor:
154-
Theme.of(context).extension<StackColors>()!.backgroundAppBar,
233+
backgroundColor: Theme.of(
234+
context,
235+
).extension<StackColors>()!.backgroundAppBar,
155236
leading: const AppBarBackButton(),
156237
title: Text(
157238
"Confirm Payment",

lib/pages/open_crypto_pay/open_crypto_pay_view.dart

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
44
import 'package:flutter_riverpod/flutter_riverpod.dart';
55

66
import '../../notifications/show_flush_bar.dart';
7+
import '../../services/open_crypto_pay/method_support.dart';
78
import '../../services/open_crypto_pay/models.dart';
89
import '../../services/open_crypto_pay/open_crypto_pay_api.dart';
910
import '../../themes/stack_colors.dart';
@@ -31,7 +32,7 @@ class OpenCryptoPayView extends ConsumerStatefulWidget {
3132

3233
final String qrUrl;
3334

34-
/// Only assets matching this coin's ticker are offered.
35+
/// Only methods/assets this wallet can safely settle are offered.
3536
final String walletId;
3637
final CryptoCurrency coin;
3738

@@ -71,30 +72,36 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
7172
}
7273
}
7374

74-
bool _matchesWalletCoin(String asset) =>
75-
widget.coin.ticker.toUpperCase() == asset.toUpperCase();
75+
bool _isSupportedOption(
76+
OpenCryptoPayTransferMethod method,
77+
OpenCryptoPayAsset asset,
78+
) => OpenCryptoPayMethodSupport.isSupportedWalletOption(
79+
coin: widget.coin,
80+
method: method,
81+
asset: asset,
82+
);
7683

77-
void _onSelected(
84+
Future<void> _onSelected(
7885
OpenCryptoPayTransferMethod method,
7986
OpenCryptoPayAsset asset,
80-
) {
87+
) async {
8188
final quote = _details?.quote;
8289
if (quote == null) return;
8390

8491
if (quote.isExpired) {
8592
unawaited(
8693
showFloatingFlushBar(
8794
type: FlushBarType.warning,
88-
message: "Quote expired, refreshing",
95+
message: "Quote expired, refreshing...",
8996
context: context,
9097
),
9198
);
92-
_fetch();
99+
await _fetch();
93100
return;
94101
}
95102

96-
Navigator.of(context).push(
97-
MaterialPageRoute<void>(
103+
final result = await Navigator.of(context).push<OpenCryptoPayConfirmResult>(
104+
MaterialPageRoute<OpenCryptoPayConfirmResult>(
98105
builder: (_) => OpenCryptoPayConfirmView(
99106
paymentDetails: _details!,
100107
selectedMethod: method,
@@ -104,17 +111,21 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
104111
),
105112
),
106113
);
114+
115+
if (result == OpenCryptoPayConfirmResult.quoteExpired && mounted) {
116+
await _fetch();
117+
}
107118
}
108119

109120
@override
110121
Widget build(BuildContext context) {
111122
return Background(
112123
child: Scaffold(
113-
backgroundColor:
114-
Theme.of(context).extension<StackColors>()!.background,
124+
backgroundColor: Theme.of(context).extension<StackColors>()!.background,
115125
appBar: AppBar(
116-
backgroundColor:
117-
Theme.of(context).extension<StackColors>()!.backgroundAppBar,
126+
backgroundColor: Theme.of(
127+
context,
128+
).extension<StackColors>()!.backgroundAppBar,
118129
leading: const AppBarBackButton(),
119130
title: Text(
120131
"Open CryptoPay",
@@ -153,11 +164,11 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
153164
return const Center(child: Text("No payment data"));
154165
}
155166

156-
// Flatten into (method, asset) pairs that this wallet actually supports.
167+
// Flatten into (method, asset) pairs that this wallet can safely settle.
157168
final options = [
158169
for (final m in details.availableMethods)
159170
for (final a in m.assets)
160-
if (_matchesWalletCoin(a.asset)) (method: m, asset: a),
171+
if (_isSupportedOption(m, a)) (method: m, asset: a),
161172
];
162173

163174
return SingleChildScrollView(
@@ -180,7 +191,8 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
180191
if (options.isEmpty)
181192
RoundedWhiteContainer(
182193
child: Text(
183-
"No payment option available for ${widget.coin.prettyName}.",
194+
"No supported Open CryptoPay option available for "
195+
"${widget.coin.prettyName}.",
184196
style: STextStyles.itemSubtitle(context),
185197
),
186198
)
@@ -275,9 +287,9 @@ class _OpenCryptoPayViewState extends ConsumerState<OpenCryptoPayView> {
275287
),
276288
Icon(
277289
Icons.chevron_right,
278-
color: Theme.of(context)
279-
.extension<StackColors>()!
280-
.textFieldDefaultSearchIconLeft,
290+
color: Theme.of(
291+
context,
292+
).extension<StackColors>()!.textFieldDefaultSearchIconLeft,
281293
),
282294
],
283295
),

0 commit comments

Comments
 (0)