dev
dart 269 lines 7.66 KB
Raw
1 import "dart:convert";
2
3 import "package:bech32/bech32.dart";
4 import "package:cw_core/amount/money.dart";
5 import "package:cw_core/crypto_currency.dart";
6 import "package:cw_core/utils/proxy_wrapper.dart";
7
8 const _BOLT_PREFIXES = ["lnbcrt", "lntbs", "lnbc", "lntb"];
9 const _LUD17_PREFIXES = ["lnurlw", "lnurlc", "lnurlp", "keyauth"];
10
11 bool isBolt11ZeroInvoice(String invoice) {
12 try {
13 final request =
14 const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length);
15
16 final prefix =
17 _BOLT_PREFIXES.firstWhere(request.hrp.startsWith, orElse: () => "");
18
19 return request.hrp.length == prefix.length;
20 } catch (e) {
21 return false;
22 }
23 }
24
25 /// Get the amount of a Bolt 11 Invoice in
26 int? _getAmountBolt11Msat(String invoice) {
27 final request =
28 const Bech32Codec().decode(invoice.replaceFirst("lightning:", ""), invoice.length);
29
30 final prefix = _BOLT_PREFIXES.firstWhere(
31 request.hrp.startsWith,
32 orElse: () => throw const FormatException("Invalid BOLT11 invoice: unknown HRP prefix."),
33 );
34
35 final amountPart = request.hrp.substring(prefix.length);
36 if (amountPart.isEmpty) {
37 return null; // zero-amount invoice
38 }
39
40 final hasMultiplier = RegExp(r"[munp]$").hasMatch(amountPart);
41 final multiplier = hasMultiplier ? amountPart[amountPart.length - 1] : "";
42 final numberStr = hasMultiplier ? amountPart.substring(0, amountPart.length - 1) : amountPart;
43
44 if (numberStr.isEmpty || !RegExp(r"^\d+$").hasMatch(numberStr)) {
45 throw const FormatException("Invalid BOLT11 invoice: invalid amount number in HRP.");
46 }
47
48 final amount = int.parse(numberStr);
49
50 switch (multiplier) {
51 case "": // bitcoin
52 return amount * 100000000000;
53 case "m": // milli-bitcoin
54 return amount * 100000000;
55 case "u": // micro-bitcoin
56 return amount * 100000;
57 case "n": // nano-bitcoin
58 return amount * 100;
59 case "p":
60 if (amount % 10 != 0) {
61 throw const FormatException(
62 "Invalid BOLT11 invoice: amount not representable in whole msat.",
63 );
64 }
65 return amount ~/ 10;
66 default:
67 throw const FormatException("Invalid BOLT11 invoice: unknown amount multiplier.");
68 }
69 }
70
71 Money? getBolt11Amount(String invoice) {
72 final msat = _getAmountBolt11Msat(invoice);
73 if (msat == null || msat % 1000 != 0) {
74 return null;
75 }
76
77 return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln);
78 }
79
80 class LNURL {
81 static Future<Money?> getPayRequestAmount(String lnurl) async {
82 final url = decode(lnurl);
83 final response = await ProxyWrapper().get(clearnetUri: url);
84
85 if (response.statusCode != 200) {
86 return null;
87 }
88
89 try {
90 final body = jsonDecode(response.body) as Map;
91 final tag = body["tag"] as String?;
92
93 if (tag != "payRequest") {
94 return null;
95 }
96
97 final msat = body["minSendable"] as int?;
98 final maxSendable = body["maxSendable"] as int?;
99
100 // if minSendable and maxSendable are the same we assume a specific payment request
101 if (msat != maxSendable) {
102 return null;
103 }
104
105 if (msat == null || msat % 1000 != 0) {
106 return null;
107 }
108 return Money.fromInt(msat ~/ 1000, CryptoCurrency.btcln);
109 } catch (_) {
110 return null;
111 }
112 }
113
114 static Future<LNURLWithdrawRequest?> getWithdrawRequest(String lnurl) async {
115 final url = decode(lnurl);
116 final response = await ProxyWrapper().get(clearnetUri: url);
117
118 if (response.statusCode != 200) {
119 return null;
120 }
121
122 try {
123 final body = jsonDecode(response.body) as Map;
124 final tag = body["tag"] as String?;
125
126 if (tag != "withdrawRequest") {
127 return null;
128 }
129
130 final minWithdrawable = body["minWithdrawable"] as int?;
131 final maxWithdrawable = body["maxWithdrawable"] as int?;
132
133 if ((minWithdrawable == null || minWithdrawable % 1000 != 0) ||
134 (maxWithdrawable == null || maxWithdrawable % 1000 != 0)) {
135 return null;
136 }
137
138 return LNURLWithdrawRequest(
139 callback: body["callback"] as String,
140 k1: body["k1"] as String,
141 description: body["defaultDescription"] as String,
142 minAmount: Money.fromInt(minWithdrawable ~/ 1000, CryptoCurrency.btcln),
143 maxAmount: Money.fromInt(maxWithdrawable ~/ 1000, CryptoCurrency.btcln),
144 );
145 } catch (_) {
146 return null;
147 }
148 }
149
150 Future<bool> commitWithdrawRequest(LNURLWithdrawRequest request, String invoice) async {
151 try {
152 final url = Uri.parse(request.callback);
153 url.queryParameters["k1"] = request.k1;
154 url.queryParameters["pr"] = invoice;
155
156 final response = await ProxyWrapper().get(clearnetUri: url);
157
158 if (response.statusCode != 200) {
159 return false;
160 }
161
162 final body = jsonDecode(response.body) as Map;
163 final status = body["status"] as String?;
164
165 if (status == "OK") {
166 return true;
167 }
168
169 return false;
170 } catch (_) {
171 return false;
172 }
173 }
174
175 static String encode(String url) {
176 final raw = _convert(utf8.encode(url), 8, 5, true);
177 return const Bech32Codec().encode(Bech32("lnurl", raw), 999);
178 }
179
180 static Uri decode(String encodedUrl) {
181 Uri decodedUri;
182
183 /// The URL doesn't have to be encoded at all as per LUD-17: Protocol schemes and raw (non bech32-encoded) URLs.
184 /// https://github.com/lnurl/luds/blob/luds/17.md
185 /// Handle non bech32-encoded LNURL
186 decodedUri = Uri.parse(encodedUrl);
187 for (final prefix in _LUD17_PREFIXES) {
188 if (decodedUri.scheme.contains(prefix)) {
189 decodedUri = decodedUri.replace(scheme: prefix);
190 }
191 }
192 if (_LUD17_PREFIXES.contains(decodedUri.scheme)) {
193 /// If the non-bech32 LNURL is a Tor address, the port has to be http instead of https for the clearnet LNURL so check if the host ends with '.onion' or '.onion.'
194 decodedUri = decodedUri.replace(
195 scheme: decodedUri.host.endsWith("onion") || decodedUri.host.endsWith("onion.")
196 ? "http"
197 : "https",
198 );
199 } else {
200 /// Try to parse the input as a lnUrl. Will throw an error if it fails.
201 final lnUrl = _findLnUrl(encodedUrl);
202
203 /// Decode the lnurl using bech32
204 final bech32 = const Bech32Codec().decode(lnUrl, lnUrl.length);
205 decodedUri = Uri.parse(utf8.decode(_convert(bech32.data, 5, 8, false)));
206 }
207 return decodedUri;
208 }
209 }
210
211 class LNURLWithdrawRequest {
212 const LNURLWithdrawRequest({
213 required this.callback,
214 required this.k1,
215 required this.description,
216 required this.minAmount,
217 required this.maxAmount,
218 });
219
220 final String callback;
221 final String k1;
222 final String description;
223 final Money minAmount;
224 final Money maxAmount;
225 }
226
227 /// Parse and return a given lnurl string if it's valid. Will remove
228 /// `lightning:` from the beginning of it if present.
229 String _findLnUrl(String input) {
230 final res = RegExp(r",*?((lnurl)([0-9]+[a-z0-9]+))").allMatches(input.toLowerCase());
231
232 if (res.length != 1) {
233 throw ArgumentError("Not a valid lnurl string");
234 }
235 return res.first.group(0)!;
236 }
237
238 /// Taken from bech32 (bitcoinjs): https://github.com/bitcoinjs/bech32
239 List<int> _convert(List<int> data, int inBits, int outBits, bool pad) {
240 var value = 0;
241 var bits = 0;
242 final maxV = (1 << outBits) - 1;
243
244 final result = <int>[];
245 for (final dataValue in data) {
246 value = (value << inBits) | dataValue;
247 bits += inBits;
248
249 while (bits >= outBits) {
250 bits -= outBits;
251 result.add((value >> bits) & maxV);
252 }
253 }
254
255 if (pad) {
256 if (bits > 0) {
257 result.add((value << (outBits - bits)) & maxV);
258 }
259 } else {
260 if (bits >= inBits) {
261 throw Exception("[BECH32] Excess padding");
262 }
263 if ((value << (outBits - bits)) & maxV > 0) {
264 throw Exception("[BECH32] Non-zero padding");
265 }
266 }
267
268 return result;
269 }