Merge branch 'main' into claude/payment-targets-zap-integration-q52kn6

Conflict in DisplayPaymentTargets.kt, where #4040 and this branch changed the
same hand-off path from opposite ends and converged on the same idea.

main extracted a shared PaymentTargetPill and routed every hand-off through one
new paymentTargetUri(target), still backed by the uriFor lambda on
PaymentTargetStyle. This branch had deleted that lambda, moving the scheme
table to commons so the zap picker and the installed-app probe could share it.

Kept main's structure — the pill and paymentTargetUri are the better shape, and
PaymentButton already calls the latter — and backed paymentTargetUri with
PaymentTargetTypes.uriFor. One hand-off entry point, one scheme table, no
behaviour change on either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
This commit is contained in:
Claude
2026-09-02 01:39:31 +00:00
14 changed files with 463 additions and 131 deletions
@@ -3533,6 +3533,15 @@ class Account(
suspend fun saveBlockedRelayList(blockedRelays: List<NormalizedRelayUrl>) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays))
/**
* Blocks a single relay, leaving the rest of the kind-10006 list alone.
*
* Once published, [com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient]
* strips the relay from every REQ, COUNT and publish, so the pool drops the socket as soon as
* the subscriptions that wanted it are recomputed.
*/
suspend fun blockRelay(relay: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(blockedRelayList.addRelay(relay))
/**
* Returns all known signed replaceable events that configure this account
* (profile, contact list, relay lists, mute list, bookmarks, etc.). Events
@@ -73,6 +73,20 @@ class BlockedRelayListState(
emptySet(),
)
/**
* Adds [relay] to the kind-10006 list, keeping whatever is already there.
*
* Callers that only want to block one relay (the NOTIFY prompt's "Block Relay" button, for
* instance) must not rebuild the list from a snapshot they captured earlier: the list is
* shared across clients and may have grown since. Reading the current note here keeps the
* add additive.
*/
suspend fun addRelay(relay: NormalizedRelayUrl): BlockedRelayListEvent {
val current = normalizeBlockedRelayListWithBackup(blockedListNote).toMutableList()
if (relay !in current) current.add(relay)
return saveRelayList(current)
}
suspend fun saveRelayList(blockedRelays: List<NormalizedRelayUrl>): BlockedRelayListEvent {
if (!signer.isWriteable()) throw SignerExceptions.ReadOnlyException()
val relayListForBlocked = getBlockedRelayList()
@@ -57,6 +57,26 @@ fun DisplayNotifyMessages(
accountViewModel = accountViewModel,
nav = nav,
onDismiss = { requests.dismissPaymentRequest(request) },
onBlockRelay =
if (accountViewModel.isWriteable()) {
{
accountViewModel.launchSigner {
accountViewModel.account.blockRelay(request.relayUrl)
// Reached only once the block is signed and published, because
// reportSignerErrors swallows a refused or timed-out signature without
// a toast: dismissing up front would close the dialog on a relay that
// is still unblocked and leave the user no sign anything failed. The
// prompt staying up is the feedback.
//
// Every queued prompt from the relay goes at once, not just the one on
// screen — a paid relay files one NOTIFY per rejected AUTH.
requests.dismissAllFrom(request.relayUrl)
}
}
} else {
null
},
)
}
}
@@ -30,6 +30,7 @@ import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -55,6 +56,11 @@ fun NotifyRequestDialog(
accountViewModel: AccountViewModel,
nav: INav,
onDismiss: () -> Unit,
/**
* Adds the relay that sent this message to the NIP-51 kind:10006 blocked list. Null hides the
* button — there is nothing to block for a read-only account, which cannot sign the list.
*/
onBlockRelay: (() -> Unit)? = null,
) {
AlertDialog(
onDismissRequest = onDismiss,
@@ -94,5 +100,29 @@ fun NotifyRequestDialog(
}
}
},
dismissButton =
onBlockRelay?.let {
{
TextButton(
onClick = it,
contentPadding = PaddingValues(horizontal = Size16dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Block,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.notify_block_relay),
color = MaterialTheme.colorScheme.error,
)
}
}
}
},
)
}
@@ -101,8 +101,15 @@ class NotifyCoordinator(
// Consume the correlation so a later, unrelated NOTIFY can't reuse a stale attribution.
// An unattributable NOTIFY (none of our auths were rejected here) is dropped rather than
// risk surfacing it under the wrong account.
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey)
account?.relayNotifications?.addPaymentRequestIfNew(message, relay)
val account = billedPubkeyAt.remove(relay)?.let(accountForPubkey) ?: return
// A relay the user has already blocked doesn't get to keep prompting. The pool drops the
// socket once the subscriptions that wanted this relay are recomputed, but frames already
// in flight can still arrive in that window — and the whole point of the dialog's "Block
// Relay" button is that the dialog stops coming back.
if (relay in account.blockedRelayList.flow.value) return
account.relayNotifications.addPaymentRequestIfNew(message, relay)
}
init {
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.flow.update
@Stable
@@ -38,12 +39,12 @@ class NotifyRequestsCache {
}
fun addPaymentRequestIfNew(paymentRequest: NotifyRequest) {
if (
!this.transientPaymentRequests.value.contains(paymentRequest) &&
!this.transientPaymentRequestDismissals.value.contains(paymentRequest)
) {
this.transientPaymentRequests.value += paymentRequest
}
if (this.transientPaymentRequestDismissals.value.contains(paymentRequest)) return
// `update` rather than `value +=`: NOTIFYs are filed from the relay's socket coroutine
// while dismissals run from the UI, and a plain read-modify-write silently drops one of
// two concurrent edits — either losing a prompt or resurrecting a dismissed one.
this.transientPaymentRequests.update { if (paymentRequest in it) it else it + paymentRequest }
}
fun dismissPaymentRequest(request: NotifyRequest) {
@@ -52,4 +53,23 @@ class NotifyRequestsCache {
this.transientPaymentRequestDismissals.update { it + request }
}
}
/**
* Drops every pending prompt from [relayUrl] at once.
*
* Used when the user blocks the relay: a relay that asks for payment usually queues one NOTIFY
* per rejected AUTH, so dismissing them one at a time would keep re-showing the dialog for a
* relay the user just told us never to talk to again.
*/
fun dismissAllFrom(relayUrl: NormalizedRelayUrl) {
// getAndUpdate so the drain and the snapshot of what was drained are one atomic step: a
// NOTIFY filed by the socket coroutine between a separate read and write would otherwise
// be dropped from the pending set without ever being recorded as dismissed.
val before = this.transientPaymentRequests.getAndUpdate { pending -> pending.filterNotTo(mutableSetOf()) { it.relayUrl == relayUrl } }
val dismissed = before.filterTo(mutableSetOf()) { it.relayUrl == relayUrl }
if (dismissed.isEmpty()) return
this.transientPaymentRequestDismissals.update { it + dismissed }
}
}
@@ -20,31 +20,10 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
@@ -52,17 +31,14 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.payment.ProfilePaymentMethod
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
import kotlinx.coroutines.launch
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES
@@ -92,6 +68,15 @@ fun inAppPaymentRouteFor(
}
}
/**
* The URI an external wallet app should receive for [target]: the type's own
* scheme (`bitcoin:`, `lightning:`, `https://cash.app/…`) and RFC 8905
* `payto://` for types Amethyst has no dedicated scheme for. Shared with the
* payment-target dialog so the same pill hands off to the same app wherever
* it is tapped.
*/
fun paymentTargetUri(target: PaymentTarget): String = PaymentTargetTypes.uriFor(target.type, target.authority)
/**
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
* FlowRow alongside the wallet-rail chips so all payment chips share one
@@ -106,72 +91,57 @@ fun PaymentTargetChip(
) {
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val copyLabel = stringRes(R.string.copy_to_clipboard)
val copiedMessage = stringRes(R.string.copied_to_clipboard)
Surface(
shape = RoundedCornerShape(50),
color = style.color.copy(alpha = 0.10f),
border = BorderStroke(1.dp, style.color.copy(alpha = 0.35f)),
modifier =
Modifier.combinedClickable(
onClick = {
// Targets one of the user's in-app wallets can pay (lightning,
// bitcoin) go to the Send Payment screen, which collects the
// amount and pays this exact target; everything else hands off
// to an external wallet app via its payment URI.
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(PaymentTargetTypes.uriFor(target.type, target.authority)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
R.string.no_payment_app_found_for_type,
style.label,
)
}
PaymentTargetPill(
target = target,
onClick = {
// Targets one of the user's in-app wallets can pay (lightning,
// bitcoin) go to the Send Payment screen, which collects the
// amount and pays this exact target; everything else hands off
// to an external wallet app via its payment URI.
val inAppRoute = inAppPaymentRouteFor(baseUser.pubkeyHex, target)
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
R.string.no_payment_app_found_for_type,
style.label,
)
}
},
onLongClick = {
scope.launch {
clipboard.setText(target.authority)
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
}
},
onLongClickLabel = copyLabel,
),
}
},
)
}
/**
* The pill for a single NIP-A3 payment target: the type's icon and tinted
* label followed by the shortened authority, with a long-press copy of the
* full authority. Shared by the profile's payment rail and the payment-target
* dialog so a target looks the same wherever it shows up.
*/
@Composable
fun PaymentTargetPill(
target: PaymentTarget,
onClick: () -> Unit,
) {
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
ProfilePaymentChip(
color = style.color,
label = style.label,
detail = remember(target.authority) { shortAddress(target.authority) },
copyValue = target.authority,
onClick = onClick,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Icon(
symbol = style.symbol,
contentDescription = style.label,
tint = style.color,
modifier = Size16Modifier,
)
Text(
text = style.label,
color = style.color,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = shortAddress(target.authority),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 180.dp),
)
}
Icon(
symbol = style.symbol,
contentDescription = null,
tint = style.color,
modifier = Size16Modifier,
)
}
}
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -47,11 +47,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -72,7 +71,6 @@ import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.ZeroPadding
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@Composable
@@ -153,6 +151,7 @@ fun PaymentTargetsDialog(
payInApp: ((PaymentTarget) -> Boolean)? = null,
) {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
var errorMessage by remember { mutableStateOf<String?>(null) }
@@ -188,15 +187,13 @@ fun PaymentTargetsDialog(
},
onPay = {
if (payInApp?.invoke(target) != true) {
try {
val intent = Intent(Intent.ACTION_VIEW, "payto://${target.type}/${target.authority}".toUri())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
context.startActivity(intent)
onDismiss()
} catch (e: Exception) {
if (e is CancellationException) throw e
errorMessage = stringRes(context, R.string.no_payment_app_found)
}
// Same handoff the profile chip does: the type's own
// scheme when it has one (payto:// only as the
// fallback), and no task flags — CLEAR_TASK used to
// wipe whatever the wallet app already had open.
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
.onSuccess { onDismiss() }
.onFailure { errorMessage = stringRes(context, R.string.no_payment_app_found) }
}
},
)
@@ -232,19 +229,13 @@ private fun PaymentTargetRow(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = target.type.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = target.authority,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// Same pill the profile page renders for this target: type icon,
// tinted type label and the shortened authority, instead of the raw
// wallet id spelled out over two lines. Tapping it pays and long-press
// copies, exactly like on the profile, so the row carries no separate
// pay button — three icon buttons left the address 0dp of width.
Box(modifier = Modifier.weight(1f)) {
PaymentTargetPill(target = target, onClick = onPay)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = onShowQr) {
@@ -263,14 +254,6 @@ private fun PaymentTargetRow(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onPay) {
Icon(
symbol = MaterialSymbols.Bolt,
contentDescription = stringRes(R.string.payment_targets),
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -290,6 +290,10 @@ fun ProfilePaymentChip(
color = color,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
// Narrow hosts (the payment-target dialog's row) would otherwise
// wrap a long type label into a two-line pill.
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (detail != null) {
Text(
+1
View File
@@ -3127,6 +3127,7 @@
</string>
<string name="payment_required_title">Message from %1$s</string>
<string name="notify_block_relay">Block Relay</string>
<string name="thread_title">Thread</string>
<string name="send_the_seller_a_message">Send the seller a message</string>
<string name="hi_seller_is_this_still_available">Hi %1$s, is this still available?</string>
@@ -0,0 +1,154 @@
/*
* 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.model.nip51Lists
import android.os.Looper
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Drives the real kind-10006 mutation behind the NOTIFY dialog's "Block Relay" button.
*
* The button hands a single relay to [BlockedRelayListState.addRelay], which read-modify-writes the
* whole list — and [BlockedRelayListEvent.updateRelayList] *replaces* every relay tag with what it
* is given. So "does blocking work" is really two questions this pins: the new relay lands, and the
* relays already blocked survive. A regression here silently wipes the user's block list on their
* next tap, which no amount of UI testing would make obvious.
*
* Each case uses a fresh keypair so its kind-10006 lives at its own address in the process-wide
* [LocalCache].
*/
class BlockedRelayListAddRelayTest {
private val paid = RelayUrlNormalizer.normalize("wss://paid.example.com")
private val alsoPaid = RelayUrlNormalizer.normalize("wss://other.example.com")
@Before
fun setup() {
// LocalCache.consume refuses the main thread; plain JVM tests have no Looper,
// where null == null reads as "main". Distinct mocks make it a worker thread.
mockkStatic(Looper::class)
every { Looper.myLooper() } returns mockk<Looper>()
every { Looper.getMainLooper() } returns mockk<Looper>()
}
@After
fun tearDown() {
unmockkStatic(Looper::class)
}
private class Fixture {
val keyPair = KeyPair()
val signer = NostrSignerInternal(keyPair)
val decryptionCache = BlockedRelayListDecryptionCache(signer)
// Stubbed rather than real: AccountSettings reaches for Resources.getSystem() to read the
// user's spoken languages, which is null outside an Android runtime. The state only asks it
// for the backup list (none here) and hands it updates to persist.
val settings = mockk<AccountSettings>(relaxed = true) { every { backupBlockedRelayList } returns null }
val state =
BlockedRelayListState(
signer = signer,
cache = LocalCache,
decryptionCache = decryptionCache,
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
settings = settings,
)
/** Mirrors what the publish path does: the signed event goes into the cache it came from. */
fun land(event: BlockedRelayListEvent) = LocalCache.justConsumeMyOwnEvent(event)
suspend fun blockedIn(event: BlockedRelayListEvent) = BlockedRelayListDecryptionCache(signer).relays(event)
}
@Test
fun blockingTheFirstRelayCreatesTheList() =
runTest {
val f = Fixture()
val event = f.state.addRelay(paid)
assertEquals(BlockedRelayListEvent.KIND, event.kind)
assertEquals(setOf(paid), f.blockedIn(event))
}
/** The one that matters: a second tap must not throw away the first block. */
@Test
fun blockingASecondRelayKeepsTheFirst() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertEquals(setOf(paid, alsoPaid), f.blockedIn(event))
}
@Test
fun blockingAnAlreadyBlockedRelayDoesNotDuplicateIt() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(paid)
assertEquals(setOf(paid), f.blockedIn(event))
assertEquals(1, BlockedRelayListDecryptionCache(f.signer).relays(event).size)
}
/**
* Blocked relays are private tags. Leaking them to public tags would publish which paid relays
* the user walked away from to anyone who reads their kind-10006.
*/
@Test
fun blockedRelaysStayInEncryptedPrivateTags() =
runTest {
val f = Fixture()
f.land(f.state.addRelay(paid))
val event = f.state.addRelay(alsoPaid)
assertTrue("blocked relays must not appear in public tags", event.publicRelays().isEmpty())
assertTrue(
"no relay url may appear in the cleartext tag array",
event.tags.none { tag -> tag.any { it.contains("paid.example.com") || it.contains("other.example.com") } },
)
}
}
@@ -0,0 +1,88 @@
/*
* 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.service.relayClient.notifyCommand.model
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class NotifyRequestsCacheTest {
private val paid = NormalizedRelayUrl("wss://paid.relay/")
private val other = NormalizedRelayUrl("wss://other.relay/")
@Test
fun blockingARelayDropsEveryPendingPromptFromIt() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
cache.addPaymentRequestIfNew("Still unpaid", paid)
cache.addPaymentRequestIfNew("Unrelated", other)
cache.dismissAllFrom(paid)
assertEquals(
setOf(NotifyRequest(other, "Unrelated")),
cache.transientPaymentRequests.value,
)
}
@Test
fun aDismissedPromptStaysDismissedWhenTheRelayRepeatsIt() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
cache.dismissAllFrom(paid)
cache.addPaymentRequestIfNew("Pay up", paid)
assertTrue(cache.transientPaymentRequests.value.isEmpty())
}
@Test
fun aConcurrentPromptFromTheSameRelayIsNotSilentlySwallowed() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Pay up", paid)
// Stands in for a NOTIFY landing on the socket coroutine while the UI drains the relay:
// whatever survives the drain must still be reachable, never removed-but-unrecorded.
cache.dismissAllFrom(paid)
cache.addPaymentRequestIfNew("A different demand", paid)
val pending = cache.transientPaymentRequests.value
val dismissed = cache.transientPaymentRequestDismissals.value
assertEquals(setOf(NotifyRequest(paid, "Pay up")), dismissed)
assertEquals(setOf(NotifyRequest(paid, "A different demand")), pending)
}
@Test
fun dismissingARelayWithNoPromptsChangesNothing() {
val cache = NotifyRequestsCache()
cache.addPaymentRequestIfNew("Unrelated", other)
cache.dismissAllFrom(paid)
assertEquals(
setOf(NotifyRequest(other, "Unrelated")),
cache.transientPaymentRequests.value,
)
assertTrue(cache.transientPaymentRequestDismissals.value.isEmpty())
}
}
@@ -73,12 +73,25 @@ class BlockedRelayFilteringClient(
relayList: Set<NormalizedRelayUrl>,
) {
val blocked = blockedRelays()
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
delegate.publish(event, if (blocked.hitsNoneOf(relayList)) relayList else relayList - blocked)
}
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
val blocked = blockedRelays()
if (blocked.isEmpty()) return this
if (blocked.hitsNoneOf(keys)) return this
return filterKeys { it !in blocked }
}
/**
* Whether none of the blocked relays appear in [targets] — i.e. whether the caller can be
* handed its own collection back untouched.
*
* Worth the extra scan because this runs on every REQ, COUNT and publish (filter assemblers
* rebuild their per-relay targets constantly) while a blocked relay is, by definition, one the
* app has stopped aiming at — so "the block list is non-empty but irrelevant to this call" is
* the overwhelmingly common case. Without the check, `filterKeys` / `minus` allocate and copy
* the whole collection every time just to reproduce it unchanged. A hash lookup per target and
* no allocation beats an allocation plus a full copy.
*/
private fun Set<NormalizedRelayUrl>.hitsNoneOf(targets: Collection<NormalizedRelayUrl>): Boolean = isEmpty() || targets.none { it in this }
}
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertSame
class BlockedRelayFilteringClientTest {
private val good = NormalizedRelayUrl("wss://good.example/")
@@ -133,6 +134,24 @@ class BlockedRelayFilteringClientTest {
assertEquals(emptySet(), inner.publishedRelays)
}
@Test
fun aBlockListThatTouchesNothingHereIsPassedThroughWithoutCopying() {
val inner = RecordingClient()
// Non-empty block list, but none of it is aimed at on this call — the common case once the
// user has blocked anything at all.
val client = BlockedRelayFilteringClient(inner) { setOf(blocked) }
val filters = mapOf(good to listOf(Filter()), alsoGood to listOf(Filter()))
val relays = setOf(good, alsoGood)
client.subscribe("sub", filters, null)
client.publish(event(), relays)
// Identity, not just equality: reproducing an unchanged map/set costs an allocation and a
// full copy on a path that runs on every REQ and publish.
assertSame(filters, inner.subscribedFilters)
assertSame(relays, inner.publishedRelays)
}
@Test
fun blockSetIsReadPerCallSoLaterChangesApply() {
val inner = RecordingClient()