Skip to content

Commit cc05055

Browse files
authored
Merge pull request #253 from synonymdev/feat/lnurl-channel
LNURL Channel
2 parents 1bc25c5 + d0bc9c2 commit cc05055

File tree

11 files changed

+468
-62
lines changed

11 files changed

+468
-62
lines changed

README.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@
1010

1111
This repository contains a **new native Android app** which is **not ready for production**.
1212

13-
## Prerequisites
14-
15-
1. Download `google-services.json` to `./app` from FCM Console
16-
1713
## Development
1814

15+
**Prerequisites**
16+
1. Download `google-services.json` to `app/` from FCM Console.
17+
1918
### References
2019

2120
- For LNURL dev testing see [bitkit-docker](https://github.com/ovitrif/bitkit-docker)
@@ -28,8 +27,7 @@ Recommended Android Studio plugins:
2827
- EditorConfig
2928
- Detekt
3029

31-
#### Commands
32-
30+
**Commands**
3331
```sh
3432
./gradlew detekt # run analysis + formatting check
3533
./gradlew detekt --auto-correct # auto-fix formatting issues
@@ -49,11 +47,12 @@ The build config supports building 3 different apps for the 3 bitcoin networks (
4947

5048
### Build for Release
5149

52-
**Prerequisite**: setup the signing config:
53-
- Add the keystore file to root dir (i.e. `./release.keystore`)
50+
**Prerequisites**
51+
Setup the signing config:
52+
- Add the keystore file to root dir (i.e. `release.keystore`)
5453
- Setup `keystore.properties` file in root dir (`cp keystore.properties.template keystore.properties`)
5554

56-
#### Routine:
55+
**Routine**
5756

5857
Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run:
5958
```sh
@@ -63,3 +62,8 @@ Increment `versionCode` and `versionName` in `app/build.gradle.kts`, then run:
6362

6463
APK is generated in `app/build/outputs/apk/_flavor_/release`. (`_flavor_` can be any of 'dev', 'mainnet', 'tnet').
6564
Example for dev: `app/build/outputs/apk/dev/release`
65+
66+
## License
67+
68+
This project is licensed under the MIT License.
69+
See the [LICENSE](./LICENSE) file for more details.

app/src/main/java/to/bitkit/repositories/LightningRepo.kt

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import com.synonym.bitkitcore.LightningInvoice
55
import com.synonym.bitkitcore.Scanner
66
import com.synonym.bitkitcore.createWithdrawCallbackUrl
77
import com.synonym.bitkitcore.decode
8-
import com.synonym.bitkitcore.getLnurlInvoice
98
import kotlinx.coroutines.CoroutineDispatcher
109
import kotlinx.coroutines.delay
1110
import kotlinx.coroutines.flow.Flow
@@ -41,6 +40,8 @@ import to.bitkit.services.CoreService
4140
import to.bitkit.services.LdkNodeEventBus
4241
import to.bitkit.services.LightningService
4342
import to.bitkit.services.LnUrlWithdrawResponse
43+
import to.bitkit.services.LnurlChannelInfoResponse
44+
import to.bitkit.services.LnurlChannelResponse
4445
import to.bitkit.services.LnurlService
4546
import to.bitkit.services.NodeEventHandler
4647
import to.bitkit.utils.Logger
@@ -378,6 +379,12 @@ class LightningRepo @Inject constructor(
378379
Result.success(Unit)
379380
}
380381

382+
suspend fun connectPeer(peer: LnPeer): Result<Unit> = executeWhenNodeRunning("connectPeer") {
383+
lightningService.connectPeer(peer)
384+
syncState()
385+
Result.success(Unit)
386+
}
387+
381388
suspend fun disconnectPeer(peer: LnPeer): Result<Unit> = executeWhenNodeRunning("Disconnect peer") {
382389
lightningService.disconnectPeer(peer)
383390
syncState()
@@ -410,15 +417,15 @@ class LightningRepo @Inject constructor(
410417
): Result<LightningInvoice> {
411418
return runCatching {
412419
// TODO use bitkit-core getLnurlInvoice if it works with callbackUrl
413-
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).pr
420+
val bolt11 = lnurlService.fetchLnurlInvoice(callbackUrl, amountSats, comment).getOrThrow().pr
414421
val decoded = (decode(bolt11) as Scanner.Lightning).invoice
415422
return@runCatching decoded
416423
}.onFailure {
417424
Logger.error("Error fetching lnurl invoice, url: $callbackUrl, amount: $amountSats, comment: $comment", it)
418425
}
419426
}
420427

421-
suspend fun handleLnUrlWithdraw(
428+
suspend fun handleLnurlWithdraw(
422429
k1: String,
423430
callback: String,
424431
paymentRequest: String,
@@ -428,6 +435,18 @@ class LightningRepo @Inject constructor(
428435
lnurlService.fetchWithdrawInfo(callbackUrl)
429436
}
430437

438+
suspend fun fetchLnurlChannelInfo(url: String): Result<LnurlChannelInfoResponse> =
439+
lnurlService.fetchLnurlChannelInfo(url)
440+
441+
suspend fun handleLnurlChannel(
442+
k1: String,
443+
callback: String,
444+
nodeId: String,
445+
): Result<LnurlChannelResponse> = executeWhenNodeRunning("handleLnurlChannel") {
446+
// TODO use bitkit-core createChannelRequestUrl after it is fixed to prevent k1 duplicating
447+
lnurlService.handleLnurlChannel(k1 = k1, callback = callback, nodeId = nodeId)
448+
}
449+
431450
suspend fun payInvoice(bolt11: String, sats: ULong? = null): Result<PaymentId> =
432451
executeWhenNodeRunning("Pay invoice") {
433452
val paymentId = lightningService.send(bolt11 = bolt11, sats = sats)

app/src/main/java/to/bitkit/services/LnurlService.kt

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ class LnurlService @Inject constructor(
1616
) {
1717

1818
suspend fun fetchWithdrawInfo(callbackUrl: String): Result<LnUrlWithdrawResponse> = runCatching {
19+
Logger.debug("Fetching LNURL withdraw info from: $callbackUrl")
20+
1921
val response: HttpResponse = client.get(callbackUrl)
2022
Logger.debug("Http call: $response")
2123

@@ -29,6 +31,7 @@ class LnurlService @Inject constructor(
2931
withdrawResponse.status == "ERROR" -> {
3032
throw Exception("LNURL error: ${withdrawResponse.reason}")
3133
}
34+
3235
else -> withdrawResponse
3336
}
3437
}.onFailure {
@@ -39,11 +42,15 @@ class LnurlService @Inject constructor(
3942
callbackUrl: String,
4043
amountSats: ULong,
4144
comment: String? = null,
42-
): LnurlPayResponse {
45+
): Result<LnurlPayResponse> = runCatching {
46+
Logger.debug("Fetching LNURL pay invoice info from: $callbackUrl")
47+
4348
val response = client.get(callbackUrl) {
4449
url {
45-
parameters.append("amount", "${amountSats * 1000u}") // convert to msat
46-
comment?.takeIf { it.isNotBlank() }?.let { parameters.append("comment", it) }
50+
parameters["amount"] = "${amountSats * 1000u}" // convert to msat
51+
comment?.takeIf { it.isNotBlank() }?.let {
52+
parameters["comment"] = it
53+
}
4754
}
4855
}
4956
Logger.debug("Http call: $response")
@@ -52,7 +59,53 @@ class LnurlService @Inject constructor(
5259
throw Exception("HTTP error: ${response.status}")
5360
}
5461

55-
return response.body<LnurlPayResponse>()
62+
return@runCatching response.body<LnurlPayResponse>()
63+
}
64+
65+
suspend fun fetchLnurlChannelInfo(url: String): Result<LnurlChannelInfoResponse> = runCatching {
66+
Logger.debug("Fetching LNURL channel info from: $url")
67+
68+
val response: HttpResponse = client.get(url)
69+
Logger.debug("Http call: $response")
70+
71+
if (!response.status.isSuccess()) {
72+
throw Exception("HTTP error: ${response.status}")
73+
}
74+
75+
return@runCatching response.body<LnurlChannelInfoResponse>()
76+
}.onFailure {
77+
Logger.warn(msg = "Failed to fetch channel info", e = it, context = TAG)
78+
}
79+
80+
suspend fun handleLnurlChannel(
81+
k1: String,
82+
callback: String,
83+
nodeId: String,
84+
): Result<LnurlChannelResponse> = runCatching {
85+
Logger.debug("Handling LNURL channel request to: $callback")
86+
87+
val response = client.get(callback) {
88+
url {
89+
parameters["k1"] = k1
90+
parameters["remoteid"] = nodeId
91+
parameters["private"] = "1" // Private channel
92+
}
93+
}
94+
Logger.debug("Http call: $response")
95+
96+
if (!response.status.isSuccess()) throw Exception("HTTP error: ${response.status}")
97+
98+
val parsedResponse = response.body<LnurlChannelResponse>()
99+
100+
when {
101+
parsedResponse.status == "ERROR" -> {
102+
throw Exception("LNURL channel error: ${parsedResponse.reason}")
103+
}
104+
105+
else -> parsedResponse
106+
}
107+
}.onFailure {
108+
Logger.warn(msg = "Failed to handle LNURL channel", e = it, context = TAG)
56109
}
57110

58111
companion object {
@@ -70,11 +123,25 @@ data class LnUrlWithdrawResponse(
70123
val defaultDescription: String? = null,
71124
val minWithdrawable: Long? = null,
72125
val maxWithdrawable: Long? = null,
73-
val balanceCheck: String? = null
126+
val balanceCheck: String? = null,
74127
)
75128

76129
@Serializable
77130
data class LnurlPayResponse(
78131
val pr: String,
79132
val routes: List<String>,
80133
)
134+
135+
@Serializable
136+
data class LnurlChannelResponse(
137+
val status: String? = null,
138+
val reason: String? = null,
139+
)
140+
141+
@Serializable
142+
data class LnurlChannelInfoResponse(
143+
val uri: String,
144+
val tag: String,
145+
val callback: String,
146+
val k1: String,
147+
)

app/src/main/java/to/bitkit/ui/ContentView.kt

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ import to.bitkit.viewmodels.BlocktankViewModel
147147
import to.bitkit.viewmodels.CurrencyViewModel
148148
import to.bitkit.viewmodels.MainScreenEffect
149149
import to.bitkit.viewmodels.RestoreState
150-
import to.bitkit.viewmodels.SendEvent
151150
import to.bitkit.viewmodels.SettingsViewModel
152151
import to.bitkit.viewmodels.TransferViewModel
153152
import to.bitkit.viewmodels.WalletViewModel
153+
import to.bitkit.ui.screens.transfer.external.LnurlChannelScreen
154154

155155
@Composable
156156
fun ContentView(
@@ -610,6 +610,14 @@ private fun RootNavHost(
610610
onCloseClick = { navController.navigateToHome() },
611611
)
612612
}
613+
composableWithDefaultTransitions<Routes.LnurlChannel> {
614+
LnurlChannelScreen(
615+
route = it.toRoute<Routes.LnurlChannel>(),
616+
onConnected = { navController.navigate(Routes.ExternalSuccess) },
617+
onBack = { navController.popBackStack() },
618+
onClose = { navController.navigateToHome() },
619+
)
620+
}
613621
composableWithDefaultTransitions<Routes.ExternalSuccess> {
614622
ExternalSuccessScreen(
615623
onContinue = { navController.popBackStack<Routes.TransferRoot>(inclusive = true) },
@@ -728,14 +736,14 @@ private fun NavGraphBuilder.shop(
728736
}
729737
)
730738
}
731-
composableWithDefaultTransitions<Routes.ShopWebView> { navBackEntry ->
739+
composableWithDefaultTransitions<Routes.ShopWebView> {
732740
ShopWebViewScreen (
733741
onClose = { navController.navigateToHome() },
734742
onBack = { navController.popBackStack() },
735-
page = navBackEntry.toRoute<Routes.ShopWebView>().page,
736-
title = navBackEntry.toRoute<Routes.ShopWebView>().title,
743+
page = it.toRoute<Routes.ShopWebView>().page,
744+
title = it.toRoute<Routes.ShopWebView>().title,
737745
onPaymentIntent = { data ->
738-
appViewModel.onScanSuccess(data)
746+
appViewModel.onScanSuccess(data)
739747
}
740748
)
741749
}
@@ -823,8 +831,8 @@ private fun NavGraphBuilder.changePinNew(navController: NavHostController) {
823831
}
824832

825833
private fun NavGraphBuilder.changePinConfirm(navController: NavHostController) {
826-
composableWithDefaultTransitions<Routes.ChangePinConfirm> { navBackEntry ->
827-
val route = navBackEntry.toRoute<Routes.ChangePinConfirm>()
834+
composableWithDefaultTransitions<Routes.ChangePinConfirm> {
835+
val route = it.toRoute<Routes.ChangePinConfirm>()
828836
ChangePinConfirmScreen(
829837
newPin = route.newPin,
830838
navController = navController,
@@ -887,9 +895,9 @@ private fun NavGraphBuilder.channelOrdersSettings(
887895
private fun NavGraphBuilder.orderDetailSettings(
888896
navController: NavHostController,
889897
) {
890-
composableWithDefaultTransitions<Routes.OrderDetail> { navBackEntry ->
898+
composableWithDefaultTransitions<Routes.OrderDetail> {
891899
OrderDetailScreen(
892-
orderItem = navBackEntry.toRoute(),
900+
orderItem = it.toRoute(),
893901
onBackClick = { navController.popBackStack() },
894902
)
895903
}
@@ -898,9 +906,9 @@ private fun NavGraphBuilder.orderDetailSettings(
898906
private fun NavGraphBuilder.cjitDetailSettings(
899907
navController: NavHostController,
900908
) {
901-
composableWithDefaultTransitions<Routes.CjitDetail> { navBackEntry ->
909+
composableWithDefaultTransitions<Routes.CjitDetail> {
902910
CJitDetailScreen(
903-
cjitItem = navBackEntry.toRoute(),
911+
cjitItem = it.toRoute(),
904912
onBackClick = { navController.popBackStack() },
905913
)
906914
}
@@ -940,19 +948,19 @@ private fun NavGraphBuilder.activityItem(
940948
activityListViewModel: ActivityListViewModel,
941949
navController: NavHostController,
942950
) {
943-
composableWithDefaultTransitions<Routes.ActivityDetail> { navBackEntry ->
951+
composableWithDefaultTransitions<Routes.ActivityDetail> {
944952
ActivityDetailScreen(
945953
listViewModel = activityListViewModel,
946-
route = navBackEntry.toRoute(),
954+
route = it.toRoute(),
947955
onExploreClick = { id -> navController.navigateToActivityExplore(id) },
948956
onBackClick = { navController.popBackStack() },
949957
onCloseClick = { navController.navigateToHome() },
950958
)
951959
}
952-
composableWithDefaultTransitions<Routes.ActivityExplore> { navBackEntry ->
960+
composableWithDefaultTransitions<Routes.ActivityExplore> {
953961
ActivityExploreScreen(
954962
listViewModel = activityListViewModel,
955-
route = navBackEntry.toRoute(),
963+
route = it.toRoute(),
956964
onBackClick = { navController.popBackStack() },
957965
onCloseClick = { navController.navigateToHome() },
958966
)
@@ -979,8 +987,8 @@ private fun NavGraphBuilder.qrScanner(
979987
private fun NavGraphBuilder.authCheck(
980988
navController: NavHostController,
981989
) {
982-
composable<Routes.AuthCheck> { navBackEntry ->
983-
val route = navBackEntry.toRoute<Routes.AuthCheck>()
990+
composable<Routes.AuthCheck> {
991+
val route = it.toRoute<Routes.AuthCheck>()
984992
AuthCheckScreen(
985993
route = route,
986994
navController = navController,
@@ -994,8 +1002,8 @@ private fun NavGraphBuilder.logs(
9941002
composableWithDefaultTransitions<Routes.Logs> {
9951003
LogsScreen(navController)
9961004
}
997-
composableWithDefaultTransitions<Routes.LogDetail> { navBackEntry ->
998-
val route = navBackEntry.toRoute<Routes.LogDetail>()
1005+
composableWithDefaultTransitions<Routes.LogDetail> {
1006+
val route = it.toRoute<Routes.LogDetail>()
9991007
LogDetailScreen(
10001008
navController = navController,
10011009
fileName = route.fileName,
@@ -1565,6 +1573,9 @@ sealed interface Routes {
15651573
@Serializable
15661574
data object ExternalFeeCustom : Routes
15671575

1576+
@Serializable
1577+
data class LnurlChannel(val uri: String, val callback: String, val k1: String) : Routes
1578+
15681579
@Serializable
15691580
data class ActivityDetail(val id: String) : Routes
15701581

0 commit comments

Comments
 (0)