feat(clink): add CLINK debit as a payment source in the Wallet screen

Surfaces debits in the existing wallet list and add flow:
- WalletViewModel reads clinkDebitWallets and emits them as spend-only rows
  (canShowBalance=false) in the unified walletInfoList; the default radio
  spans both types via setDefaultPaymentSource; remove/rename route by type.
- WalletScreen renders debit rows with a Pay only badge instead of a balance
  and disables the NWC-only detail navigation for them.
- AddWalletScreen offers a CLINK Debit type; AddClinkDebitWalletScreen pastes
  or scans an ndebit1 pointer (no secret) and saves it as a payment source.
- New Route.WalletAddClinkDebit + AppNavigation registration.

:amethyst compiles. UI rendering and the end-to-end debit payout remain
untested on device.
This commit is contained in:
Claude
2026-06-09 22:24:11 +00:00
parent 7533c9f34f
commit 002cd3cceb
7 changed files with 316 additions and 21 deletions
@@ -197,6 +197,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.hls.NewHlsVideoScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddCashuWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddClinkDebitWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddNwcWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
@@ -317,6 +318,7 @@ fun BuildNavigation(
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav, it.nip47) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletAddClinkDebit> { AddClinkDebitWalletScreen(accountViewModel, nav, it.ndebit) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWalletSettings> { CashuWalletSettingsScreen(accountViewModel, nav) }
@@ -210,6 +210,10 @@ sealed class Route {
@Serializable object WalletAddCashu : Route()
@Serializable data class WalletAddClinkDebit(
val ndebit: String? = null,
) : Route()
@Serializable object CashuWallet : Route()
@Serializable object CashuWalletSettings : Route()
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.components.util.getText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.painterRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import kotlinx.coroutines.launch
/**
* Adds a CLINK Debits pointer (`ndebit1…`) as a spend-only payment source. Unlike NWC
* there is no secret to paste — authorization is the account's own identity, pre-approved
* on the wallet service — so this screen only collects a name and the pointer.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddClinkDebitWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
ndebit: String? = null,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
var walletName by remember { mutableStateOf("") }
var ndebitUri by remember { mutableStateOf(ndebit.orEmpty()) }
var error by remember { mutableStateOf<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_add_clink_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.consumeWindowInsets(padding)
.imePadding()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = walletName,
onValueChange = { walletName = it },
label = { Text(stringRes(R.string.wallet_name)) },
placeholder = { Text(stringRes(R.string.wallet_name_hint)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
// Paste from clipboard
IconButton(
onClick = {
scope.launch {
val clipText = clipboardManager.getText()
if (clipText != null) {
ndebitUri = clipText
error = null
}
}
},
) {
Icon(
symbol = MaterialSymbols.ContentPaste,
contentDescription = stringRes(id = R.string.paste_from_clipboard),
modifier = Size24Modifier,
tint = MaterialTheme.colorScheme.primary,
)
}
// QR code scanner
IconButton(onClick = { qrScanning = true }) {
Icon(
painter = painterRes(R.drawable.ic_qrcode, 3),
contentDescription = stringRes(id = R.string.accessibility_scan_qr_code),
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (qrScanning) {
SimpleQrCodeScanner {
qrScanning = false
if (!it.isNullOrEmpty()) {
ndebitUri = it
error = null
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = ndebitUri,
onValueChange = {
ndebitUri = it
error = null
},
label = { Text(stringRes(R.string.wallet_paste_ndebit)) },
placeholder = { Text("ndebit1...") },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
val invalidMessage = stringRes(R.string.wallet_add_clink_invalid)
Button(
onClick = {
if (walletViewModel.addClinkDebitWallet(walletName.trim(), ndebitUri.trim())) {
nav.popBack()
} else {
error = invalidMessage
}
},
enabled = ndebitUri.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.wallet_save))
}
}
}
}
@@ -105,6 +105,12 @@ fun AddWalletScreen(
description = stringRes(R.string.wallet_add_cashu_description),
onClick = { nav.popUpTo(Route.WalletAddCashu, Route.WalletAdd::class) },
)
WalletTypeCard(
icon = MaterialSymbols.Bolt,
title = stringRes(R.string.wallet_add_clink_title),
description = stringRes(R.string.wallet_add_clink_description),
onClick = { nav.popUpTo(Route.WalletAddClinkDebit(), Route.WalletAdd::class) },
)
}
}
}
@@ -234,8 +234,11 @@ private fun MultiWalletHomeContent(
WalletCard(
walletInfo = walletInfo,
onSelect = {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
// The detail screen is NWC-only (balance/transactions); debits have neither.
if (walletInfo.canShowBalance) {
walletViewModel.selectWallet(walletInfo.walletId)
nav.nav(Route.WalletDetail(walletInfo.walletId))
}
},
onSetDefault = {
walletViewModel.setDefaultWallet(walletInfo.walletId)
@@ -325,7 +328,7 @@ private fun WalletCard(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onSelect),
.clickable(enabled = walletInfo.canShowBalance, onClick = onSelect),
shape = RoundedCornerShape(16.dp),
border =
if (walletInfo.isDefault) {
@@ -377,8 +380,14 @@ private fun WalletCard(
}
}
// Balance
if (walletInfo.isLoading && walletInfo.balanceSats == null) {
// Balance — debits are spend-only, so show a capability badge instead.
if (!walletInfo.canShowBalance) {
Text(
text = stringRes(R.string.clink_debit_pay_only),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else if (walletInfo.isLoading && walletInfo.balanceSats == null) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
} else {
Column(horizontalAlignment = Alignment.End) {
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.experimental.clink.pointers.ClinkPointerParser
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
@@ -94,6 +97,8 @@ data class WalletInfo(
val isDefault: Boolean = false,
val isLoading: Boolean = false,
val error: String? = null,
// CLINK debits are spend-only: no balance/transactions to fetch or show.
val canShowBalance: Boolean = true,
)
private const val NWC_TIMEOUT_MS = 30_000L
@@ -111,23 +116,43 @@ class WalletViewModel : ViewModel() {
private val _wallets = MutableStateFlow<List<NwcWalletEntryNorm>>(emptyList())
val wallets = _wallets.asStateFlow()
private val _debitWallets = MutableStateFlow<List<ClinkDebitWalletEntryNorm>>(emptyList())
val debitWallets = _debitWallets.asStateFlow()
private val _defaultWalletId = MutableStateFlow<String?>(null)
val defaultWalletId = _defaultWalletId.asStateFlow()
val walletInfoList =
combine(_wallets, _defaultWalletId, walletInfoMap) { wallets, defaultId, infoMap ->
wallets.map { wallet ->
val info = infoMap[wallet.id]
WalletInfo(
walletId = wallet.id,
name = wallet.name,
alias = info?.alias,
balanceSats = info?.balanceSats,
isDefault = wallet.id == defaultId || (defaultId == null && wallet == wallets.firstOrNull()),
isLoading = info?.isLoading == true,
error = info?.error,
)
}
combine(_wallets, _debitWallets, _defaultWalletId, walletInfoMap) { wallets, debits, defaultId, infoMap ->
// The unified default falls back to the first source overall (NWC before debits).
val effectiveDefault = defaultId ?: wallets.firstOrNull()?.id ?: debits.firstOrNull()?.id
val nwcRows =
wallets.map { wallet ->
val info = infoMap[wallet.id]
WalletInfo(
walletId = wallet.id,
name = wallet.name,
alias = info?.alias,
balanceSats = info?.balanceSats,
isDefault = wallet.id == effectiveDefault,
isLoading = info?.isLoading == true,
error = info?.error,
canShowBalance = true,
)
}
val debitRows =
debits.map { debit ->
WalletInfo(
walletId = debit.id,
name = debit.name,
isDefault = debit.id == effectiveDefault,
canShowBalance = false,
)
}
nwcRows + debitRows
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
// Selected wallet for detail view
@@ -218,8 +243,9 @@ class WalletViewModel : ViewModel() {
fun refreshWalletList() {
val acc = account ?: return
_wallets.value = acc.settings.nwcWallets.value
_debitWallets.value = acc.settings.clinkDebitWallets.value
_defaultWalletId.value = acc.settings.defaultPaymentSourceId.value
_hasWalletSetup.value = _wallets.value.isNotEmpty()
_hasWalletSetup.value = _wallets.value.isNotEmpty() || _debitWallets.value.isNotEmpty()
}
fun refreshWalletSetup() {
@@ -264,10 +290,35 @@ class WalletViewModel : ViewModel() {
fun removeWallet(walletId: String) {
val acc = account ?: return
acc.settings.removeNwcWallet(walletId)
if (_debitWallets.value.any { it.id == walletId }) {
acc.settings.removeClinkDebitWallet(walletId)
} else {
acc.settings.removeNwcWallet(walletId)
}
refreshWalletList()
}
/** Adds a CLINK debit pointer (`ndebit1…`) as a spend-only payment source. */
fun addClinkDebitWallet(
name: String,
ndebit: String,
): Boolean {
val acc = account ?: return false
val pointer = ClinkPointerParser.parse(ndebit.trim()) as? NDebit ?: return false
val entry =
ClinkDebitWalletEntryNorm(
id =
java.util.UUID
.randomUUID()
.toString(),
name = name.ifBlank { "Debit" },
pointer = pointer,
)
acc.settings.addClinkDebitWallet(entry)
refreshWalletList()
return true
}
fun addWallet(
name: String,
uri: Nip47WalletConnect.Nip47URINorm,
@@ -292,7 +343,11 @@ class WalletViewModel : ViewModel() {
newName: String,
) {
val acc = account ?: return
acc.settings.renameNwcWallet(walletId, newName)
if (_debitWallets.value.any { it.id == walletId }) {
acc.settings.renameClinkDebitWallet(walletId, newName)
} else {
acc.settings.renameNwcWallet(walletId, newName)
}
refreshWalletList()
}
+5
View File
@@ -115,6 +115,11 @@
<string name="clink_lightning_offer">Lightning Offer</string>
<string name="clink_requesting_invoice">Requesting invoice…</string>
<string name="clink_debit_no_response">The debit service did not complete the payment.</string>
<string name="clink_debit_pay_only">Pay only</string>
<string name="wallet_add_clink_title">CLINK Debit</string>
<string name="wallet_add_clink_description">Pay and zap from a wallet that pre-authorized your account. Spend only — no balance or history.</string>
<string name="wallet_add_clink_invalid">Invalid CLINK debit pointer. Expected an ndebit1… string.</string>
<string name="wallet_paste_ndebit">Paste ndebit pointer</string>
<string name="pay">Pay</string>
<string name="lightning_tips">Lightning Tips</string>
<string name="note_to_receiver">Note to Receiver</string>