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