dev
dart 468 lines 12 KB
Raw
1 import "package:cw_core/format_fixed.dart";
2
3 abstract class PaymentURI {
4 PaymentURI({required this.amount, required this.address});
5
6 final String amount;
7 final String address;
8 }
9
10 class ExternalAddressURI extends PaymentURI {
11 ExternalAddressURI({required super.amount, required super.address});
12
13 @override
14 String toString() => address;
15 }
16
17 class MoneroURI extends PaymentURI {
18 MoneroURI({required super.amount, required super.address});
19
20 @override
21 String toString() {
22 var base = "monero:$address";
23
24 if (amount.isNotEmpty) {
25 base += '?tx_amount=${amount.replaceAll(',', '.')}';
26 }
27
28 return base;
29 }
30 }
31
32 class HavenURI extends PaymentURI {
33 HavenURI({required super.amount, required super.address});
34
35 @override
36 String toString() {
37 var base = "haven:$address";
38
39 if (amount.isNotEmpty) {
40 base += '?tx_amount=${amount.replaceAll(',', '.')}';
41 }
42
43 return base;
44 }
45 }
46
47 class BitcoinURI extends PaymentURI {
48 BitcoinURI({required super.amount, required super.address, this.pjUri = ""});
49
50 final String pjUri;
51
52 @override
53 String toString() {
54 final qp = <String, String>{};
55
56 if (amount.isNotEmpty) {
57 qp["amount"] = amount.replaceAll(",", ".");
58 }
59
60 if (pjUri.isNotEmpty && !address.startsWith("sp")) {
61 qp["pjos"] = "0";
62 qp["pj"] = pjUri;
63 }
64
65 return Uri(scheme: "bitcoin", path: address, queryParameters: qp).toString();
66 }
67 }
68
69 class LitecoinURI extends PaymentURI {
70 LitecoinURI({required super.amount, required super.address});
71
72 @override
73 String toString() {
74 var base = "litecoin:$address";
75
76 if (amount.isNotEmpty) {
77 base += '?amount=${amount.replaceAll(',', '.')}';
78 }
79
80 return base;
81 }
82 }
83
84 class EthereumURI extends PaymentURI {
85 EthereumURI({required super.amount, required super.address});
86
87 @override
88 String toString() {
89 var base = "ethereum:$address";
90
91 if (amount.isNotEmpty) {
92 base += '?amount=${amount.replaceAll(',', '.')}';
93 }
94
95 return base;
96 }
97 }
98
99 class BitcoinCashURI extends PaymentURI {
100 BitcoinCashURI({required super.amount, required super.address});
101
102 @override
103 String toString() {
104 var base = address;
105
106 if (amount.isNotEmpty) {
107 base += '?amount=${amount.replaceAll(',', '.')}';
108 }
109
110 return base;
111 }
112 }
113
114 class NanoURI extends PaymentURI {
115 NanoURI({required super.amount, required super.address});
116
117 @override
118 String toString() {
119 var base = "nano:$address";
120 if (amount.isNotEmpty) {
121 base += '?amount=${amount.replaceAll(',', '.')}';
122 }
123
124 return base;
125 }
126 }
127
128 class SolanaURI extends PaymentURI {
129 SolanaURI({required super.amount, required super.address, this.contractAddress});
130
131 final String? contractAddress;
132
133 @override
134 String toString() {
135 var base = "solana:$address";
136 final params = <String>[];
137
138 if (amount.isNotEmpty) {
139 params.add('amount=${amount.replaceAll(',', '.')}');
140 }
141 if (contractAddress != null && contractAddress!.isNotEmpty) {
142 params.add("spl-token=$contractAddress");
143 }
144 if (params.isNotEmpty) {
145 base += '?${params.join('&')}';
146 }
147
148 return base;
149 }
150 }
151
152 class TronURI extends PaymentURI {
153 TronURI({required super.amount, required super.address, this.contractAddress});
154
155 final String? contractAddress;
156
157 @override
158 String toString() {
159 var base = "tron:$address";
160 final params = <String>[];
161
162 if (amount.isNotEmpty) {
163 params.add('amount=${amount.replaceAll(',', '.')}');
164 }
165 if (contractAddress != null && contractAddress!.isNotEmpty) {
166 params.add("token=$contractAddress");
167 }
168 if (params.isNotEmpty) {
169 base += '?${params.join('&')}';
170 }
171
172 return base;
173 }
174 }
175
176 class WowneroURI extends PaymentURI {
177 WowneroURI({required super.amount, required super.address});
178
179 @override
180 String toString() {
181 var base = "wownero:$address";
182
183 if (amount.isNotEmpty) {
184 base += '?tx_amount=${amount.replaceAll(',', '.')}';
185 }
186
187 return base;
188 }
189 }
190
191 class ZanoURI extends PaymentURI {
192 ZanoURI({required super.amount, required super.address});
193
194 @override
195 String toString() {
196 var base = "zano:$address";
197
198 if (amount.isNotEmpty) {
199 base += '?amount=${amount.replaceAll(',', '.')}';
200 }
201
202 return base;
203 }
204 }
205
206 class DecredURI extends PaymentURI {
207 DecredURI({required super.amount, required super.address});
208
209 @override
210 String toString() {
211 var base = "decred:$address";
212
213 if (amount.isNotEmpty) {
214 base += '?amount=${amount.replaceAll(',', '.')}';
215 }
216
217 return base;
218 }
219 }
220
221 class DogeURI extends PaymentURI {
222 DogeURI({required super.amount, required super.address});
223
224 @override
225 String toString() {
226 var base = "doge:$address";
227
228 if (amount.isNotEmpty) {
229 base += '?amount=${amount.replaceAll(',', '.')}';
230 }
231
232 return base;
233 }
234 }
235
236 class ZcashURI extends PaymentURI {
237 ZcashURI({required super.amount, required super.address});
238
239 @override
240 String toString() {
241 var base = "zcash:$address";
242
243 if (amount.isNotEmpty) {
244 base += '?amount=${amount.replaceAll(',', '.')}';
245 }
246
247 return base;
248 }
249 }
250
251 class ERC681URI extends PaymentURI {
252 ERC681URI({
253 required this.chainId,
254 required super.address,
255 required super.amount,
256 required this.contractAddress,
257 this.tokenDecimals = 18,
258 this.rawTokenAmount,
259 });
260
261 factory ERC681URI.fromUri(Uri uri) {
262 final (isContract, targetAddress) = _getTargetAddress(uri.path);
263 final chainId = _getChainID(uri.path);
264
265 final address = isContract ? uri.queryParameters["address"] ?? "" : targetAddress;
266
267 var formatedAmount = "";
268 String? rawTokenAmount;
269
270 if (isContract) {
271 formatedAmount = uri.queryParameters["amount"] ?? "";
272 final uint256Param = uri.queryParameters["uint256"];
273 if (uint256Param != null) {
274 final raw = uint256Param.replaceAll(",", ".").trim();
275 if (!_scientificPattern.hasMatch(raw) && raw.contains(".")) {
276 if (formatedAmount.isEmpty) {
277 formatedAmount = raw;
278 }
279 } else {
280 rawTokenAmount = _normalizeToIntegerWei(raw);
281 }
282 }
283 } else {
284 final valueParam = uri.queryParameters["value"];
285 if (valueParam != null) {
286 final normalized = BigInt.tryParse(_normalizeToIntegerWei(valueParam));
287 formatedAmount = normalized != null ? formatFixed(normalized, 18) : "";
288 } else {
289 formatedAmount = uri.queryParameters["amount"] ?? "";
290 }
291 }
292
293 return ERC681URI(
294 chainId: chainId,
295 address: address,
296 amount: formatedAmount,
297 contractAddress: isContract ? targetAddress : null,
298 rawTokenAmount: rawTokenAmount,
299 );
300 }
301
302 final int chainId;
303 final String? contractAddress;
304 final int tokenDecimals;
305 final String? rawTokenAmount;
306
307 static final RegExp _scientificPattern = RegExp(r"^[+-]?(\d+\.?\d*|\d*\.?\d+)[eE][+-]?\d+$");
308
309 @override
310 String toString() {
311 var uri = "ethereum:";
312
313 final targetAddress = contractAddress ?? address;
314 uri += targetAddress;
315
316 uri += "@$chainId";
317
318 if (contractAddress != null) {
319 uri += "/transfer";
320 }
321
322 final params = <String, String>{};
323
324 if (contractAddress != null) {
325 params["address"] = address;
326 // only emit an atomic uint256 when it is unambiguous, either stored raw or 18 decimals,
327 // old app versions divide any uint256 by 1e18 so other tokens rely on the amount param
328 final uint256 = rawTokenAmount ??
329 (tokenDecimals == 18 && amount.isNotEmpty
330 ? _expandDecimal(amount.replaceAll(",", "."), 18)
331 : null);
332 if (uint256 != null && uint256.isNotEmpty) {
333 params["uint256"] = uint256;
334 }
335 if (amount.isNotEmpty) {
336 params["amount"] = amount.replaceAll(",", ".");
337 }
338 } else {
339 if (amount.isNotEmpty) {
340 params["value"] = _formatAmountForNative(amount);
341 }
342 }
343
344 if (params.isNotEmpty) {
345 uri += "?${params.entries.map((e) => "${e.key}=${e.value}").join("&")}";
346 }
347
348 return uri;
349 }
350
351 /// Formats amount for native ETH payments (in wei using scientific notation)
352 String _formatAmountForNative(String amount) {
353 try {
354 // Convert decimal amount to double for scientific notation
355 final amountDouble = double.parse(amount.replaceAll(",", "."));
356
357 // Use scientific notation as recommended by ERC-681
358 return "${amountDouble}e18";
359 } catch (e) {
360 // Fallback to original amount if parsing fails
361 return amount.replaceAll(",", ".");
362 }
363 }
364
365 static int _getChainID(String path) =>
366 int.tryParse(
367 RegExp(r"@\d*").firstMatch(path)?.group(0)?.replaceAll("@", "") ?? "1",
368 ) ??
369 1;
370
371 static (bool, String) _getTargetAddress(String path) {
372 // I saw in the schema (thanks Konsti) that EIP-681 allows an optional "pay-" prefix before the target address, so adding a check for it here
373 final cleaned = path.startsWith("pay-") ? path.substring(4) : path;
374 final match = RegExp(r"^(0x)?[0-9a-f]{40}", caseSensitive: false).firstMatch(cleaned);
375 final targetAddress = match?.group(0) ?? cleaned.split("@").first.split("/").first;
376 return (cleaned.contains("/"), targetAddress);
377 }
378
379 /// Normalize an input amount into an integer wei string.
380 ///
381 /// Accepts the following forms:
382 /// - Integer string: "123000000000000000" → unchanged
383 /// - Scientific notation: "0.123e18", "1e6" → expanded to integer
384 /// - Decimal ETH: "0.123456" → shifted by 18 decimals
385 static String _normalizeToIntegerWei(String input) {
386 final raw = input.replaceAll(",", ".").trim();
387
388 // First we check if it's already a plain integer (basically just a number with no dot, no exponent)
389 try {
390 final isPlainInteger = RegExp(r"^[+-]?\d+$").hasMatch(raw) &&
391 !raw.contains(".") &&
392 !raw.toLowerCase().contains("e");
393 if (isPlainInteger) {
394 return raw.replaceFirst(RegExp(r"^\+"), "");
395 }
396
397 // Then we check if it's a scientific notation
398 if (_scientificPattern.hasMatch(raw)) {
399 final mantissaStr = raw.toLowerCase().split("e")[0];
400 final exp = int.parse(raw.toLowerCase().split("e")[1]);
401 return _expandDecimal(mantissaStr, exp);
402 }
403
404 // Lastly, we check if it's a fixed decimal ETH amount, here we shift by 18 to get wei for the amount
405 if (raw.contains(".")) {
406 return _expandDecimal(raw, 18);
407 }
408 return raw;
409 } catch (e) {
410 return raw;
411 }
412
413 // If none of these checks work, we return the raw input
414 }
415
416 /// Expands a decimal string by shifting the decimal point `expShift` places
417 /// to the right and returns an integer string (digits only, optional leading minus).
418 /// Examples:
419 /// _expandDecimal('0.123456', 18) -> '123456000000000000'
420 /// _expandDecimal('1.2', 3) -> '1200'
421 static String _expandDecimal(String decimalStr, int expShift) {
422 var s = decimalStr.trim();
423 var sign = "";
424 if (s.startsWith("-") || s.startsWith("+")) {
425 sign = s[0] == "-" ? "-" : "";
426 s = s.substring(1);
427 }
428
429 // First we split the integer and fractional parts
430 final parts = s.split(".");
431 final intPart = parts[0].isEmpty ? "0" : parts[0];
432 final fracPart = parts.length > 1 ? parts[1] : "";
433 final digits = (intPart + fracPart).replaceFirst(RegExp(r"^0+"), "");
434 final fracLen = fracPart.length;
435
436 // Then we calculate the effective shift = desired shift minus existing fractional digits
437 final shift = expShift - fracLen;
438 if (shift >= 0) {
439 final head = digits.isEmpty ? "0" : digits;
440 final zeros = List.filled(shift, "0").join();
441 final res = head + zeros;
442 return sign + (res.isEmpty ? "0" : res);
443 } else {
444 // Need to insert a decimal point within digits; return integer by truncating
445 final cut = digits.length + shift;
446 if (cut <= 0) {
447 return "0";
448 }
449 final res = digits.substring(0, cut);
450 return sign + (res.isEmpty ? "0" : res);
451 }
452 }
453 }
454
455 class LightningPaymentRequest extends PaymentURI {
456 LightningPaymentRequest({
457 required super.address,
458 required super.amount,
459 required this.lnURL,
460 this.bolt11Invoice,
461 });
462
463 final String lnURL;
464 final String? bolt11Invoice;
465
466 @override
467 String toString() => bolt11Invoice ?? "lightning:$lnURL";
468 }