feat(amethyst): NIP-BC onchain zap receive + display
Phase C of the onchain zaps plan (amethyst/plans/2026-05-14-onchain-zaps.md). Plumbs the Quartz receive foundation from Phase A.1 into the Android app so incoming kind 8333 zaps show up in note totals and the wallet screen. Subscription / fetch path (kind 8333 rides existing zap filters, no new assemblers per the plan): - FilterRepliesAndReactionsToNotes: OnchainZapEvent.KIND in RepliesAndReactionsKinds (#e on notes) - FilterUserProfileZapReceived: OnchainZapEvent.KIND in UserProfileZapReceiverKinds (#p on profiles) - UserProfileZapsViewModel: kinds = LnZap + Onchain inline - FilterNotificationsToPubkey: OnchainZapEvent.KIND in SummaryKinds - NotificationFeedFilter / NotificationDispatcher: kind 8333 in NOTIFICATION_KINDS - FilterMessagesToLiveStream / FilterGoalForLiveActivity: kind 8333 alongside 9735 Display path (Note.zapsAmount fold-in — no UI changes needed downstream): - commons/model/Note.kt: new `onchainZaps: Map<String, OnchainZapAmount>` (keyed by txid for `(txid, target)` dedup); `addOnchainZap(txid, sats, confirmed)` mutator; `updateZapTotal` now also sums confirmed onchain sats into `zapsAmount`. Pending entries are tracked but excluded from the aggregate total per the NIP-BC spec. - commons/model/OnchainZapAmount.kt: new data class for per-tx state. - LocalCache: new `onchainBackend: OnchainBackend?` slot; new `consume(OnchainZapEvent)` handler that runs the same sig-verify and computeReplyTo pipeline as `consume(LnZapEvent)`, then kicks off async verification on `applicationIOScope`. Self-zaps are rejected synchronously per the spec. Verification failures, mempool-only txs, and confirmed txs all route to `Note.addOnchainZap` with the correct flag. Without a backend wired, events are still cached (so subscriptions see them) but skip the total contribution. - LocalCache.computeReplyTo: new `is OnchainZapEvent ->` branch that enumerates `e`/`a` targets — profile-only zaps return an empty list and flow through profile zap queries instead. Wallet UI: - Wallet screen now renders a "Bitcoin" Card above the existing NIP-47 NWC wallet list. Single card (one onchain wallet per Nostr identity, derived from the pubkey). Shows the bc1p taproot address + Copy button. Balance, recent incoming zaps, and tap-to-detail come in later phases. Settings + wiring: - AccountSettings: new `onchainEsploraEndpoint: MutableStateFlow<String>`, default mempool.space, plumbed for a future settings UI. - AppModules: builds a single shared `EsploraBackend` at app init using the role-based money-flavored OkHttp client (Tor-aware), installs it on `LocalCache.onchainBackend` so verification can run. Secp256k1Instance: new `pubKeyTweakAdd` was added in Phase A.1. No PSBT, no signing, no broadcasting — send-side (Phase A.2 + D) is still pending.
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||
// NIP-BC onchain zap configuration.
|
||||
val onchainEsploraEndpoint: MutableStateFlow<String> = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT),
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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 =
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
+123
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-11
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, OnchainZapAmount>()
|
||||
private set
|
||||
|
||||
var zapPayments = mapOf<Note, Note?>()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user