feat: add support for Ethereum EIP-681 URIs in PaymentRequest and implement test coverage (#2375)
* feat: add support for Ethereum EIP-681 URIs in PaymentRequest and implement test coverage * test: add unit test for EIP-681 URI handling with contract and no chainId [skip-ci] * fix(CW-1114): rounding error for really large numbers * fix: wrong decimals for eth fee display * fix: limit fractional digits to a maximum of `decimals` in `format_fixed` function [skip-ci] * test: add unit tests for `formatFixed` function
Konstantin Ullrich committed
Jul 17, 2025 at 18:16 UTC
fea63632fe1fdb10ce0137c771d98b98c069b621
5 files changed
+234
-11
cw_core/lib/format_fixed.dart
new
+42
@@ -0,0 +1,42 @@
1
+import 'package:cw_core/parse_fixed.dart';
2
+
3
+String formatFixed(BigInt value, int? decimals,
4
+ {int? fractionalDigits, bool trimZeros = true}) {
5
+ decimals ??= 0;
6
+ fractionalDigits ??= decimals;
7
+
8
+ var multiplier = getMultiplier(decimals);
9
+ // Make sure wei is a big number (convert as necessary)
10
+ var negative = value.isNegative;
11
+ if (negative) value = value * BigInt.from(-1);
12
+
13
+ var fraction = value
14
+ .modPow(BigInt.one, BigInt.parse(multiplier))
15
+ .toString()
16
+ .padLeft(decimals, "0");
17
+
18
+ if (fractionalDigits < 0) fractionalDigits = 0;
19
+ if (fractionalDigits > decimals) fractionalDigits = decimals;
20
+ fraction = fraction.substring(0, fractionalDigits);
21
+
22
+ if (trimZeros) {
23
+ fraction = removeTrailing("0", fraction);
24
+ }
25
+
26
+ final whole = (value ~/ BigInt.parse(multiplier));
27
+
28
+ final valString = fraction.isEmpty ? "$whole" : "$whole.$fraction";
29
+
30
+ if (negative) return "-$valString";
31
+
32
+ return valString;
33
+}
34
+
35
+String removeTrailing(String pattern, String from) {
36
+ if (pattern.isEmpty) return from;
37
+ var i = from.length;
38
+ while (i > 0 && from.startsWith(pattern, i - pattern.length)) {
39
+ i -= pattern.length;
40
+ }
41
+ return from.substring(0, i);
42
+}
cw_core/test/format_fixed_test.dart
new
+64
@@ -0,0 +1,64 @@
1
+import 'package:cw_core/format_fixed.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+
4
+void main() {
5
+ group('formatFixed', () {
6
+ group('formatFixed, no fractional digits and trimming zeros', () {
7
+ test('should format 1000000 into 1',
8
+ () => expect(formatFixed(BigInt.parse("1000000"), 6), '1'));
9
+
10
+ test('should format 1000001 into 1.000001',
11
+ () => expect(formatFixed(BigInt.parse("1000001"), 6), '1.000001'));
12
+ });
13
+
14
+ group('formatFixed, different fractional digits and trimming zeros', () {
15
+ test(
16
+ 'should format 1000001 into 1',
17
+ () => expect(
18
+ formatFixed(BigInt.parse("1000001"), 6, fractionalDigits: 5), '1'),
19
+ );
20
+
21
+ test(
22
+ 'should format 1000000 into 1, fractionalDigits > decimals',
23
+ () => expect(
24
+ formatFixed(BigInt.parse("1000000"), 6, fractionalDigits: 12), '1'),
25
+ );
26
+
27
+ test(
28
+ 'should format 1000001 into 1.000001, fractionalDigits > decimals',
29
+ () => expect(
30
+ formatFixed(BigInt.parse("1000001"), 6, fractionalDigits: 12),
31
+ '1.000001',
32
+ ),
33
+ );
34
+ });
35
+
36
+ group('formatFixed, less fractional digits and not trimming zeros', () {
37
+ test(
38
+ 'should format 1000000 into 1.000000',
39
+ () => expect(
40
+ formatFixed(BigInt.parse("1000000"), 6, trimZeros: false),
41
+ '1.000000',
42
+ ),
43
+ );
44
+
45
+ test(
46
+ 'should format 1000001 into 1.00000',
47
+ () => expect(
48
+ formatFixed(BigInt.parse("1000001"), 6,
49
+ fractionalDigits: 5, trimZeros: false),
50
+ '1.00000',
51
+ ),
52
+ );
53
+
54
+ test(
55
+ 'should format 1000000 into 1.000000',
56
+ () => expect(
57
+ formatFixed(BigInt.parse("1000000"), 6,
58
+ fractionalDigits: 12, trimZeros: false),
59
+ '1.000000',
60
+ ),
61
+ );
62
+ });
63
+ });
64
+}
cw_evm/lib/pending_evm_chain_transaction.dart
+5
-11
@@ -1,6 +1,6 @@
1
-import 'dart:math';
1
import 'dart:typed_data';
2
3
+import 'package:cw_core/format_fixed.dart';
4
import 'package:cw_core/pending_transaction.dart';
5
import 'package:web3dart/crypto.dart';
6
import 'package:hex/hex.dart' as Hex;
@@ -27,23 +27,17 @@ class PendingEVMChainTransaction with PendingTransaction {
27
@override
28
String get amountFormatted {
29
if (isInfiniteApproval) return "∞";
30
- final _amount = (BigInt.parse(amount) / BigInt.from(pow(10, exponent))).toString();
31
- return _amount.substring(0, min(10, _amount.length));
30
+ return formatFixed(BigInt.parse(amount), exponent);
31
}
32
33
@override
34
Future<void> commit() async => await sendTransaction();
35
36
@override
38
- String get feeFormatted {
39
- return "$feeFormattedValue $feeCurrency";
40
- }
37
+ String get feeFormatted => "$feeFormattedValue $feeCurrency";
38
39
@override
43
- String get feeFormattedValue {
44
- final _fee = (fee / BigInt.from(pow(10, 18))).toString();
45
- return _fee.substring(0, min(10, _fee.length));
46
- }
40
+ String get feeFormattedValue => formatFixed(fee, 18, fractionalDigits: 10);
41
42
@override
43
String get hex => bytesToHex(signedTransaction, include0x: true);
@@ -53,7 +47,7 @@ class PendingEVMChainTransaction with PendingTransaction {
47
final String eip1559Hex = '0x02${hex.substring(2)}';
48
final Uint8List bytes = Uint8List.fromList(Hex.HEX.decode(eip1559Hex.substring(2)));
49
56
- var txid = keccak256(bytes);
50
+ final txid = keccak256(bytes);
51
52
return '0x${Hex.HEX.encode(txid)}';
53
}
lib/utils/payment_request.dart
+62
@@ -1,4 +1,6 @@
1
import 'package:cake_wallet/nano/nano.dart';
2
+import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
3
+import 'package:cw_core/format_fixed.dart';
4
5
class PaymentRequest {
6
PaymentRequest(this.address, this.amount, this.note, this.scheme, this.pjUri,
@@ -26,12 +28,21 @@ class PaymentRequest {
28
callbackUrl = uri.queryParameters['callback'];
29
callbackMessage = uri.queryParameters['callbackMessage'];
30
walletType = uri.queryParameters['type'];
31
+
32
+ if (scheme == "ethereum") {
33
+ final paymentUri = ERC681URI.fromUri(uri);
34
+
35
+ address = paymentUri.address;
36
+ amount = paymentUri.amount;
37
+ }
38
}
39
40
if (scheme == "nano-gpt") {
41
scheme = walletType ?? "nano";
42
}
43
44
+
45
+
46
if (nano != null) {
47
if (amount.isNotEmpty) {
48
if (address.contains("nano")) {
@@ -61,3 +72,54 @@ class PaymentRequest {
72
final String? callbackUrl;
73
final String? callbackMessage;
74
}
75
+
76
+class ERC681URI extends PaymentURI {
77
+ final int chainId;
78
+ final String? contractAddress;
79
+
80
+ ERC681URI({
81
+ required this.chainId,
82
+ required super.address,
83
+ required super.amount,
84
+ required this.contractAddress,
85
+ });
86
+
87
+ factory ERC681URI.fromUri(Uri uri) {
88
+ final (isContract, targetAddress) = _getTargetAddress(uri.path);
89
+ final chainId = _getChainID(uri.path);
90
+
91
+ final address = isContract ? uri.queryParameters["address"] ?? '' : targetAddress;
92
+ final amount = isContract
93
+ ? uri.queryParameters["uint256"]
94
+ : uri.queryParameters["value"];
95
+
96
+ var formatedAmount = "";
97
+
98
+ if (amount != null) {
99
+ formatedAmount = formatFixed(BigInt.parse(amount), 18);
100
+ } else {
101
+ formatedAmount = uri.queryParameters["amount"] ?? "";
102
+ }
103
+
104
+ return ERC681URI(
105
+ chainId: chainId,
106
+ address: address,
107
+ amount: formatedAmount,
108
+ contractAddress: isContract ? targetAddress : null,
109
+ );
110
+ }
111
+
112
+ static int _getChainID(String path) {
113
+ return int.parse(RegExp(
114
+ r'@\d*',
115
+ ).firstMatch(path)?.group(0)?.replaceAll("@", "") ??
116
+ "1");
117
+ }
118
+
119
+ static (bool, String) _getTargetAddress(String path) {
120
+ final targetAddress = RegExp(r'^(0x)?[0-9a-f]{40}', caseSensitive: false)
121
+ .firstMatch(path)!
122
+ .group(0)!;
123
+ return (path.contains("/"), targetAddress);
124
+ }
125
+}
test/utils/payment_request_test.dart
new
+61
@@ -0,0 +1,61 @@
1
+import 'package:cake_wallet/utils/payment_request.dart';
2
+import 'package:flutter_test/flutter_test.dart';
3
+
4
+void main() {
5
+ group('PaymentRequest', () {
6
+ group('Ethereum URIs', () {
7
+ test("extract address and amount from EIP681 Uri with contract", () {
8
+ final uri = Uri.parse(
9
+ "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=2000000000000000000");
10
+ final paymentRequest = PaymentRequest.fromUri(uri);
11
+
12
+ expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
13
+ expect(paymentRequest.amount, "2");
14
+ });
15
+
16
+ test("extract address and amount from EIP681 Uri", () {
17
+ final uri = Uri.parse(
18
+ "ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?value=2000000000000000000");
19
+ final paymentRequest = PaymentRequest.fromUri(uri);
20
+
21
+ expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
22
+ expect(paymentRequest.amount, "2");
23
+ });
24
+
25
+ test("extract address and amount from Cake Style Uri", () {
26
+ final uri =
27
+ Uri.parse("ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?amount=2.00");
28
+ final paymentRequest = PaymentRequest.fromUri(uri);
29
+
30
+ expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
31
+ expect(paymentRequest.amount, "2.00");
32
+ });
33
+
34
+ test("extract address from EIP681 Uri with contract", () {
35
+ final uri = Uri.parse(
36
+ "ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
37
+ final paymentRequest = PaymentRequest.fromUri(uri);
38
+
39
+ expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
40
+ expect(paymentRequest.amount, "");
41
+ });
42
+
43
+ test("extract address and amount from EIP681 Uri with contract and no chainId", () {
44
+ final uri = Uri.parse(
45
+ "ethereum:0x1234567890abcdef1234567890abcdef12345678/transfer?address=0xabcdef1234567890abcdef1234567890abcdef12&uint256=1000000000000000000");
46
+ final paymentRequest = PaymentRequest.fromUri(uri);
47
+
48
+ expect(paymentRequest.address, "0xabcdef1234567890abcdef1234567890abcdef12");
49
+ expect(paymentRequest.amount, "1");
50
+ });
51
+
52
+ test("extract address from minimal EIP681 Uri", () {
53
+ final uri = Uri.parse("ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
54
+ final paymentRequest = PaymentRequest.fromUri(uri);
55
+
56
+ expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
57
+ expect(paymentRequest.amount, "");
58
+ });
59
+ });
60
+ });
61
+}