Merge remote-tracking branch 'origin/main' into claude/beautiful-brahmagupta-17clq5

# Conflicts:
#	commons/build.gradle.kts
#	quartz/build.gradle.kts
This commit is contained in:
Claude
2026-09-12 23:11:15 +00:00
99 changed files with 914 additions and 476 deletions
@@ -143,8 +143,11 @@ messages quoted below (they surface as the NIP-01 `OK false` reason).
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01 `created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract: row in place and fails the unique index → rejected with `RejectionReason.SUPERSEDED`
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked. (`duplicate: a newer version of this replaceable event is already stored`), which the relay
session answers with `OK true` exactly like an id duplicate (NIP-01 `duplicate:` prefix; same
reply nostr-rs-relay gives). Net contract: exactly one version stored; newest wins; ties broken
by lowest id; older re-inserts blocked but acknowledged as already covered.
**STORE-W02 — addressable supersession.** Same as W01 with unique index **STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the `(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
+5 -3
View File
@@ -268,9 +268,11 @@ front:
sequentially: `for peer in aioquic picoquic quic-go quinn; do sequentially: `for peer in aioquic picoquic quic-go quinn; do
quic/interop/run-matrix.sh -s $peer; done`. Plan at quic/interop/run-matrix.sh -s $peer; done`. Plan at
`quic/interop/plans/2026-05-06-interop-runner.md`. `quic/interop/plans/2026-05-06-interop-runner.md`.
- **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): headless - **CLI suites** ([`cli/tests/README.md`](cli/tests/README.md)): every
variants need only `cargo` + a loopback `nostr-rs-relay`; the interactive relay-backed suite boots the embedded `amy serve` relay (geode) — no
Marmot variant prompts a human to drive the Android UI. external relay binary; only the Marmot suites additionally need `cargo`
for MDK's `wn`/`wnd`. The interactive Marmot variant prompts a human to
drive the Android UI.
If a change is documentation-only, UI-only, build-script-only, or otherwise If a change is documentation-only, UI-only, build-script-only, or otherwise
cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip cannot affect wire bytes / decoded audio / MLS state / DM envelopes, skip
+12 -3
View File
@@ -399,9 +399,9 @@ dependencies {
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source: // Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \ // adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver // -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing") debugImplementation(libs.androidx.compose.runtime.tracing)
debugImplementation("androidx.tracing:tracing-perfetto:1.0.1") debugImplementation(libs.androidx.tracing.perfetto)
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.1") debugImplementation(libs.androidx.tracing.perfetto.binary)
implementation(project(":quartz")) implementation(project(":quartz"))
implementation(project(":commons")) implementation(project(":commons"))
@@ -599,6 +599,15 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm) testImplementation(libs.secp256k1.kmp.jni.jvm)
// In-process Nostr relay (geode) so unit tests that drive a real
// NostrClient talk to an embedded relay instead of a public one. Same
// wiring quartz uses for its jvmAndroidTest source set: the engine, its
// testFixtures (RelayClientTest base, preload/publish helpers) and the
// JVM SQLite driver the in-memory EventStore needs on a host JVM.
testImplementation(project(":geode"))
testImplementation(testFixtures(project(":geode")))
testImplementation(libs.androidx.sqlite.bundled.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit.ktx) androidTestImplementation(libs.androidx.junit.ktx)
@@ -31,7 +31,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -62,7 +62,8 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class PlaybackErrorOverlayFitTest { class PlaybackErrorOverlayFitTest {
@get:Rule val rule = createComposeRule() @get:Rule
val rule = createComposeRule()
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
@@ -27,7 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.service.playback.composable.audioSquare import com.vitorpamplona.amethyst.service.playback.composable.audioSquare
@@ -50,7 +50,8 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class AudioPlayerBoxOverflowTest { class AudioPlayerBoxOverflowTest {
@get:Rule val rule = createComposeRule() @get:Rule
val rule = createComposeRule()
private class Bounds { private class Bounds {
var top = 0f var top = 0f
@@ -31,7 +31,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsAnimationCompat
@@ -70,7 +70,8 @@ import org.junit.Test
* fallback would silently start reading a dead value too. * fallback would silently start reading a dead value too.
*/ */
class ComposeImeInsetWedgeTest { class ComposeImeInsetWedgeTest {
@get:Rule val rule = createComposeRule() @get:Rule
val rule = createComposeRule()
private val keyboardHeight = 957 private val keyboardHeight = 957
@@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade import com.vitorpamplona.amethyst.ui.actions.DeferredCrossfade
@@ -1,112 +0,0 @@
/*
* 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.relays.eventsync
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EventSyncTest {
companion object {
val vitor = "wss://vitor.nostr1.com".normalizeRelayUrl()
val fiatjaf = "wss://pyramid.fiatjaf.com".normalizeRelayUrl()
val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val rootClient =
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.addInterceptor(DefaultContentTypeInterceptor("Amethyst/v1.05"))
.build()
val socketBuilder = BasicOkHttpWebSocket.Builder { url -> rootClient }
}
@Test
fun testSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = {
listOf(Constants.mom, Constants.nos)
},
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
NostrClient(socketBuilder, appScope)
},
scope = appScope,
)
sync.runSync()
}
@Test
fun testFiatjafSync() =
runBlocking {
val sync =
EventSync(
accountPubKey = "460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
relayDb = { listOf(fiatjaf) },
outboxTargets = { setOf(vitor) },
inboxTargets = { setOf(vitor) },
dmTargets = { setOf(vitor) },
clientBuilder = {
val newClient = NostrClient(socketBuilder, appScope)
val logger = RelayLogger(newClient, debugSending = true, debugReceiving = false)
val signer = NostrSignerInternal(KeyPair())
// Authenticates with relays.
val auth =
RelayAuthenticator(
newClient,
appScope,
signWithAllLoggedInUsers = { _, authTemplate, _ ->
listOf(signer.sign(authTemplate))
},
)
newClient
},
scope = appScope,
)
sync.runSync()
}
}
@@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizedUrls
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.bytesUsedInMemory
import com.vitorpamplona.quartz.utils.pointerSizeInBytes import com.vitorpamplona.quartz.utils.pointerSizeInBytes
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
@@ -92,6 +93,16 @@ fun collectMemorySnapshot(context: Context): MemorySnapshot {
private const val STATE_DUMP_TAG = "STATE DUMP" private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) { fun debugState(context: Context) {
// Everything below is logged at DEBUG, and every argument is built eagerly (the
// eager Log.d overload, not the lambda one). Gate on the level that would drop
// those lines, because the arguments are the expensive part: nine materialising
// LargeCache.filter scans over notes/addressables/users/channels, plus three
// passes calling Event.countMemory() — which walks every tag of every cached
// event. MainActivity.onPause() calls this unconditionally, so without the gate
// a release build (minLevel WARN) did all of that on every backgrounding and
// threw the result away. Benchmark builds sit at INFO and paid it too.
if (Log.minLevel > LogLevel.DEBUG) return
val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024) val totalMemoryMb = Runtime.getRuntime().totalMemory() / (1024 * 1024)
val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024) val freeMemoryMb = Runtime.getRuntime().freeMemory() / (1024 * 1024)
val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024) val maxMemoryMb = Runtime.getRuntime().maxMemory() / (1024 * 1024)
@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@Stable @Stable
data class VanishEventItem( data class VanishEventItem(
@@ -124,7 +125,10 @@ class VanishRequestsState(
} }
) )
} }
} catch (_: Exception) { } catch (e: Exception) {
// A cancelled check has no result. Reporting ERROR would show the relay as
// having answered badly when it was never asked.
if (e is CancellationException) throw e
item.complianceResults.update { item.complianceResults.update {
it + (relay to ComplianceStatus.ERROR) it + (relay to ComplianceStatus.ERROR)
} }
@@ -166,7 +166,8 @@ class NamecoinSharedPreferences(
} else { } else {
emptyList() emptyList()
} }
} catch (_: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
emptyList() emptyList()
} }
@@ -60,6 +60,7 @@ import java.net.URLDecoder
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction import java.nio.charset.CodingErrorAction
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.coroutines.cancellation.CancellationException
/** /**
* Fetches a resource URL on an applet's behalf — the applet has no direct network * Fetches a resource URL on an applet's behalf — the applet has no direct network
@@ -162,7 +163,10 @@ class NappletResourceFetcher(
return failure(ERROR_BLOCKED, e.message) return failure(ERROR_BLOCKED, e.message)
} catch (_: InterruptedIOException) { } catch (_: InterruptedIOException) {
return failure(ERROR_TIMEOUT) return failure(ERROR_TIMEOUT)
} catch (_: Exception) { } catch (e: Exception) {
// Cancellation is not an upstream failure — do not report it to the
// napplet as one, and do not keep the request alive past it.
if (e is CancellationException) throw e
return failure(ERROR_NETWORK) return failure(ERROR_NETWORK)
} }
} }
@@ -201,6 +201,9 @@ class EventNotificationConsumer(
.onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } } .onFailure { Log.d(TAG) { "Skipping non-decodable npub $npub: ${it.message}" } }
.getOrNull() .getOrNull()
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
private suspend fun dispatchForAccount( private suspend fun dispatchForAccount(
event: Event, event: Event,
account: Account, account: Account,
@@ -112,6 +112,9 @@ class NotificationDispatcher(
// recipient account. // recipient account.
// `internal` (was `private`) so the notification-kinds contract test // `internal` (was `private`) so the notification-kinds contract test
// can pin the push-side kind set against the in-app feed's kind set. // can pin the push-side kind set against the in-app feed's kind set.
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
internal val NOTIFICATION_KINDS: Set<Int> = internal val NOTIFICATION_KINDS: Set<Int> =
setOf( setOf(
// Direct-arrival // Direct-arrival
@@ -81,6 +81,9 @@ object CodeNotification {
event: GitPullRequestUpdateEvent, event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content) ) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be rendered.
@Suppress("DEPRECATION")
suspend fun notify( suspend fun notify(
context: Context, context: Context,
account: Account, account: Account,
@@ -65,6 +65,7 @@ import org.webrtc.RtpSender
import org.webrtc.VideoTrack import org.webrtc.VideoTrack
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
private const val TAG = "CallSession" private const val TAG = "CallSession"
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
@@ -429,6 +430,7 @@ class CallSession(
try { try {
withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) } withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e) Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
return return
} }
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.graphics.withTranslation
import com.vitorpamplona.amethyst.commons.richtext.MathParser import com.vitorpamplona.amethyst.commons.richtext.MathParser
import ru.noties.jlatexmath.JLatexMathDrawable import ru.noties.jlatexmath.JLatexMathDrawable
@@ -130,12 +131,11 @@ fun LatexEquation(
Canvas(modifier = equationModifier) { Canvas(modifier = equationModifier) {
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas val native = canvas.nativeCanvas
val checkpoint = native.save()
// Position the icon's baseline on the text baseline within the padded box. // Position the icon's baseline on the text baseline within the padded box.
native.translate(0f, drawTopPx) native.withTranslation(y = drawTopPx) {
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight) drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
drawable.draw(native) drawable.draw(this)
native.restoreToCount(checkpoint) }
} }
} }
if (trailing.isNotEmpty()) { if (trailing.isNotEmpty()) {
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.note.creators.location
import android.graphics.ColorFilter import android.graphics.ColorFilter
import android.graphics.ColorMatrix import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter import android.graphics.ColorMatrixColorFilter
import android.graphics.drawable.BitmapDrawable
import android.view.MotionEvent import android.view.MotionEvent
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -36,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -159,7 +159,7 @@ fun LocationPreviewMap(
remember(pinColor, pinEmoji) { remember(pinColor, pinEmoji) {
if (pinColor != null && pinEmoji != null) { if (pinColor != null && pinEmoji != null) {
val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density) val bitmap = roadEventPinBitmap(pinEmoji, pinColor.toArgb(), context.resources.displayMetrics.density)
BitmapDrawable(context.resources, bitmap) bitmap.toDrawable(context.resources)
} else { } else {
null null
} }
@@ -118,6 +118,5 @@ open class UserFeedViewModel(
override fun onCleared() { override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
bundler.cancel() bundler.cancel()
super.onCleared()
} }
} }
@@ -2647,7 +2647,6 @@ class AccountViewModel(
com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge com.vitorpamplona.amethyst.ui.screen.loggedIn.nests.room.activity.NestBridge
.clear() .clear()
feedStates.destroy() feedStates.destroy()
super.onCleared()
} }
fun loadMentions( fun loadMentions(
@@ -310,7 +310,6 @@ class AgentConsoleViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
stopObserving() stopObserving()
stopWatching() stopWatching()
super.onCleared()
} }
/** One decrypted observer telemetry frame rendered on the Observer tab. */ /** One decrypted observer telemetry frame rendered on the Observer tab. */
@@ -218,7 +218,6 @@ class AgentWorkBoardViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
stopWatching() stopWatching()
super.onCleared()
} }
companion object { companion object {
@@ -360,6 +360,5 @@ class BuzzDmListViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
liveJob?.cancel() liveJob?.cancel()
liveJob = null liveJob = null
super.onCleared()
} }
} }
@@ -137,7 +137,6 @@ class JobBoardViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
stopWatching() stopWatching()
super.onCleared()
} }
companion object { companion object {
@@ -258,7 +258,6 @@ class WorkflowRunBoardViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
stopWatching() stopWatching()
super.onCleared()
} }
companion object { companion object {
@@ -41,6 +41,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -58,11 +59,15 @@ import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
// Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new // Thread-safe and hoisted: previously each CalendarDateBadge recompose allocated a new
// SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling. // SimpleDateFormat, which (a) is not thread-safe and (b) created 500 allocations while scrolling.
private val MonthShortFormatter: DateTimeFormatter = // Cached per locale rather than in a single val that captures the locale once: the month
DateTimeFormatter.ofPattern("MMM", Locale.getDefault()) // names have to follow a language the user changes while the app is running.
private val monthShortFormatters = ConcurrentHashMap<Locale, DateTimeFormatter>()
private fun monthShortFormatter(locale: Locale): DateTimeFormatter = monthShortFormatters.getOrPut(locale) { DateTimeFormatter.ofPattern("MMM", locale) }
@Composable @Composable
fun CalendarEventListCard( fun CalendarEventListCard(
@@ -214,7 +219,10 @@ private fun CalendarDateBadge(startSeconds: Long?) {
Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate() Instant.ofEpochSecond(startSeconds).atZone(ZoneId.systemDefault()).toLocalDate()
} }
val day = localDate.dayOfMonth.toString() val day = localDate.dayOfMonth.toString()
val month = remember(localDate) { MonthShortFormatter.format(localDate).uppercase() } // LocalLocale rather than Locale.getDefault(): the latter is not observable, so a
// locale change while the app runs would leave the month name in the old language.
val locale = LocalLocale.current.platformLocale
val month = remember(localDate, locale) { monthShortFormatter(locale).format(localDate).uppercase() }
Column( Column(
modifier = Modifier.size(width = 52.dp, height = 60.dp), modifier = Modifier.size(width = 52.dp, height = 60.dp),
@@ -109,7 +109,6 @@ class NewCalendarCollectionViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
liveScanJob?.cancel() liveScanJob?.cancel()
super.onCleared()
} }
fun toggle(address: Address) { fun toggle(address: Address) {
@@ -119,7 +119,6 @@ class MarmotFileUploader(
// imprecisely. // imprecisely.
val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE val canonicalMediaType = MarmotMediaType.canonicalize(mimeType) ?: GENERIC_MEDIA_TYPE
val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename) val cipher = EncryptedMediaV2Cipher(exporterSecret, canonicalMediaType, filename)
val v2Cipher = cipher
item.orchestrator.uploadEncrypted( item.orchestrator.uploadEncrypted(
uri = media.uri, uri = media.uri,
@@ -142,21 +141,19 @@ class MarmotFileUploader(
// compression and metadata stripping — because that is what the // compression and metadata stripping — because that is what the
// key was derived from. // key was derived from.
val reference = val reference =
v2Cipher?.let { EncryptedMediaReferenceV2(
EncryptedMediaReferenceV2( locators =
locators = listOf(
listOf( MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url),
MediaLocatorV2(EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND, serverResult.url), ),
), ciphertextSha256 = cipher.ciphertextSha256,
ciphertextSha256 = it.ciphertextSha256, plaintextSha256 = cipher.plaintextSha256,
plaintextSha256 = it.plaintextSha256, nonce = cipher.nonce,
nonce = it.nonce, mediaType = cipher.mediaType,
mediaType = it.mediaType, filename = filename,
filename = filename, dim = serverResult.fileHeader.dim?.toString(),
dim = serverResult.fileHeader.dim?.toString(), thumbhash = serverResult.fileHeader.thumbHash?.thumbhash,
thumbhash = serverResult.fileHeader.thumbHash?.thumbhash, )
)
}
results.add( results.add(
Mip04UploadResult( Mip04UploadResult(
url = serverResult.url, url = serverResult.url,
@@ -801,7 +801,6 @@ class ChatNewMessageViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -1027,7 +1027,6 @@ open class ChannelNewMessageViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -132,7 +132,6 @@ class ChessViewModelNew(
fun clearFocusedGame() = logic.clearFocusedGame() fun clearFocusedGame() = logic.clearFocusedGame()
override fun onCleared() { override fun onCleared() {
super.onCleared()
logic.stopPolling() logic.stopPolling()
} }
@@ -739,7 +739,6 @@ class LongFormPostViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -612,7 +612,6 @@ open class NewProductViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -1977,7 +1977,6 @@ open class ShortNotePostViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
writingAssistant?.close() writingAssistant?.close()
writingAssistant = null writingAssistant = null
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
@@ -320,7 +320,6 @@ class VoiceReplyViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
cancel() cancel()
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
} }
@@ -102,7 +102,6 @@ class AddToMusicPlaylistViewModel : ViewModel() {
override fun onCleared() { override fun onCleared() {
liveScanJob?.cancel() liveScanJob?.cancel()
super.onCleared()
} }
private suspend fun rescan() { private suspend fun rescan() {
@@ -597,7 +597,6 @@ open class NestNewMessageViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -139,6 +139,9 @@ class NotificationFeedFilter(
AttestationRequestEvent.KIND, AttestationRequestEvent.KIND,
) )
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
val NOTIFICATION_KINDS = val NOTIFICATION_KINDS =
// Kinds that RENDER as a row on the Notifications tab. This is a // Kinds that RENDER as a row on the Notifications tab. This is a
// display gate over whatever is already in LocalCache — it plays no // display gate over whatever is already in LocalCache — it plays no
@@ -268,6 +271,9 @@ class NotificationFeedFilter(
// Shared with EventNotificationConsumer so push notifications and the // Shared with EventNotificationConsumer so push notifications and the
// in-app feed apply the same per-kind "is this event for me" rule. // in-app feed apply the same per-kind "is this event for me" rule.
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be routed.
@Suppress("DEPRECATION")
fun tagsAnEventByUser( fun tagsAnEventByUser(
note: Note, note: Note,
authorHex: HexKey, authorHex: HexKey,
@@ -660,7 +660,6 @@ class NewPublicMessageViewModel :
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
} }
@@ -196,6 +196,5 @@ class RelayFeedViewModel :
override fun onCleared() { override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
super.onCleared()
} }
} }
@@ -43,10 +43,12 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
@@ -90,6 +92,9 @@ class EventSync(
/** Maximum number of completed-relay entries kept in the activity log. */ /** Maximum number of completed-relay entries kept in the activity log. */
const val MAX_ACTIVITY_LOG = 5000 const val MAX_ACTIVITY_LOG = 5000
/** Poll interval while waiting for the last forwarded events to be acknowledged. */
const val OUTBOX_DRAIN_POLL_MS = 100L
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -392,6 +397,13 @@ class EventSync(
val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>() val sourceRelayOfEvent = ConcurrentHashMap<HexKey, NormalizedRelayUrl>()
// (event id, destination) pairs already counted as sent. The outbox is
// at-least-once: it writes an event as soon as the socket is ready and
// resends everything still unacknowledged when the connection finishes
// syncing, so one event can hit the same relay twice before its OK lands.
// The relay dedups the second copy; the counters must too.
val sentPairs = ConcurrentHashMap.newKeySet<String>()
val runningState = val runningState =
SyncState.Running( SyncState.Running(
relaysCompleted = 0, relaysCompleted = 0,
@@ -423,7 +435,12 @@ class EventSync(
success: Boolean, success: Boolean,
) { ) {
super.onSent(relay, cmdStr, cmd, success) super.onSent(relay, cmdStr, cmd, success)
if (cmd is EventCmd) { // `success` is "written to the socket", not "OK received". A write to a
// destination that is still connecting fails and the outbox resends it
// once the socket opens; counting the failed attempt too made every
// cold destination report one extra event sent. Likewise a successful
// resend of an unacknowledged event is the same send, not a second one.
if (cmd is EventCmd && success && sentPairs.add(cmd.event.id + relay.url.url)) {
var hasSent = false var hasSent = false
if (outboxDedup.contains(cmd.event.id)) { if (outboxDedup.contains(cmd.event.id)) {
@@ -586,6 +603,13 @@ class EventSync(
}, },
) )
// `publish` is fire-and-forget through the client's outbox, and `use` closes
// the client as soon as this block returns. Without a drain, the events
// forwarded from the last page of the last relay are still waiting for a
// socket or an OK when the outbox is destroyed — the sync reports Done and
// silently never delivers them. Bounded by the same per-relay timeout.
awaitOutboxDrain(client, outboxDedup + inboxDedup + dmDedup)
_syncState.value = _syncState.value =
SyncState.Done( SyncState.Done(
totalEventsReceived = runningState.eventsReceived.value, totalEventsReceived = runningState.eventsReceived.value,
@@ -613,4 +637,22 @@ class EventSync(
} }
} }
} }
/**
* Waits until no forwarded event in [ids] has a relay left in the client's outbox, or
* until [RELAY_TIMEOUT_MS] passes. Ids that drain are dropped from the working set so
* each poll only revisits what is still pending.
*/
private suspend fun awaitOutboxDrain(
client: INostrClient,
ids: Set<HexKey>,
) {
val pending = ids.toMutableSet()
withTimeoutOrNull(RELAY_TIMEOUT_MS) {
while (pending.isNotEmpty()) {
pending.removeAll { client.pendingPublishRelaysFor(it).isNullOrEmpty() }
if (pending.isNotEmpty()) delay(OUTBOX_DRAIN_POLL_MS)
}
}
}
} }
@@ -116,6 +116,5 @@ open class StringFeedViewModel(
override fun onCleared() { override fun onCleared() {
Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" } Log.d("Init") { "OnCleared: ${this.javaClass.simpleName}" }
bundler.cancel() bundler.cancel()
super.onCleared()
} }
} }
@@ -180,7 +180,6 @@ open class NewHlsVideoViewModel : ViewModel() {
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
currentJob?.cancel() currentJob?.cancel()
} }
@@ -459,7 +459,6 @@ class ReloadMintViewModel : ViewModel() {
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would // The pipeline runs on the AccountViewModel scope, not this VM's, so it would
// outlive the screen — cancel it when the screen goes away. // outlive the screen — cancel it when the screen goes away.
job?.cancel() job?.cancel()
super.onCleared()
} }
companion object { companion object {
@@ -267,7 +267,6 @@ class TopUpMintViewModel : ViewModel() {
// The pipeline runs on the AccountViewModel scope, not this VM's, so it would // The pipeline runs on the AccountViewModel scope, not this VM's, so it would
// outlive the screen — cancel it when the screen goes away. // outlive the screen — cancel it when the screen goes away.
job?.cancel() job?.cancel()
super.onCleared()
} }
} }
@@ -293,7 +293,6 @@ class CashuWalletWizardViewModel : ViewModel() {
} }
override fun onCleared() { override fun onCleared() {
super.onCleared()
discovery?.cancel() discovery?.cancel()
} }
} }
-6
View File
@@ -1,13 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color> <color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color> <color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="transparent">#00FFFFFF</color>
<!-- Launch splash / window background. Tracks MaterialTheme's background so the <!-- Launch splash / window background. Tracks MaterialTheme's background so the
first composed frame does not step to a different colour. See values-night. --> first composed frame does not step to a different colour. See values-night. -->
@@ -47,6 +47,9 @@ import java.io.File
*/ */
class LocalCacheSearchParityTest { class LocalCacheSearchParityTest {
companion object { companion object {
/** Hoisted out of [loadCorpus]: building a Json format is expensive enough that the compiler warns on it. */
private val json = Json { ignoreUnknownKeys = true }
private lateinit var corpus: List<Event> private lateinit var corpus: List<Event>
@BeforeClass @BeforeClass
@@ -57,7 +60,7 @@ class LocalCacheSearchParityTest {
.firstOrNull { it.isFile } .firstOrNull { it.isFile }
?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py") ?: error("tools/search-parity/fixture.json is missing; run tools/search-parity/fetch_fixtures.py")
val root = Json { ignoreUnknownKeys = true }.parseToJsonElement(file.readText()).jsonObject val root = json.parseToJsonElement(file.readText()).jsonObject
corpus = corpus =
root["cases"]!! root["cases"]!!
.jsonArray .jsonArray
@@ -153,6 +153,7 @@ class RelayAuthPromptBusTest {
* answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that * answer already sitting in the deferred, so the relay it belongs to goes unauthenticated for that
* long despite the user having answered. Marking it shown is what makes the answer land now. * long despite the user having answered. Marking it shown is what makes the answer land now.
*/ */
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() = fun anAnswerFannedOutToAQueuedPromptLandsWithoutWaitingOutTheQueueWindow() =
runTest { runTest {
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -185,6 +186,7 @@ class RelayAuthSessionGrantsTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() = fun promotingAGrantToAlwaysNeverOpensAGapThatRePrompts() =
runTest { runTest {
@@ -205,6 +207,7 @@ class RelayAuthSessionGrantsTest {
assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay))) assertEquals(RelayAuthVerdict.ALLOW, ledger.decide(askable(relay)))
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun neverAllowStopsAuthenticatingBeforeItsWriteLands() = fun neverAllowStopsAuthenticatingBeforeItsWriteLands() =
runTest { runTest {
@@ -70,6 +70,7 @@ class Nip34NotificationCoverageTest {
* A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles * A NIP-22 [com.vitorpamplona.quartz.nip22Comments.CommentEvent] handles
* modern comments through its own separate wiring. * modern comments through its own separate wiring.
*/ */
@Suppress("DEPRECATION")
private val nip34ParticipantKinds = private val nip34ParticipantKinds =
setOf( setOf(
GitPatchEvent.KIND, GitPatchEvent.KIND,
@@ -131,6 +132,7 @@ class Nip34NotificationCoverageTest {
* uppercase `E`. Asserting the wrong half passes the kind list while matching * uppercase `E`. Asserting the wrong half passes the kind list while matching
* nothing on the wire. * nothing on the wire.
*/ */
@Suppress("DEPRECATION")
@Test @Test
fun `status kinds are pulled by the lowercase-e engagement subscription`() { fun `status kinds are pulled by the lowercase-e engagement subscription`() {
val eAnchoredActivityKinds = val eAnchoredActivityKinds =
@@ -0,0 +1,223 @@
/*
* 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.relays.eventsync
import com.vitorpamplona.geode.InProcessRelays
import com.vitorpamplona.geode.RelayEngine
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocket
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Drives [EventSync] end to end against geode's in-process relays: one
* "source" relay that already holds the account's history and three empty
* destination relays (outbox / inbox / DM). No network, no public relay —
* every relay is a [RelayEngine] inside this JVM, so the assertions are on
* what actually landed in each destination store, not on "it didn't crash".
*
* The second scenario gates the source relay behind NIP-42 ([FullAuthPolicy])
* to cover the [RelayAuthenticator] wiring the sync screen relies on when a
* user's relay demands AUTH before serving REQs.
*/
class EventSyncTest : RelayClientTest() {
private val account = NostrSignerSync(KeyPair())
private val other = NostrSignerSync(KeyPair())
private val source: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://source.relay/")
private val outbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://outbox.relay/")
private val inbox: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://inbox.relay/")
private val dm: NormalizedRelayUrl = RelayUrlNormalizer.normalize("ws://dm.relay/")
/** Separate hub so only the source relay demands AUTH; destinations stay open. */
private val authHub = InProcessRelays(defaultPolicy = { FullAuthPolicy(source) })
@After
fun tearDownAuthHub() {
authHub.close()
}
private fun note(
author: NostrSignerSync,
content: String,
tagged: HexKey? = null,
): Event = author.sign(eventTemplate<Event>(1, content) { tagged?.let { pTag(it) } })
private fun legacyDm(
author: NostrSignerSync,
recipient: HexKey,
): Event = author.sign(eventTemplate<Event>(4, "ciphertext") { pTag(recipient) })
private val mine = List(3) { note(account, "mine $it") }
private val mentions = List(2) { note(other, "hey $it", tagged = account.pubKey) }
private val dmToMe = legacyDm(other, account.pubKey)
private val noise = note(other, "unrelated")
private fun corpus(): List<Event> = mine + mentions + dmToMe + noise
/** [decorate] runs on every client the sync builds, e.g. to attach an authenticator. */
private fun eventSync(
builder: WebsocketBuilder,
decorate: (NostrClient) -> Unit = {},
): EventSync =
EventSync(
accountPubKey = account.pubKey,
relayDb = { listOf(source) },
outboxTargets = { setOf(outbox) },
inboxTargets = { setOf(inbox) },
dmTargets = { setOf(dm) },
clientBuilder = { NostrClient(builder, scope).also(decorate) },
scope = scope,
)
/**
* Publishes are fire-and-forget on the client side, so the destination
* store can lag `runSync` returning by a few ticks. Poll instead of
* asserting a snapshot.
*/
private suspend fun RelayEngine.awaitCount(
filter: Filter,
expected: Int,
): Int =
withTimeoutOrNull(10_000) {
while (store.count(filter) < expected) delay(25)
store.count(filter)
} ?: store.count(filter)
private suspend fun assertRouted(hubOfTargets: InProcessRelays) {
val outboxRelay = hubOfTargets.getOrCreate(outbox)
val inboxRelay = hubOfTargets.getOrCreate(inbox)
val dmRelay = hubOfTargets.getOrCreate(dm)
assertEquals(
"every event authored by the account lands on the outbox relay",
mine.size,
outboxRelay.awaitCount(Filter(authors = listOf(account.pubKey)), mine.size),
)
assertEquals(
"non-DM mentions land on the inbox relay",
mentions.size,
inboxRelay.awaitCount(Filter(tags = mapOf("p" to listOf(account.pubKey))), mentions.size),
)
assertEquals(
"the kind-4 DM lands on the DM relay",
1,
dmRelay.awaitCount(Filter(kinds = listOf(4)), 1),
)
// Routing is exclusive per rule: nothing leaks across destinations and the
// unrelated note never leaves the source.
assertEquals("outbox holds only the account's events", mine.size, outboxRelay.store.count(Filter()))
assertEquals("inbox holds only the mentions", mentions.size, inboxRelay.store.count(Filter()))
assertEquals("dm relay holds only the DM", 1, dmRelay.store.count(Filter()))
assertEquals("noise stays on the source", 0, outboxRelay.store.count(Filter(ids = listOf(noise.id))))
}
@Test
fun syncRoutesEventsFromSourceToOutboxInboxAndDmRelays() =
runBlocking {
hub.getOrCreate(source).preload(corpus())
val sync = eventSync(hub)
withTimeout(30_000) { sync.runSync() }
val done = sync.syncState.value
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
done as EventSync.SyncState.Done
assertEquals(
"mine + mentions + dm match a routing rule; noise does not",
mine.size + mentions.size + 1,
done.totalEventsReceived,
)
// runSync drains the outbox before closing its client, so by the time
// Done is published every forwarded event has been written to its
// destination socket — not merely queued.
assertEquals(
"every routed event was sent before the client closed",
mine.size + mentions.size + 1,
done.totalEventsSent,
)
assertRouted(hub)
}
@Test
fun syncReadsFromAuthRequiredSourceOnceAuthenticated() =
runBlocking {
authHub.getOrCreate(source).preload(corpus())
// Source demands NIP-42 before serving REQs; destinations are the open hub.
val router =
object : WebsocketBuilder {
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket = if (url == source) authHub.build(url, out) else hub.build(url, out)
}
val authSigner = NostrSignerSync(KeyPair())
var authenticator: RelayAuthenticator? = null
val sync =
eventSync(router) { client ->
authenticator =
RelayAuthenticator(client = client, scope = scope) { _, template, _ ->
listOf(authSigner.sign(template))
}
}
try {
withTimeout(30_000) { sync.runSync() }
} finally {
authenticator?.destroy()
}
val done = sync.syncState.value
assertTrue("sync should finish in Done, got $done", done is EventSync.SyncState.Done)
assertEquals(
"the auth-gated source still yields every routed event",
mine.size + mentions.size + 1,
(done as EventSync.SyncState.Done).totalEventsReceived,
)
assertRouted(hub)
}
}
@@ -136,7 +136,7 @@ class HexBenchmark {
/** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */ /** The pre-existing two-pass way to safely decode an id, for comparison with [hexDecode64OrNull]. */
@Test @Test
fun hexIsHex64ThenDecode() { fun hexIsHex64ThenDecode() {
r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) else null } r.measureRepeated { if (Hex.isHex64(hex)) Hex.decode(hex) }
} }
@Test @Test
+9 -9
View File
@@ -9,18 +9,18 @@ import java.util.Properties
// compiles this buildscript {} section in an earlier stage that can't see the // compiles this buildscript {} section in an earlier stage that can't see the
// file's imports (hence the qualified Properties) or share code with the body, // file's imports (hence the qualified Properties) or share code with the body,
// but it can publish values — the gate is computed once here and read below // but it can publish values — the gate is computed once here and read below
// via `by extra`. // via the project's extra properties.
buildscript { buildscript {
val localProperties = File(rootDir, "local.properties") val localProperties = File(rootDir, "local.properties")
val sonarProperties by extra( val sonarProperties =
java.util.Properties().apply { java.util.Properties().apply {
if (localProperties.exists()) localProperties.inputStream().use { load(it) } if (localProperties.exists()) localProperties.inputStream().use { load(it) }
}, }
) extra.set("sonarProperties", sonarProperties)
val sonarEnabled by extra( val sonarEnabled =
sonarProperties.getProperty("sonar.host.url") != null && sonarProperties.getProperty("sonar.host.url") != null &&
gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }, gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }
) extra.set("sonarEnabled", sonarEnabled)
if (sonarEnabled) { if (sonarEnabled) {
repositories { repositories {
gradlePluginPortal() gradlePluginPortal()
@@ -110,9 +110,9 @@ subprojects {
// `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the // `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the
// command line. sonar.projectKey/projectName default to the root project name // command line. sonar.projectKey/projectName default to the root project name
// ("Amethyst") and only need overriding in local.properties if desired. // ("Amethyst") and only need overriding in local.properties if desired.
val sonarEnabled: Boolean by extra val sonarEnabled = extra["sonarEnabled"] as Boolean
if (sonarEnabled) { if (sonarEnabled) {
val sonarProperties: Properties by extra val sonarProperties = extra["sonarProperties"] as Properties
apply(plugin = "org.sonarqube") apply(plugin = "org.sonarqube")
sonarProperties sonarProperties
+1 -1
View File
@@ -348,7 +348,7 @@ Amy-specific layer still needs its own coverage:
| Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. | | Error / exit-code contract (bad args → 2, timeout → 124, `rejected` → 1) | `ExitCodeContractTest` — table-driven tests invoking `runCli(argv)` with captured stdout/stderr. |
| JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. | | JSON output shape (keys and types under `--json`) | `JsonContractTest` — runs commands under `--json` and asserts on the parsed object. The default text render has no shape contract and isn't asserted on. |
| File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. | | File layout on disk (`identity.json`, `shared/events.db`, `marmot/groups/*.mls`, …) | Structural assertions after a command sequence. |
| Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up a local `nostr-rs-relay` and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). | | Round-trip between two accounts on a local relay | End-to-end shell harnesses under `cli/tests/`: each spins up the embedded `amy serve` relay (geode) and a fresh `$HOME=$STATE_DIR` so amy sees a virgin `~/.amy/`, then bootstraps multiple accounts sharing one store and drives a scenario through them. Nine suites today — see [`cli/tests/README.md`](./tests/README.md). |
The JVM suite drives `runCli` **in-process** through the shared The JVM suite drives `runCli` **in-process** through the shared
`amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr, `amy(vararg argv)` harness in `CliResult.kt`: it captures stdout/stderr,
+4 -4
View File
@@ -180,11 +180,11 @@ move anything, re-audit — you're probably duplicating logic.
9. **Test suite** — largely in place, two layers: 9. **Test suite** — largely in place, two layers:
- **Shell harnesses** under `cli/tests/` — ten suites: `blossom` - **Shell harnesses** under `cli/tests/` — ten suites: `blossom`
(live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`), (live servers), `cache`, `clink`, `dm`, `git` (NIP-34 vs `amy serve`),
`marmot` (vs whitenoise-rs), `nests` (manual audio-rooms matrix), `pow`, `marmot` (vs MDK), `nests` (manual audio-rooms matrix), `pow`,
`relaygroup`, `sync`, plus the shared `headless/` helpers. See `relaygroup`, `sync`, plus the shared `headless/` helpers. See
`cli/tests/README.md`. `cli/tests/README.md`. Every relay-backed suite runs against the
None run in CI yet (the relay-backed ones need Rust + a ~3 min embedded `amy serve` relay (geode) — no external relay binary.
cold `nostr-rs-relay` build). None run in CI yet (the Marmot ones need Rust for MDK's `wn`).
- **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing, - **JVM unit suite** at `cli/src/test/kotlin/` — `Args` parsing,
exit-code contract, and `--json` shape tests driving `runCli` exit-code contract, and `--json` shape tests driving `runCli`
in-process via the `amy.home` isolation seam. in-process via the `amy.home` isolation seam.
+1 -1
View File
@@ -110,7 +110,7 @@ application {
// JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy // JVM decodes each one as ASCII (every byte > 0x7F → U+FFFD), and amy
// then signs a kind:7 whose `content` is four replacement characters. // then signs a kind:7 whose `content` is four replacement characters.
// Whitenoise rejects it with "Invalid reaction content". // Whitenoise rejects it with "Invalid reaction content".
val patchAmyLauncherCharset by tasks.registering { val patchAmyLauncherCharset = tasks.register("patchAmyLauncherCharset") {
val appName = application.applicationName val appName = application.applicationName
val startScriptsTask = tasks.named("startScripts") val startScriptsTask = tasks.named("startScripts")
dependsOn(startScriptsTask) dependsOn(startScriptsTask)
+1
View File
@@ -1,6 +1,7 @@
marmot/state/ marmot/state/
marmot/state-headless/ marmot/state-headless/
dm/state-dm-headless/ dm/state-dm-headless/
cache/state-cache-headless/
nests/state/ nests/state/
clink/state-clink-headless/ clink/state-clink-headless/
relaygroup/state-relaygroup-headless/ relaygroup/state-relaygroup-headless/
+46 -39
View File
@@ -1,21 +1,24 @@
# amy CLI test harnesses # amy CLI test harnesses
Shell-based end-to-end harnesses that drive the `amy` CLI binary — against a Shell-based end-to-end harnesses that drive the `amy` CLI binary — against an
loopback `nostr-rs-relay`, an embedded `amy serve` relay, live public servers, embedded relay (`amy serve`, i.e. **geode**, the relay this repo ships), live
or no relay at all, depending on the suite. Eleven directories: public servers, or no relay at all, depending on the suite. No suite depends on
an external relay binary or a Rust toolchain for its relay: every relay-backed
harness boots geode from the `amy` binary it already built, so the relay under
test is the same server code that runs in production. Eleven directories:
``` ```
cli/tests/ cli/tests/
├── lib.sh # shared logging, results, assertions ├── lib.sh # shared logging, results, assertions
├── headless/ # shared bits used by every harness ├── headless/ # shared bits used by every harness
│ └── helpers.sh │ └── helpers.sh # amy wrappers, assertions, embedded relay boot
├── blossom/ # Blossom blob lifecycle vs LIVE public servers ├── blossom/ # Blossom blob lifecycle vs LIVE public servers
│ └── blossom-live.sh │ └── blossom-live.sh
├── cache/ # local-store-as-cache semantics (profile show ├── cache/ # local-store-as-cache semantics (profile show
│ └── cache-headless.sh # cache/refresh, store stat) vs nostr-rs-relay │ └── cache-headless.sh # cache/refresh, store stat) vs embedded `amy serve`
├── clink/ # CLINK pointer decode — local-only, no relay ├── clink/ # CLINK pointer decode — local-only, no relay
│ └── clink-headless.sh │ └── clink-headless.sh
├── dm/ # NIP-17 DM interop (amy ↔ amy) ├── dm/ # NIP-17 DM interop (amy ↔ amy) vs embedded `amy serve`
│ ├── dm-interop-headless.sh │ ├── dm-interop-headless.sh
│ ├── setup.sh # preflight + identities │ ├── setup.sh # preflight + identities
│ └── tests-dm.sh │ └── tests-dm.sh
@@ -24,7 +27,7 @@ cli/tests/
├── marmot/ # Marmot / MLS group-messaging interop ├── marmot/ # Marmot / MLS group-messaging interop
│ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI │ ├── marmot-interop.sh # interactive — prompts Amethyst Android UI
│ ├── marmot-interop-headless.sh # zero-prompt │ ├── marmot-interop-headless.sh # zero-prompt
│ ├── setup.sh # preflight + wn + relay + identities │ ├── setup.sh # preflight + wn + identities
│ ├── tests-create.sh # tests 01–05 │ ├── tests-create.sh # tests 01–05
│ ├── tests-manage.sh # tests 06–08, 11 │ ├── tests-manage.sh # tests 06–08, 11
│ ├── tests-extras.sh # tests 09, 10, 12, 13 │ ├── tests-extras.sh # tests 09, 10, 12, 13
@@ -60,7 +63,7 @@ Suite notes:
and mined-nonce round-trips through `pow check`. and mined-nonce round-trips through `pow check`.
- **`cache/cache-headless.sh`** proves the local store is the source of - **`cache/cache-headless.sh`** proves the local store is the source of
truth for reads: `profile show` served from cache vs `--refresh`, and truth for reads: `profile show` served from cache vs `--refresh`, and
`store stat` reporting the right histogram, vs a loopback nostr-rs-relay. `store stat` reporting the right histogram, vs the embedded `amy serve` relay.
- **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/ - **`relaygroup/relaygroup-headless.sh`** runs NIP-29 create/message/join/
list/browse against an embedded relay (`amy serve`, which boots geode) — list/browse against an embedded relay (`amy serve`, which boots geode) —
no external relay binary. geode doesn't sign 39000-39003, so browse/info no external relay binary. geode doesn't sign 39000-39003, so browse/info
@@ -124,10 +127,8 @@ The Marmot harnesses come in two flavours, same scenarios:
A third, slimmer harness covers the NIP-17 DM surface: A third, slimmer harness covers the NIP-17 DM surface:
- **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and - **`dm/dm-interop-headless.sh`** — two `amy` processes (Identity A and
Identity D) exchange NIP-17 DMs through the loopback nostr-rs-relay. Identity D) exchange NIP-17 DMs through the embedded `amy serve` relay.
No MDK required — only `amy` and the relay binary (which No MDK, no Rust — only `amy`.
is shared with the Marmot harness's checkout at
`marmot/state-headless/nostr-rs-relay/`).
A harness covers Blossom blob storage (BUD-01/02/04/09) against **live** A harness covers Blossom blob storage (BUD-01/02/04/09) against **live**
public servers rather than a loopback relay: public servers rather than a loopback relay:
@@ -212,15 +213,18 @@ at `desktopApp/src/jvmTest/kotlin/.../service/upload/`.
On the machine that runs the harness: On the machine that runs the harness:
- **Rust 1.90+** — install via https://rustup.rs - **Rust 1.90+** — install via https://rustup.rs (for MDK's `wn`/`wnd` only;
the relay is `amy serve`, no Rust needed for it)
- **git**, **curl**, **jq** — package manager - **git**, **curl**, **jq** — package manager
- **~5 GB disk** for the first-run build of `wn` + `wnd` - **~5 GB disk** for the first-run build of `wn` + `wnd`
- Public internet access (for the default relay set and fetching crates) - Internet access for fetching crates on the first build. Test traffic
stays on the machine unless you pass `--public-relays`.
On the Android side: On the Android side:
- Amethyst installed on an **emulator** or a **physical device** - Amethyst installed on an **emulator** or a **physical device**
- The device must reach the same relays the harness uses (see below) - The device must reach the harness's embedded relay over the network
(see below), or the public relays when running with `--public-relays`
## Quick start ## Quick start
@@ -240,9 +244,11 @@ The script will, in order:
4. Create Nostr identities for B and C, persist their npubs in `state/run.env`. 4. Create Nostr identities for B and C, persist their npubs in `state/run.env`.
5. Ask you to paste **your Amethyst account npub** (Identity A). This is 5. Ask you to paste **your Amethyst account npub** (Identity A). This is
cached for subsequent runs. cached for subsequent runs.
6. Add the default public relays to both daemons and run a sanity check 6. Boot the embedded relay (`amy serve`, i.e. geode, on `0.0.0.0:8080`),
(publish a KP from B, fetch it from C). add it to both daemons and run a sanity check (publish a KP from B,
7. Print an **Amethyst setup checklist** — add the same relays to Amethyst, fetch it from C). With `--public-relays` the default public set is used
instead and the relay is not started.
7. Print an **Amethyst setup checklist** — add the same relay to Amethyst,
publish a KP, verify you are logged in with A. publish a KP, verify you are logged in with A.
8. Run all 13 tests sequentially. Each test either: 8. Run all 13 tests sequentially. Each test either:
- runs `wn` commands fully automatically and asserts on JSON output, **or** - runs `wn` commands fully automatically and asserts on JSON output, **or**
@@ -253,9 +259,10 @@ The script will, in order:
## Command-line flags ## Command-line flags
``` ```
--local-relays Use ws://localhost:8080 instead of the default public relays. --public-relays Use the public relay set below instead of the embedded relay.
Required if the public relays reject kinds 444/445/30443. The only mode whose test traffic leaves the machine; the
Run 'just docker-up' inside the mdk checkout first. public relays may reject kinds 444/445/30443.
--port N Port for the embedded relay (default 8080).
--transponder Run Test 14 (push notifications via the transponder service). --transponder Run Test 14 (push notifications via the transponder service).
--no-build Fail instead of rebuilding wn/wnd. Useful when iterating. --no-build Fail instead of rebuilding wn/wnd. Useful when iterating.
-h, --help Show help. -h, --help Show help.
@@ -267,31 +274,31 @@ Environment overrides:
WN_REPO=/some/path/mdk # use an existing checkout WN_REPO=/some/path/mdk # use an existing checkout
``` ```
## Default relays ## Relays
By default the harness owns the only relay: `amy serve` (geode) bound to
`0.0.0.0:8080`. The `wn` daemons reach it on loopback; Amethyst reaches it
over the network:
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays,
Settings → Key Package Relays and Settings → DM Inbox Relays.
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
With `--public-relays` the daemons are bootstrapped on
``` ```
wss://relay.damus.io wss://relay.damus.io
wss://nos.lol wss://nos.lol
wss://relay.primal.net wss://relay.primal.net
wss://nostr.bitcoiner.social
wss://nostr.mom
``` ```
These are known to accept kind 1059 (gift wraps) and kind 30000+ (addressable instead and Amethyst is left on its own relay set, so the run surfaces
events). If the **sanity check fails** — meaning C cannot read the KeyPackage real-world discovery failures (A's inbox behind NIP-42, whitelists, kinds the
that B just published — the harness warns you and continues. In that case public relays drop). If the **sanity check fails** in that mode — meaning C
re-run with `--local-relays` after starting the Docker stack: cannot read the KeyPackage that B just published — the harness warns you and
continues; re-run without `--public-relays` to rule the relays out.
```bash
cd state/mdk
just docker-up
cd ../..
./marmot-interop.sh --local-relays
```
For Amethyst with `--local-relays`:
- **Android emulator:** add `ws://10.0.2.2:8080` to Settings → Relays and
Settings → Key Package Relays.
- **Physical device on same Wi-Fi:** add `ws://<laptop-LAN-ip>:8080`.
## How human interaction works ## How human interaction works
+11 -11
View File
@@ -3,8 +3,8 @@
# cache-headless.sh — verifies the file-backed event store is the # cache-headless.sh — verifies the file-backed event store is the
# source of truth for `amy` reads. # source of truth for `amy` reads.
# #
# Two amy identities (A and B) talk to a local nostr-rs-relay. We # Two amy identities (A and B) talk to a local embedded relay
# assert that: # (`amy serve`, i.e. geode). We assert that:
# #
# 1. After A runs `amy create`, A's local store contains the bootstrap # 1. After A runs `amy create`, A's local store contains the bootstrap
# events (kind:0 / 3 / 10002 / 10050 / 10051 …). # events (kind:0 / 3 / 10002 / 10050 / 10051 …).
@@ -39,10 +39,13 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Reuse the relay binary the marmot harness builds. # Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity
# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all
# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like
# 127.0.0.1. Nothing here depends on that parse — amy publishes to and
# reads from the relay it was told about.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}" RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay" RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8092}" RELAY_PORT="${RELAY_PORT:-8092}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -72,14 +75,11 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
# shellcheck source=../lib.sh # shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh" source "$TESTS_DIR/lib.sh"
# shellcheck source=../marmot/setup.sh — provides start_local_relay / stop_local_relay # shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay
source "$TESTS_DIR/marmot/setup.sh"
# shellcheck source=../headless/helpers.sh
source "$TESTS_DIR/headless/helpers.sh" source "$TESTS_DIR/headless/helpers.sh"
# Keep the dm setup's preflight (just checks for amy + the relay) but # Keep the dm setup's preflight (just checks for amy) but define our own
# define our own identity bootstrap so we don't pull in DM-specific # identity bootstrap so we don't pull in DM-specific wiring.
# wiring.
# shellcheck source=../dm/setup.sh # shellcheck source=../dm/setup.sh
source "$TESTS_DIR/dm/setup.sh" source "$TESTS_DIR/dm/setup.sh"
+15 -17
View File
@@ -3,8 +3,9 @@
# dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness. # dm-interop-headless.sh — zero-prompt NIP-17 DM interop harness.
# #
# Two `amy` processes (Identity A and Identity D) talk to each other # Two `amy` processes (Identity A and Identity D) talk to each other
# through a local nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT. No # through a local embedded relay (`amy serve`, i.e. geode) on
# whitenoise-rs, no Marmot, no public internet traffic. # ws://127.0.0.2:$RELAY_PORT. No MDK, no Marmot, no Rust toolchain, no
# public internet traffic.
# #
# Usage: ./dm-interop-headless.sh [--port N] [--no-build] # Usage: ./dm-interop-headless.sh [--port N] [--no-build]
# #
@@ -26,16 +27,16 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Share the nostr-rs-relay checkout with the Marmot harness to avoid # Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
# rebuilding it twice. Override RELAY_REPO / RELAY_DATA if you want full # start_local_relay in headless/helpers.sh. Override RELAY_DATA if you
# isolation between runs. # want full isolation between runs.
# Bind the loopback relay to 127.0.0.2 rather than 127.0.0.1 so Quartz's # 127.0.0.2 used to dodge Quartz's `isLocalHost()` strip of loopback
# `isLocalHost()` filter doesn't silently strip it out of the kind:10050 # relays in kind:10050 inbox lists. That filter now covers all of
# inbox events during recipient-relay resolution. 127.0.0.2 is still pure # 127.0.0.0/8, so the strict-inbox sends (dm-01/02/05/06) fail with
# loopback — no network traffic, no config needed. # no_dm_relays regardless of which loopback address the relay binds;
# only the fallback-chain tests (dm-03/04) are unaffected. Kept for
# parity with the other harnesses until that routing rule is revisited.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}" RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$TESTS_DIR/marmot/state-headless/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay" RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8090}" RELAY_PORT="${RELAY_PORT:-8090}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -68,12 +69,9 @@ mkdir -p "$STATE_DIR" "$LOG_DIR"
# shellcheck source=../lib.sh # shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh" source "$TESTS_DIR/lib.sh"
# Reuse start_local_relay / stop_local_relay from the Marmot harness's # setup.sh in this dir defines the slim preflight_dm() (amy only, no
# setup.sh — the relay lifecycle is identical. preflight() there also # MDK); the relay lifecycle (start_local_relay / stop_local_relay, the
# builds whitenoise-rs, which we don't need; setup.sh in this dir # embedded `amy serve`) comes from the shared headless helpers.
# defines a slimmer preflight_dm().
# shellcheck source=../marmot/setup.sh
source "$TESTS_DIR/marmot/setup.sh"
# shellcheck source=setup.sh # shellcheck source=setup.sh
source "$SCRIPT_DIR/setup.sh" source "$SCRIPT_DIR/setup.sh"
# shellcheck source=../headless/helpers.sh # shellcheck source=../headless/helpers.sh
+7 -29
View File
@@ -3,20 +3,21 @@
# setup.sh — amy-only preflight + identity bootstrap for the # setup.sh — amy-only preflight + identity bootstrap for the
# NIP-17 DM interop harness. Much slimmer than the Marmot setup: # NIP-17 DM interop harness. Much slimmer than the Marmot setup:
# #
# - Builds `amy` (same retry-on-503 logic as setup.sh). # - Builds `amy` (same retry-on-503 logic as setup.sh). The loopback
# - Builds nostr-rs-relay if missing. # relay is `amy serve` (geode), so amy is the only binary needed.
# - Bootstraps two fresh amy identities (A and D), each with its own # - Bootstraps two fresh amy identities (A and D), each with its own
# `--data-dir`, both pointed at the loopback relay. # `--data-dir`, both pointed at the loopback relay.
# - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict # - Publishes kind:10050 (plus NIP-65) for both so NIP-17's strict
# recipient-inbox routing has something to resolve to. # recipient-inbox routing has something to resolve to.
# #
# The heavy `start_local_relay` / `stop_local_relay` helpers live in # The `start_local_relay` / `stop_local_relay` helpers (embedded
# the Marmot harness's setup.sh and are sourced by the top-level harness. # `amy serve`) live in ../headless/helpers.sh and are sourced by the
# top-level harness.
# --- preflight (amy + relay only, no wn / Marmot patches) ------------------- # --- preflight (amy only, no wn / Marmot patches, no Rust) -------------------
preflight_dm() { preflight_dm() {
banner "Preflight (DM harness)" banner "Preflight (DM harness)"
for cmd in jq git cargo; do for cmd in jq git curl; do
if ! command -v "$cmd" >/dev/null 2>&1; then if ! command -v "$cmd" >/dev/null 2>&1; then
fail_msg "missing required tool: $cmd" fail_msg "missing required tool: $cmd"
exit 1 exit 1
@@ -43,29 +44,6 @@ preflight_dm() {
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; } [[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
info "amy: $AMY_BIN" info "amy: $AMY_BIN"
# nostr-rs-relay (same build path as the Marmot harness).
if [[ ! -x "$RELAY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$RELAY_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed — retrying"
done
[[ -x "$RELAY_BIN" ]] || {
fail_msg "nostr-rs-relay still missing after $max attempts"; exit 1
}
fi
info "relay bin: $RELAY_BIN"
} }
# --- amy identity wrappers --------------------------------------------------- # --- amy identity wrappers ---------------------------------------------------
+1 -1
View File
@@ -3,7 +3,7 @@
# tests-dm.sh — NIP-17 DM interop tests for two `amy` clients. # tests-dm.sh — NIP-17 DM interop tests for two `amy` clients.
# #
# Identity A (sender) and Identity D (recipient) each live in their own # Identity A (sender) and Identity D (recipient) each live in their own
# --data-dir and share one loopback nostr-rs-relay. Tests cover: # --data-dir and share one loopback embedded relay (`amy serve` / geode). Tests cover:
# #
# dm-01 text round-trip (both directions) # dm-01 text round-trip (both directions)
# dm-02 dm list surfaces prior exchange with type:text discriminator # dm-02 dm list surfaces prior exchange with type:text discriminator
+89
View File
@@ -66,5 +66,94 @@ assert_eq() {
return 1 return 1
} }
# --- embedded relay (amy serve → geode) --------------------------------------
# Every relay-backed harness talks to ONE loopback relay, and that relay is
# `amy serve` — i.e. geode, the relay this repo ships — booted from the amy
# binary the harness already built. No Rust toolchain, no clone, no cargo
# build, no external relay binary: the relay under test is part of the
# product, so a harness run exercises the same server code `amy serve`
# and the standalone geode distribution run in production.
#
# Callers set (before sourcing or at least before calling):
# AMY_BIN amy launcher (built via `./gradlew :cli:installDist`)
# RELAY_HOST host clients connect to. The harnesses use 127.0.0.2 for
# parity with each other; note that Quartz's isLocalHost()
# treats all of 127.0.0.0/8 as loopback, so it does NOT
# survive the NIP-17 / NIP-65 relay-list parsers any better
# than 127.0.0.1 does.
# RELAY_BIND optional bind address; defaults to $RELAY_HOST. Set to
# 0.0.0.0 when a device on the LAN must reach the relay.
# RELAY_PORT listen port
# RELAY_URL ws://$RELAY_HOST:$RELAY_PORT
# RELAY_DATA scratch dir for the relay's own $HOME, pid file and logs
#
# The relay process runs as its own amy account ("relay") inside its own
# $HOME under $RELAY_DATA, so its identity and store never mix with the
# test identities. The store is in-memory (amy serve's default): every run
# starts from an empty relay, matching the state wipe the harnesses do.
start_local_relay() {
banner "Starting embedded relay (amy serve / geode) on $RELAY_URL"
local relay_home="$RELAY_DATA/home"
local bind="${RELAY_BIND:-$RELAY_HOST}"
mkdir -p "$relay_home" "$RELAY_DATA/logs"
[[ -x "$AMY_BIN" ]] || { fail_msg "amy not found at $AMY_BIN — build it with ./gradlew :cli:installDist"; exit 1; }
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
# bash's /dev/tcp probe needs no `ss`/`lsof`; a refused connect on
# loopback returns immediately.
if (exec 3<>"/dev/tcp/$RELAY_HOST/$RELAY_PORT") 2>/dev/null; then
fail_msg "port $RELAY_PORT already in use on $RELAY_HOST — pass --port N or free it"
exit 1
fi
# `amy serve` resolves its admin pubkey from the account, so the relay
# needs an identity of its own. Idempotent across --reuse-state runs.
if [[ ! -d "$relay_home/.amy/relay" ]]; then
HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext --json init \
>"$RELAY_DATA/logs/init.log" 2>&1 \
|| { fail_msg "amy init failed for the relay account (see $RELAY_DATA/logs/init.log)"; exit 1; }
fi
nohup env HOME="$relay_home" "$AMY_BIN" --account relay --secret-backend plaintext \
serve --host "$bind" --port "$RELAY_PORT" \
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
echo "$!" > "$RELAY_DATA/pid"
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
# Readiness = the NIP-11 document answers on the same port. geode serves
# it on a plain GET with `Accept: application/nostr+json` (anything else
# gets a 426 hint, which curl -f would treat as failure).
local deadline=$(( $(date +%s) + 60 ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sSf -m 1 -H 'Accept: application/nostr+json' \
"http://$RELAY_HOST:$RELAY_PORT/" >/dev/null 2>&1; then
info "relay up"
return 0
fi
if ! kill -0 "$(cat "$RELAY_DATA/pid")" 2>/dev/null; then
break
fi
sleep 0.5
done
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
stop_local_relay() {
local pid_file="$RELAY_DATA/pid"
[[ -f "$pid_file" ]] || return 0
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping relay pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
# --- wn-side pollers (delegates to lib.sh) ----------------------------------- # --- wn-side pollers (delegates to lib.sh) -----------------------------------
# Both exist in lib.sh already; this file only adds headless-specific niceties. # Both exist in lib.sh already; this file only adds headless-specific niceties.
+10 -11
View File
@@ -3,9 +3,9 @@
# marmot-interop-headless.sh — zero-prompt, zero-internet interop harness. # marmot-interop-headless.sh — zero-prompt, zero-internet interop harness.
# #
# Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and # Drives Identity A via the `amy` CLI (./gradlew :cli:installDist) and
# Identities B/C via MDK's `wn`/`wnd`. Spins up a local # Identities B/C via MDK's `wn`/`wnd`. Spins up a local embedded relay
# nostr-rs-relay on ws://127.0.0.1:$RELAY_PORT so nothing ever leaves the # (`amy serve`, i.e. geode) on ws://127.0.0.2:$RELAY_PORT so nothing ever
# machine. Matches the 13 test scenarios in marmot-interop.sh but without # leaves the machine. Matches the 13 test scenarios in marmot-interop.sh but without
# any human prompts — all checks run to completion and the exit code # any human prompts — all checks run to completion and the exit code
# reflects pass/fail totals. # reflects pass/fail totals.
# #
@@ -37,9 +37,10 @@ WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd" WND_BIN="$WN_REPO/target/release/wnd"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Local relay wiring — cloned + built during preflight, started on # Local relay wiring — the embedded `amy serve` (geode), started on
# $RELAY_PORT. The harness never touches the public internet for test # $RELAY_PORT by start_local_relay (../headless/helpers.sh). The harness
# traffic; wn/wnd/amy all point at this one loopback endpoint. # never touches the public internet for test traffic; wn/wnd/amy all
# point at this one loopback endpoint.
# #
# Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer # Bind to 127.0.0.2 rather than 127.0.0.1: Quartz's RelayUrlNormalizer
# strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox # strips literal 127.0.0.1 / localhost / 192.168.* out of NIP-17 inbox
@@ -48,8 +49,6 @@ AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Amethyst's public defaults instead of the loopback. 127.0.0.2 is # Amethyst's public defaults instead of the loopback. 127.0.0.2 is
# still pure loopback (no network traffic) but isn't on the strip list. # still pure loopback (no network traffic) but isn't on the strip list.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}" RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_REPO="${RELAY_REPO:-$STATE_DIR/nostr-rs-relay}"
RELAY_BIN="$RELAY_REPO/target/release/nostr-rs-relay"
RELAY_DATA="$STATE_DIR/relay" RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8080}" RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
@@ -73,7 +72,7 @@ BLOSSOM_PID=""
NO_BUILD=0 NO_BUILD=0
# Every run starts from empty stores. wnd already wipes B's and C's data dirs # Every run starts from empty stores. wnd already wipes B's and C's data dirs
# on each start, but A's amy home and the relay's SQLite file used to survive, # on each start, but A's amy home and the relay's state used to survive,
# and the leftovers are not inert: a KeyPackage A published in an earlier run # and the leftovers are not inert: a KeyPackage A published in an earlier run
# is still on the relay for B to invite with, an old group's kind:445 events # is still on the relay for B to invite with, an old group's kind:445 events
# still arrive and fail to decrypt, and A's cursors still say it has seen them. # still arrive and fail to decrypt, and A's cursors still say it has seen them.
@@ -119,8 +118,8 @@ while [[ $# -gt 0 ]]; do
done done
if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then if [[ $RESET_STATE -eq 1 && -d "$STATE_DIR" ]]; then
# Keep the relay checkout + its build (minutes to rebuild) and the log and # Keep the log and results history; drop everything that holds protocol
# results history; drop everything that holds protocol state. # state (the relay is in-memory, so wiping its dir just drops its identity).
# #
# run.env counts as protocol state: it is where tests hand each other group # run.env counts as protocol state: it is where tests hand each other group
# ids. Leaving it behind a wipe leaves ids naming groups nobody is in any # ids. Leaving it behind a wipe leaves ids naming groups nobody is in any
+81 -24
View File
@@ -5,12 +5,19 @@
# Sequential, all-or-nothing. Script drives the `wn` side automatically and # Sequential, all-or-nothing. Script drives the `wn` side automatically and
# prompts the human operator at each step that requires Amethyst UI action. # prompts the human operator at each step that requires Amethyst UI action.
# #
# Usage: ./marmot-interop.sh [--local-relays] [--transponder] [--no-build] # Usage: ./marmot-interop.sh [--public-relays] [--port N] [--transponder] [--no-build]
#
# By default the harness boots its own relay — `amy serve`, i.e. geode — bound
# to 0.0.0.0:$RELAY_PORT so the wn daemons reach it on loopback and the phone
# reaches it over the LAN (ws://<laptop-ip>:PORT, or ws://10.0.2.2:PORT from an
# emulator). Pass --public-relays to run the old real-world path against the
# public relay set instead; that is the only mode that touches the internet.
# #
set -uo pipefail set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state" STATE_DIR="$SCRIPT_DIR/state"
LOG_DIR="$STATE_DIR/logs" LOG_DIR="$STATE_DIR/logs"
B_DIR="$STATE_DIR/B" B_DIR="$STATE_DIR/B"
@@ -27,6 +34,24 @@ RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
WN_REPO="${WN_REPO:-$STATE_DIR/mdk}" WN_REPO="${WN_REPO:-$STATE_DIR/mdk}"
WN_BIN="" WN_BIN=""
WND_BIN="" WND_BIN=""
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Embedded relay (default mode). Bound on every interface so a device on the
# same network can reach it; the daemons connect over $RELAY_HOST. Loopback
# `ws://` relays are only accepted by MDK behind this explicit opt-in.
#
# Known limit, inherited from the old --local-relays mode: the URL wn
# advertises in its kind:10050/10051 lists is $RELAY_URL, and Amethyst's
# parsers drop loopback and RFC1918 relays from those lists, so A→B welcome
# delivery leans on Amethyst's fallback relays. Override RELAY_HOST with an
# address the device can dial (e.g. the laptop's LAN IP) to have wn
# advertise that instead; the daemons then connect to it too.
RELAY_HOST="${RELAY_HOST:-127.0.0.1}"
RELAY_BIND="${RELAY_BIND:-0.0.0.0}"
RELAY_PORT="${RELAY_PORT:-8080}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
RELAY_DATA="$STATE_DIR/relay"
export WN_ALLOW_LOOPBACK_RELAYS=1
B_NPUB="" B_NPUB=""
B_HEX="" B_HEX=""
C_NPUB="" C_NPUB=""
@@ -34,6 +59,7 @@ C_HEX=""
A_NPUB="" A_NPUB=""
A_HEX="" A_HEX=""
# Only used with --public-relays.
DEFAULT_RELAYS=( DEFAULT_RELAYS=(
"wss://relay.damus.io" "wss://relay.damus.io"
"wss://nos.lol" "wss://nos.lol"
@@ -41,7 +67,7 @@ DEFAULT_RELAYS=(
"wss://nostr.bitcoiner.social" "wss://nostr.bitcoiner.social"
"wss://nostr.mom" "wss://nostr.mom"
) )
USE_LOCAL_RELAYS=0 USE_PUBLIC_RELAYS=0
ENABLE_TRANSPONDER=0 ENABLE_TRANSPONDER=0
NO_BUILD=0 NO_BUILD=0
@@ -50,7 +76,9 @@ usage() {
marmot-interop.sh — Amethyst <-> MDK interop harness marmot-interop.sh — Amethyst <-> MDK interop harness
Options: Options:
--local-relays Use ws://localhost:8080 instead of public relays (requires 'just docker-up') --public-relays Use the public relay set instead of the embedded relay
(amy serve / geode, the default). Only mode that leaves the machine.
--port N Port for the embedded relay (default 8080)
--transponder Run Test 14 (MIP-05 push notifications) --transponder Run Test 14 (MIP-05 push notifications)
--no-build Don't rebuild wn/wnd if binaries are missing --no-build Don't rebuild wn/wnd if binaries are missing
-h, --help Show this help -h, --help Show this help
@@ -62,8 +90,12 @@ EOF
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--local-relays) USE_LOCAL_RELAYS=1 ;; --public-relays) USE_PUBLIC_RELAYS=1 ;;
--transponder) ENABLE_TRANSPONDER=1 ;; --local-relays) printf '%s\n' "note: --local-relays is now the default (embedded amy serve relay); flag ignored" >&2 ;;
--port)
[[ $# -ge 2 && "$2" != --* ]] || { printf 'missing value for --port\n' >&2; usage; exit 2; }
RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--transponder) ENABLE_TRANSPONDER=1 ;;
--no-build) NO_BUILD=1 ;; --no-build) NO_BUILD=1 ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;; *) printf 'unknown flag: %s\n' "$1" >&2; usage; exit 2 ;;
@@ -77,6 +109,8 @@ mkdir -p "$STATE_DIR" "$LOG_DIR" "$B_DIR/logs" "$C_DIR/logs"
# shellcheck source=../lib.sh # shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh" source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh — start_local_relay / stop_local_relay (embedded amy serve)
source "$TESTS_DIR/headless/helpers.sh"
# --- preflight --------------------------------------------------------------- # --- preflight ---------------------------------------------------------------
preflight() { preflight() {
@@ -88,6 +122,26 @@ preflight() {
printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE" printf ' %s: %s\n' "$cmd" "$(command -v "$cmd")" >>"$LOG_FILE"
done done
# The embedded relay is `amy serve`, so amy has to exist unless the run
# goes to the public relays. Same transient-503 retry as the headless
# harness: one bad jitpack/dl.google.com roll must not abort the run.
if [[ "$USE_PUBLIC_RELAYS" -ne 1 && ! -x "$AMY_BIN" ]]; then
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "amy not found at $AMY_BIN and --no-build set"; exit 1
fi
local attempt max_attempts=4
for attempt in $(seq 1 $max_attempts); do
step "building :cli:installDist (attempt $attempt/$max_attempts)"
if ( cd "$REPO_ROOT" && ./gradlew :cli:installDist ) 2>&1 | tee -a "$LOG_FILE" \
&& [[ -x "$AMY_BIN" ]]; then
break
fi
[[ "$attempt" -lt "$max_attempts" ]] && warn "gradle build failed (likely transient jitpack/Google 503) — retrying"
done
[[ -x "$AMY_BIN" ]] || { fail_msg "amy still missing after build"; exit 1; }
printf ' amy: %s\n' "$AMY_BIN" >>"$LOG_FILE"
fi
WN_BIN="$WN_REPO/target/release/wn" WN_BIN="$WN_REPO/target/release/wn"
WND_BIN="$WN_REPO/target/release/wnd" WND_BIN="$WN_REPO/target/release/wnd"
@@ -285,7 +339,7 @@ discover_a_relays() {
if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then if [[ -n "$kp_event_id" && "$kp_event_id" != "null" ]]; then
info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working" info "wn_b found A's KeyPackage (kind:30443) — discovery plane is working"
else else
warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${DEFAULT_RELAYS[*]}." warn "wn_b could NOT find A's KeyPackage. wn is bootstrapped on ${RELAY_LIST[*]}."
warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach." warn "Either Amethyst never published a KeyPackage, or it's only on relays wn can't reach."
warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)." warn "All later tests will fail. Fix this before continuing (tap KP publish in Amethyst settings)."
fi fi
@@ -300,7 +354,7 @@ discover_a_relays() {
if ! command -v sqlite3 >/dev/null 2>&1; then if ! command -v sqlite3 >/dev/null 2>&1; then
warn "sqlite3 not installed — skipping wn user_relays cache probe." warn "sqlite3 not installed — skipping wn user_relays cache probe."
warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun with --local-relays." warn "If Test 03 fails with 'no invite arrived', install sqlite3 or rerun without --public-relays."
return return
fi fi
@@ -383,12 +437,7 @@ discover_a_relays() {
# --- relays ------------------------------------------------------------------ # --- relays ------------------------------------------------------------------
configure_relays() { configure_relays() {
banner "Configuring relays" banner "Configuring relays"
local relays=() local relays=( "${RELAY_LIST[@]}" )
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then
relays=( "ws://localhost:8080" )
else
relays=( "${DEFAULT_RELAYS[@]}" )
fi
# Each relay × 3 types × 2 daemons produces a lot of repetitive "ok" # Each relay × 3 types × 2 daemons produces a lot of repetitive "ok"
# lines — the happy path doesn't need any of it on screen. Quiet the # lines — the happy path doesn't need any of it on screen. Quiet the
# per-add logging into $LOG_FILE and only surface real failures as # per-add logging into $LOG_FILE and only surface real failures as
@@ -527,27 +576,28 @@ configure_relays() {
info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)" info "sanity kinds 10050/1059/445 ok (B->C welcome + message round-trip)"
else else
warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)" warn "kind:445 failed — C never decrypted sanity-ping (relays may be dropping group messages)"
warn "Consider rerunning with --local-relays." warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
fi fi
# best-effort cleanup so re-runs don't accumulate dead sanity groups # best-effort cleanup so re-runs don't accumulate dead sanity groups
wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true wn_c groups leave "$sanity_c_gid" >/dev/null 2>&1 || true
wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true wn_b groups leave "$sanity_gid" >/dev/null 2>&1 || true
else else
warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists" warn "kind:10050/1059 failed — C never received welcome; relays likely dropping gift wraps or inbox lists"
warn "Consider rerunning with --local-relays (requires 'just docker-up' in the mdk checkout)." warn "Consider rerunning without --public-relays (the embedded relay stores every kind)."
fi fi
fi fi
} }
instruct_amethyst_setup() { instruct_amethyst_setup() {
if [[ "$USE_LOCAL_RELAYS" -eq 1 ]]; then if [[ "$USE_PUBLIC_RELAYS" -ne 1 ]]; then
# Offline/sandbox path: we own the only relay, so the harness DOES # Offline/sandbox path (default): we own the only relay — the embedded
# need to dictate Amethyst's relay config — nothing is discoverable # `amy serve` (geode) on 0.0.0.0:$RELAY_PORT — so the harness DOES need
# via the public network. # to dictate Amethyst's relay config; nothing is discoverable via the
prompt_human "Configure Amethyst to match this --local-relays harness: # public network.
prompt_human "Configure Amethyst to use this harness's embedded relay (amy serve / geode):
1. Settings -> Relays: add as READ+WRITE 1. Settings -> Relays: add as READ+WRITE
ws://10.0.2.2:8080 (Android emulator) ws://10.0.2.2:$RELAY_PORT (Android emulator)
ws://<your-LAN-ip>:8080 (physical device on same Wi-Fi) ws://<your-LAN-ip>:$RELAY_PORT (physical device on same Wi-Fi)
2. Settings -> Key Package Relays: add the SAME URL 2. Settings -> Key Package Relays: add the SAME URL
3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL 3. Settings -> DM Inbox Relays (NIP-17/kind:10050): add the SAME URL
4. Trigger key-package publish (toggle KP relay on/off if needed) 4. Trigger key-package publish (toggle KP relay on/off if needed)
@@ -555,7 +605,7 @@ instruct_amethyst_setup() {
return return
fi fi
# Public-relay path: the harness should behave like any real Nostr # --public-relays path: the harness should behave like any real Nostr
# client — discover A's advertised relays via kind:10002 / 10050 / # client — discover A's advertised relays via kind:10002 / 10050 /
# 10051 and publish there, rather than forcing A to adopt the # 10051 and publish there, rather than forcing A to adopt the
# harness's own relay set. That lets the tests surface real-world # harness's own relay set. That lets the tests surface real-world
@@ -1301,6 +1351,7 @@ main() {
local rc=$? local rc=$?
trap - EXIT INT TERM HUP trap - EXIT INT TERM HUP
stop_daemons stop_daemons
stop_local_relay
print_summary print_summary
exit "$rc" exit "$rc"
} }
@@ -1311,6 +1362,12 @@ main() {
banner "Amethyst <-> MDK interop harness ($RUN_TS)" banner "Amethyst <-> MDK interop harness ($RUN_TS)"
preflight preflight
if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
RELAY_LIST=( "${DEFAULT_RELAYS[@]}" )
else
RELAY_LIST=( "$RELAY_URL" )
start_local_relay
fi
start_daemon B "$B_DIR" "$B_SOCKET" start_daemon B "$B_DIR" "$B_SOCKET"
start_daemon C "$C_DIR" "$C_SOCKET" start_daemon C "$C_DIR" "$C_SOCKET"
ensure_identity B ensure_identity B
@@ -1327,7 +1384,7 @@ main() {
# plane, then summarise what wn sees. Surfaces up front the kind of # plane, then summarise what wn sees. Surfaces up front the kind of
# failure (A's 10050 unreachable from wn, missing KP list, etc.) that # failure (A's 10050 unreachable from wn, missing KP list, etc.) that
# would otherwise bite as a silent Test 03 timeout. # would otherwise bite as a silent Test 03 timeout.
if [[ "$USE_LOCAL_RELAYS" -ne 1 ]]; then if [[ "$USE_PUBLIC_RELAYS" -eq 1 ]]; then
discover_a_relays discover_a_relays
fi fi
+6 -94
View File
@@ -147,29 +147,8 @@ preflight() {
info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))" info "wn: $WN_BIN ($(git -C "$WN_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown))"
info "wnd: $WND_BIN" info "wnd: $WND_BIN"
# Clone/build nostr-rs-relay — the harness's single loopback relay. # The loopback relay is `amy serve` (geode) — see start_local_relay in
if [[ ! -x "$RELAY_BIN" ]]; then # ../headless/helpers.sh. Nothing to clone or build beyond amy itself.
if [[ "$NO_BUILD" -eq 1 ]]; then
fail_msg "nostr-rs-relay not found at $RELAY_BIN and --no-build set"; exit 1
fi
if [[ ! -d "$RELAY_REPO/.git" ]]; then
step "cloning nostr-rs-relay into $RELAY_REPO"
git clone --depth 1 https://github.com/scsibug/nostr-rs-relay "$RELAY_REPO" \
2>&1 | tee -a "$LOG_FILE"
fi
local attempt max=4
for attempt in $(seq 1 $max); do
step "building nostr-rs-relay (attempt $attempt/$max, ~3 min first run)"
( cd "$RELAY_REPO" && cargo build --release --bin nostr-rs-relay ) \
2>&1 | tee -a "$LOG_FILE"
[[ -x "$RELAY_BIN" ]] && break
[[ "$attempt" -lt "$max" ]] && warn "nostr-rs-relay build failed (likely transient 503 from crates.io) — retrying"
done
[[ -x "$RELAY_BIN" ]] || {
fail_msg "nostr-rs-relay still missing after $max build attempts"; exit 1
}
fi
info "relay bin: $RELAY_BIN"
} }
# --- local QUIC broker ------------------------------------------------------- # --- local QUIC broker -------------------------------------------------------
@@ -262,75 +241,8 @@ stop_quic_broker() {
} }
# --- local relay ------------------------------------------------------------- # --- local relay -------------------------------------------------------------
# Start nostr-rs-relay on $RELAY_PORT with a minimal config. Every test # start_local_relay / stop_local_relay live in ../headless/helpers.sh: the
# runs against this one loopback endpoint — no external network traffic. # relay is the embedded `amy serve` (geode), shared by every harness.
start_local_relay() {
banner "Starting local nostr-rs-relay on $RELAY_URL"
mkdir -p "$RELAY_DATA" "$RELAY_DATA/logs"
# Render a minimal config file each run so port/limits come from the
# harness rather than whatever was left on disk from a previous session.
cat >"$RELAY_DATA/config.toml" <<EOF
[info]
relay_url = "$RELAY_URL"
name = "amethyst-headless-harness"
description = "Loopback relay for marmot-interop-headless.sh — do not use for anything real."
[database]
data_directory = "$RELAY_DATA"
[network]
address = "${RELAY_BIND:-${RELAY_HOST:-127.0.0.1}}"
port = $RELAY_PORT
[options]
reject_future_seconds = 3600
[limits]
# Keep kind:444 / 445 / 1059 / 30443 wide open — the whole point is
# exercising Marmot traffic the public relays reject.
max_event_bytes = 524288
max_ws_message_bytes = 1048576
max_ws_frame_bytes = 1048576
EOF
# Abort early if something else is already bound to the port — failing
# with a clear error beats a mysterious-looking daemon stall later.
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$RELAY_PORT\$"; then
fail_msg "port $RELAY_PORT already in use — pass --port N or free it"
exit 1
fi
nohup "$RELAY_BIN" --db "$RELAY_DATA" --config "$RELAY_DATA/config.toml" \
>"$RELAY_DATA/logs/stdout.log" 2>"$RELAY_DATA/logs/stderr.log" &
echo "$!" > "$RELAY_DATA/pid"
step "relay pid $(cat "$RELAY_DATA/pid"); waiting for $RELAY_URL …"
local deadline=$(( $(date +%s) + 20 ))
while [[ $(date +%s) -lt $deadline ]]; do
if curl -sSf -m 1 "http://${RELAY_HOST:-127.0.0.1}:$RELAY_PORT/" >/dev/null 2>&1; then
info "relay up"
return 0
fi
sleep 0.5
done
fail_msg "relay never came up (see $RELAY_DATA/logs/stderr.log)"
tail -n 40 "$RELAY_DATA/logs/stderr.log" 2>/dev/null | sed 's/^/ /' >&2 || true
exit 1
}
stop_local_relay() {
local pid_file="$RELAY_DATA/pid"
[[ -f "$pid_file" ]] || return 0
local pid; pid=$(cat "$pid_file" 2>/dev/null || echo "")
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
info "stopping relay pid $pid"
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -f "$pid_file"
}
# --- daemons ----------------------------------------------------------------- # --- daemons -----------------------------------------------------------------
start_daemon() { start_daemon() {
@@ -480,9 +392,9 @@ configure_relays() {
step "publishing A's KeyPackage" step "publishing A's KeyPackage"
amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed" amy_a marmot key-package publish >>"$LOG_FILE" 2>&1 || warn "amy marmot key-package publish failed"
# Give nostr-rs-relay a breath to fsync the kind:10002 / 10050 / 30443 # Give the relay a breath to ingest the kind:10002 / 10050 / 30443
# writes and push them out on the discovery subscription so that the # writes and push them out on the discovery subscription so that the
# first `wn keys check` that follows actually sees them instead of # first `wn keys check` that follows actually sees them instead of
# racing the relay's WAL flush. # racing the relay's ingest queue.
sleep 2 sleep 2
} }
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.cancellation.CancellationException
/** /**
* What a front end renders for one live agent text stream. * What a front end renders for one live agent text stream.
@@ -203,6 +204,9 @@ class MarmotAgentStreamWatcher(
try { try {
quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray()) quic.subscribe(candidate, start.streamId.hexToByteArray(), startEvent.id.hexToByteArray())
} catch (e: Exception) { } catch (e: Exception) {
// Without this a cancelled watcher keeps dialling the remaining
// candidates instead of stopping.
if (e is CancellationException) throw e
Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" } Log.d("MarmotAgentStreamWatcher") { "candidate $candidate unusable: ${e.message}" }
continue continue
} }
@@ -222,6 +226,7 @@ class MarmotAgentStreamWatcher(
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e
Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" } Log.d("MarmotAgentStreamWatcher") { "stream from $candidate ended: ${e.message}" }
} finally { } finally {
runCatching { stream.close() } runCatching { stream.close() }
@@ -68,6 +68,5 @@ abstract class FeedViewModel(
override fun onCleared() { override fun onCleared() {
Log.d("Init") { "OnCleared: ${this::class.simpleName}" } Log.d("Init") { "OnCleared: ${this::class.simpleName}" }
super.onCleared()
} }
} }
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.toLongValue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -164,7 +165,7 @@ class LiveStreamTopZappersViewModel(
when (val ev = note.event) { when (val ev = note.event) {
is LnZapEvent -> { is LnZapEvent -> {
val request = ev.zapRequest ?: return null val request = ev.zapRequest ?: return null
val sats = ev.amount()?.toLong() ?: return null val sats = ev.amount()?.toLongValue() ?: return null
ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats) ZapContribution(note.idHex, request.pubKey, request.isAnonTagged(), sats)
} }
is Bolt12ZapEvent -> { is Bolt12ZapEvent -> {
@@ -180,7 +181,7 @@ class LiveStreamTopZappersViewModel(
): ZapContribution? { ): ZapContribution? {
val receiptEv = receiptNote?.event as? LnZapEvent ?: return null val receiptEv = receiptNote?.event as? LnZapEvent ?: return null
val request = zapRequestNote.event as? LnZapRequestEvent ?: return null val request = zapRequestNote.event as? LnZapRequestEvent ?: return null
val sats = receiptEv.amount()?.toLong() ?: return null val sats = receiptEv.amount()?.toLongValue() ?: return null
return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats) return ZapContribution(receiptNote.idHex, request.pubKey, request.isAnonTagged(), sats)
} }
} }
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent import com.vitorpamplona.quartz.nipB1Bolt12Zaps.zap.Bolt12ZapEvent
import com.vitorpamplona.quartz.utils.toLongValue
/** /**
* One in-flight kind-9735 zap to render as a floating overlay on the * One in-flight kind-9735 zap to render as a floating overlay on the
@@ -59,7 +60,7 @@ data class RoomZap(
eventId = event.id, eventId = event.id,
sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey, sourcePubkey = event.zapRequest?.pubKey ?: event.pubKey,
targetPubkey = event.zappedAuthor().firstOrNull(), targetPubkey = event.zappedAuthor().firstOrNull(),
amountSats = event.amount?.toLong(), amountSats = event.amount?.toLongValue(),
createdAtSec = event.createdAt, createdAtSec = event.createdAt,
) )
@@ -38,7 +38,7 @@ class PartialTokensTest {
fun aHalfWrittenFromOpensThePeoplePicker() { fun aHalfWrittenFromOpensThePeoplePicker() {
val picker = pickerAtEnd("zaps from:ali") val picker = pickerAtEnd("zaps from:ali")
assertTrue(picker is ActivePicker.People) assertTrue(picker is ActivePicker.People)
assertEquals(KeyField.FROM, (picker as ActivePicker.People).keyField) assertEquals(KeyField.FROM, picker.keyField)
assertEquals("ali", picker.token.partial) assertEquals("ali", picker.token.partial)
assertEquals(5, picker.token.start) assertEquals(5, picker.token.start)
} }
@@ -65,7 +65,7 @@ class PartialTokensTest {
fun aHalfWrittenDateOpensTheCalendar() { fun aHalfWrittenDateOpensTheCalendar() {
val picker = pickerAtEnd("since:2026-0") val picker = pickerAtEnd("since:2026-0")
assertTrue(picker is ActivePicker.Calendar) assertTrue(picker is ActivePicker.Calendar)
assertEquals(DateField.SINCE, (picker as ActivePicker.Calendar).dateField) assertEquals(DateField.SINCE, picker.dateField)
} }
@Test @Test
@@ -838,7 +838,6 @@ class NestViewModel(
closed = true closed = true
teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true) teardownBroadcast(BroadcastUiState.Idle, finalCleanup = true)
teardown(targetState = ConnectionUiState.Closed, finalCleanup = true) teardown(targetState = ConnectionUiState.Closed, finalCleanup = true)
super.onCleared()
} }
private fun observeSpeakerState(s: NestsSpeaker) { private fun observeSpeakerState(s: NestsSpeaker) {
@@ -461,9 +461,9 @@ class MarmotPublishBeforeApplyTest {
override suspend fun saveRetainedEpochs( override suspend fun saveRetainedEpochs(
nostrGroupId: String, nostrGroupId: String,
epochs: List<ByteArray>, retainedSecrets: List<ByteArray>,
) { ) {
retained[nostrGroupId] = epochs retained[nostrGroupId] = retainedSecrets
} }
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList() override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId] ?: emptyList()
@@ -105,9 +105,9 @@ class MarmotPublishDurabilityTest {
override suspend fun saveRetainedEpochs( override suspend fun saveRetainedEpochs(
nostrGroupId: String, nostrGroupId: String,
epochs: List<ByteArray>, retainedSecrets: List<ByteArray>,
) { ) {
retained[nostrGroupId] = epochs retained[nostrGroupId] = retainedSecrets
} }
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty() override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> = retained[nostrGroupId].orEmpty()
+3 -3
View File
@@ -255,7 +255,7 @@ compose.desktop {
// The arch is selected at task-execution time from the host JVM's os.arch, so // The arch is selected at task-execution time from the host JVM's os.arch, so
// the same task builds the correct AppImage on both x86_64 and aarch64 hosts. // the same task builds the correct AppImage on both x86_64 and aarch64 hosts.
// BUILDING.md documents local-dev fetch. // BUILDING.md documents local-dev fetch.
val createReleaseAppImage by tasks.registering(Exec::class) { val createReleaseAppImage = tasks.register<Exec>("createReleaseAppImage") {
group = "compose desktop" group = "compose desktop"
description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool." description = "Package createReleaseDistributable output into a Linux AppImage via appimagetool."
dependsOn("createReleaseDistributable") dependsOn("createReleaseDistributable")
@@ -331,7 +331,7 @@ val createReleaseAppImage by tasks.registering(Exec::class) {
// proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is // proguarded jkeychain-1.1.0-*.jar with all 117 KB intact), so this task is
// a regression guard, not a workaround. It's wired onto every release task so // a regression guard, not a workaround. It's wired onto every release task so
// it fails the build immediately if the .so disappears. // it fails the build immediately if the .so disappears.
val verifyJkeychainNativeSurvivesProguard by tasks.registering { val verifyJkeychainNativeSurvivesProguard = tasks.register("verifyJkeychainNativeSurvivesProguard") {
description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)" description = "Fail the release build if osxkeychain.so is stripped from proguarded output (would break macOS Keychain at runtime)"
group = "verification" group = "verification"
dependsOn("proguardReleaseJars") dependsOn("proguardReleaseJars")
@@ -396,7 +396,7 @@ listOf(
// into the .app) so the subsequent bundle signing seals already-signed code. // into the .app) so the subsequent bundle signing seals already-signed code.
// Runs only on macOS with the Developer ID identity exported — a no-op on every // Runs only on macOS with the Developer ID identity exported — a no-op on every
// other leg and on unsigned local/PR builds. // other leg and on unsigned local/PR builds.
val signMacJarNatives by tasks.registering { val signMacJarNatives = tasks.register("signMacJarNatives") {
description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized" description = "Codesign macOS Mach-O natives embedded in bundled jars before the .app is sealed + notarized"
group = "build" group = "build"
dependsOn("proguardReleaseJars") dependsOn("proguardReleaseJars")
@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowOverflow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
@@ -146,7 +145,6 @@ fun LivesSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
maxLines = 2, maxLines = 2,
overflow = FlowRowOverflow.Clip,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
ranked.forEach { channel -> ranked.forEach { channel ->
+1 -1
View File
@@ -22,7 +22,7 @@ kotlin {
// Generate a BuildConfig.kt carrying the app version from the catalog so // Generate a BuildConfig.kt carrying the app version from the catalog so
// RelayInfo.VERSION (reported over NIP-11) tracks releases automatically // RelayInfo.VERSION (reported over NIP-11) tracks releases automatically
// instead of being a hand-bumped literal. // instead of being a hand-bumped literal.
val generateVersionFile by tasks.registering { val generateVersionFile = tasks.register("generateVersionFile") {
val versionValue = libs.versions.app.get() val versionValue = libs.versions.app.get()
val outDir = layout.buildDirectory.dir("generated/version/kotlin") val outDir = layout.buildDirectory.dir("generated/version/kotlin")
inputs.property("version", versionValue) inputs.property("version", versionValue)
@@ -311,14 +311,14 @@ class KtorRelayTest {
) )
assertEquals(true, ok, "successful insert must round-trip OK true on the wire") assertEquals(true, ok, "successful insert must round-trip OK true on the wire")
// Duplicate insert returns OK false; this also exercises the // Duplicate insert returns OK true with a `duplicate:` message (NIP-01);
// "non-empty message" branch of the serializer. // this also exercises the "non-empty message" branch of the serializer.
val ok2 = val ok2 =
client.publishAndConfirm( client.publishAndConfirm(
event = event, event = event,
relayList = setOf(server.url.normalizeRelayUrl()), relayList = setOf(server.url.normalizeRelayUrl()),
) )
assertEquals(false, ok2, "duplicate insert must round-trip OK false") assertEquals(true, ok2, "duplicate insert must round-trip OK true (NIP-01 duplicate:)")
} }
/** /**
+1 -1
View File
@@ -10,7 +10,7 @@
// the authoritative one. Keeping the pattern table in one file means a new // the authoritative one. Keeping the pattern table in one file means a new
// forbidden API lands in every module at once instead of drifting per copy. // forbidden API lands in every module at once instead of drifting per copy.
val verifyKmpPurity by tasks.registering { val verifyKmpPurity = tasks.register("verifyKmpPurity") {
group = "verification" group = "verification"
description = "Fails if iOS-targeted source sets import JVM-only deps." description = "Fails if iOS-targeted source sets import JVM-only deps."
// Every source set that feeds an Apple target. Modules that don't have a // Every source set that feeds an Apple target. Modules that don't have a
+4
View File
@@ -28,6 +28,7 @@ uiautomator = "2.4.0"
biometricKtx = "1.4.0-alpha02" biometricKtx = "1.4.0-alpha02"
coil = "3.6.2" coil = "3.6.2"
composeBom = "2026.09.00" composeBom = "2026.09.00"
tracingPerfetto = "1.0.1"
composeRuntimeAnnotation = "1.12.1" composeRuntimeAnnotation = "1.12.1"
coreKtx = "1.19.0" coreKtx = "1.19.0"
datastore = "1.2.1" datastore = "1.2.1"
@@ -136,6 +137,9 @@ androidx-camera-extensions = { module = "androidx.camera:camera-extensions", ver
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" } androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidxCamera" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing" }
androidx-tracing-perfetto = { group = "androidx.tracing", name = "tracing-perfetto", version.ref = "tracingPerfetto" }
androidx-tracing-perfetto-binary = { group = "androidx.tracing", name = "tracing-perfetto-binary", version.ref = "tracingPerfetto" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" } androidx-compose-runtime-annotation = { group = "androidx.compose.runtime", name = "runtime-annotation", version.ref = "composeRuntimeAnnotation" }
androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" } androidx-collection = { group = "androidx.collection", name = "collection", version.ref = "androidxCollection" }
@@ -24,12 +24,10 @@ import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.marmot.ingest import com.vitorpamplona.amethyst.commons.marmot.ingest
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1 import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -170,7 +168,7 @@ fun benchJoinWelcome(): BenchResult =
} }
}, },
) { (bob, wrap) -> ) { (bob, wrap) ->
runBlocking { bob.manager.ingest(wrap as GiftWrapEvent) } runBlocking { bob.manager.ingest(wrap) }
} }
/** /**
@@ -223,7 +221,7 @@ fun benchIngestAppMessage(members: Int): BenchResult =
} }
}, },
) { (bob, event) -> ) { (bob, event) ->
runBlocking { bob.manager.ingest(event as GroupEvent) } runBlocking { bob.manager.ingest(event) }
} }
private const val BENCH_RELAY = "wss://bench.invalid" private const val BENCH_RELAY = "wss://bench.invalid"
+6 -6
View File
@@ -154,7 +154,7 @@ val hangInteropCacheDir =
val moqRelayVersion = "0.10.25" val moqRelayVersion = "0.10.25"
val moqTokenCliVersion = "0.5.23" val moqTokenCliVersion = "0.5.23"
val interopInstallMoqRelay by tasks.registering(Exec::class) { val interopInstallMoqRelay = tasks.register<Exec>("interopInstallMoqRelay") {
description = "cargo install moq-relay $moqRelayVersion (interop)" description = "cargo install moq-relay $moqRelayVersion (interop)"
group = "interop" group = "interop"
commandLine( commandLine(
@@ -178,7 +178,7 @@ val interopInstallMoqRelay by tasks.registering(Exec::class) {
doFirst { hangInteropCacheDir.asFile.mkdirs() } doFirst { hangInteropCacheDir.asFile.mkdirs() }
} }
val interopInstallMoqTokenCli by tasks.registering(Exec::class) { val interopInstallMoqTokenCli = tasks.register<Exec>("interopInstallMoqTokenCli") {
description = "cargo install moq-token-cli $moqTokenCliVersion (interop)" description = "cargo install moq-token-cli $moqTokenCliVersion (interop)"
group = "interop" group = "interop"
commandLine( commandLine(
@@ -201,7 +201,7 @@ val interopInstallMoqTokenCli by tasks.registering(Exec::class) {
doFirst { hangInteropCacheDir.asFile.mkdirs() } doFirst { hangInteropCacheDir.asFile.mkdirs() }
} }
val interopBuildSidecars by tasks.registering(Exec::class) { val interopBuildSidecars = tasks.register<Exec>("interopBuildSidecars") {
description = "cargo build --release for nestsClient/tests/hang-interop sidecars" description = "cargo build --release for nestsClient/tests/hang-interop sidecars"
group = "interop" group = "interop"
workingDir = hangInteropDir.asFile workingDir = hangInteropDir.asFile
@@ -226,7 +226,7 @@ val interopBuildSidecars by tasks.registering(Exec::class) {
outputs.dir(hangInteropDir.dir("target/release")) outputs.dir(hangInteropDir.dir("target/release"))
} }
val interopBuildHangSidecars by tasks.registering { val interopBuildHangSidecars = tasks.register("interopBuildHangSidecars") {
description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)." description = "Build all hang-interop binaries (sidecars + moq-relay + moq-token)."
group = "interop" group = "interop"
dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli) dependsOn(interopBuildSidecars, interopInstallMoqRelay, interopInstallMoqTokenCli)
@@ -305,7 +305,7 @@ fun resolveBunBinary(): String {
fun resolveNpxBinary(): String = fun resolveNpxBinary(): String =
System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx" System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx"
val interopBuildBrowserHarness by tasks.registering(Exec::class) { val interopBuildBrowserHarness = tasks.register<Exec>("interopBuildBrowserHarness") {
description = "bun install && bun build for the browser interop harness" description = "bun install && bun build for the browser interop harness"
group = "interop" group = "interop"
workingDir = browserInteropDir.asFile workingDir = browserInteropDir.asFile
@@ -325,7 +325,7 @@ val interopBuildBrowserHarness by tasks.registering(Exec::class) {
outputs.dir(browserInteropDir.dir("dist")) outputs.dir(browserInteropDir.dir("dist"))
} }
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) { val interopInstallPlaywrightChromium = tasks.register<Exec>("interopInstallPlaywrightChromium") {
description = "Install Playwright Chromium + dependencies for the browser interop harness" description = "Install Playwright Chromium + dependencies for the browser interop harness"
group = "interop" group = "interop"
workingDir = browserInteropDir.asFile workingDir = browserInteropDir.asFile
@@ -56,6 +56,10 @@ internal class Nip46Test {
sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356", sig = "ec39e60722a083cccbd2d82d2827e13f5499fa7cbcedac5b76011a844c077473adb629d50d01fab147835ac6c8a3d5ba9aaddd87d6723f0c3c864b9119fc4356",
) )
// The round trip is only type-safe by construction: the caller hands in a T,
// the message is encoded and decoded, and the decoder returns the same
// BunkerMessage subtype. The runtime has no way to check that.
@Suppress("UNCHECKED_CAST")
suspend fun <T : BunkerMessage> encodeDecodeEvent(req: T): T { suspend fun <T : BunkerMessage> encodeDecodeEvent(req: T): T {
val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson() val eventStr = NostrConnectEvent.create(req, remoteKey.pubKey, signer).toJson()
@@ -0,0 +1,23 @@
/*
* 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.quartz.utils
actual fun BigDecimal.toLongValue(): Long = toLong()
@@ -760,7 +760,7 @@ class MarmotInboundProcessor(
val author = payloadAuthor(candidate.content.decodeToString()) val author = payloadAuthor(candidate.content.decodeToString())
val sender = candidate.senderAccount val sender = candidate.senderAccount
val valid = author != null && sender != null && author == sender val valid = author != null && sender != null && author == sender
if (valid && sender != null) { if (valid) {
convergence.recordWitness(groupId, candidate.stateId, sender) convergence.recordWitness(groupId, candidate.stateId, sender)
} }
return GroupEventResult.AppMessageOnCandidateBranch( return GroupEventResult.AppMessageOnCandidateBranch(
@@ -561,7 +561,7 @@ class MarmotConvergenceEngine(
return mutex.withLock { return mutex.withLock {
val ctx = contexts[groupId] ?: return@withLock null val ctx = contexts[groupId] ?: return@withLock null
if (rewound && selectedTipId != null) { if (rewound) {
adoptBranch(ctx, graph, selectedTipId, inputs.baseId) adoptBranch(ctx, graph, selectedTipId, inputs.baseId)
} }
// Keep the states of branches that LOST but stay eligible: losing // Keep the states of branches that LOST but stay eligible: losing
@@ -576,7 +576,7 @@ class MarmotConvergenceEngine(
.forEach { tipId -> .forEach { tipId ->
var cursor: String? = tipId var cursor: String? = tipId
while (cursor != null && cursor !in canonical) { while (cursor != null && cursor !in canonical) {
graph.statesById[cursor]?.let { ctx.candidateStates[cursor!!] = it } graph.statesById[cursor]?.let { ctx.candidateStates[cursor] = it }
cursor = graph.parentOf[cursor] cursor = graph.parentOf[cursor]
} }
} }
@@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult
import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.IEventStore
import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip01Core.store.RawEvent
import com.vitorpamplona.quartz.nip01Core.store.RejectionReason
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd import com.vitorpamplona.quartz.nip77Negentropy.NegMsgCmd
import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd import com.vitorpamplona.quartz.nip77Negentropy.NegOpenCmd
@@ -212,7 +213,13 @@ class RelaySession(
} }
is IEventStore.InsertOutcome.Rejected -> { is IEventStore.InsertOutcome.Rejected -> {
send(OkMessage(cmd.event.id, false, outcome.reason)) // NIP-01: an event the relay already holds is acknowledged with
// `OK true` and the `duplicate:` prefix. Every real client
// (amethyst's outbox included) resends an event whose OK has not
// landed yet, and treats OK false as a rejection to surface —
// so answering false here turns a routine resend into an error.
val duplicate = outcome.reason.startsWith(RejectionReason.PREFIX_DUPLICATE)
send(OkMessage(cmd.event.id, duplicate, outcome.reason))
} }
is IEventStore.InsertOutcome.Failed -> { is IEventStore.InsertOutcome.Failed -> {
@@ -44,8 +44,21 @@ object RejectionReason {
*/ */
const val PREFIX_REPLACED = "replaced:" const val PREFIX_REPLACED = "replaced:"
/** NIP-01 prefix for "already have this event" — answered with `OK true`, not false. */
const val PREFIX_DUPLICATE = "duplicate:"
// The standard store reasons. // The standard store reasons.
const val DUPLICATE = "duplicate: already have this event" const val DUPLICATE = "duplicate: already have this event"
/**
* A replaceable or addressable event that a stored version already supersedes
* (newer `created_at`, or the same `created_at` and a lower id). Nothing is
* written, and — like [DUPLICATE] — the relay answers `OK true`: NIP-01 keeps
* `duplicate:` as the machine-readable prefix for "already covered", and that
* is what nostr-rs-relay sends here too, so clients that retry on anything
* else (MDK's `wn`) settle instead of re-offering the same event forever.
*/
const val SUPERSEDED = "duplicate: a newer version of this replaceable event is already stored"
const val EXPIRED = "blocked: Cannot insert an expired event" const val EXPIRED = "blocked: Cannot insert an expired event"
const val DELETED = "blocked: a deletion event exists" const val DELETED = "blocked: a deletion event exists"
const val VANISHED = "blocked: a request to vanish event exists" const val VANISHED = "blocked: a request to vanish event exists"
@@ -62,6 +62,14 @@ class SQLiteEventStore(
val extraPragmas: List<String> = emptyList(), val extraPragmas: List<String> = emptyList(),
) { ) {
companion object { companion object {
/** SQLite's message for the unique index on `event_headers (id)`. */
private const val DUPLICATE_ID_CONSTRAINT = "UNIQUE constraint failed: event_headers.id"
/**
* Common prefix of SQLite's messages for `replaceable_idx` (`kind, pubkey`) and
* `addressable_idx` (`kind, pubkey, d_tag`) — both start with these two columns.
*/
private const val SUPERSEDED_CONSTRAINT = "UNIQUE constraint failed: event_headers.kind, event_headers.pubkey"
const val DATABASE_VERSION = 5 const val DATABASE_VERSION = 5
} }
@@ -526,6 +534,20 @@ class SQLiteEventStore(
*/ */
private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome { private fun classifyRowError(e: Throwable): IEventStore.InsertOutcome {
val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED val message = e.message ?: e::class.simpleName ?: RejectionReason.INSERT_FAILED
// A second copy of an event the store already holds trips the unique index on
// event_headers.id. That is not a refusal of the event but a statement that it
// is already here, and NIP-01 has a dedicated answer for it (`OK true` with the
// `duplicate:` prefix) — so name it, instead of leaking SQLite's constraint text
// for the session to turn into a rejection the client then retries or reports.
if (message.contains(DUPLICATE_ID_CONSTRAINT)) {
return IEventStore.InsertOutcome.Rejected(RejectionReason.DUPLICATE)
}
// The replaceable / addressable unique indexes fire only when the supersession
// trigger found nothing older to delete, i.e. the stored version already wins
// (STORE-W01/W02). Same shape as a duplicate: nothing to write, `OK true`.
if (message.contains(SUPERSEDED_CONSTRAINT)) {
return IEventStore.InsertOutcome.Rejected(RejectionReason.SUPERSEDED)
}
val refusal = val refusal =
message.contains("blocked:") || message.contains("blocked:") ||
message.contains("duplicate:") || message.contains("duplicate:") ||
@@ -23,3 +23,15 @@ package com.vitorpamplona.quartz.utils
operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other) operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = add(other)
operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other) operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = subtract(other)
/**
* Truncate to a Long, the way Number.toLong() does on every platform.
*
* It has to be an expect *function* rather than a member of `expect class
* BigDecimal`: every actual is already a Number and so already has toLong(),
* but java.math.BigDecimal leaves toByte()/toShort() abstract, which makes
* `expect class BigDecimal : Number` impossible to actualize with the JVM
* typealias. Without this, `amount.toLong()` in shared code resolves only in
* the platform compilations and breaks `compileCommonMainKotlinMetadata`.
*/
expect fun BigDecimal.toLongValue(): Long
@@ -61,7 +61,7 @@ class AuthOutcomeTest {
phase: RelayAuthSnapshot.Phase, phase: RelayAuthSnapshot.Phase,
successCount: Int = 0, successCount: Int = 0,
) { ) {
state.value = state.value.put(relay, RelayAuthSnapshot(phase, null, successCount)) state.value = state.value.putting(relay, RelayAuthSnapshot(phase, null, successCount))
} }
} }
@@ -120,8 +120,13 @@ class NostrServerTest {
server.close() server.close()
} }
/**
* NIP-01: `["OK", <id>, true, "duplicate: already have this event"]`. A client
* resends any event whose OK has not landed, so a duplicate must read as
* success — OK false would make every such resend look like a rejection.
*/
@Test @Test
fun duplicateEventReturnsOkFalse() = fun duplicateEventReturnsOkTrueWithDuplicatePrefix() =
runTest { runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler) val dispatcher = UnconfinedTestDispatcher(testScheduler)
val store = EventStore(null) val store = EventStore(null)
@@ -138,7 +143,72 @@ class NostrServerTest {
val okMessages = collector.rawMessagesContaining("OK") val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size) assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains(",true,")) assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",false,")) assertTrue(okMessages[1].contains(",true,"))
assertTrue(okMessages[1].contains("duplicate:"))
server.close()
}
/**
* STORE-W01: a replaceable event older than the stored version is not written,
* and the relay acknowledges it the way nostr-rs-relay does — `OK true` with the
* NIP-01 `duplicate:` prefix — rather than leaking the unique-index text as a
* rejection the client would keep retrying.
*/
@Test
fun olderReplaceableIsAcknowledgedAsDuplicateNotRejected() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val store = EventStore(null)
val server = createServer(dispatcher, store)
val collector = MessageCollector()
val c1 = server.connect(collector.sendCallback)
val newer = testEvent(id = hexId(2), kind = 0, createdAt = 2000L)
val older = testEvent(id = hexId(3), kind = 0, createdAt = 1000L)
c1.insert(newer)
c1.insert(older)
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[0].contains(",true,"))
assertTrue(okMessages[1].contains(",true,"), "older version must be acked, got ${okMessages[1]}")
assertTrue(okMessages[1].contains("duplicate:"), "older version must carry the duplicate: prefix")
val stored = store.query<Event>(Filter(kinds = listOf(0)))
assertEquals(listOf(newer.id), stored.map { it.id }, "the newer version stays the only stored one")
server.close()
}
/**
* STORE-W02 tie: two addressable events with the same `d` tag and the same
* `created_at` — the lower id wins, the other is acknowledged as superseded.
* This is the exact shape MDK's `wn keys publish` produces when it mints a
* second KeyPackage within the same second as the first.
*/
@Test
fun sameSecondAddressableTieLoserIsAcknowledgedAsDuplicate() =
runTest {
val dispatcher = UnconfinedTestDispatcher(testScheduler)
val store = EventStore(null)
val server = createServer(dispatcher, store)
val collector = MessageCollector()
val c1 = server.connect(collector.sendCallback)
val dTag = arrayOf(arrayOf("d", "kp"))
val lowerId = testEvent(id = hexId(4), kind = 30443, createdAt = 5000L, tags = dTag)
val higherId = testEvent(id = hexId(5), kind = 30443, createdAt = 5000L, tags = dTag)
c1.insert(lowerId)
c1.insert(higherId)
val okMessages = collector.rawMessagesContaining("OK")
assertEquals(2, okMessages.size)
assertTrue(okMessages[1].contains(",true,"), "tie loser must be acked, got ${okMessages[1]}")
assertTrue(okMessages[1].contains("duplicate:"))
val stored = store.query<Event>(Filter(kinds = listOf(30443)))
assertEquals(listOf(lowerId.id), stored.map { it.id }, "lowest id wins the tie")
server.close() server.close()
} }
@@ -0,0 +1,23 @@
/*
* 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.quartz.utils
actual fun BigDecimal.toLongValue(): Long = toLong()
@@ -245,8 +245,8 @@ class LastResortKeyPackageReuseTest {
override suspend fun load(): ByteArray? = bytes override suspend fun load(): ByteArray? = bytes
override suspend fun save(data: ByteArray) { override suspend fun save(snapshot: ByteArray) {
bytes = data bytes = snapshot
} }
override suspend fun delete() { override suspend fun delete() {
@@ -110,10 +110,16 @@ class InternTradeoffBenchmark {
} }
} }
/**
* Holds the last measured corpus so the JIT cannot drop the allocations we
* just paid for. A field rather than a local: a local's assignments are
* visible to the compiler's data flow, which then folds the `check` below
* into a constant.
*/
private var sink: Any? = null
@Test @Test
fun internCostAndBenefit() { fun internCostAndBenefit() {
var sink: Any? = null
println("\n=== corpus: $EVENTS events, $AUTHORS authors, $RELAYS relays ===") println("\n=== corpus: $EVENTS events, $AUTHORS authors, $RELAYS relays ===")
// ---- memory ---- // ---- memory ----
@@ -131,7 +131,7 @@ class IndexableContentGoldenTest {
} }
} }
val rejoined = visited.joinToString(event.indexableSeparator()) val rejoined = visited.joinToString(event.indexableSeparator())
if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)!!} content=${event.indexableContent().take(120)}" if (rejoined == event.indexableContent()) null else "kind $kind: visitor=${rejoined.take(120)} content=${event.indexableContent().take(120)}"
} }
assertEquals("visitor and indexed content disagree", emptyList<String>(), disagreements) assertEquals("visitor and indexed content disagree", emptyList<String>(), disagreements)
} }
@@ -0,0 +1,23 @@
/*
* 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.quartz.utils
actual fun BigDecimal.toLongValue(): Long = toLong()
@@ -203,9 +203,7 @@ class Http3GetClient(
body += frame.body body += frame.body
} }
else -> { else -> {}
Unit
}
} }
} }
} }