feat(amethyst): scaffold NIP-60 Cashu wallet UI + state

Adds the user-visible scaffolding for a Cashu wallet alongside the
existing NWC wallets. View-only for now — minting, send/receive, and
NIP-61 nutzaps land in a follow-up commit on this branch.

UI
  * AddWalletScreen is now a wallet-type chooser. The existing NWC
    flow moves verbatim to AddNwcWalletScreen; AddCashuWalletScreen
    is new: takes one or more mint URLs, auto-generates a separate
    P2PK key for nutzap receiving (or accepts a pasted hex key), and
    publishes a kind:17375 wallet event via the account's signer
    using CashuWalletEvent.build(mints, privkey).
  * CashuWalletScreen renders the wallet's mint list, total balance
    in sats (summed across all unspent kind:7375 token events the
    signer can decrypt, with rollover applied via the `del` field),
    and a chronological history view sourced from kind:7376.
  * WalletScreen surfaces the Cashu wallet as a card under "Your
    Wallets" when one exists, so the Wallets entry point shows both
    wallet kinds side by side.

Relay subscription
  * CashuWalletFilterAssembler (commons) subscribes one filter per
    relay covering kinds 17375/7375/7376/7374/10019 by author and
    one targeting inbound kind:9321 via #p. Not yet wired into
    Account.kt — the view path works because we feed our own writes
    through cache.justConsumeMyOwnEvent. Cross-device sync requires
    the assembler subscription wiring, which comes next.

Plumbing
  * Routes.WalletAddNwc / WalletAddCashu / CashuWallet added and
    registered in AppNavigation.
  * CashuWalletEvent.createAddress(pubKey) mirrors MetadataEvent for
    looking up the replaceable wallet event from LocalCache.

