diff --git a/README.md b/README.md index cbcd657..ca711a7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # dart_lnurl [![pub package](https://img.shields.io/badge/pub-0.0.1-blueviolet.svg)](https://pub.dev/packages/dart_lnurl) -A Dart implementation of lnurl to decode bech32 lnurl strings. Currently supports the following tags: +A Dart implementation of lnurl to decode bech32 and parse non-bech32 lnurl strings. Currently supports the following tags: * withdrawRequest * payRequest * channelRequest @@ -10,7 +10,7 @@ A Dart implementation of lnurl to decode bech32 lnurl strings. Currently support ## Features * ✅ Decode a bech32-encoded lnurl string. * ✅ Handles LUD-17 non-bech32 lnurl string (lnurlw, lnurlp, lnurlc, keyauth). -* ✅ Make GET request to the decoded ln service and return the response. +* ✅ Make GET request to the ln service and return the response. @@ -18,8 +18,8 @@ Learn more about the lnurl spec here: https://github.com/btcontract/lnurl-rfc # API Reference -`Future getParams(String encodedUrl)` -Use this to parse an encoded bech32 lnurl string, call the decoded URI, and return the parsed response from the lnurl service. The `encodedUrl` can either have `lightning:` in it or not. +`Future getParams(String url)` +Use this to parse a lnurl string, call the (decoded if needed) URI, and return the parsed response from the lnurl service. The bech32-encoded `url` can either have `lightning:` in it or not and non-bech32 `url` can either have `lnurlw:`,`lnurlp:`,`lnurlc:` or `keyauth:`. `String decryptSuccessActionAesPayload({LNURLPaySuccessAction successAction, String preimage})` diff --git a/lib/dart_lnurl.dart b/lib/dart_lnurl.dart index f870907..492f343 100644 --- a/lib/dart_lnurl.dart +++ b/lib/dart_lnurl.dart @@ -12,35 +12,24 @@ export 'src/types.dart'; export 'src/success_action.dart'; export 'src/bech32.dart'; -Uri decodeUri(String encodedUrl) { - Uri decodedUri; - - /// The URL doesn't have to be encoded at all as per LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. - /// https://github.com/lnurl/luds/blob/luds/17.md - /// Handle non bech32-encoded LNURL - final lud17prefixes = ['lnurlw', 'lnurlc', 'lnurlp', 'keyauth']; - decodedUri = Uri.parse(encodedUrl); - for (final prefix in lud17prefixes) { - if (decodedUri.scheme.contains(prefix)) { - decodedUri = decodedUri.replace(scheme: prefix); - } - } - if (lud17prefixes.contains(decodedUri.scheme)) { - /// 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.' - decodedUri = decodedUri.replace( - scheme: decodedUri.host.endsWith('onion') || - decodedUri.host.endsWith('onion.') - ? 'http' - : 'https'); - } else { +Uri parseLnUri(String input) { + Uri parsedUri; + //Handle the cases when Uri doesn't have to be bech32 encoded, as per LUD-17 + + if (isbech32(input)) { + /// Bech32 encoded URL /// Try to parse the input as a lnUrl. Will throw an error if it fails. - final lnUrl = findLnUrl(encodedUrl); + final lnUrl = findLnUrl(input); /// Decode the lnurl using bech32 final bech32 = Bech32Codec().decode(lnUrl, lnUrl.length); - decodedUri = Uri.parse(utf8.decode(fromWords(bech32.data))); + parsedUri = Uri.parse(utf8.decode(fromWords(bech32.data))); + } else { + /// Non-Bech32 encoded URL + final String lnUrl = findLnUrlNonBech32(input); + parsedUri = Uri.parse(lnUrl); } - return decodedUri; + return parsedUri; } /// Get params from a lnurl string. Possible types are: @@ -51,11 +40,11 @@ Uri decodeUri(String encodedUrl) { /// * `LNURLPayParams` /// /// Throws [ArgumentError] if the provided input is not a valid lnurl. -Future getParams(String encodedUrl) async { - final decodedUri = decodeUri(encodedUrl); +Future getParams(String url) async { + final parsedUri = parseLnUri(url); try { /// Call the lnurl to get a response - final res = await http.get(decodedUri); + final res = await http.get(parsedUri); /// If there's an error then throw it if (res.statusCode >= 300) { @@ -70,8 +59,8 @@ Future getParams(String encodedUrl) async { error: LNURLErrorResponse.fromJson({ ...parsedJson, ...{ - 'domain': decodedUri.host, - 'url': decodedUri.toString(), + 'domain': parsedUri.host, + 'url': parsedUri.toString(), } }), ); @@ -113,8 +102,8 @@ Future getParams(String encodedUrl) async { error: LNURLErrorResponse.fromJson({ ...parsedJson, ...{ - 'domain': decodedUri.host, - 'url': decodedUri.toString(), + 'domain': parsedUri.host, + 'url': parsedUri.toString(), } }), ); @@ -126,9 +115,9 @@ Future getParams(String encodedUrl) async { return LNURLParseResult( error: LNURLErrorResponse.fromJson({ 'status': 'ERROR', - 'reason': '${decodedUri.toString()} returned error: ${e.toString()}', - 'url': decodedUri.toString(), - 'domain': decodedUri.host, + 'reason': '${parsedUri.toString()} returned error: ${e.toString()}', + 'url': parsedUri.toString(), + 'domain': parsedUri.host, }), ); } diff --git a/lib/src/lnurl.dart b/lib/src/lnurl.dart index 98ace54..7920507 100644 --- a/lib/src/lnurl.dart +++ b/lib/src/lnurl.dart @@ -11,3 +11,34 @@ String findLnUrl(String input) { throw ArgumentError('Not a valid lnurl string'); } } + +String findLnUrlNonBech32(String input) { + /// The URL doesn't have to be encoded at all as per LUD-17: Protocol schemes and raw (non bech32-encoded) URLs. + /// https://github.com/lnurl/luds/blob/luds/17.md + /// Handle non bech32-encoded LNURL + + final lud17prefixes = ['lnurlw', 'lnurlp', 'keyauth', 'lnurlc']; + Uri parsedUri = Uri.parse(input); + for (final prefix in lud17prefixes) { + if (parsedUri.scheme.contains(prefix)) { + parsedUri = parsedUri.replace(scheme: prefix); + break; + } + } + if (lud17prefixes.contains(parsedUri.scheme)) { + /// 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.' + parsedUri = parsedUri.replace( + scheme: parsedUri.host.endsWith('onion') || + parsedUri.host.endsWith('onion.') + ? 'http' + : 'https'); + } + return parsedUri.toString(); +} + +bool isbech32(String input) { + final match = new RegExp( + r',*?((lnurl)([0-9]{1,}[a-z0-9]+){1})', + ).allMatches(input.toLowerCase()); + return match.length == 1 ? true : false; +} diff --git a/test/dart_lnurl_test.dart b/test/dart_lnurl_test.dart index a5818c0..4612cf2 100644 --- a/test/dart_lnurl_test.dart +++ b/test/dart_lnurl_test.dart @@ -8,7 +8,7 @@ void main() { test('should handle bolt card lnurlw:// ', () async { final url = 'lnurlw://lnbits.btcslovnik.cz/boltcards/api/v1/scan/wpyeilzhasqu8rgsmfqbv9?p=D13EFAAEC499E07F611B279BA3EE982C&c=DF6C74D375DF8300'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -17,7 +17,7 @@ void main() { test('should handle onion bolt card lnurlw:// ', () async { final url = 'lnurlw://lnbits.btcslovnikxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/boltcards/api/v1/scan/wpyeilzhasqu8rgsmfqbv9?p=D13EFAAEC499E07F611B279BA3EE982C&c=DF6C74D375DF8300'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -27,7 +27,7 @@ void main() { () async { final url = 'enlnurlw://lnbits.btcslovnik.cz/boltcards/api/v1/scan/wpyeilzhasqu8rgsmfqbv9?p=D13EFAAEC499E07F611B279BA3EE982C&c=DF6C74D375DF8300'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -38,7 +38,7 @@ void main() { () async { final url = 'enlnurlw://lnbits.btcslovnikxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/boltcards/api/v1/scan/wpyeilzhasqu8rgsmfqbv9?p=D13EFAAEC499E07F611B279BA3EE982C&c=DF6C74D375DF8300'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -47,21 +47,86 @@ void main() { test('should handle static lnurlw://', () async { final url = 'lnurlw://lnbits.cz/lnurlw/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect(res, Uri.parse('https://lnbits.cz/lnurlw/357')); }); + test('should handle static lnurlw with lightning:', () async { + final url = + 'lightning:LNURL1DP68GURN8GHJ7MRWVF5HGUEWVF6XXURVV96XY7FWVDAZ7AMFW35XGUNPWUHKZURF9AMRZTMVDE6HYMP0GF8Y5V63FFX9WKT5FDTHXD3CVF9XG42DW9VSN09NNG'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'https://lnbits.btcplatby.cz/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); + + test( + 'should handle static lnurlw with lightning: and with additional non-related prefix', + () async { + final url = + 'enlightning:LNURL1DP68GURN8GHJ7MRWVF5HGUEWVF6XXURVV96XY7FWVDAZ7AMFW35XGUNPWUHKZURF9AMRZTMVDE6HYMP0GF8Y5V63FFX9WKT5FDTHXD3CVF9XG42DW9VSN09NNG'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'https://lnbits.btcplatby.cz/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); + + test('should handle static onion lnurlw with lightning:', () async { + final url = + 'lightning:LNURL1DP68GUP69UHKCMNZD968XTNZW33HQMRPW338J7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC9EHKU6T0DCHHW6T5DPJ8YCTH9ASHQ6F0WCCJ7MRWW4EXCT6ZFE9RX522F3T4JAZT2AENVWRZFFJ92NT3TYXRL2DG'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'http://lnbits.btcplatbyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); + + test( + 'should handle static onion lnurlw with lightning: and with additional non-related prefix', + () async { + final url = + 'enlightning:LNURL1DP68GUP69UHKCMNZD968XTNZW33HQMRPW338J7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC0PU8S7RC9EHKU6T0DCHHW6T5DPJ8YCTH9ASHQ6F0WCCJ7MRWW4EXCT6ZFE9RX522F3T4JAZT2AENVWRZFFJ92NT3TYXRL2DG'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'http://lnbits.btcplatbyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); + + test('should handle static onion lowercase lnurlw with lightning:', () async { + final url = + 'lightning:lnurl1dp68gup69uhkcmnzd968xtnzw33hqmrpw338j7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc9ehku6t0dchhw6t5dpj8ycth9ashq6f0wccj7mrww4exct6zfe9rx522f3t4jazt2aenvwrzffj92nt3tyxrl2dg'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'http://lnbits.btcplatbyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); + + test( + 'should handle static onion lowercase lnurlw with lightning and other non-related prefix:', + () async { + final url = + 'enlightning:lnurl1dp68gup69uhkcmnzd968xtnzw33hqmrpw338j7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc0pu8s7rc9ehku6t0dchhw6t5dpj8ycth9ashq6f0wccj7mrww4exct6zfe9rx522f3t4jazt2aenvwrzffj92nt3tyxrl2dg'; + final res = parseLnUri(url); + expect( + res, + Uri.parse( + 'http://lnbits.btcplatbyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/withdraw/api/v1/lnurl/BNJ3QJLWYtKWs68bJdUMqY')); + }); test('should handle static lnurlw:// with additional non-related prefix', () async { final url = 'enlnurlw://lnbits.cz/lnurlw/357'; - final res = await decodeUri(url); + final res = await parseLnUri(url); //expect(res.payParams?.tag, 'payRequest'); expect(res, Uri.parse('https://lnbits.cz/lnurlw/357')); }); test('should handle static onion lnurlw://', () async { final url = 'lnurlw://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlw/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -72,7 +137,7 @@ void main() { () async { final url = 'enlnurlw://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlw/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -81,19 +146,19 @@ void main() { test('should handle lnurlp://', () async { final url = 'lnurlp://lnbits.cz/lnurlp/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect(res, Uri.parse('https://lnbits.cz/lnurlp/357')); }); test('should handle lnurlp:// with additional non-related prefix', () async { final url = 'enlnurlp://lnbits.cz/lnurlp/357'; - final res = await decodeUri(url); + final res = await parseLnUri(url); //expect(res.payParams?.tag, 'payRequest'); expect(res, Uri.parse('https://lnbits.cz/lnurlp/357')); }); test('should handle onion lnurlp://', () async { final url = 'lnurlp://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlp/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -103,7 +168,7 @@ void main() { () async { final url = 'enlnurlp://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlp/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -112,19 +177,19 @@ void main() { test('should handle lnurlc://', () async { final url = 'lnurlc://lnbits.cz/lnurlc/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect(res, Uri.parse('https://lnbits.cz/lnurlc/357')); }); test('should handle lnurlc:// with additional non-related prefix', () async { final url = 'enlnurlc://lnbits.cz/lnurlc/357'; - final res = await decodeUri(url); + final res = await parseLnUri(url); //expect(res.payParams?.tag, 'payRequest'); expect(res, Uri.parse('https://lnbits.cz/lnurlc/357')); }); test('should handle onion lnurlc://', () async { final url = 'lnurlc://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlc/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -134,7 +199,7 @@ void main() { () async { final url = 'enlnurlc://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/lnurlc/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -143,19 +208,19 @@ void main() { test('should handle keyauth://', () async { final url = 'keyauth://lnbits.cz/keyauth/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect(res, Uri.parse('https://lnbits.cz/keyauth/357')); }); test('should handle keyauth:// with additional non-related prefix', () async { final url = 'enkeyauth://lnbits.cz/keyauth/357'; - final res = await decodeUri(url); + final res = await parseLnUri(url); //expect(res.payParams?.tag, 'payRequest'); expect(res, Uri.parse('https://lnbits.cz/keyauth/357')); }); test('should handle onion keyauth://', () async { final url = 'keyauth://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/keyauth/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -165,7 +230,7 @@ void main() { () async { final url = 'enkeyauth://lnbitsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.onion/keyauth/357'; - final res = decodeUri(url); + final res = parseLnUri(url); expect( res, Uri.parse( @@ -210,10 +275,46 @@ void main() { expect(decrypted, plainText); }); - test('should decode lnurl-pay', () async { + test('should parse lnurl-pay', () async { final url = 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTNZD9NHXATW9EU8J730D3H82UNV94CXZ7FLWDJHXUMFDAHR6C3NXGCNGCEE8YEX2CFHXCCRYCNXXUENVEFHXQCR2EF5XS6R2D35XUERSEFC8YCKGDF5XAJNGCTX8PJRYVP4XDJNVD3CX3NRVEFCX4SSWRA86F'; - final res = decodeUri(url); + final res = parseLnUri(url); expect(res, isNotNull); }); + + test('should parse lnurl without lightning:', () { + final lnurl = + 'lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mf0w5lhz0t9xcekzv34vgcx2vfkvcurxwphvgcrwefjvgcnqwrpxqmkxven89skgvp3vs6nwvpjvy6njdfsx5ekgephvcurxdf5xcerwvecvyunsf32lqq'; + expect( + parseLnUri(lnurl), + Uri.parse( + 'https://lnurl-toolbox.degreesofzero.com/u?q=e63a25b0e16f8387b07e2b108a07c339ad01d5702a595053dd7f835462738a98')); + }); + + test('should parse lnurl with lightning:', () { + final lnurl = + 'lightning:lnurl1dp68gurn8ghj7mrww4exctt5dahkccn00qhxget8wfjk2um0veax2un09e3k7mf0w5lhz0t9xcekzv34vgcx2vfkvcurxwphvgcrwefjvgcnqwrpxqmkxven89skgvp3vs6nwvpjvy6njdfsx5ekgephvcurxdf5xcerwvecvyunsf32lqq'; + expect( + parseLnUri(lnurl), + Uri.parse( + 'https://lnurl-toolbox.degreesofzero.com/u?q=e63a25b0e16f8387b07e2b108a07c339ad01d5702a595053dd7f835462738a98')); + }); + + test('should parse LNURL without lightning:', () { + final lnurl = + 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTT5DAHKCCN00QHXGET8WFJK2UM0VEAX2UN09E3K7MF0W5LHZ0T9XCEKZV34VGCX2VFKVCURXWPHVGCRWEFJVGCNQWRPXQMKXVEN89SKGVP3VS6NWVPJVY6NJDFSX5EKGEPHVCURXDF5XCERWVECVYUNSF32LQQ'; + expect( + parseLnUri(lnurl), + Uri.parse( + 'https://lnurl-toolbox.degreesofzero.com/u?q=e63a25b0e16f8387b07e2b108a07c339ad01d5702a595053dd7f835462738a98')); + }); + + test('should parse LNURL with lightning:', () { + final lnurl = + 'lightning:LNURL1DP68GURN8GHJ7MRWW4EXCTT5DAHKCCN00QHXGET8WFJK2UM0VEAX2UN09E3K7MF0W5LHZ0T9XCEKZV34VGCX2VFKVCURXWPHVGCRWEFJVGCNQWRPXQMKXVEN89SKGVP3VS6NWVPJVY6NJDFSX5EKGEPHVCURXDF5XCERWVECVYUNSF32LQQ'; + expect( + parseLnUri(lnurl), + Uri.parse( + 'https://lnurl-toolbox.degreesofzero.com/u?q=e63a25b0e16f8387b07e2b108a07c339ad01d5702a595053dd7f835462738a98')); + }); }