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 package com.bitcointxoko.gudariwallet.api
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Url import retrofit2.http.Url
@@ -144,4 +146,61 @@ interface LNbitsApi {
*/ */
@GET("api/v1/currencies") @GET("api/v1/currencies")
suspend fun getSupportedCurrencies(): List<String> 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("data") val data: List<PaymentRecord>,
@SerializedName("total") val total: Int @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 { override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey(), checkingId) 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 package com.bitcointxoko.gudariwallet.data
import com.bitcointxoko.gudariwallet.api.LnurlScanResponse 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.PaginatedPaymentsResponse
import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse import com.bitcointxoko.gudariwallet.api.PaymentDetailResponse
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
@@ -41,4 +43,20 @@ interface WalletRepository {
suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse
suspend fun getFiatRate(currency: String): Double suspend fun getFiatRate(currency: String): Double
suspend fun getSupportedCurrencies(): List<String> 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.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Cable
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat import com.bitcointxoko.gudariwallet.util.formatFiat
@@ -38,6 +39,7 @@ import com.bitcointxoko.gudariwallet.i18n.AppStrings
fun HomeTab( fun HomeTab(
vm: WalletViewModel, vm: WalletViewModel,
onHistoryClick: () -> Unit, onHistoryClick: () -> Unit,
onNwcClick : () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
@@ -50,235 +52,252 @@ fun HomeTab(
val isRefreshing = balanceState is BalanceState.Loading val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true || (balanceState as? BalanceState.Success)?.isRefreshing == true
Box(modifier = Modifier.fillMaxSize()) {
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() }, onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) { ) {
when (val s = balanceState) { Column(
modifier = Modifier
is BalanceState.Loading -> { .fillMaxSize()
CircularProgressIndicator() .verticalScroll(rememberScrollState())
Spacer(Modifier.height(12.dp)) .padding(24.dp),
Text( horizontalAlignment = Alignment.CenterHorizontally,
text = strings.loadingBalance, verticalArrangement = Arrangement.Center
style = MaterialTheme.typography.bodyMedium, ) {
color = MaterialTheme.colorScheme.onSurfaceVariant when (val s = balanceState) {
)
}
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))
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text( Text(
text = if (balanceHidden) "••••••" else formatSats(s.sats), text = strings.loadingBalance,
style = MaterialTheme.typography.displayLarge.copy( style = MaterialTheme.typography.bodyMedium,
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
),
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.width(8.dp))
} }
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency is BalanceState.Success -> {
if (currency != null) { val selectedCurrency by vm.selectedCurrency.collectAsState()
Row( var showCurrencyPicker by remember { mutableStateOf(false) }
verticalAlignment = Alignment.CenterVertically, var isLoadingCurrencies by remember { mutableStateOf(false) }
horizontalArrangement = Arrangement.Center var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
) { LaunchedEffect(showCurrencyPicker) {
if (s.fiatAmount != null && s.fiatCurrency != null) { if (showCurrencyPicker && currencyList.isEmpty()) {
Text( isLoadingCurrencies = true
text = if (balanceHidden) "••••" currencyList = vm.getSupportedCurrencies()
else formatFiat(s.fiatAmount, s.fiatCurrency), isLoadingCurrencies = false
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, Row(
modifier = Modifier verticalAlignment = Alignment.CenterVertically
.clickable( ) {
onClick = { showCurrencyPicker = true }, Icon(
onClickLabel = strings.changeCurrency, imageVector = if (balanceHidden)
indication = null, 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() } 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(
text = currency, text = strings.showFiat,
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
),
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( Spacer(Modifier.height(8.dp))
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp) if (!s.isRefreshing) {
) { val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated, strings)
}
Text( Text(
text = strings.showFiat, text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall, 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) { TextButton(onClick = onHistoryClick) {
val updatedLabel: String = remember(s.lastUpdated) { Text(
formatLastUpdated(s.lastUpdated, strings) 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)) // ── NEW: Language switcher ─────────────────────────────────
TextButton(onClick = onHistoryClick) {
Text(
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
// Spacer(Modifier.height(8.dp)) // Spacer(Modifier.height(8.dp))
// LanguageSwitcherButton( // LanguageSwitcherButton(
// currentTag = lyricist.languageTag, // currentTag = lyricist.languageTag,
// supportedTags = supportedTags, // supportedTags = supportedTags,
// onSelect = { tag -> lyricist.languageTag = tag } // onSelect = { tag -> lyricist.languageTag = tag }
// ) // )
// ────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────
if (showCurrencyPicker) { if (showCurrencyPicker) {
CurrencyPickerSheet( CurrencyPickerSheet(
currencies = currencyList, currencies = currencyList,
selectedCurrency = selectedCurrency, selectedCurrency = selectedCurrency,
onSelect = { code -> onSelect = { code ->
vm.setSelectedCurrency(code) vm.setSelectedCurrency(code)
showCurrencyPicker = false showCurrencyPicker = false
}, },
isLoading = isLoadingCurrencies, isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false } 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))
} }
Surface( is BalanceState.Error -> {
color = MaterialTheme.colorScheme.errorContainer, if (s.staleSats != null) {
shape = MaterialTheme.shapes.small, Text(
modifier = Modifier.fillMaxWidth(0.85f) text = if (balanceHidden) "••••••" else formatSats(s.staleSats),
) { style = MaterialTheme.typography.displayLarge.copy(
Text( fontFamily = FontFamily.Monospace
text = if (s.staleSats != null) ),
strings.couldNotRefresh color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
else )
strings.couldNotLoadBalance, Text(
style = MaterialTheme.typography.bodySmall, text = strings.sats,
color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(12.dp) 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.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel 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.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.ui.send.SendStrings 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_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history" private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}" private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
private const val ROUTE_NWC = "nwc"
@Composable @Composable
fun WalletScreen( fun WalletScreen(
@@ -120,6 +123,9 @@ fun WalletScreen(
) )
} }
val historyVm: HistoryViewModel = viewModel(factory = historyFactory) 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 navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -127,10 +133,12 @@ fun WalletScreen(
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER && currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY && currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
val isFullScreenDestination = currentRoute == ROUTE_HISTORY val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL || currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER || currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
// ── Notification tap ────────────────────────────────────────────────────── // ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value val hash = pendingPaymentHash.value
@@ -322,6 +330,11 @@ fun WalletScreen(
launchSingleTop = true launchSingleTop = true
} }
}, },
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
lyricist = lyricist 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 ──── // ── 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) }
}