Compiles clean on playDebug + fdroidDebug; BDHKE jvm tests still
pass (7/7).

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:39 +00:00
parent 023e2df542
commit a64cc274cc
11 changed files with 1278 additions and 162 deletions
@@ -186,7 +186,10 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsSc
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.AddNwcWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.AddWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.CashuWalletScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainTransactionsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.WalletReceiveScreen
@@ -291,6 +294,9 @@ fun BuildNavigation(
composableFromEnd<Route.OnchainTransactions> { OnchainTransactionsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.WalletDetail> { WalletDetailScreen(it.walletId, accountViewModel, nav) }
composableFromEnd<Route.WalletAdd> { AddWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletAddNwc> { AddNwcWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.WalletAddCashu> { AddCashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.CashuWallet> { CashuWalletScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
@@ -170,6 +170,12 @@ sealed class Route {
@Serializable object WalletAdd : Route()
@Serializable object WalletAddNwc : Route()
@Serializable object WalletAddCashu : Route()
@Serializable object CashuWallet : Route()
@Serializable object Search : Route()
@Serializable object SecurityFilters : Route()
@@ -0,0 +1,238 @@
/*
* 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddCashuWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: CashuWalletViewModel = viewModel()
LaunchedEffect(Unit) { viewModel.init(accountViewModel) }
val mints = remember { mutableStateListOf<String>() }
var mintInput by remember { mutableStateOf("") }
var autoGenPrivkey by remember { mutableStateOf(true) }
var manualPrivkey by remember { mutableStateOf("") }
val createState by viewModel.createState.collectAsState()
LaunchedEffect(createState) {
if (createState is CashuWalletCreateState.Success) {
nav.popBack()
viewModel.resetCreateState()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_add_cashu_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
) {
Text(
text = stringRes(R.string.cashu_mints),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
mints.forEachIndexed { index, mint ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = mint,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyMedium,
)
IconButton(onClick = { mints.removeAt(index) }) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(R.string.cashu_remove_mint),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = mintInput,
onValueChange = { mintInput = it },
label = { Text(stringRes(R.string.cashu_mint_url)) },
placeholder = { Text("https://mint.example.com") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = {
val trimmed = mintInput.trim().trimEnd('/')
if (trimmed.isNotEmpty() && trimmed !in mints) {
mints.add(trimmed)
mintInput = ""
}
},
enabled = mintInput.isNotBlank(),
) {
Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringRes(R.string.cashu_p2pk_section),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringRes(R.string.cashu_p2pk_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringRes(R.string.cashu_p2pk_autogen))
Switch(checked = autoGenPrivkey, onCheckedChange = { autoGenPrivkey = it })
}
if (!autoGenPrivkey) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = manualPrivkey,
onValueChange = { manualPrivkey = it },
label = { Text(stringRes(R.string.cashu_p2pk_manual_label)) },
placeholder = { Text("hex…") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
val err = (createState as? CashuWalletCreateState.Error)?.message
if (err != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = err,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
viewModel.saveWallet(
mints = mints.toList(),
autoGenPrivkey = autoGenPrivkey,
manualPrivkey = manualPrivkey.takeIf { !autoGenPrivkey },
)
},
enabled =
mints.isNotEmpty() &&
createState !is CashuWalletCreateState.Saving,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.wallet_save))
}
}
}
}
@@ -0,0 +1,240 @@
/*
* 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.OutlinedButton
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.platform.LocalUriHandler
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.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddNwcWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
var walletName by remember { mutableStateOf("") }
var nwcUri by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_add_nwc_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(padding)
.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))
// Connect action buttons: Connect Wallet app, Paste, QR scan
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
modifier = Modifier.weight(1f),
shape = ButtonBorder,
onClick = {
try {
uri.openUri(
"nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc",
)
} catch (_: IllegalArgumentException) {
accountViewModel.toastManager.toast(
R.string.couldnt_find_nwc_wallets,
R.string.couldnt_find_nwc_wallets_description,
)
}
},
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.wallet_connect_connect_app))
}
Spacer(DoubleHorzSpacer)
// Paste from clipboard
IconButton(
onClick = {
scope.launch {
val clipText = clipboardManager.getText()
if (clipText != null) {
nwcUri = 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()) {
nwcUri = it
error = null
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nwcUri,
onValueChange = {
nwcUri = it
error = null
},
label = { Text(stringRes(R.string.wallet_paste_uri)) },
placeholder = { Text("nostr+walletconnect://...") },
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))
Button(
onClick = {
try {
val parsed = Nip47WalletConnect.parse(nwcUri.trim())
walletViewModel.addWallet(walletName.trim(), parsed)
nav.popBack()
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: "Invalid NWC connection URI"
}
},
enabled = nwcUri.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.wallet_save))
}
}
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -29,66 +31,44 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
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.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
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.MaterialSymbol
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.navigation.routes.Route
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.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
/**
* Wallet-type chooser. Each card routes to a dedicated setup screen for the
* selected wallet kind.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddWalletScreen(
accountViewModel: AccountViewModel,
@Suppress("UNUSED_PARAMETER") accountViewModel: AccountViewModel,
nav: INav,
) {
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
var walletName by remember { mutableStateOf("") }
var nwcUri by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var qrScanning by remember { mutableStateOf(false) }
val uri = LocalUriHandler.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.wallet_add_connection)) },
title = { Text(stringRes(R.string.wallet_add_choose_type)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
@@ -105,136 +85,75 @@ fun AddWalletScreen(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp),
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.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(),
WalletTypeCard(
icon = MaterialSymbols.Bolt,
title = stringRes(R.string.wallet_add_nwc_title),
description = stringRes(R.string.wallet_add_nwc_description),
onClick = { nav.nav(Route.WalletAddNwc) },
)
Spacer(modifier = Modifier.height(16.dp))
// Connect action buttons: Connect Wallet app, Paste, QR scan
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
modifier = Modifier.weight(1f),
shape = ButtonBorder,
onClick = {
try {
uri.openUri(
"nostrnwc://connect?appname=Amethyst&appicon=https%3A%2F%2Fraw.githubusercontent.com%2Fvitorpamplona%2Famethyst%2Frefs%2Fheads%2Fmain%2Ficon.png&callback=amethyst%2Bwalletconnect%3A%2F%2Fdlnwc",
)
} catch (_: IllegalArgumentException) {
accountViewModel.toastManager.toast(
R.string.couldnt_find_nwc_wallets,
R.string.couldnt_find_nwc_wallets_description,
)
}
},
) {
Icon(
symbol = MaterialSymbols.Add,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = stringRes(R.string.wallet_connect_connect_app))
}
Spacer(DoubleHorzSpacer)
// Paste from clipboard
IconButton(
onClick = {
scope.launch {
val clipText = clipboardManager.getText()
if (clipText != null) {
nwcUri = 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()) {
nwcUri = it
error = null
}
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nwcUri,
onValueChange = {
nwcUri = it
error = null
},
label = { Text(stringRes(R.string.wallet_paste_uri)) },
placeholder = { Text("nostr+walletconnect://...") },
minLines = 3,
maxLines = 5,
modifier = Modifier.fillMaxWidth(),
WalletTypeCard(
icon = MaterialSymbols.AccountBalanceWallet,
title = stringRes(R.string.wallet_add_cashu_title),
description = stringRes(R.string.wallet_add_cashu_description),
onClick = { nav.nav(Route.WalletAddCashu) },
)
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))
Button(
onClick = {
try {
val parsed = Nip47WalletConnect.parse(nwcUri.trim())
walletViewModel.addWallet(walletName.trim(), parsed)
nav.popBack()
} catch (e: Exception) {
if (e is CancellationException) throw e
error = e.message ?: "Invalid NWC connection URI"
}
},
enabled = nwcUri.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringRes(R.string.wallet_save))
}
}
}
}
@Composable
private fun WalletTypeCard(
icon: MaterialSymbol,
title: String,
description: String,
onClick: () -> Unit,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = icon,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowForward,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
@@ -0,0 +1,287 @@
/*
* 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import java.text.DateFormat
import java.text.NumberFormat
import java.util.Date
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CashuWalletScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val viewModel: CashuWalletViewModel = viewModel()
LaunchedEffect(Unit) { viewModel.init(accountViewModel) }
val walletEvent by viewModel.walletEvent.collectAsState()
val mints by viewModel.mints.collectAsState()
val balanceSats by viewModel.balanceSats.collectAsState()
val history by viewModel.history.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringRes(R.string.cashu_wallet_title)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
symbol = MaterialSymbols.AutoMirrored.ArrowBack,
contentDescription = stringRes(R.string.back),
)
}
},
actions = {
IconButton(onClick = { nav.nav(Route.WalletAddCashu) }) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription = stringRes(R.string.cashu_edit_wallet),
)
}
},
)
},
) { padding ->
if (walletEvent == null) {
EmptyCashuWallet(
modifier = Modifier.padding(padding),
onCreate = { nav.nav(Route.WalletAddCashu) },
)
} else {
CashuWalletContent(
modifier = Modifier.padding(padding),
balanceSats = balanceSats,
mints = mints,
history = history,
)
}
}
}
@Composable
private fun EmptyCashuWallet(
modifier: Modifier,
onCreate: () -> Unit,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringRes(R.string.cashu_no_wallet),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.cashu_no_wallet_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onCreate) {
Text(stringRes(R.string.wallet_add_cashu_title))
}
}
}
@Composable
private fun CashuWalletContent(
modifier: Modifier,
balanceSats: Long,
mints: List<String>,
history: List<CashuSpendingHistoryEvent>,
) {
LazyColumn(
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Spacer(modifier = Modifier.height(8.dp))
BalanceCard(balanceSats)
}
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringRes(R.string.cashu_mints),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
items(mints, key = { it }) { mint ->
MintRow(mint)
}
if (history.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringRes(R.string.cashu_history),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
items(history, key = { it.id }) { entry ->
HistoryRow(entry)
}
}
item { Spacer(modifier = Modifier.height(24.dp)) }
}
}
@Composable
private fun BalanceCard(balanceSats: Long) {
val formatted =
remember(balanceSats) {
NumberFormat.getIntegerInstance().format(balanceSats)
}
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringRes(R.string.cashu_balance),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatted,
fontSize = 36.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
Text(
text = stringRes(R.string.wallet_sats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@Composable
private fun MintRow(mint: String) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(12.dp))
Text(text = mint, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun HistoryRow(entry: CashuSpendingHistoryEvent) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.id.take(12) + "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text =
DateFormat
.getDateTimeInstance()
.format(Date(entry.createdAt * 1000)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,191 @@
/*
* 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.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class CashuWalletCreateState {
data object Idle : CashuWalletCreateState()
data object Saving : CashuWalletCreateState()
data object Success : CashuWalletCreateState()
data class Error(
val message: String,
) : CashuWalletCreateState()
}
/**
* Lightweight ViewModel for the NIP-60 Cashu wallet screens view + create.
*
* Send/receive/mint operations will land in a follow-up commit alongside the
* BDHKE+mint-HTTP layer; this model only covers the view + bootstrap path.
*/
class CashuWalletViewModel : ViewModel() {
private var account: Account? = null
private var accountViewModel: AccountViewModel? = null
private val _walletEvent = MutableStateFlow<CashuWalletEvent?>(null)
val walletEvent = _walletEvent.asStateFlow()
private val _mints = MutableStateFlow<List<String>>(emptyList())
val mints = _mints.asStateFlow()
private val _balanceSats = MutableStateFlow(0L)
val balanceSats = _balanceSats.asStateFlow()
private val _tokenEvents = MutableStateFlow<List<CashuTokenEvent>>(emptyList())
val tokenEvents = _tokenEvents.asStateFlow()
private val _history = MutableStateFlow<List<CashuSpendingHistoryEvent>>(emptyList())
val history = _history.asStateFlow()
private val _createState = MutableStateFlow<CashuWalletCreateState>(CashuWalletCreateState.Idle)
val createState = _createState.asStateFlow()
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
refresh()
}
/**
* Scans the local cache for our wallet event and its associated token /
* history events, decrypting whatever the current signer can decrypt and
* re-emitting state flows.
*
* Today this is fire-and-forget. Reactive observation of the underlying
* notes is a follow-up for the first slice, the screen calls refresh()
* on entry and after a write.
*/
fun refresh() {
val acc = account ?: return
viewModelScope.launch(Dispatchers.IO) {
val pubKey = acc.signer.pubKey
val cache = LocalCache
val walletNote = cache.getOrCreateAddressableNote(CashuWalletEvent.createAddress(pubKey))
val walletEvt = walletNote.event as? CashuWalletEvent
_walletEvent.value = walletEvt
if (walletEvt != null) {
runCatching { walletEvt.mints(acc.signer) }
.onSuccess { _mints.value = it }
} else {
_mints.value = emptyList()
}
val tokenList = mutableListOf<CashuTokenEvent>()
val historyList = mutableListOf<CashuSpendingHistoryEvent>()
cache.notes.forEach { _, note ->
val e = note.event ?: return@forEach
if (e.pubKey != pubKey) return@forEach
when (e) {
is CashuTokenEvent -> tokenList.add(e)
is CashuSpendingHistoryEvent -> historyList.add(e)
else -> Unit
}
}
// Apply rollover: drop any token whose ID appears in a newer token's `del`.
val deletedIds = mutableSetOf<String>()
val decoded = mutableMapOf<String, TokenContent>()
tokenList.forEach { tok ->
runCatching { tok.tokenContent(acc.signer) }
.getOrNull()
?.let { content ->
decoded[tok.id] = content
deletedIds.addAll(content.del)
}
}
val unspent = tokenList.filter { it.id !in deletedIds }
_tokenEvents.value = unspent.sortedByDescending { it.createdAt }
val balance = unspent.sumOf { decoded[it.id]?.totalAmount() ?: 0L }
_balanceSats.value = balance
_history.value = historyList.sortedByDescending { it.createdAt }
}
}
/**
* Builds and publishes a kind:17375 wallet event. If `autoGenPrivkey` is true,
* a fresh P2PK private key is generated for receiving NIP-61 nutzaps.
*
* The wallet event is replaceable, so calling this again with different mints
* (or a different privkey) overwrites the previous one the existing logic
* in account.sendLiterallyEverywhere takes care of broadcasting and local
* cache consumption.
*/
fun saveWallet(
mints: List<String>,
autoGenPrivkey: Boolean,
manualPrivkey: String? = null,
) {
val vm = accountViewModel ?: return
val acc = account ?: return
if (mints.isEmpty()) {
_createState.value = CashuWalletCreateState.Error("Add at least one mint")
return
}
_createState.value = CashuWalletCreateState.Saving
vm.launchSigner {
try {
val privkey =
when {
autoGenPrivkey -> Bdhke.randomScalar().toHexKey()
!manualPrivkey.isNullOrBlank() -> manualPrivkey.trim()
else -> null
}
val template = CashuWalletEvent.build(mints, privkey, acc.signer)
val event = acc.signer.sign(template)
acc.sendLiterallyEverywhere(event)
_createState.value = CashuWalletCreateState.Success
refresh()
} catch (e: Exception) {
_createState.value =
CashuWalletCreateState.Error(e.message ?: "Failed to save wallet")
}
}
}
fun resetCreateState() {
_createState.value = CashuWalletCreateState.Idle
}
}
@@ -61,6 +61,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -90,7 +91,15 @@ fun WalletScreen(
val walletViewModel: WalletViewModel = viewModel()
walletViewModel.init(accountViewModel)
val hasWallet by walletViewModel.hasWalletSetup.collectAsState()
val cashuWalletViewModel: CashuWalletViewModel = viewModel()
LaunchedEffect(Unit) { cashuWalletViewModel.init(accountViewModel) }
val hasNwcWallet by walletViewModel.hasWalletSetup.collectAsState()
val cashuWalletEvent by cashuWalletViewModel.walletEvent.collectAsState()
val hasCashuWallet = cashuWalletEvent != null
val hasWallet = hasNwcWallet || hasCashuWallet
val cashuBalanceSats by cashuWalletViewModel.balanceSats.collectAsState()
val cashuMints by cashuWalletViewModel.mints.collectAsState()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
@@ -145,6 +154,9 @@ fun WalletScreen(
modifier = Modifier,
listState = listState,
nav = nav,
hasCashuWallet = hasCashuWallet,
cashuBalanceSats = cashuBalanceSats,
cashuMintCount = cashuMints.size,
)
}
}
@@ -191,6 +203,9 @@ private fun MultiWalletHomeContent(
modifier: Modifier,
listState: LazyListState,
nav: INav,
hasCashuWallet: Boolean,
cashuBalanceSats: Long,
cashuMintCount: Int,
) {
val walletInfoList by walletViewModel.walletInfoList.collectAsState()
@@ -246,6 +261,16 @@ private fun MultiWalletHomeContent(
)
}
if (hasCashuWallet) {
item {
CashuWalletRow(
balanceSats = cashuBalanceSats,
mintCount = cashuMintCount,
onClick = { nav.nav(Route.CashuWallet) },
)
}
}
item {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
@@ -462,6 +487,69 @@ private fun WalletCard(
}
}
@Composable
private fun CashuWalletRow(
balanceSats: Long,
mintCount: Int,
onClick: () -> Unit,
) {
val formattedBalance =
remember(balanceSats) { NumberFormat.getIntegerInstance().format(balanceSats) }
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringRes(R.string.cashu_wallet_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text =
pluralStringResource(
R.plurals.cashu_wallet_subtitle_mints,
mintCount,
mintCount,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = formattedBalance,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = stringRes(R.string.wallet_sats),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun RenameWalletDialog(
currentName: String,
+23
View File
@@ -1868,6 +1868,24 @@
<string name="wallet_no_wallets">No wallets connected</string>
<string name="wallet_no_wallets_description">Connect one or more NWC wallets to send and receive payments.</string>
<string name="wallet_add_connection">Add NWC Connection</string>
<string name="wallet_add_choose_type">Choose wallet type</string>
<string name="wallet_add_nwc_title">Connect a Lightning wallet (NWC)</string>
<string name="wallet_add_nwc_description">Pay via an external Lightning wallet using Nostr Wallet Connect.</string>
<string name="wallet_add_cashu_title">Cashu wallet (NIP-60)</string>
<string name="wallet_add_cashu_description">An in-app ecash wallet backed by a Cashu mint. Tokens are stored encrypted on relays.</string>
<string name="cashu_wallet_title">Cashu Wallet</string>
<string name="cashu_no_wallet">No Cashu wallet yet</string>
<string name="cashu_no_wallet_description">Create a Cashu wallet to hold ecash tokens and receive nutzaps.</string>
<string name="cashu_balance">Balance</string>
<string name="cashu_mints">Mints</string>
<string name="cashu_mint_url">Mint URL</string>
<string name="cashu_remove_mint">Remove mint</string>
<string name="cashu_history">History</string>
<string name="cashu_edit_wallet">Edit wallet</string>
<string name="cashu_p2pk_section">Nutzap key (advanced)</string>
<string name="cashu_p2pk_explainer">A separate private key used only to receive NIP-61 nutzaps. Not your Nostr identity key.</string>
<string name="cashu_p2pk_autogen">Auto-generate</string>
<string name="cashu_p2pk_manual_label">P2PK private key (hex)</string>
<string name="wallet_paste_uri">Paste nostr+walletconnect:// URI</string>
<string name="wallet_save">Save</string>
<string name="wallet_rename">Rename</string>
@@ -2779,6 +2797,11 @@
<item quantity="other">%1$d years</item>
</plurals>
<plurals name="cashu_wallet_subtitle_mints">
<item quantity="one">%1$d mint</item>
<item quantity="other">%1$d mints</item>
</plurals>
<string name="event_sync_less_than_until">&lt;%1$s</string>
<string name="event_sync_status_connecting">Connecting</string>
<string name="event_sync_status_downloading">Downloading</string>
@@ -0,0 +1,114 @@
/*
* 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.commons.relayClient.assemblers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
/**
* Query state for the NIP-60 / NIP-61 wallet subscription.
*
* `pubkey` is the wallet owner used both as `authors=` for their own
* NIP-60 events and as the `#p` tag value for inbound nutzaps.
*/
data class CashuWalletQueryState(
val pubkey: HexKey,
val relays: Set<NormalizedRelayUrl>,
)
/**
* Subscribes to all NIP-60 / NIP-61 events that participate in this
* account's Cashu wallet:
*
* - kind 17375 the wallet event (replaceable)
* - kind 7375 unspent proofs (token events)
* - kind 7376 spending history
* - kind 7374 quote state events
* - kind 10019 nutzap info (replaceable, for incoming nutzaps)
* - kind 9321 inbound nutzaps tagged with the user's pubkey
*
* All authored events are queried by `authors=[pubkey]`; nutzaps are queried by
* `#p=[pubkey]` (we receive them, not send them, from this filter's perspective).
*/
class CashuWalletFilterAssembler(
client: INostrClient,
allKeys: () -> Set<CashuWalletQueryState>,
) : SingleSubEoseManager<CashuWalletQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun distinct(key: CashuWalletQueryState): Any = key.pubkey
override fun updateFilter(
keys: List<CashuWalletQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
val pubkey = keys.first().pubkey
val relays =
keys
.flatMap { it.relays }
.toSet()
.ifEmpty { return null }
val ownedFilter =
Filter(
kinds =
listOf(
CashuWalletEvent.KIND,
CashuTokenEvent.KIND,
CashuSpendingHistoryEvent.KIND,
CashuMintQuoteEvent.KIND,
NutzapInfoEvent.KIND,
),
authors = listOf(pubkey),
)
val inboundNutzapsFilter =
Filter(
kinds = listOf(NutzapEvent.KIND),
tags = mapOf("p" to listOf(pubkey)),
)
return relays.flatMap { relay ->
val sinceTime = since?.get(relay)?.time
listOf(
RelayBasedFilter(
relay,
if (sinceTime != null) ownedFilter.copy(since = sinceTime) else ownedFilter,
),
RelayBasedFilter(
relay,
if (sinceTime != null) inboundNutzapsFilter.copy(since = sinceTime) else inboundNutzapsFilter,
),
)
}
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip60Cashu.wallet
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
@@ -71,6 +72,9 @@ class CashuWalletEvent(
companion object {
const val KIND = 17375
const val ALT_DESCRIPTION = "Cashu wallet"
const val FIXED_D_TAG = ""
fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG)
suspend fun build(
mints: List<String>,