diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 466779ad6..6675445fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.DEFAULT_ESPLORA_ENDPOINT import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState @@ -99,6 +100,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -330,6 +332,19 @@ class AppModules( // Caches all events in Memory val cache: LocalCache = LocalCache + // NIP-BC onchain zap verification backend. Wired up once at app init so + // LocalCache.consume(OnchainZapEvent) can sum the on-chain output values + // that pay the recipient's derived Taproot address. Endpoint is currently + // a fixed default; per-account override (AccountSettings.onchainEsploraEndpoint) + // is plumbed for a future Settings UI. + init { + cache.onchainBackend = + EsploraBackend( + baseUrl = { DEFAULT_ESPLORA_ENDPOINT }, + client = roleBasedHttpClientBuilder.okHttpClientForMoney(DEFAULT_ESPLORA_ENDPOINT), + ) + } + // Provides a relay pool val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 4a5511df5..1e6e0135a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -141,6 +141,9 @@ sealed class TopFilter( ) : TopFilter("InterestSet/${address.toValue()}") } +/** Default Esplora-compatible Bitcoin chain backend for NIP-BC onchain zaps. */ +const val DEFAULT_ESPLORA_ENDPOINT = "https://mempool.space/api" + @Stable class AccountSettings( val keyPair: KeyPair, @@ -176,6 +179,8 @@ class AccountSettings( val defaultFollowPacksFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val nwcWallets: MutableStateFlow> = MutableStateFlow(emptyList()), val defaultNwcWalletId: MutableStateFlow = MutableStateFlow(null), + // NIP-BC onchain zap configuration. + val onchainEsploraEndpoint: MutableStateFlow = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d50daf6b7..246b7a9ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -240,6 +240,10 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRejectEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.OnchainBackend +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.OnchainZapVerifier +import com.vitorpamplona.quartz.nipBCOnchainZaps.verify.VerifiedOnchainZap +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.DualCase @@ -286,6 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider { val paymentTracker = NwcPaymentTracker() + /** + * Bitcoin chain backend used by [consume]`(OnchainZapEvent)` to verify NIP-BC zaps + * against the actual on-chain transaction. `null` disables verification (incoming + * onchain zap events still get cached, but no `Note.zapsAmount` contribution). + * Set during account init. + */ + @Volatile + var onchainBackend: OnchainBackend? = null + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() @@ -904,6 +917,18 @@ object LocalCache : ILocalCache, ICacheProvider { (event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList()) } + is OnchainZapEvent -> { + // NIP-BC zaps can target an event id (e), an addressable event (a), + // or just the recipient profile (p). Profile-only zaps have no + // Note target and are surfaced through profile zap queries. + buildList { + event.zappedEvent()?.let { checkGetOrCreateNote(it)?.let { add(it) } } + event.zappedAddress()?.let { coord -> + Address.parse(coord)?.let { add(getOrCreateAddressableNote(it)) } + } + } + } + is LnZapRequestEvent -> { event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } + event.taggedAddresses().map { getOrCreateAddressableNote(it) } @@ -1712,6 +1737,60 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: OnchainZapEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val note = getOrCreateNote(event.id) + if (note.event != null) return false + + if (!(wasVerified || justVerify(event))) return false + + // Anti-spoofing: NIP-BC requires rejecting self-zaps. + val recipient = event.recipient() ?: return false + if (event.pubKey.equals(recipient, ignoreCase = true)) return false + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + note.loadEvent(event, author, repliesTo) + refreshNewNoteObservers(note) + + // Verification needs a chain backend. Without one (e.g. before Account + // wires its EsploraBackend) the event is still cached so subscriptions + // and profile zap views see it, but it can't contribute to Note totals. + val backend = onchainBackend ?: return true + val verifier = OnchainZapVerifier(backend) + + Amethyst.instance.applicationIOScope.launch { + try { + when (val result = verifier.verify(event)) { + is VerifiedOnchainZap.Confirmed -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = true) + } + } + + is VerifiedOnchainZap.Pending -> { + repliesTo.forEach { + it.addOnchainZap(result.txid, result.verifiedSats, confirmed = false) + } + } + + is VerifiedOnchainZap.Rejected -> { + Log.d("OnchainZap") { + "rejected ${result.txid}: ${result.reason}" + } + } + } + } catch (t: Throwable) { + Log.w("OnchainZap", "verification failed for ${event.id}", t) + } + } + + return true + } + private fun attachZapToLiveActivityChannel( event: LnZapEvent, note: Note, @@ -3142,6 +3221,10 @@ object LocalCache : ILocalCache, ICacheProvider { consume(event, relay, wasVerified) } + is OnchainZapEvent -> { + consume(event, relay, wasVerified) + } + is NIP90StatusEvent -> { consumeRegularEvent(event, relay, wasVerified) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt index df17bfe01..cedd7daea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationDispatcher.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -96,6 +97,7 @@ class NotificationDispatcher( // Direct-arrival PrivateDmEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ReactionEvent.KIND, TextNoteEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 941b972fa..10cca27dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent val SummaryKinds = listOf( @@ -62,6 +63,7 @@ val SummaryKinds = RepostEvent.KIND, GenericRepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ) val NotificationsPerKeyKinds = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 0160962bb..f9a505235 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.mapOfSet val RepliesAndReactionsKinds = @@ -53,6 +54,7 @@ val RepliesAndReactionsKinds = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt index 427101e5c..7880c23d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterGoalForLiveActivity.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent /** * Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts @@ -55,7 +56,7 @@ fun filterGoalForLiveActivities( relay = relay, filter = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("e" to listOf(goalId)), limit = 200, since = since?.get(relay)?.time, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt index c1c47325a..93c998485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterMessagesToLiveStream.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent fun filterMessagesToLiveActivities( channel: LiveActivitiesChannel, @@ -44,6 +45,7 @@ fun filterMessagesToLiveActivities( LiveActivitiesRaidEvent.KIND, LiveActivitiesClipEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, ), tags = mapOf("a" to listOfNotNull(channel.address.toValue())), limit = 200, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index c24217b76..d58a7d7c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -121,6 +122,7 @@ class NotificationFeedFilter( ReactionEvent.KIND, RepostEvent.KIND, LnZapEvent.KIND, + OnchainZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, PollEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt index d03a3d7ac..2cc37d239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileZapReceived.kt @@ -26,8 +26,9 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent -val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND) +val UserProfileZapReceiverKinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND) fun filterUserProfileZapsReceived( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt index 33ebc1b20..703f3d7a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/dal/UserProfileZapsViewModel.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent import com.vitorpamplona.quartz.utils.BigDecimal import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -52,7 +53,7 @@ class UserProfileZapsViewModel( ) : ViewModel() { val zapsToUser = Filter( - kinds = listOf(LnZapEvent.KIND), + kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND), tags = mapOf("p" to listOf(user.pubkeyHex)), ) 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 new file mode 100644 index 000000000..b4934bb62 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt @@ -0,0 +1,123 @@ +/* + * 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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.TaprootAddress +import kotlinx.coroutines.launch + +/** + * "Bitcoin" section card on the wallet screen, shown above the lightning NWC + * wallet list. Every account has exactly one Taproot address (derived from + * its Nostr pubkey via NIP-BC / BIP-341), so this is always a single card — + * not a list. + * + * Phase C scope: derive + display + copy address. Balance, recent zaps, send, + * and tap-to-detail UI come in later phases. + */ +@Composable +fun OnchainSection( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + val pubKey = accountViewModel.account.signer.pubKey + + // Cache the derived address — bech32m + tap-tweak is cheap but pubkey doesn't + // change for the lifetime of the screen. + val address = + remember(pubKey) { + runCatching { TaprootAddress.fromPubKey(pubKey) }.getOrNull() + } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Bitcoin", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (address == null) { + Text( + text = "Address derivation unavailable for this account.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Your Taproot address", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = address, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + CopyAddressRow(address) + } + } + } +} + +@Composable +private fun CopyAddressRow(address: String) { + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedButton(onClick = { + scope.launch { clipboard.setText(address) } + }) { + Text("Copy address") + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt index 971628c5b..164669436 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletScreen.kt @@ -124,18 +124,24 @@ fun WalletScreen( } }, ) { padding -> - if (!hasWallet) { - NoWalletSetup( - modifier = Modifier.padding(padding), - nav = nav, - ) - } else { - MultiWalletHomeContent( - walletViewModel = walletViewModel, - modifier = Modifier.padding(padding), - listState = listState, - nav = nav, + Column(modifier = Modifier.padding(padding)) { + OnchainSection( + accountViewModel = accountViewModel, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) + if (!hasWallet) { + NoWalletSetup( + modifier = Modifier, + nav = nav, + ) + } else { + MultiWalletHomeContent( + walletViewModel = walletViewModel, + modifier = Modifier, + listState = listState, + nav = nav, + ) + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index 1a2790d08..5a3a16c87 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -156,6 +156,15 @@ open class Note( var zapsAmount: BigDecimal = BigDecimal.ZERO + /** + * NIP-BC verified onchain zaps targeting this note. + * Key: Bitcoin txid (lowercase 64-char hex). Value: verified satoshis paid to the recipient + * (NOT the sender-claimed amount). Confirmed and pending entries live here together; + * `updateZapTotal` only counts confirmed amounts. + */ + var onchainZaps = mapOf() + private set + var zapPayments = mapOf() private set @@ -318,6 +327,7 @@ open class Note( boosts = listOf() reports = mapOf() zaps = mapOf() + onchainZaps = mapOf() zapPayments = mapOf() zapsAmount = BigDecimal.ZERO relays = listOf() @@ -417,6 +427,36 @@ open class Note( } } + @Synchronized + private fun innerAddOnchainZap( + txid: String, + amount: OnchainZapAmount, + ): Boolean { + val existing = onchainZaps[txid] + // Allow upgrading pending → confirmed but never reduce already-stored confirmations. + if (existing != null && existing.confirmed && !amount.confirmed) return false + if (existing == amount) return false + onchainZaps = onchainZaps + Pair(txid, amount) + return true + } + + /** + * Register a NIP-BC verified onchain zap targeting this note. `verifiedSats` MUST come + * from on-chain verification (sum of outputs paying the recipient's derived Taproot + * address), not the sender-claimed `amount` tag. + */ + fun addOnchainZap( + txid: String, + verifiedSats: Long, + confirmed: Boolean, + ) { + val inserted = innerAddOnchainZap(txid, OnchainZapAmount(verifiedSats, confirmed)) + if (inserted) { + updateZapTotal() + flowSet?.zaps?.invalidateData() + } + } + @Synchronized private fun innerAddZapPayment( zapPaymentRequest: Note, @@ -629,6 +669,14 @@ open class Note( } } + // NIP-BC onchain zaps — verified amounts only, confirmed only. + // Pending/unconfirmed entries are tracked but excluded from the total per spec. + onchainZaps.values.forEach { entry -> + if (entry.confirmed) { + sumOfAmounts += BigDecimal.valueOf(entry.verifiedSats) + } + } + zapsAmount = sumOfAmounts } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt new file mode 100644 index 000000000..d7c4bcb65 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/OnchainZapAmount.kt @@ -0,0 +1,40 @@ +/* + * 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.model + +import androidx.compose.runtime.Immutable + +/** + * Per-(note, txid) verified NIP-BC onchain zap state. + * + * @property verifiedSats Satoshis verified to have paid the recipient's derived Taproot + * address on chain. NEVER the sender-claimed `amount` tag. + * @property confirmed True when the transaction has at least one confirmation. Unconfirmed + * zaps are tracked but excluded from aggregate totals per the NIP-BC + * spec ("Unconfirmed transactions MAY be displayed as pending... + * SHOULD either exclude them from aggregate totals or clearly label + * them as pending"). + */ +@Immutable +data class OnchainZapAmount( + val verifiedSats: Long, + val confirmed: Boolean, +)