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 +}