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.robohash.CachedRobohash
|
||||||
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
import com.vitorpamplona.amethyst.commons.tor.TorSettings
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
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.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.UiSettings
|
import com.vitorpamplona.amethyst.model.UiSettings
|
||||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
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.NamecoinNameResolver
|
||||||
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -330,6 +332,19 @@ class AppModules(
|
|||||||
// Caches all events in Memory
|
// Caches all events in Memory
|
||||||
val cache: LocalCache = LocalCache
|
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
|
// Provides a relay pool
|
||||||
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,9 @@ sealed class TopFilter(
|
|||||||
) : TopFilter("InterestSet/${address.toValue()}")
|
) : 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
|
@Stable
|
||||||
class AccountSettings(
|
class AccountSettings(
|
||||||
val keyPair: KeyPair,
|
val keyPair: KeyPair,
|
||||||
@@ -176,6 +179,8 @@ class AccountSettings(
|
|||||||
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||||
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
|
||||||
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
val defaultNwcWalletId: MutableStateFlow<String?> = MutableStateFlow(null),
|
||||||
|
// NIP-BC onchain zap configuration.
|
||||||
|
val onchainEsploraEndpoint: MutableStateFlow<String> = MutableStateFlow(DEFAULT_ESPLORA_ENDPOINT),
|
||||||
var hideDeleteRequestDialog: Boolean = false,
|
var hideDeleteRequestDialog: Boolean = false,
|
||||||
var hideBlockAlertDialog: Boolean = false,
|
var hideBlockAlertDialog: Boolean = false,
|
||||||
var hideNIP17WarningDialog: 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.nipACWebRtcCalls.events.CallRenegotiateEvent
|
||||||
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
import com.vitorpamplona.quartz.nipB0WebBookmarks.WebBookmarkEvent
|
||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
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.nipC0CodeSnippets.CodeSnippetEvent
|
||||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||||
import com.vitorpamplona.quartz.utils.DualCase
|
import com.vitorpamplona.quartz.utils.DualCase
|
||||||
@@ -286,6 +290,15 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
|
|
||||||
val paymentTracker = NwcPaymentTracker()
|
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 relayHints = HintIndexer()
|
||||||
|
|
||||||
val deletionIndex = DeletionIndex()
|
val deletionIndex = DeletionIndex()
|
||||||
@@ -904,6 +917,18 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
(event.zapRequest?.taggedAddresses()?.map { getOrCreateAddressableNote(it) } ?: emptyList())
|
(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 -> {
|
is LnZapRequestEvent -> {
|
||||||
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
event.zappedPost().mapNotNull { checkGetOrCreateNote(it) } +
|
||||||
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
event.taggedAddresses().map { getOrCreateAddressableNote(it) }
|
||||||
@@ -1712,6 +1737,60 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
return false
|
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(
|
private fun attachZapToLiveActivityChannel(
|
||||||
event: LnZapEvent,
|
event: LnZapEvent,
|
||||||
note: Note,
|
note: Note,
|
||||||
@@ -3142,6 +3221,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
|||||||
consume(event, relay, wasVerified)
|
consume(event, relay, wasVerified)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is OnchainZapEvent -> {
|
||||||
|
consume(event, relay, wasVerified)
|
||||||
|
}
|
||||||
|
|
||||||
is NIP90StatusEvent -> {
|
is NIP90StatusEvent -> {
|
||||||
consumeRegularEvent(event, relay, wasVerified)
|
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.nip84Highlights.HighlightEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
@@ -96,6 +97,7 @@ class NotificationDispatcher(
|
|||||||
// Direct-arrival
|
// Direct-arrival
|
||||||
PrivateDmEvent.KIND,
|
PrivateDmEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
|
OnchainZapEvent.KIND,
|
||||||
ReactionEvent.KIND,
|
ReactionEvent.KIND,
|
||||||
TextNoteEvent.KIND,
|
TextNoteEvent.KIND,
|
||||||
CommentEvent.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.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
|
|
||||||
val SummaryKinds =
|
val SummaryKinds =
|
||||||
listOf(
|
listOf(
|
||||||
@@ -62,6 +63,7 @@ val SummaryKinds =
|
|||||||
RepostEvent.KIND,
|
RepostEvent.KIND,
|
||||||
GenericRepostEvent.KIND,
|
GenericRepostEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
|
OnchainZapEvent.KIND,
|
||||||
)
|
)
|
||||||
|
|
||||||
val NotificationsPerKeyKinds =
|
val NotificationsPerKeyKinds =
|
||||||
|
|||||||
+2
@@ -43,6 +43,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
|||||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryResponse.NIP90ContentDiscoveryResponseEvent
|
||||||
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
import com.vitorpamplona.quartz.nip90Dvms.status.NIP90StatusEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||||
|
|
||||||
val RepliesAndReactionsKinds =
|
val RepliesAndReactionsKinds =
|
||||||
@@ -53,6 +54,7 @@ val RepliesAndReactionsKinds =
|
|||||||
GenericRepostEvent.KIND,
|
GenericRepostEvent.KIND,
|
||||||
ReportEvent.KIND,
|
ReportEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
|
OnchainZapEvent.KIND,
|
||||||
OtsEvent.KIND,
|
OtsEvent.KIND,
|
||||||
TextNoteModificationEvent.KIND,
|
TextNoteModificationEvent.KIND,
|
||||||
CommentEvent.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.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
|
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
|
* Fetches the NIP-75 zap goal referenced by a live stream plus the zap receipts
|
||||||
@@ -55,7 +56,7 @@ fun filterGoalForLiveActivities(
|
|||||||
relay = relay,
|
relay = relay,
|
||||||
filter =
|
filter =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(LnZapEvent.KIND),
|
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
|
||||||
tags = mapOf("e" to listOf(goalId)),
|
tags = mapOf("e" to listOf(goalId)),
|
||||||
limit = 200,
|
limit = 200,
|
||||||
since = since?.get(relay)?.time,
|
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.clip.LiveActivitiesClipEvent
|
||||||
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
|
|
||||||
fun filterMessagesToLiveActivities(
|
fun filterMessagesToLiveActivities(
|
||||||
channel: LiveActivitiesChannel,
|
channel: LiveActivitiesChannel,
|
||||||
@@ -44,6 +45,7 @@ fun filterMessagesToLiveActivities(
|
|||||||
LiveActivitiesRaidEvent.KIND,
|
LiveActivitiesRaidEvent.KIND,
|
||||||
LiveActivitiesClipEvent.KIND,
|
LiveActivitiesClipEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
|
OnchainZapEvent.KIND,
|
||||||
),
|
),
|
||||||
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
tags = mapOf("a" to listOfNotNull(channel.address.toValue())),
|
||||||
limit = 200,
|
limit = 200,
|
||||||
|
|||||||
+2
@@ -74,6 +74,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent
|
|||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
|
||||||
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
|
||||||
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
import com.vitorpamplona.quartz.nipA4PublicMessages.PublicMessageEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ class NotificationFeedFilter(
|
|||||||
ReactionEvent.KIND,
|
ReactionEvent.KIND,
|
||||||
RepostEvent.KIND,
|
RepostEvent.KIND,
|
||||||
LnZapEvent.KIND,
|
LnZapEvent.KIND,
|
||||||
|
OnchainZapEvent.KIND,
|
||||||
LiveActivitiesChatMessageEvent.KIND,
|
LiveActivitiesChatMessageEvent.KIND,
|
||||||
PictureEvent.KIND,
|
PictureEvent.KIND,
|
||||||
PollEvent.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.client.pool.RelayBasedFilter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
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(
|
fun filterUserProfileZapsReceived(
|
||||||
user: User,
|
user: User,
|
||||||
|
|||||||
+2
-1
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
|
|||||||
import com.vitorpamplona.amethyst.model.User
|
import com.vitorpamplona.amethyst.model.User
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||||
|
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||||
import com.vitorpamplona.quartz.utils.BigDecimal
|
import com.vitorpamplona.quartz.utils.BigDecimal
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -52,7 +53,7 @@ class UserProfileZapsViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val zapsToUser =
|
val zapsToUser =
|
||||||
Filter(
|
Filter(
|
||||||
kinds = listOf(LnZapEvent.KIND),
|
kinds = listOf(LnZapEvent.KIND, OnchainZapEvent.KIND),
|
||||||
tags = mapOf("p" to listOf(user.pubkeyHex)),
|
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 ->
|
) { padding ->
|
||||||
if (!hasWallet) {
|
Column(modifier = Modifier.padding(padding)) {
|
||||||
NoWalletSetup(
|
OnchainSection(
|
||||||
modifier = Modifier.padding(padding),
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
|
||||||
} else {
|
|
||||||
MultiWalletHomeContent(
|
|
||||||
walletViewModel = walletViewModel,
|
|
||||||
modifier = Modifier.padding(padding),
|
|
||||||
listState = listState,
|
|
||||||
nav = nav,
|
|
||||||
)
|
)
|
||||||
|
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
|
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?>()
|
var zapPayments = mapOf<Note, Note?>()
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -318,6 +327,7 @@ open class Note(
|
|||||||
boosts = listOf()
|
boosts = listOf()
|
||||||
reports = mapOf()
|
reports = mapOf()
|
||||||
zaps = mapOf()
|
zaps = mapOf()
|
||||||
|
onchainZaps = mapOf()
|
||||||
zapPayments = mapOf()
|
zapPayments = mapOf()
|
||||||
zapsAmount = BigDecimal.ZERO
|
zapsAmount = BigDecimal.ZERO
|
||||||
relays = listOf()
|
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
|
@Synchronized
|
||||||
private fun innerAddZapPayment(
|
private fun innerAddZapPayment(
|
||||||
zapPaymentRequest: Note,
|
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
|
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