feat: nwc

This commit is contained in:
2026-06-09 15:48:38 +02:00
parent 2490ea2f75
commit 7fad5ec9dd
9 changed files with 1288 additions and 194 deletions
@@ -1,9 +1,11 @@
package com.bitcointxoko.gudariwallet.api
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Url
@@ -144,4 +146,61 @@ interface LNbitsApi {
*/
@GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String>
// ── NWC Provider ──────────────────────────────────────────────────────────
@GET("nwcprovider/api/v1/permissions")
suspend fun getNwcPermissions(
@Header("X-Api-Key") apiKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/nwc")
suspend fun getNwcKeys(
@Header("X-Api-Key") adminKey: String,
@Query("include_expired") includeExpired: Boolean = false
): List<NwcGetResponse>
@GET("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun getNwcKey(
@Header("X-Api-Key") apiKey: String,
@Path("pubkey") pubkey: String,
@Query("include_expired") includeExpired: Boolean = false,
@Query("refresh_last_used") refreshLastUsed: Boolean = false
): NwcGetResponse
@PUT("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun registerNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String,
@Body request: NwcRegistrationRequest
): NwcGetResponse
@DELETE("nwcprovider/api/v1/nwc/{pubkey}")
suspend fun deleteNwcKey(
@Header("X-Api-Key") adminKey: String,
@Path("pubkey") pubkey: String
)
@GET("nwcprovider/api/v1/pairing/{secret}")
suspend fun getNwcPairingUrl(
@Header("X-Api-Key") apiKey: String,
@Path("secret") secret: String
): String
@GET("nwcprovider/api/v1/config")
suspend fun getNwcConfig(
@Header("X-Api-Key") adminKey: String
): Map<String, Any>
@GET("nwcprovider/api/v1/config/{key}")
suspend fun getNwcConfigValue(
@Header("X-Api-Key") adminKey: String,
@Path("key") key: String
): Map<String, Any>
@POST("nwcprovider/api/v1/config")
suspend fun setNwcConfig(
@Header("X-Api-Key") adminKey: String,
@Body config: Map<String, Any>
): Map<String, Any>
}
@@ -322,3 +322,43 @@ data class PaginatedPaymentsResponse(
@SerializedName("data") val data: List<PaymentRecord>,
@SerializedName("total") val total: Int
)
// ── NWC Provider models ───────────────────────────────────────────────────────
data class NwcKey(
val pubkey: String,
val wallet: String,
val description: String,
val expires_at: Long,
val permissions: String, // comma-separated permission names
val created_at: Long,
val last_used: Long
)
data class NwcBudget(
val id: Int,
val pubkey: String,
val budget_msats: Long,
val refresh_window: Int, // seconds
val created_at: Long,
val used_budget_msats: Long = 0
)
/** Response for GET /nwc and GET /nwc/{pubkey} */
data class NwcGetResponse(
val data: NwcKey,
val budgets: List<NwcBudget>
)
data class NwcNewBudget(
val budget_msats: Long,
val refresh_window: Int // seconds
)
/** Request body for PUT /nwc/{pubkey} */
data class NwcRegistrationRequest(
val permissions: List<String>,
val description: String,
val expires_at: Long, // unix timestamp; 0 = no expiry
val budgets: List<NwcNewBudget> = emptyList()
)
@@ -286,4 +286,134 @@ class LNbitsWalletRepository(
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
}
// ── NWC Provider ───────────────────────────────────────────────────────────
/**
* Returns the map of supported NWC permission names.
*/
override suspend fun getNwcPermissions(): Map<String, Any> {
Timber.d("NWC [PERMISSIONS] fetching supported permissions")
val result = api.getNwcPermissions(secrets.invoiceKey())
Timber.i("NWC [PERMISSIONS] received ${result.size} permission(s)")
return result
}
/**
* Returns all NWC connection keys for the current wallet.
*/
override suspend fun getNwcKeys(includeExpired: Boolean): List<NwcGetResponse> {
Timber.d("NWC [KEYS ] fetching all keys (includeExpired=$includeExpired)")
val result = api.getNwcKeys(secrets.adminKey(), includeExpired)
Timber.i("NWC [KEYS ] received ${result.size} key(s)")
return result
}
/**
* Returns a single NWC connection key by its public key.
*/
override suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean,
refreshLastUsed : Boolean
): NwcGetResponse {
Timber.d("NWC [KEY ] fetching key pubkey=${pubkey.take(16)}")
val result = api.getNwcKey(
apiKey = secrets.invoiceKey(),
pubkey = pubkey,
includeExpired = includeExpired,
refreshLastUsed = refreshLastUsed
)
Timber.i("NWC [KEY ] fetched key pubkey=${pubkey.take(16)}… description='${result.data.description}'")
return result
}
/**
* Registers / creates a new NWC connection key.
* Requires admin key.
*/
override suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse {
Timber.d(
"NWC [REGISTER ] pubkey=${pubkey.take(16)}" +
"permissions=${request.permissions} " +
"expires_at=${request.expires_at} " +
"budgets=${request.budgets.size}"
)
val result = api.registerNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey,
request = request
)
Timber.i("NWC [REGISTER ] registered key pubkey=${pubkey.take(16)}… ✓")
return result
}
/**
* Deletes an NWC connection key by its public key.
* Requires admin key.
*/
override suspend fun deleteNwcKey(pubkey: String) {
Timber.d("NWC [DELETE ] pubkey=${pubkey.take(16)}")
api.deleteNwcKey(
adminKey = secrets.adminKey(),
pubkey = pubkey
)
Timber.i("NWC [DELETE ] deleted key pubkey=${pubkey.take(16)}… ✓")
}
/**
* Returns the pairing URL (nostr+walletconnect://…) for a given secret.
*/
override suspend fun getNwcPairingUrl(secret: String): String {
Timber.d("NWC [PAIRING ] fetching pairing URL for secret=${secret.take(8)}")
val url = api.getNwcPairingUrl(
apiKey = secrets.invoiceKey(),
secret = secret
)
Timber.i("NWC [PAIRING ] pairing URL received ✓")
return url
}
/**
* Returns all extension configuration settings.
* Requires admin key.
*/
override suspend fun getNwcConfig(): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching all config settings")
val result = api.getNwcConfig(secrets.adminKey())
Timber.i("NWC [CONFIG ] received ${result.size} setting(s)")
return result
}
/**
* Returns a single configuration value by key.
* Requires admin key.
*/
override suspend fun getNwcConfigValue(key: String): Map<String, Any> {
Timber.d("NWC [CONFIG ] fetching config key='$key'")
val result = api.getNwcConfigValue(
adminKey = secrets.adminKey(),
key = key
)
Timber.i("NWC [CONFIG ] received value for key='$key'")
return result
}
/**
* Sets / updates one or more configuration values.
* Requires admin key.
*/
override suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any> {
Timber.d("NWC [CONFIG SET] updating ${config.size} setting(s): keys=${config.keys}")
val result = api.setNwcConfig(
adminKey = secrets.adminKey(),
config = config
)
Timber.i("NWC [CONFIG SET] config updated ✓")
return result
}
}
@@ -1,6 +1,8 @@
package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.api.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord
@@ -41,4 +43,20 @@ interface WalletRepository {
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String>
suspend fun getNwcPermissions(): Map<String, Any>
suspend fun getNwcKeys(includeExpired: Boolean = false): List<NwcGetResponse>
suspend fun getNwcKey(
pubkey : String,
includeExpired : Boolean = false,
refreshLastUsed : Boolean = false
): NwcGetResponse
suspend fun registerNwcKey(
pubkey : String,
request : NwcRegistrationRequest
): NwcGetResponse
suspend fun deleteNwcKey(pubkey: String)
suspend fun getNwcPairingUrl(secret: String): String
suspend fun getNwcConfig(): Map<String, Any>
suspend fun getNwcConfigValue(key: String): Map<String, Any>
suspend fun setNwcConfig(config: Map<String, Any>): Map<String, Any>
}
@@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit,
onNwcClick : () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) {
val strings = LocalAppStrings.current
@@ -50,235 +52,252 @@ fun HomeTab(
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
}
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "••••"
else formatFiat(s.fiatAmount, s.fiatCurrency),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(4.dp))
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = strings.changeCurrency,
indication = null,
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 4.dp, vertical = 4.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.width(8.dp))
}
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "••••"
else formatFiat(s.fiatAmount, s.fiatCurrency),
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(4.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = strings.changeCurrency,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(horizontal = 4.dp, vertical = 4.dp)
) {
Text(
text = currency,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.75f
)
)
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
} else {
TextButton(
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = currency,
style = MaterialTheme.typography.bodyMedium.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
)
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
text = strings.showFiat,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
}
}
} else {
TextButton(
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
}
Text(
text = strings.showFiat,
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(16.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
Text(
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
// ── NEW: Language switcher ─────────────────────────────────
// Spacer(Modifier.height(8.dp))
// LanguageSwitcherButton(
// currentTag = lyricist.languageTag,
// supportedTags = supportedTags,
// onSelect = { tag -> lyricist.languageTag = tag }
// )
// ──────────────────────────────────────────────────────────
// ──────────────────────────────────────────────────────────
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
strings.couldNotRefresh
else
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
style = MaterialTheme.typography.displayLarge.copy(
fontFamily = FontFamily.Monospace
),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
strings.couldNotRefresh
else
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
// ── NWC button — top-right corner ─────────────────────────────────
IconButton(
onClick = onNwcClick,
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
Icon(
imageVector = Icons.Filled.Cable,
contentDescription = "Wallet Connect",
tint = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = 0.7f)
)
}
}
}
@@ -62,6 +62,8 @@ import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings
@@ -81,6 +83,7 @@ private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_NWC = "nwc"
@Composable
fun WalletScreen(
@@ -120,6 +123,9 @@ fun WalletScreen(
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo) // same pattern as historyVm
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
@@ -127,10 +133,12 @@ fun WalletScreen(
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
@@ -322,6 +330,11 @@ fun WalletScreen(
launchSingleTop = true
}
},
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
lyricist = lyricist
)
}
@@ -415,6 +428,18 @@ fun WalletScreen(
}
}
}
composable(
ROUTE_NWC,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
NwcScreen(
viewModel = nwcVm,
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
}
)
}
}
// ── Clipboard banner — floats over content, slides in from top ────
@@ -0,0 +1,559 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitcointxoko.gudariwallet.api.NwcBudget
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
// ── Available permissions ─────────────────────────────────────────────────────
private data class NwcPermission(
val serverKey : String, // what we send in the permissions array
val displayLabel : String, // shown in the UI
val default : Boolean // pre-checked by default
)
private val ALL_PERMISSIONS = listOf(
NwcPermission("pay", "Send payments", default = true),
NwcPermission("invoice", "Create invoices", default = true),
NwcPermission("lookup", "Lookup invoice status", default = true),
NwcPermission("history", "Read transaction history", default = true),
NwcPermission("balance", "Read wallet balance", default = true),
NwcPermission("info", "Read account info", default = true)
)
// ── Screen ────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NwcScreen(
viewModel : NwcViewModel,
onBack : () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val isDeleting by viewModel.isDeleting.collectAsStateWithLifecycle()
val isCreating by viewModel.isCreating.collectAsStateWithLifecycle()
val pairingUrl by viewModel.pairingUrl.collectAsStateWithLifecycle()
var showAddSheet by remember { mutableStateOf(false) }
var pendingDeletePubkey by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Wallet Connect") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showAddSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add connection")
}
}
) { innerPadding ->
val isRefreshing = uiState is NwcUiState.Loading
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { viewModel.loadConnections() },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (val state = uiState) {
is NwcUiState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
is NwcUiState.Error -> {
NwcErrorContent(
message = state.message,
onRetry = { viewModel.loadConnections() },
modifier = Modifier.fillMaxSize()
)
}
is NwcUiState.Success -> {
if (state.connections.isEmpty()) {
NwcEmptyContent(
onAddNew = { showAddSheet = true },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.connections,
key = { it.data.pubkey }
) { connection ->
NwcConnectionCard(
connection = connection,
isDeleting = isDeleting,
onDeleteClick = { pendingDeletePubkey = connection.data.pubkey }
)
}
}
}
}
}
}
}
// ── Add connection sheet ───────────────────────────────────────────────────
if (showAddSheet) {
NwcAddConnectionSheet(
isCreating = isCreating,
onCreate = { description, permissions, expiresAt ->
viewModel.createConnection(
description = description,
permissions = permissions,
expiresAt = expiresAt
)
showAddSheet = false
},
onDismiss = { showAddSheet = false }
)
}
// ── Pairing URL dialog ─────────────────────────────────────────────────────
pairingUrl?.let { url ->
NwcPairingUrlDialog(
url = url,
onDismiss = { viewModel.clearPairingUrl() }
)
}
// ── Delete confirmation dialog ─────────────────────────────────────────────
pendingDeletePubkey?.let { pubkey ->
AlertDialog(
onDismissRequest = { pendingDeletePubkey = null },
title = { Text("Remove connection?") },
text = { Text("This will permanently revoke this Nostr Wallet Connect key. Any app using it will lose access.") },
confirmButton = {
TextButton(
onClick = {
viewModel.deleteConnection(pubkey)
pendingDeletePubkey = null
}
) { Text("Remove", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = { pendingDeletePubkey = null }) { Text("Cancel") }
}
)
}
}
// ── Add connection bottom sheet ───────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NwcAddConnectionSheet(
isCreating : Boolean,
onCreate : (description: String, permissions: List<String>, expiresAt: Long) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var description by remember { mutableStateOf("") }
var selectedPermissions by remember {
mutableStateOf(ALL_PERMISSIONS.filter { it.default }.map { it.serverKey }.toSet())
}
var neverExpires by remember { mutableStateOf(true) }
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "New connection",
style = MaterialTheme.typography.titleMedium
)
// ── Description ───────────────────────────────────────────────
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Label (e.g. Zeus, Alby)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// ── Permissions ───────────────────────────────────────────────
Text(
text = "Permissions",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
ALL_PERMISSIONS.forEach { perm ->
val checked = perm.serverKey in selectedPermissions
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = checked,
onCheckedChange = { on ->
selectedPermissions = if (on)
selectedPermissions + perm.serverKey
else
selectedPermissions - perm.serverKey
}
)
Spacer(Modifier.width(8.dp))
Text(
text = perm.displayLabel,
style = MaterialTheme.typography.bodyMedium
)
}
}
// ── Expiry ────────────────────────────────────────────────────
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = neverExpires,
onCheckedChange = { neverExpires = it }
)
Spacer(Modifier.width(8.dp))
Text(
text = "Never expires",
style = MaterialTheme.typography.bodyMedium
)
}
// ── Create button ─────────────────────────────────────────────
Button(
onClick = {
onCreate(
description.trim().ifBlank { "Unnamed" },
selectedPermissions.toList(),
if (neverExpires) 0L else 0L // expiry picker can be added later
)
},
enabled = selectedPermissions.isNotEmpty() && !isCreating,
modifier = Modifier.fillMaxWidth()
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCreating) "Creating…" else "Create connection")
}
}
}
}
// ── Pairing URL dialog ────────────────────────────────────────────────────────
@Composable
private fun NwcPairingUrlDialog(
url : String,
onDismiss : () -> Unit
) {
val clipboard = LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Connection created") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Copy this connection string into your app. It will not be shown again.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {
Text(
text = url,
style = MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace
),
modifier = Modifier.padding(12.dp),
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
AnimatedVisibility(visible = copied) {
Text(
text = "✓ Copied to clipboard",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
},
confirmButton = {
Button(
onClick = {
clipboard.setText(AnnotatedString(url))
copied = true
}
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text("Copy")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Done") }
}
)
}
// ── Connection Card ───────────────────────────────────────────────────────────
@Composable
private fun NwcConnectionCard(
connection : NwcGetResponse,
isDeleting : Boolean,
onDeleteClick: () -> Unit,
modifier : Modifier = Modifier
) {
val key = connection.data
val budgets = connection.budgets
val expired = key.expires_at > 0 && key.expires_at < Instant.now().epochSecond
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (expired)
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = key.description.ifBlank { "Unnamed connection" },
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (expired) {
Text(
text = "Expired",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
IconButton(onClick = onDeleteClick, enabled = !isDeleting) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete connection",
tint = MaterialTheme.colorScheme.error
)
}
}
Text(
text = key.pubkey.take(16) + "" + key.pubkey.takeLast(8),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
HorizontalDivider()
if (key.permissions.isNotBlank()) {
NwcPermissionChips(permissions = key.permissions)
}
NwcMetaRow(
label = "Expires",
value = if (key.expires_at <= 0L) "Never" else formatEpoch(key.expires_at)
)
NwcMetaRow(
label = "Last used",
value = if (key.last_used <= 0L) "Never" else formatEpoch(key.last_used)
)
if (budgets.isNotEmpty()) {
HorizontalDivider()
budgets.forEach { NwcBudgetRow(budget = it) }
}
}
}
}
// ── Permission chips ──────────────────────────────────────────────────────────
@Composable
private fun NwcPermissionChips(permissions: String, modifier: Modifier = Modifier) {
val perms = remember(permissions) {
permissions.split(",").map { it.trim() }.filter { it.isNotEmpty() }
}
Row(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
perms.forEach { perm ->
SuggestionChip(
onClick = {},
label = {
Text(
text = perm.replace("_", " "),
style = MaterialTheme.typography.labelSmall
)
}
)
}
}
}
// ── Budget row ────────────────────────────────────────────────────────────────
@Composable
private fun NwcBudgetRow(budget: NwcBudget, modifier: Modifier = Modifier) {
val totalSats = budget.budget_msats / 1_000L
val usedSats = budget.used_budget_msats / 1_000L
val progress = if (totalSats > 0) (usedSats.toFloat() / totalSats.toFloat()).coerceIn(0f, 1f) else 0f
val windowHours = budget.refresh_window / 3600
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("Budget (${windowHours}h window)", style = MaterialTheme.typography.labelMedium)
Text(
"$usedSats / $totalSats sats",
style = MaterialTheme.typography.labelMedium,
color = if (progress >= 1f) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = if (progress >= 1f) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.primary
)
}
}
// ── Meta row ──────────────────────────────────────────────────────────────────
@Composable
private fun NwcMetaRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodySmall)
}
}
// ── Empty / Error states ──────────────────────────────────────────────────────
@Composable
private fun NwcEmptyContent(onAddNew: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("No connections yet", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(
"Tap + to connect an app via Nostr Wallet Connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
Button(onClick = onAddNew) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Add connection")
}
}
}
@Composable
private fun NwcErrorContent(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Something went wrong", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
Text(message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
OutlinedButton(onClick = onRetry) { Text("Retry") }
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private val dateFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
.withZone(ZoneId.systemDefault())
private fun formatEpoch(epochSeconds: Long): String =
runCatching { dateFormatter.format(Instant.ofEpochSecond(epochSeconds)) }.getOrDefault("")
@@ -0,0 +1,175 @@
package com.bitcointxoko.gudariwallet.ui.nwc
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.api.NwcGetResponse
import com.bitcointxoko.gudariwallet.api.NwcNewBudget
import com.bitcointxoko.gudariwallet.api.NwcRegistrationRequest
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.util.NwcKeyGenerator
import com.bitcointxoko.gudariwallet.util.NwcKeypair
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
private const val TAG = "NwcViewModel"
// ── UI State ──────────────────────────────────────────────────────────────────
sealed interface NwcUiState {
data object Loading : NwcUiState
data class Success(val connections: List<NwcGetResponse>) : NwcUiState
data class Error(val message: String) : NwcUiState
}
// ── ViewModel ─────────────────────────────────────────────────────────────────
class NwcViewModel(
private val repo: WalletRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<NwcUiState>(NwcUiState.Loading)
val uiState: StateFlow<NwcUiState> = _uiState.asStateFlow()
/** Whether a delete operation is currently in flight (disables buttons). */
private val _isDeleting = MutableStateFlow(false)
val isDeleting: StateFlow<Boolean> = _isDeleting.asStateFlow()
// Holds the keypair only while the "add connection" flow is active.
// Cleared as soon as the user dismisses the QR screen.
private val _pendingKeypair = MutableStateFlow<NwcKeypair?>(null)
val pendingKeypair: StateFlow<NwcKeypair?> = _pendingKeypair.asStateFlow()
/** True while a createConnection call is in flight. */
private val _isCreating = MutableStateFlow(false)
val isCreating: StateFlow<Boolean> = _isCreating.asStateFlow()
/** Holds the pairing URL after a successful registration, until dismissed. */
private val _pairingUrl = MutableStateFlow<String?>(null)
val pairingUrl: StateFlow<String?> = _pairingUrl.asStateFlow()
init {
loadConnections()
}
// ── Load ──────────────────────────────────────────────────────────────────
fun loadConnections(includeExpired: Boolean = false) {
Timber.d("[$TAG] loadConnections(includeExpired=$includeExpired)")
_uiState.value = NwcUiState.Loading
viewModelScope.launch {
runCatching { repo.getNwcKeys(includeExpired) }
.onSuccess { keys ->
Timber.i("[$TAG] loaded ${keys.size} NWC connection(s)")
_uiState.value = NwcUiState.Success(keys)
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to load NWC connections")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to load connections"
)
}
}
}
/**
* Generates a keypair, registers it with the server, then fetches
* the nostr+walletconnect:// pairing URL.
* Private key is held only in local scope — never stored.
*/
fun createConnection(
description : String,
permissions : List<String>,
expiresAt : Long = 0L,
budgets : List<NwcNewBudget> = emptyList()
) {
Timber.d("[$TAG] createConnection description='$description' permissions=$permissions")
_isCreating.value = true
viewModelScope.launch {
runCatching {
val keypair = NwcKeyGenerator.generate()
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}")
check(keypair.pubKeyHex.length == 64) {
"pubKeyHex must be 64 hex chars, got ${keypair.pubKeyHex.length}"
}
check(keypair.privKeyHex.length == 64) {
"privKeyHex must be 64 hex chars, got ${keypair.privKeyHex.length}"
}
Timber.d("[$TAG] generated keypair pubkey=${keypair.pubKeyHex.take(16)}… (${keypair.pubKeyHex.length} chars)")
Timber.d("[$TAG] REGISTERING pubkey=${keypair.pubKeyHex}")
Timber.d("[$TAG] PAIRING secret=${keypair.privKeyHex}")
repo.registerNwcKey(
pubkey = keypair.pubKeyHex,
request = NwcRegistrationRequest(
description = description,
permissions = permissions,
expires_at = expiresAt,
budgets = budgets
)
)
Timber.i("[$TAG] registered key ✓")
val url = repo.getNwcPairingUrl(keypair.privKeyHex)
Timber.i("[$TAG] pairing URL fetched ✓")
Timber.d("[$TAG] URI=$url")
url
}
.onSuccess { url ->
_pairingUrl.value = url
loadConnections()
}
.onFailure { e ->
Timber.e(e, "[$TAG] createConnection failed")
_uiState.value = NwcUiState.Error(e.message ?: "Failed to create connection")
}
.also { _isCreating.value = false }
}
}
/** Wipes the pairing URL from memory once the user has copied it. */
fun clearPairingUrl() {
Timber.d("[$TAG] clearing pairing URL from memory")
_pairingUrl.value = null
}
// ── Delete ────────────────────────────────────────────────────────────────
fun deleteConnection(pubkey: String) {
Timber.d("[$TAG] deleteConnection pubkey=${pubkey.take(16)}")
_isDeleting.value = true
viewModelScope.launch {
runCatching { repo.deleteNwcKey(pubkey) }
.onSuccess {
Timber.i("[$TAG] deleted connection pubkey=${pubkey.take(16)}… ✓")
// Remove locally for instant feedback, then reload to sync
val current = _uiState.value
if (current is NwcUiState.Success) {
_uiState.value = NwcUiState.Success(
current.connections.filter { it.data.pubkey != pubkey }
)
}
loadConnections()
}
.onFailure { e ->
Timber.e(e, "[$TAG] failed to delete connection pubkey=${pubkey.take(16)}")
_uiState.value = NwcUiState.Error(
e.message ?: "Failed to delete connection"
)
}
.also { _isDeleting.value = false }
}
}
// ── Factory ───────────────────────────────────────────────────────────────
class Factory(private val repo: WalletRepository) :
ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NwcViewModel(repo) as T
}
}
@@ -0,0 +1,69 @@
package com.bitcointxoko.gudariwallet.util
import fr.acinq.secp256k1.Secp256k1
import java.security.SecureRandom
data class NwcKeypair(
val privKeyHex: String, // 32-byte hex — the "secret" in the pairing URL
val pubKeyHex : String // 32-byte BIP340 x-only hex — the {pubkey} path param
)
object NwcKeyGenerator {
private val rng = SecureRandom()
fun generate(): NwcKeypair {
val secp256k1 = Secp256k1.get()
val privKey = ByteArray(32)
do { rng.nextBytes(privKey) } while (!secp256k1.secKeyVerify(privKey))
// BIP340: if the derived pubkey has odd Y, negate the private key.
// The connecting app (Damus, Zeus, etc.) will do the same negation
// before signing — so we must register the pubkey of the (possibly negated) key.
val bip340PrivKey = normalizeToBip340(secp256k1, privKey)
val pubKeyInternal = secp256k1.pubkeyCreate(bip340PrivKey)
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal) // 33 bytes: 02||x or 03||x
// After BIP340 normalization the prefix is always 02 (even Y).
// x-only = drop the prefix byte.
val xOnlyPubKey = pubKeyCompressed.copyOfRange(1, 33)
return NwcKeypair(
privKeyHex = bip340PrivKey.toHex(), // ← send the (possibly negated) privkey as secret
pubKeyHex = xOnlyPubKey.toHex()
)
}
/**
* BIP340 normalization: if the public key derived from [privKey] has an odd Y coordinate
* (compressed prefix 0x03), negate the private key so that the resulting pubkey has even Y.
* This ensures the secret we hand to the connecting app and the pubkey we register
* are consistent with how BIP340/Schnorr signing works.
*/
private fun normalizeToBip340(secp256k1: Secp256k1, privKey: ByteArray): ByteArray {
val pubKeyInternal = secp256k1.pubkeyCreate(privKey)
val pubKeyCompressed = secp256k1.pubKeyCompress(pubKeyInternal)
val hasOddY = pubKeyCompressed[0] == 0x03.toByte()
return if (hasOddY) {
// Negate: n - privKey (secp256k1 group order)
val n = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"
val negated = (java.math.BigInteger(n, 16) - java.math.BigInteger(1, privKey))
.toByteArray()
.let { bytes ->
// BigInteger.toByteArray() may prepend a 0x00 sign byte — strip it and pad to 32
val stripped = if (bytes[0] == 0x00.toByte()) bytes.copyOfRange(1, bytes.size) else bytes
ByteArray(32 - stripped.size) + stripped // left-pad with zeros if needed
}
negated
} else {
privKey
}
}
private fun ByteArray.toHex(): String =
joinToString("") { "%02x".format(it) }
}