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
This commit is contained in:
Konstantin Ullrich
2025-07-17 19:16:54 +03:00
committed by GitHub
parent 0da7047c2a
commit fea63632fe
5 changed files with 234 additions and 11 deletions
+42
View File
@@ -0,0 +1,42 @@
import 'package:cw_core/parse_fixed.dart';
String formatFixed(BigInt value, int? decimals,
{int? fractionalDigits, bool trimZeros = true}) {
decimals ??= 0;
fractionalDigits ??= decimals;
var multiplier = getMultiplier(decimals);
// Make sure wei is a big number (convert as necessary)
var negative = value.isNegative;
if (negative) value = value * BigInt.from(-1);
var fraction = value
.modPow(BigInt.one, BigInt.parse(multiplier))
.toString()
.padLeft(decimals, "0");
if (fractionalDigits < 0) fractionalDigits = 0;
if (fractionalDigits > decimals) fractionalDigits = decimals;
fraction = fraction.substring(0, fractionalDigits);
if (trimZeros) {
fraction = removeTrailing("0", fraction);
}
final whole = (value ~/ BigInt.parse(multiplier));
final valString = fraction.isEmpty ? "$whole" : "$whole.$fraction";
if (negative) return "-$valString";
return valString;
}
String removeTrailing(String pattern, String from) {
if (pattern.isEmpty) return from;
var i = from.length;
while (i > 0 && from.startsWith(pattern, i - pattern.length)) {
i -= pattern.length;
}
return from.substring(0, i);
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:cw_core/format_fixed.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('formatFixed', () {
group('formatFixed, no fractional digits and trimming zeros', () {
test('should format 1000000 into 1',
() => expect(formatFixed(BigInt.parse("1000000"), 6), '1'));
test('should format 1000001 into 1.000001',
() => expect(formatFixed(BigInt.parse("1000001"), 6), '1.000001'));
});
group('formatFixed, different fractional digits and trimming zeros', () {
test(
'should format 1000001 into 1',
() => expect(
formatFixed(BigInt.parse("1000001"), 6, fractionalDigits: 5), '1'),
);
test(
'should format 1000000 into 1, fractionalDigits > decimals',
() => expect(
formatFixed(BigInt.parse("1000000"), 6, fractionalDigits: 12), '1'),
);
test(
'should format 1000001 into 1.000001, fractionalDigits > decimals',
() => expect(
formatFixed(BigInt.parse("1000001"), 6, fractionalDigits: 12),
'1.000001',
),
);
});
group('formatFixed, less fractional digits and not trimming zeros', () {
test(
'should format 1000000 into 1.000000',
() => expect(
formatFixed(BigInt.parse("1000000"), 6, trimZeros: false),
'1.000000',
),
);
test(
'should format 1000001 into 1.00000',
() => expect(
formatFixed(BigInt.parse("1000001"), 6,
fractionalDigits: 5, trimZeros: false),
'1.00000',
),
);
test(
'should format 1000000 into 1.000000',
() => expect(
formatFixed(BigInt.parse("1000000"), 6,
fractionalDigits: 12, trimZeros: false),
'1.000000',
),
);
});
});
}
+5 -11
View File
@@ -1,6 +1,6 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:cw_core/format_fixed.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:web3dart/crypto.dart';
import 'package:hex/hex.dart' as Hex;
@@ -27,23 +27,17 @@ class PendingEVMChainTransaction with PendingTransaction {
@override
String get amountFormatted {
if (isInfiniteApproval) return "∞";
final _amount = (BigInt.parse(amount) / BigInt.from(pow(10, exponent))).toString();
return _amount.substring(0, min(10, _amount.length));
return formatFixed(BigInt.parse(amount), exponent);
}
@override
Future<void> commit() async => await sendTransaction();
@override
String get feeFormatted {
return "$feeFormattedValue $feeCurrency";
}
String get feeFormatted => "$feeFormattedValue $feeCurrency";
@override
String get feeFormattedValue {
final _fee = (fee / BigInt.from(pow(10, 18))).toString();
return _fee.substring(0, min(10, _fee.length));
}
String get feeFormattedValue => formatFixed(fee, 18, fractionalDigits: 10);
@override
String get hex => bytesToHex(signedTransaction, include0x: true);
@@ -53,7 +47,7 @@ class PendingEVMChainTransaction with PendingTransaction {
final String eip1559Hex = '0x02${hex.substring(2)}';
final Uint8List bytes = Uint8List.fromList(Hex.HEX.decode(eip1559Hex.substring(2)));
var txid = keccak256(bytes);
final txid = keccak256(bytes);
return '0x${Hex.HEX.encode(txid)}';
}
+62
View File
@@ -1,4 +1,6 @@
import 'package:cake_wallet/nano/nano.dart';
import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart';
import 'package:cw_core/format_fixed.dart';
class PaymentRequest {
PaymentRequest(this.address, this.amount, this.note, this.scheme, this.pjUri,
@@ -26,12 +28,21 @@ class PaymentRequest {
callbackUrl = uri.queryParameters['callback'];
callbackMessage = uri.queryParameters['callbackMessage'];
walletType = uri.queryParameters['type'];
if (scheme == "ethereum") {
final paymentUri = ERC681URI.fromUri(uri);
address = paymentUri.address;
amount = paymentUri.amount;
}
}
if (scheme == "nano-gpt") {
scheme = walletType ?? "nano";
}
if (nano != null) {
if (amount.isNotEmpty) {
if (address.contains("nano")) {
@@ -61,3 +72,54 @@ class PaymentRequest {
final String? callbackUrl;
final String? callbackMessage;
}
class ERC681URI extends PaymentURI {
final int chainId;
final String? contractAddress;
ERC681URI({
required this.chainId,
required super.address,
required super.amount,
required this.contractAddress,
});
factory ERC681URI.fromUri(Uri uri) {
final (isContract, targetAddress) = _getTargetAddress(uri.path);
final chainId = _getChainID(uri.path);
final address = isContract ? uri.queryParameters["address"] ?? '' : targetAddress;
final amount = isContract
? uri.queryParameters["uint256"]
: uri.queryParameters["value"];
var formatedAmount = "";
if (amount != null) {
formatedAmount = formatFixed(BigInt.parse(amount), 18);
} else {
formatedAmount = uri.queryParameters["amount"] ?? "";
}
return ERC681URI(
chainId: chainId,
address: address,
amount: formatedAmount,
contractAddress: isContract ? targetAddress : null,
);
}
static int _getChainID(String path) {
return int.parse(RegExp(
r'@\d*',
).firstMatch(path)?.group(0)?.replaceAll("@", "") ??
"1");
}
static (bool, String) _getTargetAddress(String path) {
final targetAddress = RegExp(r'^(0x)?[0-9a-f]{40}', caseSensitive: false)
.firstMatch(path)!
.group(0)!;
return (path.contains("/"), targetAddress);
}
}
+61
View File
@@ -0,0 +1,61 @@
import 'package:cake_wallet/utils/payment_request.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('PaymentRequest', () {
group('Ethereum URIs', () {
test("extract address and amount from EIP681 Uri with contract", () {
final uri = Uri.parse(
"ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41&uint256=2000000000000000000");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
expect(paymentRequest.amount, "2");
});
test("extract address and amount from EIP681 Uri", () {
final uri = Uri.parse(
"ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?value=2000000000000000000");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
expect(paymentRequest.amount, "2");
});
test("extract address and amount from Cake Style Uri", () {
final uri =
Uri.parse("ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41@1?amount=2.00");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
expect(paymentRequest.amount, "2.00");
});
test("extract address from EIP681 Uri with contract", () {
final uri = Uri.parse(
"ethereum:0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174@1/transfer?address=0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
expect(paymentRequest.amount, "");
});
test("extract address and amount from EIP681 Uri with contract and no chainId", () {
final uri = Uri.parse(
"ethereum:0x1234567890abcdef1234567890abcdef12345678/transfer?address=0xabcdef1234567890abcdef1234567890abcdef12&uint256=1000000000000000000");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xabcdef1234567890abcdef1234567890abcdef12");
expect(paymentRequest.amount, "1");
});
test("extract address from minimal EIP681 Uri", () {
final uri = Uri.parse("ethereum:0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
final paymentRequest = PaymentRequest.fromUri(uri);
expect(paymentRequest.address, "0xCfc1650da7C961FD82998e7e30ca5f699D0aBf41");
expect(paymentRequest.amount, "");
});
});
});
}