From 6ce193d46a7bf9585da43c8ba270857bbe0ddcf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 14 May 2026 04:58:18 +0000 Subject: [PATCH] feat(amethyst): show onchain balance on wallet screen Bitcoin section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnchainSection now fetches UTXOs for the derived Taproot address from the configured Esplora backend and displays the sum as the section balance. Loading / unavailable / error states are surfaced inline. No retry on failure — the user refreshes by re-entering the screen. Balance fetch runs in a LaunchedEffect keyed on the derived address (which is keyed on the account pubkey), so it kicks off once per account view. --- .../screen/loggedIn/wallet/OnchainSection.kt | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt index b4934bb629..087c62556a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -31,18 +31,26 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.NumberFormat /** * "Bitcoin" section card on the wallet screen, shown above the lightning NWC @@ -67,6 +75,32 @@ fun OnchainSection( runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() } + // Balance fetched from the configured OnchainBackend. null = unknown / loading + // / unconfigured. We intentionally do not retry on failure here — the user can + // refresh by leaving and re-entering the screen. + var balanceSats by remember(pubKey) { mutableStateOf(null) } + var balanceState by remember(pubKey) { mutableStateOf(BalanceState.LOADING) } + + LaunchedEffect(address) { + if (address == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + val backend = LocalCache.onchainBackend + if (backend == null) { + balanceState = BalanceState.UNAVAILABLE + return@LaunchedEffect + } + balanceState = BalanceState.LOADING + try { + val utxos = withContext(Dispatchers.IO) { backend.getUtxosForAddress(address) } + balanceSats = utxos.sumOf { it.valueSats } + balanceState = BalanceState.READY + } catch (t: Throwable) { + balanceState = BalanceState.ERROR + } + } + Card( modifier = modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), @@ -87,6 +121,7 @@ fun OnchainSection( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { + BalanceRow(state = balanceState, sats = balanceSats) Text( text = "Your Taproot address", style = MaterialTheme.typography.labelMedium, @@ -104,6 +139,39 @@ fun OnchainSection( } } +private enum class BalanceState { LOADING, READY, ERROR, UNAVAILABLE } + +@Composable +private fun BalanceRow( + state: BalanceState, + sats: Long?, +) { + val text = + when (state) { + BalanceState.LOADING -> { + "Loading balance…" + } + + BalanceState.READY -> { + val formatted = NumberFormat.getNumberInstance().format(sats ?: 0L) + "$formatted sats" + } + + BalanceState.ERROR -> { + "Balance unavailable" + } + + BalanceState.UNAVAILABLE -> { + "Chain backend not configured" + } + } + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + ) +} + @Composable private fun CopyAddressRow(address: String) { val clipboard = LocalClipboard.current