diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2a019ff766..0d3293a869 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -160,6 +160,17 @@ Summarize the survey in your plan: for each component, note whether it's reused as-is, extracted from `amethyst/` to `commons/`, genuinely new (platform-specific only), or a duplicate of an existing pattern to avoid. +**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.** +One-shot and high-level relay operations (fetch a set, fetch one, page past the +relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are +`INostrClient` **extension functions** in +`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe +helpers). Because they're extensions, they don't surface under "usages of +`NostrClient`" or in completion — grep that package (or read its `README.md`, which +catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`, +`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`, +etc. instead of re-implementing them. + **Share vs keep platform-native:** - **Share** → `quartz/commonMain/` (business logic, data models, protocol) and diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 0bdea82309..c1019cde8f 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -123,6 +123,12 @@ Each subscription tracks "End of Stored Events" per relay. The eose manager in ` ## Related +- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go + through `Subscribable` — use the `INostrClient` extension functions in + `quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`, + `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`, + …). They're extensions, so they don't show up under "usages of `NostrClient`" — see + that package's `README.md` for the catalog before writing a raw subscribe/collect loop. - `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for. - `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers). - `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions). diff --git a/.github/workflows/bump-homebrew-formula.yml b/.github/workflows/bump-homebrew-formula.yml new file mode 100644 index 0000000000..d229d22eea --- /dev/null +++ b/.github/workflows/bump-homebrew-formula.yml @@ -0,0 +1,157 @@ +name: Bump Homebrew Formula (amy CLI) + +# Sibling of bump-homebrew.yml, but for a DIFFERENT Homebrew artifact: +# - bump-homebrew.yml -> Cask `amethyst-nostr` (the desktop GUI app / DMG) +# - this workflow -> Formula `amy` (the headless CLI jar bundle) +# +# What it does today: after a stable release, download the published +# `amy--jvm.tar.gz` bundle, compute its sha256, and open a PR that +# syncs `cli/packaging/homebrew/amy.rb`'s url + sha256 to that release. That is +# exactly the manual step the formula header calls out ("replace the version in +# the url and the sha256 with the values for the actual published release +# asset"), so keeping the in-repo reference formula accurate makes the eventual +# homebrew-core submission a copy-paste. +# +# What it does NOT do yet: open a PR against Homebrew/homebrew-core. `brew +# bump-formula-pr` can only bump a formula that already EXISTS in homebrew-core, +# and `amy` has never been submitted there — that first submission is a manual, +# human-reviewed new-formula PR (the one-time bootstrap). Once it lands, wire the +# auto-bump here (symmetric to the cask action in bump-homebrew.yml) — see the +# "TODO(bootstrap)" note at the bottom of this file. + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: 'Release tag to sync (for manual recovery)' + required: true + type: string + +permissions: + contents: write + pull-requests: write + # The "Report failure" step opens a [release-ops] issue via + # github.rest.issues.create, which needs issues:write. + issues: write + +concurrency: + # Serialize per tag; do not cancel in-progress runs. + group: bump-homebrew-formula-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +jobs: + sync-formula: + if: github.event_name == 'workflow_dispatch' || github.event.release.prerelease == false + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Re-assert stable release + uses: ./.github/actions/assert-stable-release + with: + tag: ${{ github.event.release.tag_name || inputs.tag }} + is_prerelease: ${{ github.event.release.prerelease || 'false' }} + is_draft: ${{ github.event.release.draft || 'false' }} + + - name: Resolve version + id: ver + run: | + set -euo pipefail + TAG="${{ github.event.release.tag_name || inputs.tag }}" + VER="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "ver=$VER" >> "$GITHUB_OUTPUT" + + - name: Download jvm bundle and compute sha256 + id: asset + run: | + set -euo pipefail + TAG="${{ steps.ver.outputs.tag }}" + VER="${{ steps.ver.outputs.ver }}" + URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/amy-${VER}-jvm.tar.gz" + echo "Fetching $URL" + # The `released` event can fire a hair before every matrix leg finishes + # uploading; retry with backoff (mirrors the repo's push/pull retry ethos). + ok=0 + for i in 1 2 3 4 5; do + if curl -fsSL -o amy-jvm.tar.gz "$URL"; then ok=1; break; fi + wait=$(( 2 ** i )) + echo "attempt $i failed; retrying in ${wait}s" + sleep "$wait" + done + [[ "$ok" == 1 ]] || { echo "::error::could not download $URL"; exit 1; } + test -s amy-jvm.tar.gz + SHA=$(shasum -a 256 amy-jvm.tar.gz | awk '{print $1}') + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + echo "amy-${VER}-jvm.tar.gz -> $SHA" + + - name: Update reference formula + run: | + set -euo pipefail + FORMULA=cli/packaging/homebrew/amy.rb + URL="${{ steps.asset.outputs.url }}" + SHA="${{ steps.asset.outputs.sha256 }}" + # Rewrite the two indented lines in the formula block. Anchoring on the + # 2-space indent avoids touching the header comment's example curl url. + sed -i -E "s|^( url ).*|\1\"${URL}\"|" "$FORMULA" + sed -i -E "s|^( sha256 ).*|\1\"${SHA}\"|" "$FORMULA" + echo "----- $FORMULA -----" + grep -E "^ (url|sha256) " "$FORMULA" + + - name: Open or update the formula-sync PR + # peter-evans/create-pull-request is MIT-licensed CI-only tooling (not + # linked into any shipped artifact). It no-ops when there is no diff. + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/bump-amy-formula-${{ steps.ver.outputs.tag }} + add-paths: cli/packaging/homebrew/amy.rb + commit-message: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' + title: 'chore: sync amy Homebrew formula to ${{ steps.ver.outputs.tag }}' + body: | + Auto-synced `cli/packaging/homebrew/amy.rb` to the + `${{ steps.ver.outputs.tag }}` release: + + - `url` -> `${{ steps.asset.outputs.url }}` + - `sha256` -> `${{ steps.asset.outputs.sha256 }}` + + Opened by `.github/workflows/bump-homebrew-formula.yml`. Merge to keep + the reference formula ready for the homebrew-core submission/bump. + + # TODO(bootstrap): once `amy` is accepted into Homebrew/homebrew-core, add a + # step here that opens the homebrew-core bump PR automatically — symmetric to + # the cask bump in bump-homebrew.yml (a pinned macauley/action-homebrew-bump- + # formula, or `brew bump-formula-pr amy --url= --sha256=` with a + # HOMEBREW_TOKEN). It is intentionally omitted until then because + # bump-formula-pr errors on a formula that is not yet in the tap. + + - name: Report failure + if: failure() + uses: actions/github-script@v9 + with: + script: | + const tag = context.payload.release?.tag_name || context.payload.inputs?.tag || 'unknown'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `[release-ops] bump-homebrew-formula failed for ${tag}`, + body: [ + `amy Homebrew formula sync failed for release \`${tag}\`.`, + ``, + `- Run: ${runUrl}`, + `- Channel: Homebrew Formula (\`amy\` CLI)`, + ``, + `Recovery options:`, + `1. Re-run the workflow once the underlying issue is fixed`, + `2. Manually update \`cli/packaging/homebrew/amy.rb\` (url + sha256) from the release asset`, + `3. Check the release actually published \`amy-${tag.replace(/^v/, '')}-jvm.tar.gz\`` + ].join('\n'), + labels: ['release-ops', 'bug'] + }); diff --git a/.github/workflows/bump-homebrew.yml b/.github/workflows/bump-homebrew.yml index e22343a18f..257b83d5a2 100644 --- a/.github/workflows/bump-homebrew.yml +++ b/.github/workflows/bump-homebrew.yml @@ -15,6 +15,10 @@ on: permissions: contents: read + # The "Report failure" step below opens a [release-ops] issue via + # github.rest.issues.create; that needs issues:write. Without it the failure + # reporter itself 403s and no alert is ever filed. + issues: write concurrency: # Serialize bumps per tag; do not cancel in-progress bumps. diff --git a/.github/workflows/bump-winget.yml b/.github/workflows/bump-winget.yml index d03b4914aa..0c00610d56 100644 --- a/.github/workflows/bump-winget.yml +++ b/.github/workflows/bump-winget.yml @@ -12,6 +12,10 @@ on: permissions: contents: read + # The "Report failure" step below opens a [release-ops] issue via + # github.rest.issues.create; that needs issues:write. Without it the failure + # reporter itself 403s and no alert is ever filed. + issues: write concurrency: group: bump-winget-${{ github.event.release.tag_name || inputs.tag }} diff --git a/BUILDING.md b/BUILDING.md index 7a85691bdf..54b6021e97 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -213,6 +213,85 @@ toolchain drifted — file it before publishing. --- +## Local SonarQube analysis (opt-in) + +The build supports running a [SonarQube](https://www.sonarsource.com/products/sonarqube/) +analysis against a locally hosted server. It is **off by default**: unless you +opt in, the scanner plugin is neither downloaded nor applied and the build is +unaffected. + +### 1. Install and start a local SonarQube server + +Either run the official Docker image: + +```bash +docker run -d --name sonarqube -p 9000:9000 sonarqube:community +``` + +or download the [Community Build zip](https://www.sonarsource.com/products/sonarqube/downloads/), +unzip it, and start it (requires a JDK 17+ on `PATH`): + +```bash +cd sonarqube- +bin/macosx-universal-64/sonar.sh console # pick the folder matching your OS +``` + +Once it reports up, open (first login `admin`/`admin`, +you'll be asked to change it), create a **local project** named `Amethyst` with +project key `Amethyst`, and generate a **project analysis token** for it +(*Project Settings → Analysis Method → With Gradle*, or +*My Account → Security → Generate token*). The token looks like `sqp_…`. + +### 2. Point the build at your server + +Add the server and token to `local.properties` (gitignored — the token never +lands in the repo): + +```properties +sonar.host.url=http://localhost:9000 +sonar.token=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 3. Run the analysis + +```bash +./gradlew sonar +``` + +When it finishes, browse the results at +. + +### 4. Optional: include Android Lint results + +The scanner auto-imports each Android module's lint report and shows the +findings as external issues alongside Sonar's own. It only *imports* — it never +runs lint itself — so without the reports on disk the analysis warns +`Unable to import Android Lint report file(s)`. Generate them first, then run +the scan as a **separate** invocation (chaining lint and `sonar` in one Gradle +call does not guarantee lint finishes first): + +```bash +./gradlew :amethyst:lintPlayDebug :benchmark:lintBenchmark :nappletHost:lintDebug +./gradlew sonar +``` + +The reports persist under each module's `build/reports/`, so re-run lint only +when you want fresh lint data in the next scan. + +Every `sonar.*` entry in `local.properties` is forwarded to the scanner, so any +[analysis parameter](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) +can be set there. `sonar.projectKey` / `sonar.projectName` default to the root +project name (`Amethyst`). + +Even when opted in, the scanner plugin only loads on invocations that actually +request the `sonar` task — ordinary builds and IDE syncs are unaffected (which +is also why `./gradlew tasks` doesn't list it). + +Note: the SonarQube Gradle scanner plugin is LGPL-3.0. It is a build-time-only +tool fetched after explicit opt-in; it is never linked into shipped artifacts. + +--- + ## Release runbook The release flow is driven by a tag push. Every cut ships Android + Desktop + diff --git a/amethyst/build.gradle.kts b/amethyst/build.gradle.kts index 1c6dc4e336..e4df9eb8bf 100644 --- a/amethyst/build.gradle.kts +++ b/amethyst/build.gradle.kts @@ -87,8 +87,10 @@ android { vectorDrawables { useSupportLibrary = true } - @Suppress("UnstableApiUsage") - resourceConfigurations += + } + + androidResources { + localeFilters += listOf( "ar", "ar-rSA", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index f32cdf0517..31b4ea27e8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -155,13 +155,11 @@ class Amethyst : Application() { if (isNappletSandbox) return instance.trim(level) // Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the - // user or Android reclaims them). Deliberately NOT on UI_HIDDEN/BACKGROUND — those fire on every - // backgrounding, and a pinned tab should survive that. Only on real pressure levels, R+ only. - val pressure = - level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW || - level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL || - level == ComponentCallbacks2.TRIM_MEMORY_MODERATE || - level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE + // user or Android reclaims them). Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND: + // BACKGROUND means the process is on the system LRU list (real reclaim pressure), while UI_HIDDEN + // fires on every app switch — so evict only at BACKGROUND and above, letting a pinned tab survive + // a plain backgrounding. R+ only. + val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { EmbeddedTabHost.evictAll() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 34efbe282e..3dc465b555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -912,61 +912,36 @@ class AppModules( trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level) // Trim in-process caches proportional to OS memory pressure. // - // Background levels (app not visible, ordered highest-first so the when - // chain short-circuits at the right tier): - // COMPLETE (80) — at the bottom of the LRU list, kill imminent - // MODERATE (60) — system is hurting, neighbouring apps being killed - // BACKGROUND(40) — backgrounded, mild system pressure - // UI_HIDDEN (20) — just backgrounded, no pressure yet + // Since API 34 the OS only ever delivers two trim levels (the foreground + // RUNNING_* levels and the deeper MODERATE/COMPLETE background tiers were + // deprecated because apps are no longer notified of them): + // BACKGROUND(40) — process is on the system LRU list: real reclaim + // pressure, and the strongest signal we still get. + // UI_HIDDEN (20) — just backgrounded, no pressure yet. Fires on EVERY + // app switch. // - // Foreground levels (app is active but system is low): - // RUNNING_CRITICAL (15), RUNNING_LOW (10) - // - // UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches - // (Robohash SVG assembly, rich-text parsing) there — clearing them - // forces a full rebuild on every resume and causes visible jank. + // So we key off exactly those two. UI_HIDDEN is frequent, so it only trims + // images (bitmaps are the largest allocations) and keeps the CPU-heavy + // caches (Robohash SVG assembly, rich-text parsing) warm — clearing them + // would force a full rebuild on every resume and cause visible jank. + // BACKGROUND trims hard but keeps a small working set: it means "on the LRU + // list" (real reclaim pressure), not the imminent kill that COMPLETE used to + // signal — so leave just enough warm to redraw the screen the user left on. when { - level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { - // Kill imminent: free everything. - memoryCache.trimToSize(0) - CachedRichTextParser.trimToSize(0) - CachedRobohash.trimToSize(0) - nip11Cache.trimToSize(0) - } - level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> { - // System under real pressure: clear images and most parsed state. - memoryCache.trimToSize(0) - CachedRichTextParser.trimToSize(50) - CachedRobohash.trimToSize(10) - nip11Cache.trimToSize(100) - } level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> { - // Backgrounded with mild pressure: trim significantly. - memoryCache.trimToSize(memoryCache.maxSize / 4) - CachedRichTextParser.trimToSize(100) + // On the LRU list under real pressure: trim hard, but keep a small + // working set so a returning user doesn't rebuild the visible screen + // from scratch. memoryCache is byte-sized (Coil), the rest are entry counts. + memoryCache.trimToSize(memoryCache.maxSize / 10) + CachedRichTextParser.trimToSize(10) CachedRobohash.trimToSize(20) - nip11Cache.trimToSize(200) + nip11Cache.trimToSize(10) } level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { - // Just backgrounded, no pressure yet: trim images (bitmaps are the - // largest allocations) but keep parsed-text and avatar caches warm - // so resuming is instant. + // Just backgrounded, no pressure yet: trim images but keep the + // parsed-text and avatar caches warm so resuming is instant. memoryCache.trimToSize(memoryCache.maxSize / 2) } - level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { - // Foreground, critically low memory. - memoryCache.trimToSize(memoryCache.maxSize / 4) - CachedRichTextParser.trimToSize(100) - CachedRobohash.trimToSize(20) - nip11Cache.trimToSize(200) - } - level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { - // Foreground, low memory. - memoryCache.trimToSize(memoryCache.maxSize / 2) - CachedRichTextParser.trimToSize(250) - CachedRobohash.trimToSize(50) - nip11Cache.trimToSize(500) - } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f4399cc130..09bbdc0e5f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2664,7 +2664,6 @@ object LocalCache : ILocalCache, ICacheProvider { } } catch (e: Exception) { if (e is CancellationException) throw e - null } return liveChatChannels.filter { _, channel -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt index 3c29d78546..0a70fd4923 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/eventCache/MemoryTrimmingService.kt @@ -34,19 +34,18 @@ class MemoryTrimmingService( var isTrimmingMemoryMutex = AtomicBoolean(false) /** - * Tiered pruning scaled to the OS memory-pressure level. + * Two-tier pruning keyed to the OS trim levels still delivered since API 34 + * (the foreground RUNNING_* and deeper MODERATE/COMPLETE levels were deprecated + * because apps are no longer notified of them). * - * Tier 1 — mild pressure (UI hidden, running-moderate): + * Tier 1 — UI hidden (fires on every app switch): * Sweep stale WeakRefs, drop expired and superseded-replaceable events. * Safe to run frequently; no UI-visible side effects. * - * Tier 2 — low memory (running-low, background): - * Tier 1 + old chat messages + unobserved thread replies / reactions. - * May cause feeds to re-fetch content that was scrolled past. - * - * Tier 3 — critical / imminent kill (running-critical, moderate, complete): - * Tier 2 + sever all observer links + drop every event from muted/blocked users. - * Aggressive; triggers recomposition wherever StateFlows were cleared. + * Tier 2 — background / real reclaim pressure (process on the LRU list): + * Tier 1 + drop events from muted/blocked users + old chat messages + + * unobserved thread replies / reactions. May cause feeds to re-fetch content + * that was scrolled past; triggers recomposition wherever StateFlows cleared. */ private fun doTrim( account: Collection, @@ -60,16 +59,13 @@ class MemoryTrimmingService( cache.pruneExpiredEvents() cache.prunePastVersionsOfReplaceables() - if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) { - // Tier 2: medium pressure — drop events from muted/blocked users + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { + // Tier 2: real reclaim pressure — drop events from muted/blocked users, old + // messages, and unobserved reactions. account.forEach { cache.pruneHiddenEvents(it) cache.pruneHiddenMessages(it) } - } - - if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { - // Tier 3: critical pressure — drop old messages and unobserved reactions val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet() cache.pruneOldMessages() cache.pruneRepliesAndReactions(accounts) @@ -79,7 +75,7 @@ class MemoryTrimmingService( suspend fun run( account: Collection, otherAccounts: List, - level: Int = ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL, + level: Int = ComponentCallbacks2.TRIM_MEMORY_BACKGROUND, ) { if (isTrimmingMemoryMutex.compareAndSet(false, true)) { Log.d("ServiceManager", "Trimming Memory (level=$level)") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index f955953c93..ff69a65ff0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -108,9 +108,7 @@ fun ControlWhenPlayerIsActive( } } - else -> { - Unit - } + else -> {} } } lifecycleOwner.lifecycle.addObserver(observer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt index 8416f986db..62129db719 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/PcmTapRegistry.kt @@ -65,7 +65,7 @@ class SpectrumAudioBufferSink( private var channels = 1 private var encoding = C.ENCODING_PCM_16BIT - @OptIn(ExperimentalCoroutinesApi::class) + @kotlin.OptIn(ExperimentalCoroutinesApi::class) override fun flush( sampleRateHz: Int, channelCount: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 4b818f1c8b..59fca51fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -166,7 +166,9 @@ class PlaybackService : MediaSessionService() { override fun onTrimMemory(level: Int) { super.onTrimMemory(level) - if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + // Since API 34 the OS only delivers UI_HIDDEN and BACKGROUND; BACKGROUND (process on + // the system LRU list) is the real reclaim-pressure signal, so release the warm pool then. + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { poolNoProxy?.exoPlayerPool?.releaseWarmPool() poolWithProxy?.exoPlayerPool?.releaseWarmPool() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt index 5fafb991be..57fcf4ec33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/podcasts/PodcastRemoteContent.kt @@ -50,7 +50,7 @@ object PodcastRemoteContent { .build() okHttpClient.newCall(request).executeAsync().use { response -> if (!response.isSuccessful) return@use null - val body = response.body ?: return@use null + val body = response.body // Reject an oversized declared length outright; cap the read for chunked bodies. if (body.contentLength() > MAX_BYTES) return@use null body.string().take(MAX_BYTES.toInt()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt index 9b6fc4a151..f82ca37bcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsImageScreen.kt @@ -285,8 +285,6 @@ fun ShareNoteAsImageScreen( *finalState.params, ) } - - else -> {} } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 2faf0a678e..86c9897bfd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -145,11 +145,12 @@ class AccountFeedContentStates( val webBookmarks = FeedContentState(WebBookmarkFeedFilter(account), scope, LocalCache) init { - // Under critical memory pressure, trim every feed down to 50 items to release - // the strong Note references that would otherwise keep pruned cache objects alive. + // Under real memory pressure (process on the system LRU list — the strongest trim + // level the OS still delivers since API 34), trim every feed down to release the + // strong Note references that would otherwise keep pruned cache objects alive. scope.launch(Dispatchers.IO) { Amethyst.instance.trimLevelEvents.collect { level -> - if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { trimFeedsToSize(200) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt index 7d68d70882..e25c76a2be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/calendars/detail/CalendarEventDetailScreen.kt @@ -215,7 +215,7 @@ fun CalendarEventDetailScreen( } // The Edit affordance is only meaningful when the current account is the // author — relays will reject a signed-by-stranger replacement. - if (isOwnEvent && event != null) { + if (isOwnEvent) { IconButton(onClick = { nav.nav( Route.EditCalendarEvent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 98584c6460..fcf118d781 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -559,7 +559,6 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { "Copy" to { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("selection", pageSel.text)) - Unit }, ), onMagnify = onMagnify, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt index 6298397653..56157e82f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/gitRepo/GitRepositoryScreen.kt @@ -163,6 +163,7 @@ fun GitRepositoryPullsScreen( internal class GitRepositoryBrowserViewModelFactory( private val okHttpClient: (String) -> OkHttpClient, ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T = GitRepositoryBrowserViewModel(okHttpClient) as T } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 514335a40d..fc2f1a88ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -460,10 +460,10 @@ fun DisplayLiveBubbles( val feedState by liveSection.feedContent.collectAsStateWithLifecycle() when (val state = feedState) { - is ChannelFeedState.Empty -> null - is ChannelFeedState.FeedError -> null + is ChannelFeedState.Empty -> {} + is ChannelFeedState.FeedError -> {} is ChannelFeedState.Loaded -> DisplayLiveBubbles(state, accountViewModel, nav) - is ChannelFeedState.Loading -> null + is ChannelFeedState.Loading -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt index deaceab27d..d466830ba1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/participants/ParticipantHostActionsSheet.kt @@ -199,9 +199,7 @@ internal fun ParticipantHostActionsSheet( ) } - null -> { - Unit - } + null -> {} } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt index 274b9b3cae..951ec72a3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/screen/NestActionBar.kt @@ -255,9 +255,7 @@ private fun StartCluster( // On-stage controls live in [StageControlsBar]; audience // has nothing to do here (system volume keys are enough). - is ConnectionUiState.Connected -> { - Unit - } + is ConnectionUiState.Connected -> {} } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt index 26322c6de0..361515edd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/pictures/PictureCardCompose.kt @@ -75,6 +75,9 @@ fun PictureCardCompose( // Image content PictureCardImage(baseNote, event, backgroundColor, accountViewModel) + // Title and content + PictureCardCaption(event) + // Reactions row ReactionsRow( baseNote = baseNote, @@ -84,9 +87,6 @@ fun PictureCardCompose( accountViewModel = accountViewModel, nav = nav, ) - - // Title and content - PictureCardCaption(event) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/ProfileClinkOfferResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/ProfileClinkOfferResolver.kt index 4228e0348d..a7f62be4e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/ProfileClinkOfferResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/payment/ProfileClinkOfferResolver.kt @@ -76,7 +76,7 @@ fun rememberProfileClinkOffer( // Fall back to the NIP-05 .well-known clink_offer (cached per address). val id = nip05?.let { Nip05Id.parse(it) } offer = - if (id != null && nip05 != null) { + if (nip05 != null && id != null) { // Distinguish "cache miss" from a cached "no offer" (null) so we don't refetch. val cacheKey = nip05.lowercase() val cached = clinkOfferNip05Cache.get(cacheKey) diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 9a1284fba7..8598c5bf53 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -13,7 +13,7 @@ Ez a bejegyzés több mint %1$d kulcsszót tartalmaz - %1$d asset egybecsomagolva + %1$d összetevő egybecsomagolva %1$d asset egybecsomagolva @@ -56,12 +56,12 @@ Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy válaszolni tudjon Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy meg tudja tolni a bejegyzéseket Ön nyilvános kulcsot használ, és a nyilvános kulcsok csak olvashatóak. Jelentkezzen be a privát kulccsal a hozzászólások kedveléséhez - Nincs beállítva Zap-összeg. Koppintson hosszan a beállításhoz + Nincs beállítva zapösszeg. Koppintson hosszan a beállításhoz %1$s satot küldött Névtelen raidet indít - létrehozott egy klippet - Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy Zap-et tudjon küldeni + létrehozott egy klipet + Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatók a bejegyzések. Jelentkezzen be a privát kulcsával, hogy zapet tudjon küldeni Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy követni tudjon embereket Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy ki tudja követni az embereket, akiket követ Ön nyilvános kulcsot használ, és a nyilvános kulccsal csak olvashatóak a bejegyzések. Jelentkezzen be a privát kulcsával, hogy egy szót vagy mondat el tudjon rejteni @@ -104,7 +104,7 @@ A terhelési szolgáltató nem teljesítette a fizetést. Kifizetés megerősítése %1$s kifizetése ezzel: %2$s? - Kifizeti ezt számlát a(z) %1$s használatával? + Kifizeti ezt a számlát a(z) %1$s használatával? Összeg (satoshiban) Adjon meg egy érvényes összeget ehhez az ajánlathoz. Engedélyezett tartomány: %1$s-%2$s satoshi @@ -120,7 +120,7 @@ Havonta Csak fizetés CLINK terhelés - Fizetés és zappelés olyan tárcából, amely előzetesen engedélyezte az Ön fiókját. Csak költés — nincs egyenleg vagy előzmény. + Fizetés és zapelés olyan tárcából, amely előzetesen engedélyezte az Ön fiókját. Csak költés — nincs egyenleg vagy előzmény. Érvénytelen CLINK terhelési mutató. Várt mutató: egy ndebit1… karakterlánc. Ndebit-mutató beillesztése Fizetés @@ -137,10 +137,10 @@ Összeg Ajánlat által meghatározott ár Zap-igazolás - Közvetlen Lightning fizetés - nem lesz Zap-igazolás közzétéve a Nostr-on. - Egy láncon bebelüli Zap-igazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést. + Közvetlen lightning-fizetés - nem lesz zapigazolás közzétéve a Nostr-on. + Egy láncon belüli zapigazolás közzé lesz téve a Nostr-on, így a címzett megtalálhatja a kifizetést. Fizetés közvetlenül a láncon belüli pénztárcából a profil megadott bitcoin-címére (%1$s) - nem lesz zapigazolás közzétéve. - A nutzap-esemény magát az ecash-t kézbesíti, így az mindig közzé lesz téve. + A nutzap-esemény magát az ecasht kézbesíti, így az mindig közzé lesz téve. Számla kérése… Számla kérése a Nostr hálózaton keresztül… Kifizetés ezzel: %1$s… @@ -155,7 +155,7 @@ %1$s satoshi kifizetése Fizetés Ennek a profilnak nincsenek olyan fizetési módjai, amelyeket az Amethyst közvetlenül ki tudna fizetni. - A láncon belüli zap-ekhez legalább %1$s satoshi szükséges + A láncon belüli zapekhez legalább %1$s satoshi szükséges Nincs elegendő egyenleg egy olyan pénzverdében, amit ez a címzett elfogad. Előbb fel kell tölteni a cashu-pénztárcát. A címzett számára elküldhető összeg: %1$s satoshi Hálózati díj @@ -211,7 +211,7 @@ A videó letöltése megkezdődött… A média letöltése megkezdődött… Kitűzés felülre - Kitűzés megszűntetése + Kitűzés megszüntetése Kitűzve felülre Nem sikerült menteni a képet Videó mentve a videógalériába @@ -300,7 +300,7 @@ Nem sikerült megtalálni ezt az üzenetet - Minden átjátszó le lett kérve · érintse meg a megtekintéshez + Minden átjátszó le lett kérve · koppintson ide a megtekintéshez "Hiba a válaszok betöltésekor: " Próbálja újra Még nincsenek értesítések. @@ -430,7 +430,7 @@ Bejelentés közzététele Letiltás és bejelentés Letiltás - Kézi Zap-megosztás + Kézi zapmegosztás Könyvjelző Könyvjelzők Saját könyvjelzők @@ -457,7 +457,7 @@ Következő hétfő délelőtt 9 órakor Bekapcsolja a folyamatos értesítési szolgáltatást? Az ütemezett bejegyzések csak akkor jelennek meg biztosan, ha a „Folyamatos értesítési szolgáltatás” engedélyezve van. Ellenkező esetben előfordulhat, hogy csak az alkalmazás következő megnyitásakor jelennek meg. - Beállítások menyitása + Beállítások megnyitása Folytatás mindenképpen %1$s → %2$s %1$s · %2$s ezelőtt @@ -479,7 +479,7 @@ Küldés… Sikertelen Elküldve - Megszakitva + Megszakítva Önnek %d ütemezett bejegyzése van, amely még nem lett közzétéve. A kijelentkezéssel véglegesen törli azt. Önnek %d ütemezett bejegyzése van, amelyek még nem lettek közzétéve. A kijelentkezéssel véglegesen törli azokat. @@ -515,7 +515,7 @@ Név, npub vagy NIP-05 Tulajdonos Átjátszók - Válasszon átjátzsókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni. + Válasszon átjátszókat, amelyek a kéréseket, jóváhagyásokat vagy a közösségi szerzők metaadatait fogják tárolni. Bármelyik Szerző Kérések @@ -698,7 +698,7 @@ Billentyűkötések hozzárendelése Események olvasása, aláírása és közzététele Saját privát tárhely - Lightning számlák kifizetése + Lightning-számlák kifizetése Webes és Blossom erőforrások lekérése Fájlok feltöltése a saját médiakiszolgálóra Téma @@ -892,7 +892,7 @@ Ezen előadó visszaállítása Profil megtekintése Zap küldése - A Zap-megosztás nem támogatott hangszobán belül. Nyissa meg a profilképernyőt a küldéshez. + A zapmegosztás nem támogatott hangszobán belül. Nyissa meg a profilképernyőt a küldéshez. Követés Követés megszüntetése Némítás @@ -941,7 +941,7 @@ Saját kiszolgálók Kind-10112 típusú cserélhető eseményként mentve, így más kliensek is olvashatják az Ön beállítását. Átjátszó (WebTransport) webcíme - Hitelesítési (JWT mint) webcím + Hitelesítési (JWT-pénzverde) webcím Hozzáadás Átjátszó Hitelesítés @@ -1172,13 +1172,13 @@ A könyvjelzőlisták metaadatai a Nostr-on bárki számára láthatók. Csak a privát tagok vannak titkosítva. Áthelyezés a nyilvános könyvjelzőkbe Áthelyezés a privát könyvjelzőkbe - Gyors Zap-összegek - A „Zap” gomb megnyomásakor jelenik meg. Az egyes összegeket a címzett által támogatott bármely fizetési csatornán keresztül ki lehet fizetni - Lightning, Cashu vagy láncon belül (láncon belül csak nagyobb összegek esetén). Érintse meg az összeget annak eltávolításához. Ha üresen hagyja, a rendszer minden alkalommal megnyitja az összeg megadására szolgáló párbeszédpanelt. + Gyors zapösszegek + A „Zap” gomb megnyomásakor jelenik meg. Az egyes összegeket a címzett által támogatott bármely fizetési csatornán keresztül ki lehet fizetni - Lightning, Cashu vagy láncon belül (láncon belül csak nagyobb összegek esetén). Koppintson az összegre annak eltávolításához. Ha üresen hagyja, a rendszer minden alkalommal megnyitja az összeg megadására szolgáló párbeszédpanelt. Küldés inkább láncon belül - Mint feltöltése + Pénzverde feltöltése %1$s satoshi Feltöltés összege - Feltöltendő mint + Feltöltendő pénzverde Fedezet innen Kifizetés Lightninggal Új ecash verése a saját Lightning tárcából @@ -1194,8 +1194,8 @@ további %1$s satoshi szükséges feltöltve Számla másolása - Mint feltöltése - Ezen mint feltöltése + Pénzverde feltöltése + Ezen pénzverde feltöltése Hozzáadandó összeg Feltöltés Zap adatvédelem @@ -1240,7 +1240,7 @@ LLM által javasolt, szerkessze bátran LLM-javaslatok eltüntetése Zaptípus - Zap-típus minden lehetőséghez + Zaptípus minden lehetőséghez Nyilvános Mindenki láthatja a tranzakciót és az üzenetet Privát @@ -1280,7 +1280,7 @@ Tömörítés… Feltöltés… Feldolgozás… - Letöltés… + Letöltés Kivonatolás Kész Hiba @@ -1373,14 +1373,14 @@ Privát üzenetek Értesítés, ha privát üzenet érkezik Zapet kapott - Értesítés, amikor valaki Zap-et küld Önnek + Értesítés, amikor valaki zapet küld Önnek %1$s satoshi Tőle: %1$s neki: %1$s Válasz Megjelölés olvasottként Új üzenetek - Új zap-ek + Új zapek Reakciók Értesítés, amikor valaki reagál az egyik bejegyzésre %1$s reagált az Ön bejegyzésére @@ -1431,7 +1431,7 @@ Engedély szükséges Az Amethyst alkalmazásnak hozzáférésre van szüksége a mikrofonhoz a hanghívások kezdeményezéséhez. Engedélyezze ezt az alkalmazás beállításaiban. Az Amethyst alkalmazásnak hozzáférésre van szüksége a kamerához és a mikrofonhoz a videohívások kezdeményezéséhez. Engedélyezze ezeket az alkalmazás beállításaiban. - Beállítások menyitása + Beállítások megnyitása Mégse Hívásbeállítások Hang- és videóhívások engedélyezése @@ -1440,7 +1440,7 @@ Legmagasabb videó-bitsebesség TURN-/ STUN-kiszolgálók Az alapértelmezett STUN- és TURN-kiszolgálók minden esetben biztosítottak. Korlátozó hálózatok esetén adjon hozzá egyéni TURN kiszolgálókat. - Alapértelmezett kiszolgálók (mindíg aktívak) + Alapértelmezett kiszolgálók (mindig aktívak) Egyéni TURN-kiszolgálók Nincsenek egyéni TURN-kiszolgálók beállítva. TURN-kiszolgáló hozzáadása @@ -1500,10 +1500,10 @@ Nincsenek rejtett szavak. Adjon hozzá egy szót alább, hogy elrejtse az azt tartalmazó bejegyzéseket. Új reakció-szimbólum A felhasználó számára nincsenek előre kiválasztott reakciótípusok. Hosszan nyomja meg a szív gombot a módosításhoz - Zap-gyűjtés + Zapgyűjtés Hozzáadja a bejegyzéshez a satoshi célösszeget, hogy megemelje a bejegyzést. Az ezt támogató kliensek ezt egy előrehaladási sávval jeleníthetik meg, hogy adományozásra ösztönözzenek Célösszeg satoshiban - A Zap-gyűjtés jelenleg: %1$s. %2$s satoshi kell még a célig + A zapgyűjtés jelenleg: %1$s. %2$s satoshi kell még a célig Olvasás az átjátszóról Írás az átjátszóra Az átjátszónak küldött bájt-mennyiség, beleértve a szűrőket és eseményeket is @@ -1785,13 +1785,13 @@ Moderátorok Bejelentkezés Amberrel Állapot frissítése - A szavazatok egy Zap összeggel vannak súlyozva. Beállíthat egy minimális összeget, hogy elkerülje a spamelőket, és egy maximális összeget, hogy elkerülje, hogy a nagy Zap-elők átvegyék a szavazást. Használja ugyanazt az összeget mindkét mezőben, hogy minden szavazatot ugyanannyira értékeljen. Hagyja üresen, hogy bármilyen összeget elfogadjon. - Nem sikerült Zap-et küldeni + A szavazatok egy zapösszeggel vannak súlyozva. Beállíthat egy minimális összeget, hogy elkerülje a spamelőket, és egy maximális összeget, hogy elkerülje, hogy a nagy zapelők átvegyék a szavazást. Használja ugyanazt az összeget mindkét mezőben, hogy minden szavazatot ugyanannyira értékeljen. Hagyja üresen, hogy bármilyen összeget elfogadjon. + Nem sikerült zapet küldeni Üzenet a felhasználónak Üzenet: %1$s OK Zapek megosztása és továbbítása - A funkciót támogató kliensek megosztják és továbbítják a Zap-eket az itt hozzáadott felhasználóknak az Ön felhasználói helyett + A funkciót támogató kliensek megosztják és továbbítják a zapeket az itt hozzáadott felhasználóknak az Ön felhasználói helyett Felhasználó keresése és hozzáadása Felhasználónév vagy megjelenítendő név Hiányzó lightning-beállítás @@ -1805,7 +1805,7 @@ Megnyitás böngészőben Aláírási kérés elutasítva Győződjön meg arról, hogy ezt a tranzakciót az aláíró-alkalmazás hitelesítette-e - Nem található pénztárca a Lighning-számla kifizetéséhez (Hiba: %1$s). A Zap-ek használatához telepítsen egy Lightning-pénztárcát + Nem található pénztárca a lighning-számla kifizetéséhez (Hiba: %1$s). A zapek használatához telepítsen egy lightning-pénztárcát Nem található pénztárca a Lighning-számla kifizetéséhez. A Zap-ek használatához telepítsen egy Lightning-pénztárcát Nem lehet megnyitni a Blossom-hivatkozásokat Nem található Blossom-alkalmazás. Telepítsen egy helyi Blossom-alkalmazást a fájl megtekintéséhez @@ -1835,7 +1835,7 @@ Hiba történt a(z) %1$s JSON elemzésekor. Ellenőrizze a felhasználó Lightning-beállítását Nem található a visszahívási webcím a(z) %1$s válaszából Helytelen (%1$s satoshi) számlaösszeg a következőtől: %2$s. A következőnek kellett volna lennie: %3$s - Nem sikerült a Zap-összeg elküldése előtt lightning-számlát készíteni. A címzett lightning-pénztárcája a következő hibát küldte: %1$s + Nem sikerült a zapösszeg elküldése előtt lightning-számlát készíteni. A címzett lightning-pénztárcája a következő hibát küldte: %1$s Csak olvasható felhasználó Nincs reakcióbeállítás Értesítések @@ -1923,7 +1923,7 @@ Ez a fájlformátum nem támogatja a metaadatok eltávolítását. Lehet, hogy a fájl tartalmaz személyes adatokat, például hely- és eszközadatokat. Mindenképp fel akarja tölteni? Feltöltés mindenképp Nem sikerült eltávolítani a médiafájlokból a privát metaadatokat. Feltöltés megszakítva. - Feltölrés megszakítva + Feltöltés megszakítva Nem sikerült eltávolítani az AVIF-fájl metaadatait: %1$s Piszkozat szerkesztése Bejelentkezés QR-kóddal @@ -1956,8 +1956,8 @@ Elküldött Frissítés Összes - Zap-ek - Nem-zap-ek + Zapek + Nem-zapek Pénztárca hozzáadása Alapértelmezett Beállítás alapértelmezettként @@ -2009,9 +2009,9 @@ Pénzverdék kiválasztása Egyenleg Pénzverdék - Mint webcíme - Mint eltávolítása - Mint hozzáadása + Pénzverde webcíme + Pénzverde eltávolítása + Pénzverde hozzáadása Előzmények A pénztárcája automatikusan mentésre kerül, amikor pénzverdét ad hozzá vagy távolít el. Egy nutzap kulcs jön létre az Ön számára, amikor először ad hozzá egy pénzverdét. Mentés… @@ -2040,7 +2040,7 @@ Összeg (satoshiban) Válasszon pénzverdét Megjegyzés (nem kötelező) - Lightning számla (bolt11) + Lightning-számla (bolt11) Cashu token Számla másolása Számla kérése @@ -2050,13 +2050,13 @@ Cashu pénztárca beállításai Saját pénzverdék Adja hozzá vagy távolítsa el a pénztárcájában használt pénzverdéket. - Saját mint-ajánlások + Saját pénzverde-ajánlások Vállaljon nyilvánosan kezességet az Ön által megbízhatónak tartott pénzverdékért, és vonja vissza az ajánlásokat. - Még nem ajánlott egy mintet sem. Koppintson a felfelé mutató hüvelykujjra egy mint mellett, hogy nyilvánosan ajánlja azt. + Még nem ajánlott egy pénzverdét sem. Koppintson a felfelé mutató hüvelykujjra egy pénzverde mellett, hogy nyilvánosan ajánlja azt. Ajánlás törlése Visszavonja az ajánlást? Törlési kérés közzététele a(z) %1$s pénzverdéről szóló kind:38000 ajánlásához. Azok az átjátszók, amelyek tiszteletben tartják a NIP-09-et, el fogják távolítani. - Egy mint ajánlása + Egy pénzverde ajánlása Visszaállítás seed-ből Minden korábbi titok újralevezetése, és minden mint megkérdezése, hogy visszaadja-e a még nyilvántartásban lévő vak aláírásokat. Hasznos eszközvesztés vagy egy megbízhatatlan átjátszó általi token-esemény törlése esetén. Pénzverdék vizsgálata… @@ -2065,14 +2065,14 @@ Nutzapok fogadásának leállítása Vonja vissza a kind:10019 eseményét, hogy mások ne küldhessenek Önnek több nutzapot. Az Ön pénztárcája és egyenlege változatlan marad. Leállítja a nutzapok fogadását? - A nutzap-info eseményét egy üresre cseréljük, és kérni fogjuk a törlését. A továbbiakban nem fognak tudni nutzapot küldeni Önnek. Ezt a pénztárcája szerkesztésével bármikor visszakapcsolhatja. Ez az egyenlegét nem érinti. + A nutzap-info eseményét egy üresre cseréljük, és kérni fogjuk a törlését. A továbbiakban nem fognak tudni nutzapet küldeni Önnek. Ezt a pénztárcája szerkesztésével bármikor visszakapcsolhatja. Ez az egyenlegét nem érinti. Nutzap-kulcs újraelőállítása Egy teljesen új kulcs előállítása a nutzapok fogadásához. Ritkán van rá szükség - általában csak akkor, ha a jelenlegi kulcsa kiszivárgott. A régi kulcsára már elküldött, de még be nem váltott nutzapok elvesznek. Újraelőállítja a nutzap-kulcsot? Egy új P2PK kulcs kerül előállításra és közzétételre a kind:10019 és kind:17375 eseményeiben, megtartva a jelenlegi pénzverdéit. A küldők elkezdenek nutzapokat zárolni az új kulcshoz. A régi kulcsára már elküldött, de még be nem váltott nutzapok helyreállíthatatlanná válnak. Erre ritkán van szükség. Kulcs újraelőállítása Nutzap-kulcs importálása - Cserélje le a nutzap-kulcsát egy beillesztett kulcsra - például a pénztárca biztonsági mentésből történő visszaállításához. Ritkán van rá szükség. A régi kulcsához zárolt nutzapok a továbbiakban nem lesznek beválthatók. + Cserélje le a nutzap-kulcsát egy beillesztett kulcsra - például a pénztárca biztonsági mentésből történő visszaállításához. Ritkán van rá szükség. A régi kulcsához zárolt nutzapek a továbbiakban nem lesznek beválthatók. Importálja a nutzap-kulcsot? Illessze be a nutzapok fogadásához használandó P2PK privát kulcsot (hex). A kind:10019 és kind:17375 eseményei újra közzé lesznek téve ezzel a kulccsal, megtartva a jelenlegi pénzverdéit. A jelenlegi kulcsához zárolt nutzapok többé nem lesznek beválthatók, kivéve, ha a kulcs megegyezik. Erre ritkán van szükség. P2PK privát kulcs (hex) @@ -2080,15 +2080,15 @@ Pénztárca törlése Távolítsa el a pénztárcát és állítsa le a nutzapokat. A fennmaradó egyenleg helyreállíthatatlanná válhat. Törli ezt a pénztárcát? - Ez a művelet a kind:17375 pénztárca és a kind:10019 nutzap-információ törlését kéri. Az ecash-igazolások nem törlődnek a Mintekből, de a pénztárca kulcsának eltűnésével a megmaradt egyenleg és a be nem váltott nutzapok helyreállíthatatlanná válhatnak. Győződjön meg arról, hogy először elköltötte vagy áthelyezte a pénzét. Ez a művelet nem vonható vissza. + Ez a művelet a kind:17375 pénztárca és a kind:10019 nutzap-információ törlését kéri. Az ecash-igazolások nem törlődnek a pénzverdékből, de a pénztárca kulcsának eltűnésével a megmaradt egyenleg és a be nem váltott nutzapok helyreállíthatatlanná válhatnak. Győződjön meg arról, hogy először elköltötte vagy áthelyezte a pénzét. Ez a művelet nem vonható vissza. Számla kifizetése Árajánlat kérése Árajánlat kérése a pénzverdétől… Kifizetsz %1$s satot + legfeljebb %2$s sat díjat? Ellenőrzés - ✓ A mint elérhető + ✓ A pénzverde elérhető ✓ %1$s - A mint nem érhető el: %1$s + A pénzverde nem érhető el: %1$s Nutzap Nem sikerült a nutzap Nincs megadva a címzett nyilvános kulcsa a bejegyzésen @@ -2099,7 +2099,7 @@ Kész Számla kérése a pénzverdétől… Várakozás a számla kifizetésére… - Mint ellenőrzése… + Pénzverde ellenőrzése… Bizonyítékok kiállítása… Fizetés a minten keresztül… Bizonyítékok cseréje… @@ -2150,7 +2150,7 @@ %1$s sat küldése, %2$d felé osztva - Ennek a bejegyzésnek a(z) %1$d-felé osztott zap-jének használata + Ennek a bejegyzésnek a(z) %1$d-felé osztott zapjének használata Ennek a bejegyzésnek a(z) %1$d-felé osztott zap-jének használata @@ -2331,7 +2331,7 @@ Megtolás vagy Idézés Tetszik Zap - Láncon belüli Bitcoin Zap-ke + Láncon belüli Bitcoinzap Függőben lévő megerősítés Gyors reakciók megváltoztatása Alsó navigációs sáv @@ -2348,6 +2348,7 @@ Ajánlott alkalmazások Beérkezett zapek hírfolyama Követők hírfolyama + Bitcoin (láncon belüli) pénztárca Reakciósor Állítsa be, hogy mely reakciógombok jelenjenek meg, azok sorrendjét, valamint a számlálók megjelenítését. Engedélyezve @@ -2401,8 +2402,8 @@ Szavazás kikapcsolása Bitcoin-számla Bitcoin-számla visszavonása - Zap-gyűjtés - Zap-gyűjtés visszavonása + Zapgyűjtés + Zapgyűjtés visszavonása Helyszín Helyszín eltávolítása Tartalmi figyelmeztetés hozzáadása @@ -2447,7 +2448,7 @@ Ez az átjátszótípus tárolja az összes tartalmat. Az Amethyst ide küldi az Ön bejegyzéseit, és mások ezeket az átjátszókat fogják használni, hogy megtalálják az Ön tartalmát. Adjon hozzá 1–3 átjátszót. Ezek lehetnek személyes-, fizetett- vagy nyilvános átjátszók. Nyilvános bejövő átjátszók A felhasználó ezeken az átjátszókon keresztül fogadja az értesítéseket - Ez az átjátszótípus fogadja az összes választ, hozzászólást, kedvelést és Zap-et az Ön bejegyzéseire. Ezek lehetnek fizetős vagy ingyenes átjátszók. Az átjátszó üzemeltetője által beállított korlátok korlátozhatják a jó és a rossz értesítések számát. Ha például a hozzászólásokban kéretlen üzenet-támadások érik, a fizetős átjátszók kiszűrhetik a kéretlen tartalmakat. Vegyen fel 1–3 átjátszót. + Ez az átjátszótípus fogadja az összes választ, hozzászólást, kedvelést és zapet az Ön bejegyzéseire. Ezek lehetnek fizetős vagy ingyenes átjátszók. Az átjátszó üzemeltetője által beállított korlátok korlátozhatják a jó és a rossz értesítések számát. Ha például a hozzászólásokban kéretlen üzenet-támadások érik, a fizetős átjátszók kiszűrhetik a kéretlen tartalmakat. Vegyen fel 1–3 átjátszót. Bejövő közvetlen üzenet-átjátszók A felhasználó ezeken az átjátszókon keresztül fogadja a közvetlen üzeneteket Adjon hozzá 1–3 átjátszót, hogy privát postafiókként szolgáljon. Mások ezeket az átjátszókat használják, hogy Önnek privát üzeneteket küldjenek. A bejövő privát üzenetek átjátszóinak bárkitől el kell fogadniuk minden üzenetet, de azok letöltését csak Ön engedélyezheti. Jó választási lehetőségek:\n - inbox.nostr.wine (fizetős)\n - auth.nostr1.com (ingyenes)\n - you.nostr1.com (személyes átjátszók - fizetős) @@ -2606,7 +2607,7 @@ A piszkozatokra nem lehet válaszolni A piszkozatokat nem lehet idézni A piszkozatokra nem lehet reagálni - A piszkozatokra nem lehet Zap-et küldeni + A piszkozatokra nem lehet zapet küldeni Piszkozat Üzenet tőle Alkalmazás keresése @@ -2873,7 +2874,7 @@ Git válasz Beolvasztási kérés Beolvasztási kérés frissítése - Zap-célok + Zapcélok Kulcsszókövetések Kiemelések Http-hitelesítés @@ -2887,7 +2888,7 @@ Zap-ek NWC-kérések NWC-válasz - Privát zap-ek + Privát zapek Zap-kérés Blogok Tárgyalószoba @@ -2909,7 +2910,7 @@ Képek Edzések Rögzítettek - Zap-szavazás + Zapszavazás Szavazás Szavazásválasz NIP-04 közvetlen üzenetek @@ -3088,7 +3089,7 @@ Cím Adjon egy címet a videónak Leírás - Miról szól ez a video? + Miról szól ez a videó? Indoklás (nem kötelező) Kodek H.265 (jobb tömörítés) @@ -3155,6 +3156,7 @@ %1$s gyűlt össze a(z) %2$s satoshis célból Véget ér ekkor: %1$s Láncon belüli adomány + Madárfelismerés Madárnapló · %1$d faj Madárnapló · %1$d faj @@ -3164,6 +3166,9 @@ %1$s +%2$d további + PS1 memóriakártya-mentés + %1$d blokk + Üres hely Rendőrség Sebességmérő kamera @@ -3331,7 +3336,7 @@ Emodzsi hozzáadása Egyéni emodzsi hozzáadása %1$s eltávolítása:? - Érintsen meg hosszan egy emodzsit az eltávolításhoz + Koppintson hosszan egy emodzsira annak eltávolításhoz A(z) „%1$s” már az emodzsilistában van A(z) „%1$s” még nincs az emodzsilistában Emodzsicsomag-műveletek diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 97def21ed3..f26c06a2ad 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -2404,6 +2404,7 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Rekomendacje aplikacji Kanał, który otrzymał Zapa Kanał Obserwowanych + Portfel Bitcoin (on-chain) Ustawienia reakcji Skonfiguruj które przyciski reakcji będą wyświetlane, ich kolejność i czy wyświetlić liczniki. Włączone @@ -3246,6 +3247,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest %1$s +%2$d więcej + Zapis karty pamięci PS1 + blok %1$d + Pusty slot Policja Fotoradar diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index cb2220977a..f458bd62b6 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -988,6 +988,122 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Podkasti Prikaži epizode Trenutno ni najdenih epizod + Napovednik + Sezona %1$d + Ekspicitno + Zaključeno + Premium + Podpri tole epizodo + od %1$s + S%1$d · E%2$d + Ep %1$d + Sezona %1$d + Video + Prepis + Poglavja + Vrednost za vrednost + %1$d%% + Zapi bojo razdeljeni med: + Gostitelji in Gostje + Predvajaj vrhunec + Naj podporniki + + %1$d poglavje + %1$d poglavji + %1$d poglavja + %1$d poglavij + + Gostitelj + So-gostitelj + Urejevalec + Preverjen avtor + Napaka pri vrednost za vrednost + Ta podcast nima določenih prejemnikov sredstev. + Povežite denarnico Nostr Wallet Connect za pošiljanje k Keysend (vozlišča) prejemnikom. + Sprotno plačevanje satov + %1$d satov/min + Samodejno pošiljanje vrednosti med poslušanjem. + V tej seji ste sproti plačali %1$d satov + Povežite denarnico Nostr Wallet Connect ali debetno denarnico za sprotno plačevanje satov med poslušanjem. + Nova epizoda + Uredi epizodo + Objavljam… + Dodaj naslovnico + Kvadratna slika prikazana za epizodo + Naslov + Naslov epizode + Povzetek + Trajanje (sekunde) + Več podrobnosti + Sezona + Epizoda # + URL videa + URL prepisa + URL poglavja + Teme + z vejico ločene oznake + URL zvočnega posnetka + https://…/episode.mp3 + Zvočni posnetek pripravljen + Dodaj zvočni posnetek + MP3, M4A, ali ostali zvočni posnetki + Izbriši epizodo + Izbrišem to epizodo? Tega ni mogoče razveljavit + Vaš podkast + Dodaj naslovnico + Kvadratna slika za vašo oddajo + Prikaži naslov + Moj podcast + Opis + Avtor + E-pošta za stik + Spletna stran + Kategorije + Tehnologija, novice + Povezave za financiranje + https://… + Jezik + en + Avtorske pravice + Prikaži tip + Epizodno + Serijsko + Eksplicitna vsebina + Oddaja zaključena (ni več novih epizod) + Zaklenjeno (Premijsko) + Nov napovednik + Dodaj napovednik + Kratek predogled (zvok ali video) + Naslov napovednika + Medijski URL + Vaš podkast + Brez naslova + Še ni epizod. Tapnite »Nova epizoda« in objavite svojo prvo. + Ustvarite svoj podkast + Tapnite za urejanje podrobnosti oddaje + Nastavite naslov, naslovnico in podrobnosti oddaje + + %1$d napovednik + %1$d napovednika + %1$d napovedniki + %1$d napovednikov + + Dodajte prejemnike za delitev prejetih satov po teži. Poslušalci lahko prispevajo (boost) ali sprotno plačujejo (stream) vrednost na te naslove. + Dodaj prejemnika + Ročno dodaj naslov + Dodaj Nostr uporabnika + Išči po imanu ali @uporabniškem imenu + Ta uporabnik nima lightning naslova + Odstrani prejemnika + Ime (neobvezno) + Lightning naslov + Vozlišče (keysend) + Lightning naslov + Ime@primer.com + Pubkej vozlišča + 02abc… (33-byte hex) + Teža + Provizija %1$d epizoda %1$d epizodi @@ -1022,6 +1138,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Javni zaznamki Repozitoriji Vaši zaznamovani repozitoriji Git + Podkasti + Vaši zaznamki podkastov in epizod Dodaj v privatne zaznamke Dodaj v javne zaznamke Odstrani iz privatnih zaznamkov @@ -2300,6 +2418,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Priporočene aplikacije Vir vsebin prejetih zapov Vir vsebin sledilcev + Bitcoin (on-chain) denarnica Vrstica odzivnih ikon Nastavite prikaza gumbov za odzive, njihov vrstni red in prikaz števcev. Omogočeno @@ -3130,6 +3249,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Zbranih %1$s od ciljnih %2$s satov Izteče se: %1$s On-chain donacija + Zaznana ptica Birdex · %1$d vrsta Birdex · %1$d vrsti @@ -3143,6 +3263,9 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, %1$s in še %2$d drugih + Shrani na pomnilniško kartico PS1 + %1$d blok + Prazen prostor Policija Hitrostna kamera @@ -3271,6 +3394,9 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Nastavitve urejevalnika Samodejno ustvari osnutke Samodejno shrani osnutek, ko tipkaš ali zapustiš urejevalnik z neposlanim besedilom, in ga pošlje v tvoje zasebne odhodne releje. + Podpis + Doda se na konec sporočila pri ustvarjanju nove objave, odgovora, citata ali članka. Pustite prazno, da onemogočite. + Vaš podpis Uporabi to Prekliči Pravilno diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index c1a2f7711b..a5dda0a9b3 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -3124,6 +3124,9 @@ %1$s + 另%2$d + PS1 内存卡保存 + 块 %1$d + 空槽位 警察 测速照相 diff --git a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt index 599860025f..064d951d04 100644 --- a/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt +++ b/amethyst/src/play/java/com/vitorpamplona/amethyst/service/notifications/PushNotificationReceiverService.kt @@ -86,6 +86,7 @@ class PushNotificationReceiverService : FirebaseMessagingService() { super.onDestroy() } + @Suppress("OVERRIDE_DEPRECATION") override fun onNewToken(token: String) { scope.launch(Dispatchers.IO) { Log.d("PushNotificationService", "PushNotificationReceiverService.onNewToken") diff --git a/build.gradle.kts b/build.gradle.kts index 3a9b1df6dd..6b87fbd049 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,36 @@ +import com.android.build.gradle.tasks.GenerateResValues import com.diffplug.gradle.spotless.SpotlessExtensionPredeclare +import java.util.Properties + +// Local SonarQube analysis is opt-in: it activates only when `sonar.host.url` +// is present in local.properties (gitignored) AND a sonar task was requested, +// so neither developers who haven't opted in nor ordinary builds/IDE syncs of +// opted-in developers resolve or apply the scanner plugin. The Kotlin DSL +// 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, +// but it can publish values — the gate is computed once here and read below +// via `by extra`. +buildscript { + val localProperties = File(rootDir, "local.properties") + val sonarProperties by extra( + java.util.Properties().apply { + if (localProperties.exists()) localProperties.inputStream().use { load(it) } + }, + ) + val sonarEnabled by extra( + sonarProperties.getProperty("sonar.host.url") != null && + gradle.startParameter.taskNames.any { it.substringAfterLast(":") in setOf("sonar", "sonarqube") }, + ) + if (sonarEnabled) { + repositories { + gradlePluginPortal() + } + dependencies { + // LGPL-3.0, build-time only — never linked into shipped artifacts. + classpath(libs.sonarqube.gradle.plugin) + } + } +} plugins { alias(libs.plugins.androidApplication) apply false @@ -73,6 +105,32 @@ subprojects { } } +// Second half of the opt-in local SonarQube support gated above in buildscript {}. +// All sonar.* entries in local.properties are forwarded as system properties, so +// `./gradlew sonar` behaves exactly like passing them via -Dsonar.xxx=... on the +// command line. sonar.projectKey/projectName default to the root project name +// ("Amethyst") and only need overriding in local.properties if desired. +val sonarEnabled: Boolean by extra +if (sonarEnabled) { + val sonarProperties: Properties by extra + apply(plugin = "org.sonarqube") + + sonarProperties + .stringPropertyNames() + .filter { it.startsWith("sonar.") } + .forEach { System.setProperty(it, sonarProperties.getProperty(it)) } + + // The scanner's sonarResolver task reads AGP's generated-res-values provider + // but doesn't depend on the task that produces it — wire it up in every + // module that has both (today only :amethyst enables resValues, but the + // scanner defect is module-agnostic). + subprojects { + tasks.named { it == "sonarResolver" }.configureEach { + dependsOn(tasks.withType()) + } + } +} + val installGitHook = tasks.register("installGitHook") { val dotGit = File(rootProject.rootDir, ".git") val hooksDir: File = if (dotGit.isFile) { diff --git a/cli/DEVELOPMENT.md b/cli/DEVELOPMENT.md index 6b9eb42376..5f9203cd89 100644 --- a/cli/DEVELOPMENT.md +++ b/cli/DEVELOPMENT.md @@ -38,6 +38,15 @@ What every caller — user, script, agent, CI — can rely on: copy to move. Tests isolate by overriding `$HOME` for the amy subprocess (`HOME=/tmp/run.123 amy --account alice …`) — same convention `git`, `gpg`, and `npm` use. +- **An account is only required to _sign_.** Read-only verbs (relay + queries, the shared `store`, `offer`/`debit info`, and the stateless + primitives) run against an empty `~/.amy/` — `DataDir.resolveOptional` + hands them an accountless dir (its `hasAccount = false`) pointing only at + the shared event store, and `Context.openOrAnonymous` gives them an + ephemeral key-less identity (they read fine, they just can't + authenticate). Signing verbs go through `Context.open`, which re-asserts + the account requirement — `init`/`create`/`login`/`logoff`/`whoami` + resolve strictly, since they operate on the account dir itself. Only the `--json` shape and the exit codes are public API. The default text format is allowed to change between releases. The five design diff --git a/cli/README.md b/cli/README.md index ae3ad7ed48..7b9628ac97 100644 --- a/cli/README.md +++ b/cli/README.md @@ -374,6 +374,8 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy login KEY [--password X]` | Import an existing identity (`nsec`/`ncryptsec`/mnemonic/`npub`/`nprofile`/hex/NIP-05). | | `amy whoami` | Print the active account's name + npub. | | `amy use NAME` / `--clear` / no-arg | Pin / clear / inspect the active account. | +| `amy status` | Read-only overview of everything under `~/.amy/`: every account, which one is current, each signer type (local keychain/ncryptsec/plaintext, NIP-46 bunker, or read-only) and whether it can sign, the local Marmot / Cashu / alias / sync-cursor footprint per account, and the shared event store's size. Built for the returning user. No keychain prompt, no network. | +| `amy logoff [--yes] [--keep-events]` | Log off an account: delete its key + backend secret, the whole `~/.amy//` directory (run-state, aliases, cashu counters, Marmot state), the `current` pin if it points here, and the account's events (authored + `#p`-addressed) in the shared store. `--keep-events` leaves the shared cache alone. Destructive and irreversible — requires `--yes`; without it, prints a dry run and exits 2. | ### Social @@ -383,6 +385,41 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | `amy notes feed [--author USER \| --following] [--limit N]` | Read recent kind:1 notes (yours, one user's, or your follow set). | | `amy profile show [USER]` | Print kind:0 metadata. USER accepts npub/nprofile/hex/NIP-05; defaults to self. | | `amy profile edit --name … --about … --picture URL …` | Patch and re-publish your kind:0. | +| `amy follow USER` / `amy unfollow USER` | Add/remove USER from your kind:3 contact list (fetches the freshest list first). | +| `amy graperank [OBSERVER] [--offline] [--publish] [--min-rank N] [--publish-relay URL]` | Compute GrapeRank web-of-trust scores (0..1) over the follow/mute/report graph. Exhaustively crawls each user's kind:10002 outbox for their latest kind:3/10000/1984 until every discovered user is checked (no user cap), dropping reports the author retracted via NIP-09. With `--publish`, reconciles NIP-85 kind:30382 cards signed by a per-observer **service key**: publishes changed/new ranks (cutoff `--min-rank`, default 2), skips unchanged, and **retracts** (kind:5) any card whose target left the graph or fell below the cutoff. | +| `amy graperank operator [status \| relay … \| providers]` | Manage the machine's operator keys (independent of any account, under `~/.amy/operator/`). `relay` sets where cards + retractions publish; `status` shows the master pubkey and relays; `providers` lists the observer → service-pubkey map. | +| `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL]` | Declare a NIP-85 provider in your kind:10040 so clients can discover it (default: self as the `30382:rank` provider). | +| `amy graperank providers [USER]` | List a user's declared NIP-85 trusted providers (public + your own private entries). | + +#### Publishing GrapeRank scores (NIP-85) + +Ranks are published as kind:30382 cards, but **not** under your account key. A +machine holds one **operator master** seed (`~/.amy/operator/`, stored via the +same `--secret-backend` as accounts, independent of any account). From it a +distinct, deterministic **service key** is derived per observer: + +``` +serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerHex) +``` + +Because kind:30382 is addressable (`pubkey + d-tag`), the stable per-observer key +means re-publishing **replaces** a target's card instead of orphaning it — and +losing everything but the master seed still re-derives every key. Set up once and +publish: + +```bash +amy graperank operator relay wss://relay.example.com # where all cards live +amy graperank --publish # sign with the observer's service key +``` + +Each publish **reconciles** against what the service key already published: new or +changed ranks (≥ `--min-rank`, default 2) are signed and sent; unchanged ranks are +skipped (no new event id); and any card whose target dropped out of the graph or +fell below the cutoff is **retracted** with a kind:5. When the observer is your +own account (we hold the key), Amy also writes their kind:10040 pointing +`30382:rank → serviceKey @ operator relay` to their outbox, so clients can find +the cards. For a third-party observer, `graperank operator providers` prints the +`observer → service-pubkey` mapping to wire their kind:10040 out-of-band. ### Direct messages (NIP-17) @@ -574,7 +611,18 @@ matches that: 1. If `~/.amy/current` is set, use it. 2. Else if exactly one account exists, use it (silent auto-pick). -3. Else error and list the candidates so you can disambiguate. +3. Else — for a **read-only** verb, run **anonymously**; for a **signing** + verb, error and list the candidates so you can disambiguate. + +**No account? Reads still work.** Verbs that only query relays or the shared +event store — `fetch`, `subscribe`, `count`, `publish` (broadcasts a +pre-signed event), `outbox`, `search`, `sync`, `store …`, the read halves of +`profile`/`notes`/`git`/`podcast`/`podcast20`, `nsite`/`napplet` fetch/serve/ +list, `blossom download`/`check`, `offer`/`debit info`, and every stateless +primitive — run against an empty `~/.amy/` with a throwaway key. They read +fine; they just can't authenticate. Only verbs that **sign or encrypt with +your key** (post, edit, follow, dm, marmot, zap, relay-list edits, blossom +upload/list/delete, cashu, …) require an account — and say so. `amy use NAME` writes `~/.amy/current`; `amy use --clear` removes it. For one-off override, prepend `--account NAME` to any command. @@ -640,11 +688,12 @@ Inside the amy process there's no test mode — it just sees a fresh ## Troubleshooting -- **`no account at ~/.amy`** — you haven't created one yet. Run +- **`no account configured` / `multiple accounts in ~/.amy (alice, bob)`** — + only **signing** verbs raise these; reads run anonymously instead (see + "No account? Reads still work" above). Create one with `amy --account NAME init` (bare keypair) or `amy --account NAME create` - (full Amethyst-style bootstrap). -- **`multiple accounts in ~/.amy (alice, bob)`** — pin one with - `amy use NAME` or pass `--account NAME` per command. + (full Amethyst-style bootstrap), or pin/select one with `amy use NAME` / + `--account NAME`. - **`current pins 'X' but ~/.amy/X doesn't exist`** — the active-account marker is stale. Rewrite with `amy use OTHER` or `amy use --clear`. - **`no_dm_relays`** — recipient hasn't published a kind:10050 inbox. diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index d34696864a..56f2bb0514 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -43,6 +43,8 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · |---|---|---| | Identity create / import (`nsec`, `ncryptsec`, mnemonic, `npub`, `nprofile`, hex, NIP-05) | ✅ | `LoginCommand` + Quartz NIP-05 / NIP-06 / NIP-49 | | Account bootstrap (nine events) | ✅ | `commons/account/AccountBootstrapEvents.kt` | +| Account logoff (`amy logoff`) — delete key + per-account state + the account's events in the shared store | ✅ | `LogoffCommand`. `--yes`-gated; `--keep-events` skips the shared-cache purge. | +| Status overview (`amy status`) — every account, current pin, signer type + can-sign, per-account Marmot/Cashu/alias/cursor footprint, shared event-store size | ✅ | `StatusCommand`. Cross-account, read-only, metadata-only (no keychain prompt, no network). Store stats via shared `StoreStats`. | | Relay config — every relay-list bucket (nip65 10002 via `outbox`/`inbox`/`nip65` nouns with spec read/write merge, dm 10050, key-package 10051, search 10007, private-outbox 10013, blocked 10006, trusted 10089, proxy 10087, indexer 10086, broadcast 10088, favorite 10012) — noun-first `relay add/remove/set/clear/list` + fan-out `relay add/remove` + publish | ✅ | `RelayCommands`. Mirrors the Android relay-settings screen. Local relays (device pref) + relay sets (30002) intentionally out of scope. | | MLS KeyPackage publish + fetch | ✅ | `commons/marmot/MarmotManager` | | Marmot group create / add / rename / promote / demote / remove / leave | ✅ | `commons/marmot/` | @@ -58,6 +60,7 @@ Status legend: ✅ shipped · 📦 logic lives in `commons/`, needs a command · | NIP-51 lists (bookmarks, mute, follow sets) | 🆕 | `amethyst/model/nip51Lists/` | | NIP-57 zaps (send + verify) | 🆕 | Needs LN-URL plumbing; `amethyst/service/lnurl/`. | | NIP-65 outbox model queries | 🆕 | | +| NIP-85 GrapeRank web-of-trust (`amy graperank`) | ✅ | `GrapeRankCommand` — outbox-model crawl + scoring engine in `commons/wot/` (`GrapeRank`, `TrustGraph`, `TrustGraphBuilder`); publishes kind:30382 `ContactCardEvent` (diffed against prior ranks), plus `register` / `providers` for the kind:10040 `TrustProviderListEvent` discovery layer. | | NIP-72 communities | 🆕 | | | NIP-78 app-specific data (settings sync) | 🆕 | | | Long-form (NIP-23) publish / read | 🆕 | | diff --git a/cli/packaging/homebrew/amy.rb b/cli/packaging/homebrew/amy.rb index dc1526a931..ec89e2d0e3 100644 --- a/cli/packaging/homebrew/amy.rb +++ b/cli/packaging/homebrew/amy.rb @@ -1,9 +1,16 @@ # Reference Homebrew formula for `amy`, the Amethyst CLI. # -# This file is NOT consumed by any build in this repo. It is the artifact you -# submit to Homebrew/homebrew-core (`brew bump-formula-pr` / a new-formula PR). -# Once accepted, homebrew-core's copy is the source of truth; keep this in sync -# for reference and to make version bumps a copy-paste. +# Reference Homebrew formula for `amy`, the Amethyst CLI. Submit this to +# Homebrew/homebrew-core (new-formula PR) or drop it into a personal tap +# (`Formula/amy.rb`) for an instant `brew install /amy`. +# +# The url + sha256 below are kept in sync automatically on every stable release +# by .github/workflows/bump-homebrew-formula.yml (it downloads the published +# `amy--jvm.tar.gz`, recomputes the sha256, and opens a PR). To refresh +# by hand instead: +# curl -fsSL -o amy-jvm.tar.gz \ +# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz +# shasum -a 256 amy-jvm.tar.gz # # Why a pre-built jar bundle instead of building from source: # homebrew-core builds inside a network sandbox, so a Gradle build cannot @@ -11,17 +18,11 @@ # to download a pre-built, no-JRE jar bundle and depend on the system openjdk. # We publish exactly that as `amy--jvm.tar.gz` (bin/amy + lib/*.jar, # no bundled runtime) from .github/workflows/create-release.yml. -# -# Before submitting: replace the version in the url and the sha256 with the -# values for the actual published release asset: -# curl -fsSL -o amy-jvm.tar.gz \ -# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amy-X.Y.Z-jvm.tar.gz -# shasum -a 256 amy-jvm.tar.gz class Amy < Formula - desc "Command-line Nostr client from the Amethyst project" + desc "Nostr client from the Amethyst project" homepage "https://github.com/vitorpamplona/amethyst" - url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.1/amy-1.12.1-jvm.tar.gz" - sha256 "REPLACE_WITH_RELEASE_ASSET_SHA256" + url "https://github.com/vitorpamplona/amethyst/releases/download/v1.12.6/amy-1.12.6-jvm.tar.gz" + sha256 "209316d704a4622ddef1fd86b958b7619e9d049c20f3543dff60348ec73affd6" license "MIT" # Lets homebrew-core's BrewTestBot auto-open version-bump PRs when a new diff --git a/cli/plans/2026-07-06-graperank-brainstorm-parity.md b/cli/plans/2026-07-06-graperank-brainstorm-parity.md new file mode 100644 index 0000000000..52f09056d9 --- /dev/null +++ b/cli/plans/2026-07-06-graperank-brainstorm-parity.md @@ -0,0 +1,134 @@ +# GrapeRank score parity with NosFabrica Brainstorm + +Goal: `amy graperank` should output scores **numerically very close** to +NosFabrica's Brainstorm service, the reference GrapeRank implementation. + +Sources analysed: +- `NosFabrica/brainstorm_graperank_algorithm` — the Java scoring worker. +- `NosFabrica/brainstorm_server` — the Python orchestration server. + +## How Brainstorm builds its service + +A four-stage pipeline: + +1. **Ingest.** `app/nostr_event_transferer/nostr_event_transferer.py` copies raw + social-graph events — **kinds 0, 3, 10000, 1984** (profiles, follows, mutes, + reports) — from a strfry relay into the server. Same four kinds we crawl. +2. **Graph.** Events land in **Neo4j** as a directed graph of follow / mute / + report edges between pubkeys. Redis + Postgres back the job queue and config. +3. **Score.** The Java worker (`grape/GrapeRankAlgorithm.java`) runs GrapeRank + from an observer, producing a **`ScoreCard`** per user + (`rank/ScoreCard.java`): `observer, observee, hops, averageScore, input, + confidence, influence, verified, trustedFollowers, trustedReporters`. + **There is no `rank` field — the trust value is `influence` ∈ [0,1].** +4. **Serve / publish.** Presets are tunable per deployment + (`DEFAULT` / `PERMISSIVE` / `RESTRICTIVE`, `graperank_preset` table, validated + by `GrapeRankPresetParams`). Java `GrapeRankParams` mirrors the Python model + field-for-field; the README states Python is the source of truth and both + repos must stay in sync. + +## The algorithm (their `grape/GrapeRankAlgorithm.java`) + +``` +rigority = -log(rigor) +confidence(sumWeights) = 1 - exp(-sumWeights * rigority) # weight -> confidence +per edge: weight = edgeConfidence * influenceOfRater * attenuationFactor + wxr = weight * edgeRating +averageScore = sumWxR / sumWeights (0 if sumWeights == 0) +influence = max(averageScore * confidence(sumWeights), 0) +``` + +- Observer seeded at `influence = 1.0` (fixed authority). +- Non-observers seeded by hop distance, then **iterated until every user's + influence delta < 0.0001** (`loopBreakDelta`). Seeding only affects the + starting guess; attenuation < 1 makes the update a contraction, so the fixed + point is unique. +- The rater weight uses the rater's **`influence`**, and + `influence = max(weightToConfidence(sumW) * sumWR/sumW, 0)`. + +## Side-by-side: Brainstorm DEFAULT vs `commons/wot` + +`Constants.java` `DEFAULT_PARAMS` (== the Pydantic `GrapeRankPresetParams` +DEFAULT) against our `GrapeRankParams` defaults: + +| Brainstorm field | value | our field | value | match | +|---|---|---|---|---| +| `attenuationFactor` | 0.85 | `attenuation` | 0.85 | ✅ | +| `rigor` | 0.5 | `rigor` | 0.5 | ✅ | +| `followRating` | 1.0 | `FOLLOW.rating` | 1.0 | ✅ | +| `muteRating` | -0.1 | `MUTE.rating` | -0.1 | ✅ | +| `reportRating` | -0.1 | `REPORT.rating` | -0.1 | ✅ | +| `followConfidenceOfObserver` | 0.5 | `directFollowConfidence` | 0.5 | ✅ | +| `followConfidence` | 0.03 | `indirectFollowConfidence` | 0.03 | ✅ | +| `muteConfidence` | 0.5 | `muteConfidence` | 0.5 | ✅ | +| `reportConfidence` | 0.5 | `reportConfidence` | 0.5 | ✅ | +| `loopBreakDelta` | 0.0001 | `convergence` | 0.0001 | ✅ | + +The three `verified*InfluenceCutoff`s (followers 0.02, reporters 0.1, +muters 0.01) only flag a derived `verified` boolean; they do **not** affect the +score. + +**Conclusion: our formula is identical and every scoring parameter matches +DEFAULT.** Our `score` *is* their `influence` +(`max(weightToConfidence(sumW) * sumWR/sumW, 0)`), propagated as the rater +weight — the exact same quantity. On the same input graph the two produce the +same influence to floating-point precision. Our published `rank = round(score * +100)` is a presentation choice on top of that influence (their `ScoreCard` +exposes `influence` as a raw float via the API). + +## Where divergence can still come from — and why it's small + +It is **data**, not math: + +1. **Graph completeness.** Brainstorm ingests the whole strfry graph into Neo4j; + we crawl outward from the observer via the outbox model. **This matters less + than it seems:** a mute/report contributes `confidence * influenceOfRater * + attenuation`, so a signal from a user with **zero influence** (someone outside + the observer's trust graph) contributes **zero**. Only follows/mutes/reports + authored by users *inside* the follow graph move a score — and those are + exactly the users our crawl discovers and whose kind 3/10000/1984 we fetch. + So the effective scoring input is the same, as long as the crawl actually + checks every discovered user's outbox — which it now does exhaustively (no + user cap, retrying an unreachable outbox a few times). +2. **Fringe users / crawl gaps.** A relay timeout that drops a contact list + removes edges and shifts nearby scores. The injector mitigates this with a + two-stage model mirroring the app's `pickRelaysToLoadUsers`, plus a + completeness loop that retries until every user's outbox has been checked: + - **Relay-list discovery** (kind:10002) queries the account's relays + + bootstrap + event-finder + **indexer relays** (purplepag.es, coracle, …). + Indexers aggregate kind:10002 (and kind:0) for the whole network, so this is + where a stranger's outbox is found — the biggest completeness lever. + - **Content** (kind:3/10000/1984/0) is fetched from each user's **own outbox** + write relays, with harvested **relay hints** (from the `p`-tag hints in + contact lists we crawl) and general-purpose relays as a best-effort fallback + when the outbox is unknown/down. **Indexers are not used for content** — they + don't serve those kinds; kind:3/mutes/reports live only on the user's outbox. + The crawl loops round by round, retrying any member whose contact list still + didn't arrive (a few times), until every discovered user's outbox + has been checked. Remaining mitigation lever: a generous `--timeout`. +3. **Convergence precision.** Both stop at delta 0.0001; residual error is + < ~0.0001 in influence ⇒ < ~0.01 rank points ⇒ identical integer `rank`. +4. **Seeding.** Their hop-distance seed vs our zero seed — same fixed point, no + effect on the result. + +## Recommendations + +- **Keep the current DEFAULT params** — they are byte-for-byte the Brainstorm + DEFAULT preset. No change needed for parity. +- **The crawl is exhaustive by default** (no user cap; every reachable user's + outbox is checked, unreachable outboxes retried a few times). An + incomplete crawl is the single biggest source of drift, so avoid capping it. +- **Optional, for fuller parity (not required for close scores):** + - Add `--preset default|permissive|restrictive`. DEFAULT is confirmed; the + PERMISSIVE / RESTRICTIVE numbers are DB-seeded in `brainstorm_server` (an + alembic seed migration) and were not extractable from the public tree — + pull them from a running instance before hard-coding. + - Optionally expose `influence` as a raw float alongside `rank` in `--json`, + and compute the `verified` flag from the cutoffs, to mirror their + `ScoreCard` shape for interop diffing. + +## Verification idea + +Point `amy graperank --offline` at a store seeded from the same +strfry snapshot Brainstorm ingested, and diff our `score` against their +`ScoreCard.influence` for the same observer. Expect agreement to ~1e-4. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index dd7c0fa984..48f19276c4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -143,6 +143,16 @@ data class Identity( npub = pubHex.hexToByteArray().toNpub(), ) + /** + * Ephemeral, key-less identity for anonymous read-only runs (no + * account on disk). It mints a throwaway public key so the + * relay-list fallbacks (`outboxRelays()` etc.) resolve to the + * built-in defaults, and it carries no private key, so any attempt + * to sign/encrypt fails loudly — "you can read, you just can't + * auth". Used by [com.vitorpamplona.amethyst.cli.Context.openOrAnonymous]. + */ + fun anonymous(): Identity = fromPublicKeyHex(KeyPair().pubKey.toHexKey()) + /** * Rebuild an in-memory identity after a load. Accepts the public * parts that live on disk and a private key resolved from the @@ -204,6 +214,17 @@ class DataDir( val eventsDir: File, val accountName: String, val secrets: SecretStore, + /** + * Whether this points at a concrete account. `false` for the + * accountless directory [resolveOptional] hands back when `~/.amy/` + * has no unambiguous account — [root] then points at the shared + * sibling and only [eventsDir] (the cross-account event store) is + * meaningful. Read-only verbs run anonymously against it; signing + * verbs get [noAccountDetail] via `Context.open`. + */ + val hasAccount: Boolean = true, + /** Human-readable reason there is no account, for the signing-verb error. */ + val noAccountDetail: String? = null, ) { val identityFile = File(root, "identity.json") val stateFile = File(root, "state.json") @@ -213,14 +234,34 @@ class DataDir( val groupsDir = File(marmotDir, "groups") val keyPackageBundleFile = File(marmotDir, "keypackages.bundle") + /** + * SQLite event-store DB file, a sibling of [eventsDir] under + * `/shared/`. Used when the store backend is SQLite (the + * default — see [StoreFactory]); the FS backend uses [eventsDir] + * instead. Kept alongside the FS store so switching backends never + * clobbers the other's data. + */ + val eventsDbFile: File = File(eventsDir.parentFile ?: root, "events.db") + + /** + * Machine-level operator keys for GrapeRank trusted-assertion publishing, + * rooted at `~/.amy/operator/` (the account root's parent) so a single + * operator master is shared across accounts. See [OperatorKeys]. + */ + fun operatorKeys(): OperatorKeys = OperatorKeys(root.parentFile ?: root, secrets) + init { SecureFileIO.secureMkdirs(root) - SecureFileIO.secureMkdirs(groupsDir) - // Tighten perms on any data already on disk from an older, unhardened CLI. - SecureFileIO.tighten(identityFile) - SecureFileIO.tighten(stateFile) - SecureFileIO.tighten(marmotDir) - SecureFileIO.tighten(keyPackageBundleFile) + // The accountless dir only ever exposes the shared event store; don't + // seed per-account marmot dirs / tighten identity files under it. + if (hasAccount) { + SecureFileIO.secureMkdirs(groupsDir) + // Tighten perms on any data already on disk from an older, unhardened CLI. + SecureFileIO.tighten(identityFile) + SecureFileIO.tighten(stateFile) + SecureFileIO.tighten(marmotDir) + SecureFileIO.tighten(keyPackageBundleFile) + } } /** @@ -375,6 +416,69 @@ class DataDir( ) } + /** + * Like [resolve], but never throws when there is no account: read-only + * verbs can run without one. When `--account` is given it is honoured; + * otherwise the pin / sole-account are used if unambiguous. Failing + * that, returns an *accountless* [DataDir] (`hasAccount = false`) whose + * [root] is the shared sibling and whose [eventsDir] is still the + * cross-account event store — enough for anonymous relay queries and + * `store` maintenance. The reason no account was chosen is carried in + * [DataDir.noAccountDetail] so a signing verb can surface it. + */ + fun resolveOptional( + accountFlag: String?, + secrets: SecretStore, + ): DataDir { + val rootBase = DEFAULT_ROOT + val sharedEvents = File(rootBase, "$SHARED_DIR_NAME/events-store").absoluteFile + if (accountFlag != null) { + val name = validateName(accountFlag) + return DataDir(File(rootBase, name).absoluteFile, sharedEvents, name, secrets) + } + val picked = pickAccountOptional(rootBase) + return if (picked.name != null) { + DataDir(File(rootBase, picked.name).absoluteFile, sharedEvents, picked.name, secrets) + } else { + DataDir( + root = File(rootBase, SHARED_DIR_NAME).absoluteFile, + eventsDir = sharedEvents, + accountName = SHARED_DIR_NAME, + secrets = secrets, + hasAccount = false, + noAccountDetail = picked.detail, + ) + } + } + + /** Result of [pickAccountOptional]: an account [name], or null plus a [detail] reason. */ + private data class OptionalPick( + val name: String?, + val detail: String?, + ) + + /** Non-throwing sibling of [pickAccount]: null [name] with a [detail] when 0 / ambiguous. */ + private fun pickAccountOptional(rootBase: File): OptionalPick { + val current = File(rootBase, CURRENT_MARKER_NAME) + if (current.isFile) { + val pinned = current.readText().trim() + if (pinned.isNotEmpty() && File(rootBase, pinned).isDirectory) { + return OptionalPick(pinned, null) + } + } + val accounts = listAccounts(rootBase) + return when (accounts.size) { + 0 -> OptionalPick(null, "no account configured (create one with `amy --account init`)") + 1 -> OptionalPick(accounts.single(), null) + else -> + OptionalPick( + null, + "multiple accounts in ${rootBase.absolutePath} (${accounts.joinToString(", ")}); " + + "pick one with --account or `amy use `", + ) + } + } + /** * Auto-select an account when `--name` was not given. Honours * `/current` first (explicit pin from `amy use`), then diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index dd0bac444e..e74998b91d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -38,12 +38,14 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.verify -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DrainFailure +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.classifyDrainFailure import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder @@ -55,7 +57,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketF import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote @@ -69,6 +71,7 @@ import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachabilityStore import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.SeenIds import kotlinx.coroutines.CompletableDeferred @@ -78,7 +81,9 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Dispatcher import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit /** * Per-invocation wiring. Each CLI run constructs a Context, does its work, @@ -98,9 +103,10 @@ import okhttp3.OkHttpClient * Every Nostr event Amy observes — whether received from a relay * subscription, unwrapped from a NIP-59 gift wrap, or generated locally * before publish — is verified (NIP-01 signature + id check via - * [Event.verify]) and persisted to the file-backed [IEventStore] at - * `/events-store/`. Malformed events are dropped before - * reaching command code. + * [Event.verify]) and persisted to the shared [IEventStore] under + * `/shared/` (a SQLite DB by default, or the FS tree when + * `AMY_STORE=fs` — see [StoreFactory]). Malformed events are dropped + * before reaching command code. * * This makes [store] the authoritative cache of everything Amy has ever * seen: profile metadata, relay lists, contact lists, gift wraps, @@ -115,8 +121,40 @@ class Context( val dataDir: DataDir, val identity: Identity, val state: RunState, + /** + * Anonymous read-only run: no account on disk, [identity] is an ephemeral + * key-less identity (see [Identity.anonymous]). Marmot state is not + * restored and run-state is not persisted — the run only reads relays and + * the shared event store. Signing verbs never take this path; they go + * through [Companion.open], which requires a real account. + */ + val anonymous: Boolean = false, ) : AutoCloseable { - private val okhttp = OkHttpClient.Builder().socketFactory(TcpNoDelaySocketFactory).build() + private val okhttp = + OkHttpClient + .Builder() + .socketFactory(TcpNoDelaySocketFactory) + // The crawl opens WebSockets to thousands of relays. Each WS-upgrade + // handshake is an async call through OkHttp's shared Dispatcher, whose + // default cap (maxRequests=64) throttles the connection ramp — worse, + // a dead relay holds a slot for the whole connectTimeout, starving live + // relays queued behind it. Widen the dispatcher so handshakes fan out, + // and keep connectTimeout tight-ish so an unreachable relay frees its + // slot fast. This is orthogonal to REQ concurrency (that runs on + // already-open sockets, bounded by AdaptiveRelayLimiter), so it can't + // trip a relay's REQ rate-limit — it only speeds connection setup. The + // executor thread pool is unbounded on demand, so raising maxRequests + // just lets more of those short-lived handshakes proceed at once. 7s + // (not 5s): a 5s cap struck too many merely-busy relays as connect + // failures — the crawl treats a connect *timeout* as retryable anyway, + // but the extra headroom lets slow-but-alive relays finish the handshake. + .connectTimeout(7, TimeUnit.SECONDS) + .dispatcher( + Dispatcher().apply { + maxRequests = 256 + maxRequestsPerHost = 16 + }, + ).build() val client: NostrClient = NostrClient( @@ -146,6 +184,52 @@ class Context( ) } ?: NostrSignerInternal(identity.keyPair()) + /** + * Client-wide tally of relay feedback — NOTICE frames, CLOSED reasons + * (auth-required / rate-limited / restricted / …), and NIP-42 AUTH + * challenges — so a failed REQ can be explained instead of guessed at. + * Registered on [client] for the life of this run. + */ + val relayDiagnostics: RelayDiagnostics = RelayDiagnostics().also { client.addConnectionListener(it) } + + /** + * Adaptive per-relay concurrent-subscription cap. Starts every relay + * generous (100) and demotes only the ones that complain about concurrency + * (100 → 20 → 10), driven straight off the NOTICE/CLOSED frames it observes + * as a connection listener. [drain]'s `gatePerRelay` path holds a relay's + * permit for the life of that relay's subscription, so we never exceed the + * cap the relay itself asked for. Idle for commands that don't opt in. + */ + val relayLimiter: AdaptiveRelayLimiter = + AdaptiveRelayLimiter( + // The starting per-relay concurrent-sub cap dominates whether the crawl + // floods a popular relay into timing out. Benchmarked: 16 is ~30% faster + // on a from-scratch GrapeRank crawl than the old 100 (which drowned + // damus/nos.lol in 100 concurrent giant REQs) at equal completeness, and + // is still generous for the single-user fetches other amy commands do. + startCap = 16, + ).also { client.addConnectionListener(it) } + + /** + * NIP-42 responder: answers a relay's AUTH challenge by signing with the + * account key, so auth-gated relays serve our reads instead of CLOSing the + * subscription. Constructing it registers its own listener on [client]. + * Only a local key auto-signs — a remote bunker signer is skipped, since a + * per-relay remote round-trip during a crawl would stall it (and signing an + * auth event with any key still unlocks relays that just want *some* auth). + */ + private val relayAuth: RelayAuthenticator = + RelayAuthenticator( + client = client, + signWithAllLoggedInUsers = { _, template -> + if (signer is NostrSignerInternal) { + runCatching { listOf(signer.sign(template)) }.getOrElse { emptyList() } + } else { + emptyList() + } + }, + ) + /** * NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys. * Uses the same OkHttp instance as the WebSocket client so we share connection @@ -158,32 +242,40 @@ class Context( .OkHttpNip05Fetcher { _ -> okhttp }, ) - private val mlsStore = FileMlsGroupStateStore(dataDir.groupsDir) - private val keyPackageStore = FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) - private val messageStore = FileMarmotMessageStore(dataDir.groupsDir) + // Lazy so an anonymous read (no account dir) never materialises the + // per-account marmot stores — constructing them would `mkdir` group dirs + // under the shared root. Real accounts build them on first marmot use. + private val mlsStore by lazy { FileMlsGroupStateStore(dataDir.groupsDir) } + private val keyPackageStore by lazy { FileKeyPackageBundleStore(dataDir.keyPackageBundleFile) } + private val messageStore by lazy { FileMarmotMessageStore(dataDir.groupsDir) } /** - * Filesystem-backed Nostr event store, rooted at [DataDir.eventsDir]. - * Lazy so commands that don't touch persistent event state pay zero - * open cost (no `.lock` file, no seed allocation). Closed by - * [close] when this Context shuts down. - * - * Files are written pretty-printed (not the compact NIP-01 canonical - * form) so `cat`, `jq`, `git diff` are useful out of the box — - * humans inspect these files. Verification always re-canonicalises, - * so the stored bytes never feed back into a signature check. + * Shared Nostr event store for this run, opened via [StoreFactory] + * (SQLite by default, or the FS tree when `AMY_STORE=fs`). Lazy so + * commands that don't touch persistent event state pay zero open cost + * (no DB file / `.lock`, no seed allocation). Closed by [close] when + * this Context shuts down. */ - private val storeDelegate: Lazy = - lazy { - FsEventStore( - root = dataDir.eventsDir.toPath(), - eventToJson = JacksonMapper::toJsonPretty, - ) - } + private val storeDelegate: Lazy = lazy { StoreFactory.open(dataDir) } val store: IEventStore by storeDelegate + /** + * Shared relay-reachability cache (NIP-66 kind:30166 records in [store]), signed by + * the machine's dedicated monitor key — derived from the operator master, NOT the + * account (see [OperatorKeys.monitorKey]). The crawler and the WoT updater read its + * dead set to skip proven-dead relays and write their findings back, so liveness + * knowledge is shared across procedures and runs instead of rediscovered each time. + * Lazy so a run that never touches relays doesn't materialize the operator master. + */ + val reachability: RelayReachabilityStore by lazy { + RelayReachabilityStore( + store = store, + signer = NostrSignerInternal(dataDir.operatorKeys().monitorKey()), + ) + } + /** Fully-wired manager. Call [prepare] once before use to load persisted state. */ - val marmot: MarmotManager = MarmotManager(signer, mlsStore, messageStore, keyPackageStore) + val marmot: MarmotManager by lazy { MarmotManager(signer, mlsStore, messageStore, keyPackageStore) } // ------------------------------------------------------------------ // Cashu (NIP-60 / NIP-61) — shared wallet code from commons @@ -302,7 +394,9 @@ class Context( */ suspend fun prepare() { if (prepared) return - marmot.restoreAll() + // Anonymous runs have no account and therefore no marmot state to + // restore (and touching `marmot` would allocate the per-account stores). + if (!anonymous) marmot.restoreAll() client.connect() // A bunker account must open its NIP-46 response subscription and run // the connect handshake before any signing/encryption call. @@ -370,6 +464,23 @@ class Context( /** Union of all three buckets. */ suspend fun anyRelays(): Set = outboxRelays() + inboxRelays() + keyPackageRelays() + /** + * Index relays — the shared, app-global set used to fetch profile + * metadata (kind 0) and follow lists (kind 3). Mirrors the Desktop + * app's `LocalRelayCategories.indexRelays` by reading from the same + * `java.util.prefs` node + * (`com/vitorpamplona/amethyst/relays/index`). Falls back to the + * shipping defaults when the user hasn't configured anything. + * + * This is what `amy wot sync` uses; `outboxRelays()` / + * `inboxRelays()` remain for callers that want relay lists derived + * from NIP-65 identity semantics. + */ + fun indexRelays(): Set = + com.vitorpamplona.amethyst.commons.relays.index + .PreferencesIndexRelays() + .effective() + /** * Seed relays for "look up someone we know nothing about" queries — * fetching another user's kind:10002 / 10050 / 10051 / 30443 before we @@ -411,15 +522,26 @@ class Context( * Subscribe to the given filters across the given relays, drain all events * until either every relay has sent EOSE or the timeout elapses, and * return them. Used for one-shot catch-up queries — not live subscriptions. + * + * When [deadOut] is provided, every relay that reported it could not be + * connected to (`onCannotConnect`) is added to it, so callers can prune + * proven-dead relays from future routing instead of paying the full + * [timeoutMs] on them again. Slow-but-connected relays are NOT reported — + * only hard connect failures, so a temporarily-busy relay isn't discarded. */ suspend fun drain( filters: Map>, timeoutMs: Long = 8_000, + diagnoseSlow: Boolean = false, + deadOut: MutableMap? = null, ): List> { if (filters.isEmpty()) return emptyList() val eventChannel = Channel>(UNLIMITED) - val doneChannel = Channel(UNLIMITED) + // Carries the terminal reason per relay so a timeout can distinguish a slow + // relay (never terminal) from a connect failure / CLOSED. + val doneChannel = Channel>(UNLIMITED) val remaining = filters.keys.toMutableSet() + val doneReasons = HashMap() val subId = newSubId() val listener = object : SubscriptionListener { @@ -436,7 +558,7 @@ class Context( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(relay) + doneChannel.trySend(relay to "eose") } override fun onClosed( @@ -444,7 +566,7 @@ class Context( relay: NormalizedRelayUrl, forFilters: List?, ) { - doneChannel.trySend(relay) + doneChannel.trySend(relay to "closed:$message") } override fun onCannotConnect( @@ -452,37 +574,75 @@ class Context( message: String, forFilters: List?, ) { - doneChannel.trySend(relay) + doneChannel.trySend(relay to "cannot:$message") } } val collected = mutableListOf>() try { client.subscribe(subId, filters, listener) - withTimeoutOrNull(timeoutMs) { - while (remaining.isNotEmpty()) { - select { - eventChannel.onReceive { pair -> - if (verifyAndStore(pair.second)) collected.add(pair) + val completed = + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + eventChannel.onReceive { pair -> + if (verifyAndStore(pair.second)) collected.add(pair) + } + doneChannel.onReceive { (relay, reason) -> + remaining.remove(relay) + doneReasons[relay] = reason + } } - doneChannel.onReceive { r -> remaining.remove(r) } } + // Drain any events that landed after EOSE but before cancel + while (true) { + val r = eventChannel.tryReceive() + if (!r.isSuccess) break + val pair = r.getOrThrow() + if (verifyAndStore(pair.second)) collected.add(pair) + } + true } - // Drain any events that landed after EOSE but before cancel - while (true) { - val r = eventChannel.tryReceive() - if (!r.isSuccess) break - val pair = r.getOrThrow() - if (verifyAndStore(pair.second)) collected.add(pair) - } + if (diagnoseSlow && completed == null && remaining.isNotEmpty()) { + logSlowDrain(timeoutMs, remaining, doneReasons, collected) } } finally { client.unsubscribe(subId) eventChannel.close() doneChannel.close() } + deadOut?.let { out -> + for ((relay, reason) in doneReasons) { + classifyDrainFailure(reason)?.let { out[relay] = it } + } + } return collected } + /** + * On a [drain] timeout, report which relays stalled and why — a relay that + * never sent EOSE (slow, possibly still streaming) vs one that couldn't be + * reached (CANNOT-CONNECT, which points at our side / the network) vs one + * that CLOSED the sub. Includes how many events each slow relay did send, so + * "relay is slow" and "we never connected" are easy to tell apart. + */ + private fun logSlowDrain( + timeoutMs: Long, + stalled: Set, + doneReasons: Map, + collected: List>, + ) { + val eventsPer = collected.groupingBy { it.first }.eachCount() + val cannot = doneReasons.filterValues { it.startsWith("cannot") } + val closed = doneReasons.filterValues { it.startsWith("closed") } + val slowDetail = stalled.take(12).joinToString(", ") { "${it.url}(${eventsPer[it] ?: 0}ev)" } + val cannotDetail = cannot.entries.take(8).joinToString(", ") { "${it.key.url}=${it.value.removePrefix("cannot:").take(40)}" } + System.err.println( + "[drain] timeout ${timeoutMs}ms: ${stalled.size} slow(no EOSE), ${cannot.size} cannot-connect, ${closed.size} closed" + + (if (slowDetail.isNotEmpty()) " | slow: $slowDetail" else "") + + (if (cannotDetail.isNotEmpty()) " | cannot: $cannotDetail" else ""), + ) + } + /** * Like [drain], but paginates every relay to completion via * [fetchAllPagesFromPool] instead of stopping at the first EOSE — so a query @@ -582,26 +742,17 @@ class Context( } /** - * Verify [event]'s NIP-01 id+signature and, if valid, persist it - * to [store]. Returns `true` when the event was accepted (and - * therefore should be surfaced to callers). Persistence failures - * (I/O errors, full disk) are logged but do not propagate. + * Verify [event]'s NIP-01 id+signature and, if valid, persist it to [store]. + * Returns `true` when the event was accepted (and therefore should be surfaced + * to callers). Persistence failures (I/O errors, full disk) are logged but do + * not propagate; a UNIQUE-constraint rejection is normal and swallowed quietly. * - * Every event-arrival path in the CLI funnels through this method - * so that [store] is the authoritative cache of what Amy has seen. + * Every event-arrival path in the CLI funnels through this so that [store] is + * the authoritative cache of what Amy has seen. Delegates to the shared quartz + * [verifyAndInsert] sink so the CLI and the GrapeRank crawler apply the exact + * same verify-then-store policy. */ - suspend fun verifyAndStore(event: Event): Boolean { - if (!event.verify()) { - System.err.println("[cli] dropped event ${event.id.take(8)} kind=${event.kind} — bad signature") - return false - } - try { - store.insert(event) - } catch (t: Throwable) { - System.err.println("[cli] store insert failed for ${event.id.take(8)}: ${t.message}") - } - return true - } + suspend fun verifyAndStore(event: Event): Boolean = store.verifyAndInsert(event) // ------------------------------------------------------------------ // Cache-first reads from [store] @@ -852,7 +1003,8 @@ class Context( } override fun close() { - dataDir.saveRunState(state) + // Nothing to persist for an anonymous run (no account dir to write into). + if (!anonymous) dataDir.saveRunState(state) (signer as? NostrSignerRemote)?.let { try { it.closeSubscription() @@ -881,12 +1033,21 @@ class Context( */ private const val GIFT_WRAP_LOOKBACK_SECS: Long = 2L * 24 * 60 * 60 - /** Build a Context but require an identity to already exist — most commands can't run without one. */ + /** + * Build a Context but require an account with a usable identity — + * signing verbs can't run without one. Throws [IllegalArgumentException] + * (→ exit 2) when no account was resolvable, carrying the "which + * account?" hint from [DataDir.resolveOptional]; throws + * [IllegalStateException] when the account exists but has no identity. + */ fun open(dataDir: DataDir): Context { + require(dataDir.hasAccount) { + dataDir.noAccountDetail ?: "no account selected; pass --account or run `amy use `" + } val identity = dataDir.loadIdentityOrNull() ?: run { - System.err.println("No identity found at ${dataDir.identityFile}. Run `amethyst-cli init` first.") + System.err.println("No identity found at ${dataDir.identityFile}. Run `amy --account ${dataDir.accountName} init` first.") throw IllegalStateException("no identity") } return Context( @@ -895,5 +1056,24 @@ class Context( state = dataDir.loadRunState(), ) } + + /** + * Context for read-only verbs: use the resolved account when one is + * present, otherwise run anonymously (ephemeral key-less identity, no + * persisted state). Lets `fetch`/`subscribe`/`count`/`publish`/`outbox`/ + * … query relays and the shared store with no account on disk — they + * read fine, they just can't sign. + */ + fun openOrAnonymous(dataDir: DataDir): Context = + if (dataDir.hasAccount && dataDir.identityExists()) { + open(dataDir) + } else { + Context( + dataDir = dataDir, + identity = Identity.anonymous(), + state = RunState(), + anonymous = true, + ) + } } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 49157a3c10..786f06dfbe 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -38,12 +38,14 @@ import com.vitorpamplona.amethyst.cli.commands.FilterCommand import com.vitorpamplona.amethyst.cli.commands.FollowCommand import com.vitorpamplona.amethyst.cli.commands.GiftCommands import com.vitorpamplona.amethyst.cli.commands.GitCommands +import com.vitorpamplona.amethyst.cli.commands.GrapeRankCommand import com.vitorpamplona.amethyst.cli.commands.GroupCommands import com.vitorpamplona.amethyst.cli.commands.InitCommands import com.vitorpamplona.amethyst.cli.commands.KeyCommands import com.vitorpamplona.amethyst.cli.commands.KeyPackageCommands import com.vitorpamplona.amethyst.cli.commands.KindCommand import com.vitorpamplona.amethyst.cli.commands.LoginCommand +import com.vitorpamplona.amethyst.cli.commands.LogoffCommand import com.vitorpamplona.amethyst.cli.commands.MarmotResetCommand import com.vitorpamplona.amethyst.cli.commands.MessageCommands import com.vitorpamplona.amethyst.cli.commands.NamecoinCommand @@ -61,16 +63,20 @@ import com.vitorpamplona.amethyst.cli.commands.RelayCommands import com.vitorpamplona.amethyst.cli.commands.RelayGroupCommands import com.vitorpamplona.amethyst.cli.commands.SearchCommand import com.vitorpamplona.amethyst.cli.commands.ServeCommand +import com.vitorpamplona.amethyst.cli.commands.StatusCommand import com.vitorpamplona.amethyst.cli.commands.StoreCommands import com.vitorpamplona.amethyst.cli.commands.SubscribeCommand import com.vitorpamplona.amethyst.cli.commands.SyncCommand import com.vitorpamplona.amethyst.cli.commands.UseCommand import com.vitorpamplona.amethyst.cli.commands.VerifyCommand +import com.vitorpamplona.amethyst.cli.commands.WotCommand import com.vitorpamplona.amethyst.cli.commands.ZapCommand import com.vitorpamplona.amethyst.cli.commands.cashu.CashuCommands import com.vitorpamplona.amethyst.cli.commands.cashu.CashuMintCommands import com.vitorpamplona.amethyst.cli.commands.route import com.vitorpamplona.amethyst.cli.secrets.SecretStore +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.coroutines.runBlocking import kotlin.system.exitProcess @@ -104,6 +110,12 @@ fun main(argv: Array) { // braces guard for invocations that bypass the launcher scripts. System.setProperty("java.awt.headless", "true") + // Quiet quartz's internal DEBUG chatter (relay auth, MLS restore, URL + // rejection, throttle notices) by default so it doesn't drown a command's + // own output; --verbose / -v restores full DEBUG. Set before dispatch so + // even startup logging is gated. + Log.minLevel = if (argv.any { it == "--verbose" || it == "-v" }) LogLevel.DEBUG else LogLevel.WARN + // Set output mode before dispatch so even argument-parsing errors // honour --json. if (argv.any { it == "--json" || it == "--json=true" }) { @@ -129,6 +141,16 @@ class AwaitTimeout( message: String, ) : RuntimeException(message) +/** + * Verbs that create, select, or delete the account/identity on disk. They + * write to (or read) the per-account directory directly rather than through + * `Context.open`, so they need a concrete account and must resolve strictly — + * an accountless run has nowhere to put a new identity. Every other verb + * resolves via [DataDir.resolveOptional] and either runs anonymously (reads) + * or re-asserts the requirement inside `Context.open` (signing). + */ +private val STRICT_ACCOUNT_VERBS = setOf("init", "create", "login", "logoff", "whoami") + private suspend fun dispatch(argv: Array): Int { if (argv.isEmpty() || argv[0] == "--help" || argv[0] == "-h") { printUsage() @@ -150,6 +172,7 @@ private suspend fun dispatch(argv: Array): Int { GlobalFlag.SECRET_BACKEND -> secretBackendFlag = consumed.value GlobalFlag.PASSPHRASE_FILE -> passphraseFileFlag = consumed.value GlobalFlag.JSON -> Output.mode = Output.Mode.JSON + GlobalFlag.VERBOSE -> Unit // level already applied in main(); just strip it here null -> filteredArgs.add(a) } i += consumed.tokensConsumed @@ -170,6 +193,14 @@ private suspend fun dispatch(argv: Array): Int { return UseCommand.run(tail) } + // `status` is a cross-account, read-only overview of everything on + // disk under ~/.amy/. Like `use`, it must work regardless of how many + // accounts exist (zero, one, or many), so it dispatches before account + // resolution rather than through the single-account DataDir path. + if (head == "status") { + return StatusCommand.run(tail) + } + // Stateless local primitives (nak-style army-knife verbs). They operate // purely on their arguments — no identity, no relays, no `~/.amy/` — so // they dispatch before account resolution and work with zero state. @@ -197,13 +228,33 @@ private suspend fun dispatch(argv: Array): Int { return CashuMintCommands.dispatch(tail.drop(1).toTypedArray()) } + // `offer info NOFFER` / `debit info NDEBIT` decode a CLINK pointer locally — + // no network, no account. The rest of `offer`/`debit` operates on the account. + if (head == "offer" && tail.firstOrNull() == "info") { + return OfferCommands.info(tail.drop(1).toTypedArray()) + } + if (head == "debit" && tail.firstOrNull() == "info") { + return DebitCommands.info(tail.drop(1).toTypedArray()) + } + val secrets = SecretStore.from(backendFlag = secretBackendFlag, passphraseFile = passphraseFileFlag) - val dataDir = DataDir.resolve(accountFlag = accountFlag, secrets = secrets) + // Identity-lifecycle verbs create / select / delete the account itself, so + // they need a concrete account and resolve strictly (helpful ambiguity + // errors). Everything else resolves optionally: read-only verbs then run + // anonymously when there is no account, while signing verbs re-assert the + // requirement through `Context.open`. + val dataDir = + if (head in STRICT_ACCOUNT_VERBS) { + DataDir.resolve(accountFlag = accountFlag, secrets = secrets) + } else { + DataDir.resolveOptional(accountFlag = accountFlag, secrets = secrets) + } return when (head) { "init" -> InitCommands.init(dataDir, Args(tail)) "create" -> CreateCommand.run(dataDir, tail) "login" -> LoginCommand.run(dataDir, tail) + "logoff" -> LogoffCommand.run(dataDir, tail) "whoami" -> InitCommands.whoami(dataDir) "relay" -> RelayCommands.dispatch(dataDir, tail) "marmot" -> marmotDispatch(dataDir, tail) @@ -216,6 +267,7 @@ private suspend fun dispatch(argv: Array): Int { "store" -> StoreCommands.dispatch(dataDir, tail) "follow" -> FollowCommand.follow(dataDir, tail) "unfollow" -> FollowCommand.unfollow(dataDir, tail) + "graperank" -> GrapeRankCommand.dispatch(dataDir, tail) "search" -> SearchCommand.dispatch(dataDir, tail) "zap" -> ZapCommand.dispatch(dataDir, tail) "offer" -> OfferCommands.dispatch(dataDir, tail) @@ -238,6 +290,7 @@ private suspend fun dispatch(argv: Array): Int { "podcast" -> PodcastCommands.dispatch(dataDir, tail) "podcast20" -> Podcast20Commands.dispatch(dataDir, tail) "bunker" -> BunkerCommand.run(dataDir, tail) + "wot" -> WotCommand.dispatch(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -267,11 +320,13 @@ private suspend fun marmotDispatch( private enum class GlobalFlag( val long: String, val takesValue: Boolean = true, + val short: String? = null, ) { ACCOUNT("--account"), SECRET_BACKEND("--secret-backend"), PASSPHRASE_FILE("--passphrase-file"), JSON("--json", takesValue = false), + VERBOSE("--verbose", takesValue = false, short = "-v"), } private data class ConsumedFlag( @@ -290,7 +345,7 @@ private fun extractGlobalFlag( idx: Int, ): Pair { for (flag in GlobalFlag.values()) { - if (token == flag.long) { + if (token == flag.long || token == flag.short) { return if (flag.takesValue) { flag to ConsumedFlag(argv.getOrNull(idx + 1), 2) } else { @@ -315,20 +370,27 @@ private fun printUsage() { | [--secret-backend auto|keychain|ncryptsec|plaintext] | [--passphrase-file PATH] | [--json] + | [--verbose|-v] | [args...] | |Account selection: | All state lives under ~/.amy/. Per-account directories | ~/.amy// hold identity, cursors, MLS state, and | aliases; every observed Nostr event lands in the shared - | ~/.amy/shared/events-store/. ACCOUNT must match + | store under ~/.amy/shared/ (a SQLite `events.db` by default, or + | the `events-store/` tree when AMY_STORE=fs). ACCOUNT must match | [a-zA-Z0-9_-]{1,64} (no spaces, no slashes). | | Resolution order: | 1. --account X if given. | 2. ~/.amy/current marker (set by `amy use X`). | 3. Sole subdirectory of ~/.amy/ other than shared/. - | 4. Error — disambiguate with --account or `amy use`. + | 4. Read-only verbs (fetch, subscribe, count, publish, outbox, + | search, sync, store, profile/git/podcast reads, nsite/napplet + | fetch, decode/encode/… primitives, offer/debit info) run + | ANONYMOUSLY — they query relays and the shared store with no + | account, they just can't sign. Signing verbs error here: + | disambiguate with --account or `amy use`. | | Test harnesses isolate by overriding ${'$'}HOME for the amy | subprocess (`HOME=/tmp/run.123 amy --account alice ...`). @@ -336,6 +398,9 @@ private fun printUsage() { | use NAME pin NAME as the active account | use --clear remove the pin | use print current pin + available accounts + | status read-only overview of every account, signer + | type, local Marmot/Cashu state, and the shared + | event store (no keychain prompt, no network) | |Output: | Default: human-readable text on stdout. @@ -382,6 +447,9 @@ private fun printUsage() { | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://) | whoami print current identity + | logoff [--yes] [--keep-events] log off: delete this account's key, per-account state, + | and its events in the shared store (--keep-events skips the + | cache purge). Requires --yes; without it, prints a dry run. | |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a @@ -526,6 +594,42 @@ private fun printUsage() { | unfollow USER [--timeout SECS] remove USER from your contact list | (USER: npub|nprofile|hex|name@domain) | + |Web of Trust (GrapeRank): + | graperank [OBSERVER] compute subjective trust scores (0..1) for every + | [--limit N] [--min-score X] user reachable in the follow/mute/report graph. + | [--rigor X] [--attenuation X] Exhaustively crawls each user's kind:10002 outbox + | [--max-rounds N] [--max-hops N] for their latest kind:3/10000/1984 until every + | [--offline] [--timeout SECS] discovered user has been checked (no user cap; + | [--diagnose] --max-hops bounds follow distance, e.g. 8; + | --diagnose dumps per-relay telemetry: outcome + | mix, yield, latency, and a LIVE/DEAD + limits + | classification table of every relay contacted). + | [--publish] [--min-rank N] OBSERVER: npub|nprofile|hex|name@domain (default: + | [--publish-limit N] [--publish-relay URL] active account). --offline scores from the local + | store only. --publish reconciles NIP-85 kind:30382 + | cards signed by a per-observer service key: sends + | new/changed ranks >= --min-rank (default 2), skips + | unchanged, and retracts (kind:5) any card whose + | target left the graph or fell below the cutoff. + | graperank update [--down] [--up] refresh every locally-known author's WoT record kinds + | [--no-sync-deletions] [--timeout SECS] (0/3/10002/1984) from their own outbox: reads all + | [--relay-concurrency N] [--author-chunk N] kind:10002 in the store, groups authors by write + | [--min-authors N] [--report-limit N] relay, and runs one NIP-77 negentropy reconcile per + | relay scoped to its authors. Bidirectional by default; + | the deletion settle downloads the relay's kind:5 when + | an uploaded record was rejected (author retracted it). + | Falls back to a full paged download when a relay + | can't reconcile via negentropy. + | graperank operator [status|relay … manage the machine's operator keys (~/.amy/operator/, + | |providers] independent of accounts): relay sets where cards + + | retractions publish; status shows master + relays; + | providers lists observer -> service-pubkey. + | graperank register [PROVIDER] declare a NIP-85 provider in your kind:10040 so + | [--service KIND:TAG] [--relay URL] clients can discover it (default: self as the + | [--private] 30382:rank provider at your first outbox relay). + | graperank providers [USER] [--refresh] list a user's declared NIP-85 trusted providers + | [--timeout SECS] (default: active account). + | |Zaps (NIP-57): | zap user USER SATS build a profile zap-request, fetch a BOLT11 | [--comment X] [--anon|--private] invoice from the recipient's LN service @@ -617,11 +721,15 @@ private fun printUsage() { | | marmot reset [--yes] wipe all local MLS/KeyPackage state (destructive) | - |Local event store (`/events-store/`): - | store stat event count, kind histogram, disk usage + |Local event store (shared, under `/shared/`): + | Backend selected by AMY_STORE: sqlite (default; `shared/events.db`) + | or fs (`AMY_STORE=fs`; the `shared/events-store/` tree). SQLite is + | far more compact at scale — the FS tree spends one file per index + | posting, so large crawls balloon on disk. + | store stat event count + disk usage (kind histogram/mtime on fs) | store sweep-expired delete events past their NIP-40 expiration - | store scrub rebuild idx/ from canonical events (after edits / crashes) - | store compact drop dangling idx entries (canonical gone) + | store scrub fs: rebuild idx/ from canonical events; sqlite: no-op + | store compact fs: drop dangling idx entries; sqlite: VACUUM | store reindex-fts rebuild the NIP-50 search index (after a searchable-kinds change) """.trimMargin(), ) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt new file mode 100644 index 0000000000..68c095b234 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/OperatorKeys.kt @@ -0,0 +1,175 @@ +/* + * 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.cli + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret +import com.vitorpamplona.amethyst.cli.secrets.SecretStore +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File + +/** + * Operator-level signing keys for GrapeRank trusted-assertion publishing. + * + * A machine holds ONE operator master seed, **independent of any amy account**, + * stored under `~/.amy/operator/` through the same [SecretStore] backend the + * accounts use (OS keychain / NIP-49 ncryptsec / plaintext). From it we + * deterministically derive ONE service key per observer: + * + * ``` + * serviceKey(observer) = sha256(masterPriv ‖ "graperank-provider:" ‖ observerHex ‖ counter) + * ``` + * + * That service key signs the observer's kind:30382 rank cards (and their kind:5 + * retractions). Deterministic derivation buys two things: + * - **Stable identity** — the same observer always maps to the same key, so + * re-signing a card *replaces* the prior one (kind:30382 is addressable) + * instead of orphaning it and spamming clients with duplicates. + * - **One-secret backup** — back up only the master seed; every service key is + * re-derivable even if the [providers] manifest is lost. + * + * The manifest (`~/.amy/operator/operator.json`) records the master pubkey, the + * configured operator relay(s), and the observer → provider-pubkey mapping. Only + * the master itself is a secret; it rides the [SecretStore] descriptor, so the + * manifest holds public data. + */ +class OperatorKeys( + amyHome: File, + private val secrets: SecretStore, +) { + private val dir = File(amyHome, DIR_NAME) + private val configFile = File(dir, CONFIG_NAME) + + data class ProviderRecord( + val providerPubKey: HexKey = "", + ) + + data class Config( + val masterPubKey: HexKey = "", + val master: IdentitySecret? = null, + val relays: List = emptyList(), + val providers: MutableMap = mutableMapOf(), + ) + + private fun load(): Config? = if (configFile.exists()) Output.mapper.readValue(configFile.readText()) else null + + private fun save(cfg: Config) { + SecureFileIO.secureMkdirs(dir) + configFile.writeText(Output.mapper.writeValueAsString(cfg)) + SecureFileIO.tighten(configFile) + } + + /** True once an operator master exists on this machine. */ + fun exists(): Boolean = load()?.master != null + + /** Load (or, on first use, create + persist) the operator master private key. */ + private fun masterPriv(): ByteArray { + load()?.master?.let { return secrets.resolve(it).hexToByteArray() } + val kp = KeyPair() + val pub = kp.pubKey.toHexKey() + val secret = secrets.store(pub, kp.privKey!!.toHexKey()) + save(Config(masterPubKey = pub, master = secret)) + System.err.println("[operator] created operator master ${pub.take(8)}… at ${configFile.path}") + return kp.privKey!! + } + + /** The operator master pubkey, creating the master on first use. */ + fun masterPubKey(): HexKey { + masterPriv() + return load()!!.masterPubKey + } + + /** + * The deterministic service key for [observerHex], recording the observer → + * provider-pubkey mapping in the manifest. The counter loop only ever runs + * once in practice — it's a guard for the ~2^-128 chance a sha256 output isn't + * a valid secp256k1 scalar. + */ + fun serviceKey(observerHex: HexKey): KeyPair { + val master = masterPriv() + var counter = 0 + while (true) { + val material = master + "$DERIVATION_LABEL$observerHex:$counter".encodeToByteArray() + val kp = runCatching { KeyPair(privKey = sha256(material)) }.getOrNull() + if (kp?.privKey != null) { + recordProvider(observerHex, kp.pubKey.toHexKey()) + return kp + } + counter++ + } + } + + /** + * The machine's dedicated NIP-66 relay-monitor identity, derived once from the + * operator master (independent of any amy account). Unlike [serviceKey] this is + * NOT per-observer — the machine publishes relay-reachability (kind:30166) under a + * single, stable monitor pubkey, so a re-probe *replaces* the prior 30166 for a + * relay instead of orphaning it. Re-derivable from the one master seed alone. + */ + fun monitorKey(): KeyPair { + val master = masterPriv() + var counter = 0 + while (true) { + val material = master + "$MONITOR_LABEL$counter".encodeToByteArray() + val kp = runCatching { KeyPair(privKey = sha256(material)) }.getOrNull() + if (kp?.privKey != null) return kp + counter++ + } + } + + private fun recordProvider( + observerHex: HexKey, + providerPubKey: HexKey, + ) { + val cfg = load() ?: return + if (cfg.providers[observerHex]?.providerPubKey == providerPubKey) return + cfg.providers[observerHex] = ProviderRecord(providerPubKey) + save(cfg) + } + + /** Relays the operator publishes all its 30382 cards + retractions to. */ + fun operatorRelays(): Set = + load() + ?.relays + .orEmpty() + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + + fun setRelays(urls: List) { + masterPriv() // make sure the config (and master) exists first + save(load()!!.copy(relays = urls)) + } + + fun providers(): Map = load()?.providers.orEmpty() + + companion object { + private const val DIR_NAME = "operator" + private const val CONFIG_NAME = "operator.json" + private const val DERIVATION_LABEL = "graperank-provider:" + private const val MONITOR_LABEL = "relay-monitor:" + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt index 7f30d19349..d24b83a3dc 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Output.kt @@ -86,6 +86,12 @@ object Output { return 1 } + /** + * Shared `bad_args` failure for any command that takes a relay-URL + * argument, so every command names the offending input the same way. + */ + fun invalidRelayUrl(raw: String): Int = error("bad_args", "invalid relay url: $raw") + private fun renderText(value: Any?): String { val color = Ansi.forStream(isStderr = false) val out = StringBuilder() diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt new file mode 100644 index 0000000000..be275a71dd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/RelayDiagnostics.kt @@ -0,0 +1,96 @@ +/* + * 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.cli + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong + +/** + * Client-wide tally of the relay feedback the crawl would otherwise never see: + * `NOTICE` frames, `CLOSED` reasons (`auth-required` / `rate-limited` / + * `restricted` / …), and NIP-42 `AUTH` challenges. Registered as a + * [RelayConnectionListener] on the shared client, so every incoming message + * during a run is counted and a REQ failure can be explained instead of + * guessed at. + * + * Callbacks fire on the per-relay socket threads, so all state is concurrent. + */ +class RelayDiagnostics : RelayConnectionListener { + private val closedByReason = ConcurrentHashMap() + private val noticeSamples = ConcurrentHashMap() + private val authChallenges = AtomicLong() + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + when (msg) { + // CLOSED reasons follow the NIP-01 machine-readable "word: text" + // convention, so the prefix categorises the failure. + is ClosedMessage -> bump(closedByReason, prefix(msg.message)) + // NOTICE is free-form; keep the (truncated) text so recurring + // relay complaints ("too many concurrent REQs", …) are visible. + is NoticeMessage -> if (noticeSamples.size < MAX_DISTINCT_NOTICES) bump(noticeSamples, msg.message.trim().take(80)) + is AuthMessage -> authChallenges.incrementAndGet() + else -> Unit + } + } + + private fun bump( + map: ConcurrentHashMap, + key: String, + ) { + map.getOrPut(key) { AtomicLong() }.incrementAndGet() + } + + /** The NIP-01 machine-readable prefix (`word` before `:`), or `other`. */ + private fun prefix(message: String): String { + val head = message.substringBefore(':').trim().lowercase() + return head.ifEmpty { "other" }.take(24) + } + + fun hadFeedback(): Boolean = authChallenges.get() > 0 || closedByReason.isNotEmpty() || noticeSamples.isNotEmpty() + + /** JSON-friendly summary for the command output. */ + fun snapshot(): Map = + mapOf( + "auth_challenges" to authChallenges.get(), + "closed_by_reason" to closedByReason.entries.associate { it.key to it.value.get() }.toSortedMap(), + "notices" to noticeSamples.values.sumOf { it.get() }, + "notice_top" to + noticeSamples.entries + .sortedByDescending { it.value.get() } + .take(TOP_NOTICES) + .map { "${it.key} (${it.value.get()})" }, + ) + + companion object { + private const val MAX_DISTINCT_NOTICES = 500 + private const val TOP_NOTICES = 8 + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt new file mode 100644 index 0000000000..8c75bbf25f --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreFactory.kt @@ -0,0 +1,89 @@ +/* + * 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.cli + +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import kotlin.io.path.Path + +/** On-disk backend for the shared event store. */ +enum class StoreBackend { + /** + * Single SQLite database file at [DataDir.eventsDbFile]. Postings live + * in shared B-tree pages, so an event's kind/author/tag indexes cost a + * handful of rows — not one 4 KB-block file each, the way the FS store + * lays them out. For crawl-scale corpora (hundreds of thousands of + * follow lists) this is several times smaller on disk and the default. + */ + SQLITE, + + /** + * Filesystem tree at [DataDir.eventsDir] — one pretty-printed JSON file + * per event plus one file per index posting. Human-inspectable with + * `cat`/`jq`/`git diff`, but every posting rounds up to a filesystem + * block, so a large corpus balloons. Opt in with `AMY_STORE=fs`. + */ + FS, +} + +/** + * Chooses and opens the event-store backend for `amy`. The backend is + * selected by the `AMY_STORE` environment variable and defaults to + * [StoreBackend.SQLITE]; set `AMY_STORE=fs` for the legacy filesystem + * store. Both backends implement [IEventStore], so every command works + * unchanged regardless of the choice — the only user-visible difference + * is where bytes land ([DataDir.eventsDbFile] vs [DataDir.eventsDir]) and + * how much disk they take. + */ +object StoreFactory { + const val ENV = "AMY_STORE" + + /** Resolve the configured backend. Unrecognised values fall back to the default. */ + fun backend(): StoreBackend = + when (System.getenv(ENV)?.trim()?.lowercase()) { + "fs", "file", "files", "filesystem" -> StoreBackend.FS + else -> StoreBackend.SQLITE + } + + /** + * Open the store for [dataDir] using the configured [backend]. Events + * are written pretty-printed on the FS backend so the on-disk JSON stays + * inspection-friendly; the SQLite backend stores the compact NIP-01 + * form internally. Neither is re-used for signature checks (verification + * always re-canonicalises), so the stored representation is purely an + * implementation detail. Callers own [IEventStore.close]. + */ + fun open(dataDir: DataDir): IEventStore = + when (backend()) { + StoreBackend.SQLITE -> { + // BundledSQLiteDriver won't create parent directories. + dataDir.eventsDbFile.parentFile?.mkdirs() + EventStore(dbName = dataDir.eventsDbFile.absolutePath, relay = null) + } + StoreBackend.FS -> + FsEventStore( + root = Path(dataDir.eventsDir.absolutePath), + eventToJson = JacksonMapper::toJsonPretty, + ) + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt new file mode 100644 index 0000000000..234d66c167 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/StoreStats.kt @@ -0,0 +1,121 @@ +/* + * 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.cli + +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.TimeUnit +import kotlin.io.path.exists + +/** + * Read-only introspection of a file-backed Nostr event store on disk. + * + * Pure filesystem walk — no relay traffic, no writer lock, no [Context]. + * Shared by `amy store stat` (full detail) and `amy status` (a compact + * roll-up alongside the account overview). + */ +data class StoreStats( + val events: Long, + /** Per-kind event counts derived from `idx/kind//`, sorted by kind string. */ + val byKind: Map, + val diskBytes: Long, + /** Oldest / newest event file mtime, in unix seconds. Null on an empty store. */ + val oldestAt: Long?, + val newestAt: Long?, + val root: Path, +) { + val distinctKinds: Int get() = byKind.size + + companion object { + /** Compute stats for the store rooted at [storeRoot]. Missing dir → all-zero. */ + fun of(storeRoot: Path): StoreStats { + if (!storeRoot.exists()) { + return StoreStats(0, emptyMap(), 0L, null, null, storeRoot.toAbsolutePath()) + } + + val eventsRoot = storeRoot.resolve("events") + var count = 0L + var oldest: Long? = null + var newest: Long? = null + if (Files.isDirectory(eventsRoot)) { + Files.walk(eventsRoot).use { stream -> + for (p in stream) { + if (!Files.isRegularFile(p)) continue + if (!p.fileName.toString().endsWith(".json")) continue + count++ + val mt = + try { + Files.getLastModifiedTime(p).to(TimeUnit.SECONDS) + } catch (_: IOException) { + continue + } + val o = oldest + if (o == null || mt < o) oldest = mt + val n = newest + if (n == null || mt > n) newest = mt + } + } + } + + // Histogram from idx/kind// — for a healthy store this is + // exactly one entry per (kind, event), so summing matches `count`. + // Mismatch points at index drift; run `amy store scrub` to fix. + val kindRoot = storeRoot.resolve("idx/kind") + val byKind = sortedMapOf() + if (Files.isDirectory(kindRoot)) { + Files.list(kindRoot).use { stream -> + for (kindDir in stream) { + if (!Files.isDirectory(kindDir)) continue + val n = Files.list(kindDir).use { it.count() } + byKind[kindDir.fileName.toString()] = n + } + } + } + + return StoreStats( + events = count, + byKind = byKind, + diskBytes = walkSize(storeRoot), + oldestAt = oldest, + newestAt = newest, + root = storeRoot.toAbsolutePath(), + ) + } + + private fun walkSize(root: Path): Long { + if (!Files.exists(root)) return 0L + var total = 0L + Files.walk(root).use { stream -> + for (p in stream) { + if (!Files.isRegularFile(p)) continue + total += + try { + Files.size(p) + } catch (_: IOException) { + 0L + } + } + } + return total + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt index d377861911..1c80df3c3d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/AdminCommand.kt @@ -55,7 +55,7 @@ object AdminCommand { val args = Args(rest) val relayArg = args.positionalOrNull(0) ?: return Output.error("bad_args", "usage: admin RELAY METHOD [args]") val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods") - val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.error("bad_args", "invalid relay url: $relayArg") + val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg) val p2 = args.positionalOrNull(2) val reason = args.flag("reason") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index f7ed5a8288..56d1847c30 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -85,7 +85,8 @@ object BlossomCommands { .map { it.trim() } .filter { it.isNotEmpty() } - Context.open(dataDir).use { _ -> + // Read-only HEAD probe — no auth, so it runs anonymously without an account. + Context.openOrAnonymous(dataDir).use { _ -> val http = OkHttpClient() val results = hashes.map { hash -> @@ -188,7 +189,8 @@ object BlossomCommands { val server = args.flag("server") val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target - Context.open(dataDir).use { ctx -> + // Public download — no auth, so it runs anonymously without an account. + Context.openOrAnonymous(dataDir).use { ctx -> val bytes = BlossomClient().download(url) ?: return Output.error("not_found", "server returned no blob for $url") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt index 3d2c4e004e..f912f83253 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/CountCommand.kt @@ -47,7 +47,7 @@ object CountCommand { val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 15L) * 1000 val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt index f89d79c8b0..c6b668d42c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/DebitCommands.kt @@ -60,7 +60,7 @@ object DebitCommands { ) /** Local decode of an `ndebit` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val debit = ClinkPointerParser.parse(args.positional(0, "ndebit").trim()) as? NDebit diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt index 25bce1e8b8..cf207405f0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FeedCommand.kt @@ -61,7 +61,10 @@ object FeedCommand { val until = args.flag("until")?.toLongOrNull() val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account. `--author` / + // `--following` still work; the bare "self" feed just has no self to + // resolve without an account. + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val (authors, mode) = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt index 9126779780..9118f8f925 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/FetchCommand.kt @@ -94,7 +94,7 @@ object FetchCommand { val filter = RawEventSupport.buildFilter(args).copy(limit = effectiveLimit) val paginate = args.bool("paginate") || args.bool("all") - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") @@ -148,7 +148,7 @@ object FetchCommand { timeoutMs: Long, ): Int { val code = codeArg.removePrefix("nostr:") - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() var filter: Filter diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt index 9b14eeac03..6c4d0c8054 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GitCommands.kt @@ -113,7 +113,9 @@ object GitCommands { rest: Array, ): Int { val args = Args(rest) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (defaults to + // the anonymous key, so pass a USER to list someone's repos). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) @@ -143,7 +145,7 @@ object GitCommands { return Output.error("bad_args", "not a git repository address (expected kind ${GitRepositoryEvent.KIND}, got ${addr.kind})") } - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val repo = fetchRepo(ctx, addr, args) ?: return Output.error("not_found", "no repository announcement found for $coord") Output.emit(repoSummary(repo) + mapOf("event_id" to repo.id, "content" to repo.content)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt new file mode 100644 index 0000000000..e9a7177e36 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/GrapeRankCommand.kt @@ -0,0 +1,990 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.defaults.Constants +import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList +import com.vitorpamplona.quartz.experimental.graperank.GrapeRank +import com.vitorpamplona.quartz.experimental.graperank.GrapeRankCrawler +import com.vitorpamplona.quartz.experimental.graperank.GrapeRankParams +import com.vitorpamplona.quartz.experimental.graperank.GrapeRankPublisher +import com.vitorpamplona.quartz.experimental.graperank.GrapeRankUpdater +import com.vitorpamplona.quartz.experimental.graperank.TrustGraphBuilder +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +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.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionIndex +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.serviceProviders +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ProviderTypes +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag +import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceType +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI +import java.util.concurrent.Executors +import kotlin.math.roundToInt + +/** + * `amy graperank [OBSERVER] [flags]` — compute GrapeRank web-of-trust scores. + * + * GrapeRank assigns every user reachable in the follow/mute/report graph a + * subjective trust score in `[0, 1]` from the observer's point of view (the + * observer has full self-trust). It crawls the follow graph outward using the + * outbox model — each user's kind:10002 write relays are located first, then + * their kind:3 / kind:10000 / kind:1984 events are fetched from *their own* + * relays. The crawl is exhaustive: it keeps going, with no user cap, until every + * discovered user's outbox has been checked and their contact list pulled (an + * unreachable outbox is retried a few times), then runs the scoring engine in + * `commons/wot`. + * + * Prints a ranked list (text, or one JSON object under `--json`). With + * `--publish`, results are also published as NIP-85 kind:30382 `ContactCardEvent` + * trusted assertions (one per scored user, `rank = round(score*100)`). + * + * The crawl and the computation are separable, because the crawl persists every + * event it fetches to the store and the score is a pure function over it: + * - `amy graperank crawl [OBSERVER]` — network only: crawl the reachable graph's + * kind 3/10000/1984/10002 into the local store (aliased as the former `sync`). + * Idempotent and cumulative, so run it a few times to make sure everything is + * loaded. Scores nothing. + * - `amy graperank score [OBSERVER]` — local only: build the graph from the store + * and score (same as bare `--offline`). Instant and param-tunable; repeat with + * different `--rigor`/`--attenuation`/cutoffs without re-crawling. + * - bare `amy graperank [OBSERVER]` — the convenience combo: crawl then score. + * + * Sub-verbs complete the NIP-85 provider experience — the discovery layer that + * lets clients find and consume those assertions: + * - `amy graperank register` — advertise a `30382:rank` provider in the + * account's kind:10040 [TrustProviderListEvent] (defaults to self, so a + * provider publishing ranks announces where to find them). + * - `amy graperank providers [USER]` — list a user's trusted providers. + */ +object GrapeRankCommand { + // Broad, big general relays that carry kind:10002 for many users, added to the + // crawler's discovery set to raise the odds of resolving a stranger's outbox. + private val EXTRA_DISCOVERY_RELAYS: Set = + listOf( + "wss://relay.damus.io", + "wss://relay.snort.social", + "wss://offchain.pub", + "wss://nostr.land", + "wss://eden.nostr.land", + ).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + // Network-wide aggregators that scrape and hold kind:3 for users whose own + // outbox lacks it. The crawler queries these for a straggler's CONTENT (kind:3), + // not just their kind:10002 relay list. Measured on observer 460c25e6, the distinct + // missing authors whose kind:3 each holds: kindpag.es 369, yabu 126, oxtr.dev 76, + // nos.lol 72, ditto 56, nostr1 29, momostr 11, mostr 3. So beyond the profile + // indexers (kindpag/purplepag/coracle/yabu/nostr1) and the ActivityPub bridges + // (ditto/momostr/mostr, which host bridged users' lists), two big general relays -- + // nostr.oxtr.dev and nos.lol -- carry ~150 more that no indexer has. + private val CONTENT_AGGREGATOR_RELAYS: Set = + DefaultIndexerRelayList + + listOf( + "wss://relay.ditto.pub", + "wss://relay.momostr.pink", + "wss://relay.mostr.pub", + "wss://nostr.oxtr.dev", + "wss://nos.lol", + ).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + private const val PROBE_TIMEOUT_MS = 2000 + + // The probe does BLOCKING DNS + TCP connect, and dead-domain DNS lookups can hang + // far past the connect timeout. On the shared Dispatchers.IO those hanging lookups + // starve the crawl's own IO — measured +462s on the finishing drain at hop-3. Run + // them on a dedicated, isolated daemon pool instead so the crawl's IO is untouched. + private val probeDispatcher = + Executors + .newFixedThreadPool(128) { r -> Thread(r, "relay-probe").apply { isDaemon = true } } + .asCoroutineDispatcher() + + /** + * Cheap reachability pre-probe: a raw TCP connect (one round trip) with a tight + * timeout. Returns false only when the port won't even accept a socket — a dead + * dropper, refusal, or unroutable/onion/LAN host — which the crawler drops into + * deadHosts before the WS path pays its 7s connectTimeout. A busy-but-alive relay + * accepts the SYN instantly at the kernel level (its slowness is at the app layer), + * so it passes here and is left for the real WS attempt. Unparseable host → true, + * so an odd URL is never culled on a parse quirk — let the WS decide. + */ + private suspend fun tcpReachable(relay: NormalizedRelayUrl): Boolean = + withContext(probeDispatcher) { + val hostPort = relayHostPort(relay) ?: return@withContext true + try { + Socket().use { it.connect(InetSocketAddress(hostPort.first, hostPort.second), PROBE_TIMEOUT_MS) } + true + } catch (e: Exception) { + if (e is CancellationException) throw e + false + } + } + + private fun relayHostPort(relay: NormalizedRelayUrl): Pair? = + try { + val uri = URI(relay.url) + val host = uri.host ?: return null + val port = + if (uri.port > 0) { + uri.port + } else if (relay.url.startsWith("wss://", ignoreCase = true)) { + 443 + } else { + 80 + } + host to port + } catch (e: Exception) { + null + } + + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int = + // Sub-verbs are explicit words; anything else (npub / hex / nprofile / + // NIP-05, or nothing) is the OBSERVER positional for a score computation. + when (tail.firstOrNull()) { + "register" -> register(dataDir, tail.drop(1).toTypedArray()) + "providers" -> providers(dataDir, tail.drop(1).toTypedArray()) + "operator" -> operator(dataDir, tail.drop(1).toTypedArray()) + // `sync` is the pre-rename name kept as a back-compat alias; `crawl` is + // canonical (disambiguates from negentropy `amy sync` / `graperank update`). + "crawl", "sync" -> crawl(dataDir, tail.drop(1).toTypedArray()) + "update" -> update(dataDir, tail.drop(1).toTypedArray()) + "score" -> run(dataDir, tail.drop(1).toTypedArray(), forceOffline = true) + else -> run(dataDir, tail) + } + + suspend fun run( + dataDir: DataDir, + rest: Array, + forceOffline: Boolean = false, + ): Int { + val args = Args(rest) + val observerArg = args.positionalOrNull(0) + // Crawl to full convergence by default (every reachable user's outbox + // checked). --max-rounds is only a safety backstop; --max-hops bounds the + // follow-graph distance from the observer that we crawl (Brainstorm uses 8). + val limit = args.intFlag("limit", 100) + val minScore = args.flag("min-score")?.toDoubleOrNull() ?: 0.0 + // `graperank score` forces the local (no-network) path; `--offline` does the + // same on the bare command. Either way we build + score from the store only. + val offline = forceOffline || args.bool("offline") + // Crawl tuning (--max-rounds/--max-hops/--timeout/--diagnose/--drain-concurrency) + // is read straight from args by [newCrawler]; only these two are surfaced in + // the result JSON, so keep local copies for that. + val parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000 + val insertBatch = args.intFlag("insert-batch", 500) + val doPublish = args.bool("publish") + // Publish cutoff: only cards with rank >= this are published; existing + // cards for targets below it (or gone from the graph) are retracted. Rank + // is round(score*100), so 2 drops the ~0.015-and-below barely-trusted tail. + val minRank = args.intFlag("min-rank", 2) + val publishLimit = args.intFlag("publish-limit", 500) + val publishRelaysArg = args.flag("publish-relay") + // Benchmark: build + sign one kind:30382 card per scored user (rank >= + // --min-rank) with a throwaway key and time it, WITHOUT publishing. + // Measures the id-hash + Schnorr-sign cost of emitting the full card set. + val benchSign = args.bool("bench-sign") + + val params = + GrapeRankParams( + attenuation = args.flag("attenuation")?.toDoubleOrNull() ?: GrapeRankParams().attenuation, + rigor = args.flag("rigor")?.toDoubleOrNull() ?: GrapeRankParams().rigor, + ) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + + // Contact lists stream straight into a compact int-CSR structure as the + // crawl finds them and the Event is discarded, so the whole network fits + // in memory without holding millions of kind:3 objects. + val builder = TrustGraphBuilder() + var contactListsFed = 0 + // Wall time to read + deserialize the contact lists out of the store + // (offline path only; online streams them in during the crawl). + var storeLoadMs: Long? = null + // Crawl telemetry (online path only): rounds, relays contacted, the + // per-hop histogram, and the network-bound download time that dominates a + // from-scratch run. Null on the offline path. + var crawlStats: GrapeRankCrawler.Stats? = null + + if (!offline) { + val stats = newCrawler(ctx, args).crawl(observer, builder) + crawlStats = stats + contactListsFed = stats.contactListsFed + flushReachability(ctx, args, stats) + reportRelayFeedback(ctx) + } else { + // Offline: stream contact lists from the local store into the graph. + val loadStart = System.nanoTime() + for (event in ctx.store.query(Filter(kinds = listOf(ContactListEvent.KIND)))) { + if (event is ContactListEvent) { + builder.addFollows(event.pubKey, event.verifiedFollowKeySet()) + contactListsFed++ + } + } + storeLoadMs = (System.nanoTime() - loadStart) / 1_000_000 + System.err.println("[graperank] offline: $contactListsFed contact lists from local store in $storeLoadMs ms") + } + + // Mutes + reports come from the store (both paths). Far fewer than contact + // lists, so materialising them is cheap. + for (event in ctx.store.query(Filter(kinds = listOf(MuteListEvent.KIND)))) { + if (event is MuteListEvent) builder.addMutes(event.pubKey, event.linkedPubKeys()) + } + val reportsDeleted = materializeReports(ctx, builder) + + val buildStart = System.nanoTime() + val graph = builder.build() + val buildMs = (System.nanoTime() - buildStart) / 1_000_000 + System.err.println("[graperank] graph built: ${graph.nodeCount} users, ${graph.edgeCount()} edges in $buildMs ms; scoring…") + + // Live scoring progress: fires once per Gauss-Seidel sweep with the + // running node-update count and how many nodes still moved more than the + // convergence delta this sweep — that second number trends to 0, so a + // large graph shows convergence instead of hanging silently. + val scoreStart = System.nanoTime() + var sweeps = 0 + val scores = + GrapeRank(params).compute(graph, observer) { visited, stillMoving -> + sweeps++ + System.err.println("[graperank] scoring sweep $sweeps: $visited node-updates, $stillMoving still moving") + } + + fun rankOf(score: Double) = (score * 100).roundToInt() + + val observerId = graph.idOf(observer) + // Reachable users with positive trust at or above --min-score, high→low. + val rankedIds = ArrayList() + for (id in 0 until graph.nodeCount) { + if (id != observerId && scores[id] > 0.0 && scores[id] >= minScore) rankedIds.add(id) + } + rankedIds.sortByDescending { scores[it] } + val scoringMs = (System.nanoTime() - scoreStart) / 1_000_000 + System.err.println("[graperank] scored ${rankedIds.size} users in $scoringMs ms") + + val hopHistogram = crawlStats?.hopHistogram.orEmpty() + val result = + linkedMapOf( + "observer" to observer, + "crawl_rounds" to (crawlStats?.rounds ?: 0), + "relays_contacted" to (crawlStats?.relaysContacted ?: 0), + "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null, + "relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null, + "max_hop_reached" to (hopHistogram.keys.maxOrNull() ?: 0), + "users_by_hop" to hopHistogram.mapKeys { it.key.toString() }, + "contact_lists_by_hop" to crawlStats?.contactsFedByHop.orEmpty().mapKeys { it.key.toString() }, + "graph_users" to graph.nodeCount, + "graph_edges" to graph.edgeCount(), + "reports_deleted" to reportsDeleted, + "users_scored" to rankedIds.size, + "download_ms" to crawlStats?.downloadMs, + "verify_ms" to crawlStats?.verifyMs, + "insert_ms" to crawlStats?.insertMs, + "events_stored" to crawlStats?.eventsStored, + "insert_batch" to insertBatch, + "park_timeout_ms" to parkTimeoutMs, + "store_load_ms" to storeLoadMs, + "graph_build_ms" to buildMs, + "scoring_ms" to scoringMs, + "scoring_sweeps" to sweeps, + "scores" to + rankedIds.take(limit).map { + mapOf("pubkey" to graph.pubkeyOf(it), "score" to scores[it], "rank" to rankOf(scores[it])) + }, + ) + + if (doPublish) { + // The cards for THIS observer are signed by a dedicated, stable + // per-observer service key derived from the machine's operator + // master (see OperatorKeys) — not the account key. Same key across + // runs means re-signing a card replaces the addressable prior one. + val opKeys = ctx.dataDir.operatorKeys() + val serviceKey = opKeys.serviceKey(observer) + val serviceSigner = NostrSignerInternal(serviceKey) + val providerPubkey = serviceKey.pubKey.toHexKey() + result["provider_pubkey"] = providerPubkey + + // Cards go to the operator's own relay(s), where the whole + // trusted-assertion set lives; --publish-relay overrides. + val relays = + publishRelaysArg + ?.split(",") + ?.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it.trim()) } + ?.toSet() + ?.takeIf { it.isNotEmpty() } + ?: opKeys.operatorRelays() + + if (relays.isEmpty()) { + result["published"] = 0 + result["publish_error"] = "no operator relay configured — run `amy graperank operator relay ` or pass --publish-relay" + } else { + // The scorer's desired card set: every user at or above the rank + // cutoff, as (target, rank). GrapeRankPublisher reconciles this + // against what this provider key already published and upserts / + // retracts the difference. + val publishable = + rankedIds + .filter { rankOf(scores[it]) >= minRank } + .map { graph.pubkeyOf(it) to rankOf(scores[it]) } + + val publisher = GrapeRankPublisher(ctx.store) { event, to -> ctx.publish(event, to) } + val pub = + publisher.reconcileAndPublish( + providerSigner = serviceSigner, + providerPubkey = providerPubkey, + scored = publishable, + relays = relays, + publishLimit = publishLimit, + ) + + result["skipped_unchanged"] = pub.skippedUnchanged + if (pub.truncated > 0) result["publish_truncated"] = pub.truncated + result["published"] = pub.published + result["publish_rejected"] = pub.publishRejected + result["deleted"] = pub.deleted + result["delete_rejected"] = pub.deleteRejected + result["published_kind"] = ContactCardEvent.KIND + result["published_to"] = relays.map { it.url } + + // Help the observer point clients at this provider: publish their + // kind:10040 (30382:rank -> providerPubkey @ operator relay) to + // their outbox — but only when we actually hold their key. + maybePublishObserverProviderList(ctx, observer, providerPubkey, relays.first())?.let { + result["observer_10040"] = it + } + } + } + + if (benchSign) { + // Throwaway key — these cards are for timing only and never leave + // the process, so no real identity signs them. + val tempSigner = NostrSignerInternal(KeyPair()) + val cards = + rankedIds + .filter { rankOf(scores[it]) >= minRank } + .map { graph.pubkeyOf(it) to rankOf(scores[it]) } + val signStart = System.nanoTime() + val signed = signCards(cards, tempSigner) + val signMs = (System.nanoTime() - signStart) / 1_000_000 + val perSec = if (signMs > 0) signed * 1000L / signMs else 0 + System.err.println("[graperank] signed $signed kind:30382 cards in $signMs ms ($perSec/s, temp key, not published)") + result["bench_signed"] = signed + result["bench_sign_ms"] = signMs + } + + Output.emit(result) + return 0 + } + } + + /** + * Configure the outbox-model crawler from the crawl flags on [args] plus the + * account's relay policy. Shared by the bare command and `graperank crawl`. + * Relay policy — where a stranger's kind:10002 is found (index/discovery + * aggregators + general defaults) and best-effort general relays that might + * hold content when an outbox is unknown — lives in app code, so the quartz + * crawler takes it injected. + */ + private suspend fun newCrawler( + ctx: Context, + args: Args, + ): GrapeRankCrawler { + val discoveryRelays = + ctx.bootstrapRelays() + Constants.eventFinderRelays + DefaultIndexerRelayList + EXTRA_DISCOVERY_RELAYS + val contentFallback = ctx.bootstrapRelays() + Constants.eventFinderRelays + // Aggregator kind:3 recovery for stragglers is on by default; --no-aggregators + // disables it for A/B comparison. + val aggregators = if (args.bool("no-aggregators")) emptySet() else CONTENT_AGGREGATOR_RELAYS + // Seed the crawl with relays a prior run/monitor proved dead within the cache's + // TTL, so the WS path never re-pays their connect timeouts (--no-reachability-cache + // to skip). The crawl's own final live/dead set is flushed back by the caller. + val knownDead = + if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead + return GrapeRankCrawler( + client = ctx.client, + store = ctx.store, + limiter = ctx.relayLimiter, + config = + GrapeRankCrawler.Config( + relayListDiscoveryRelays = discoveryRelays, + knownDeadRelays = knownDead, + contentFallbackRelays = contentFallback, + contentAggregatorRelays = aggregators, + maxRounds = args.intFlag("max-rounds", Int.MAX_VALUE), + maxHops = args.intFlag("max-hops", Int.MAX_VALUE), + timeoutMs = args.longFlag("timeout", 10L) * 1000, + parkTimeoutMs = args.longFlag("park-timeout", 40L) * 1000, + diagnose = args.bool("diagnose"), + insertBatchSize = args.intFlag("insert-batch", 500), + drainConcurrency = args.intFlag("drain-concurrency", 24), + timeoutEvictStrikes = args.intFlag("timeout-evict", 3), + // Cheap TCP reachability pre-probe (--no-probe to disable). No Tor + // transport here, so .onion relays are skipped on sight. + reachabilityProbe = if (args.bool("no-probe")) null else ::tcpReachable, + torEnabled = false, + // shedDeadDiscovery / shardRotations keep their benchmarked-best + // Config defaults. + ), + log = { System.err.println(it) }, + ) + } + + /** Echo any relay NOTICE/CLOSED feedback + adaptive throttling the crawl saw. */ + private fun reportRelayFeedback(ctx: Context) { + if (ctx.relayDiagnostics.hadFeedback()) { + System.err.println("[graperank] relay feedback: ${ctx.relayDiagnostics.snapshot()}") + } + if (ctx.relayLimiter.hadThrottling()) { + System.err.println("[graperank] relay throttling: ${ctx.relayLimiter.snapshot()}") + } + } + + /** + * Flush the crawl's final live/dead relay verdicts into the shared reachability + * cache (NIP-66 kind:30166) so the next crawl and the WoT updater start warm and + * skip proven-dead relays. Best-effort and behind `--no-reachability-cache`: a + * cache write must never fail the crawl it is summarizing. + */ + private suspend fun flushReachability( + ctx: Context, + args: Args, + stats: GrapeRankCrawler.Stats, + ) { + if (args.bool("no-reachability-cache")) return + runCatching { + ctx.reachability.record(reachable = stats.liveRelays, dead = stats.deadRelays) + System.err.println( + "[graperank] reachability cache: recorded ${stats.liveRelays.size} live, ${stats.deadRelays.size} dead", + ) + }.onFailure { System.err.println("[graperank] reachability cache flush failed: ${it.message}") } + } + + /** + * `amy graperank crawl [OBSERVER]` — network-only WoT data crawl (aliased as the + * former `sync`). Crawls the reachable follow/mute/report graph into the local + * store (kind 3/10000/1984/10002) and reports what it loaded, WITHOUT scoring. + * Idempotent + cumulative: run it a few times to make sure everything is loaded, + * then `graperank score`. + */ + private suspend fun crawl( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val observerArg = args.positionalOrNull(0) + Context.open(dataDir).use { ctx -> + ctx.prepare() + val observer = observerArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + // Persist-only crawl: no in-memory graph (null builder); every event + // still lands in the store for a later `score`. + val stats = newCrawler(ctx, args).crawl(observer, null) + flushReachability(ctx, args, stats) + reportRelayFeedback(ctx) + Output.emit( + linkedMapOf( + "observer" to observer, + "crawl_rounds" to stats.rounds, + "relays_contacted" to stats.relaysContacted, + "relay_feedback" to if (ctx.relayDiagnostics.hadFeedback()) ctx.relayDiagnostics.snapshot() else null, + "relay_throttling" to if (ctx.relayLimiter.hadThrottling()) ctx.relayLimiter.snapshot() else null, + "max_hop_reached" to (stats.hopHistogram.keys.maxOrNull() ?: 0), + "users_by_hop" to stats.hopHistogram.mapKeys { it.key.toString() }, + "contact_lists_by_hop" to stats.contactsFedByHop.mapKeys { it.key.toString() }, + "users_discovered" to stats.hopHistogram.values.sum(), + "contact_lists_fed" to stats.contactListsFed, + "download_ms" to stats.downloadMs, + "verify_ms" to stats.verifyMs, + "insert_ms" to stats.insertMs, + "events_stored" to stats.eventsStored, + ), + ) + } + return 0 + } + + /** + * `amy graperank update [flags]` — refresh every locally-known author's WoT + * record kinds (0 / 3 / 10002 / 1984) straight from their own outbox, so the + * next `graperank score` runs on current data without a full follow-graph crawl. + * + * Thin wrapper over quartz's [GrapeRankUpdater]: it reads every kind:10002 in the + * store, inverts them into a `write-relay -> authors` map (the outbox model), and + * runs one NIP-77 negentropy reconcile per write relay scoped to its authors — + * bidirectional, settling deletions over the residual (its applyDown direction + * downloads the relay's kind:5 when an uploaded record was rejected), and falling + * back to a full paged download when a relay can't reconcile. This command only + * parses flags and renders the [GrapeRankUpdater.Result] as text/JSON. + * + * Flags: `--timeout SECS` (per-group idle watchdog, default 30), + * `--relay-concurrency N` (relays reconciled at once, default 4), + * `--author-chunk N` (authors per reconcile filter, default 500), + * `--min-authors N` (skip relays hosting fewer than N of our authors, default 1), + * `--report-limit N` (per-relay rows in the JSON, default 50), + * `--down` / `--up` / `--no-sync-deletions`. + */ + private suspend fun update( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val reportLimit = args.intFlag("report-limit", 50).coerceAtLeast(0) + // Default is bidirectional; a single --down/--up narrows to that direction. + val downFlag = args.bool("down") + val upFlag = args.bool("up") + + Context.openOrAnonymous(dataDir).use { ctx -> + ctx.prepare() + + // Skip relays a crawl/monitor proved dead within the cache's TTL — a dead + // relay cannot serve its authors, so reconciling it only burns a timeout. + // Live author-advertised relays are always synced (--no-reachability-cache + // to reconcile every relay regardless). + val knownDead = + if (args.bool("no-reachability-cache")) emptySet() else ctx.reachability.snapshot().dead + + val updater = + GrapeRankUpdater( + client = ctx.client, + store = ctx.store, + config = + GrapeRankUpdater.Config( + down = downFlag || !upFlag, + up = upFlag || !downFlag, + syncDeletions = !args.bool("no-sync-deletions"), + relayConcurrency = args.intFlag("relay-concurrency", 4), + authorChunk = args.intFlag("author-chunk", 500), + minAuthors = args.intFlag("min-authors", 1), + idleTimeoutMs = args.longFlag("timeout", 30L) * 1000, + knownDead = knownDead, + ), + log = { System.err.println(it) }, + ) + + val result = updater.update() + + if (result.relays == 0) { + Output.emit( + linkedMapOf( + "relay_lists_in_store" to result.relayListsInStore, + "authors_with_outbox" to result.authorsWithOutbox, + "relays" to 0, + "note" to "no kind:10002 write relays in the local store — run `graperank crawl` first", + ), + ) + return 0 + } + + // Busiest relays first, capped so a many-thousand-relay run still emits a + // bounded JSON object; totals below always cover every relay. + val report = + result.perRelay + .sortedByDescending { it.downloaded + it.uploaded } + .take(reportLimit) + .map { + linkedMapOf( + "relay" to it.relay.url, + "authors" to it.authors, + "need" to it.need, + "have" to it.have, + "downloaded" to it.downloaded, + "uploaded" to it.uploaded, + "deletions_sent_up" to it.deletionsSentUp, + "deletions_applied_down" to it.deletionsAppliedDown, + "paged_fallback" to it.pagedFallback, + "error" to it.error, + ) + } + + Output.emit( + linkedMapOf( + "kinds" to GrapeRankUpdater.DEFAULT_KINDS, + "relay_lists_in_store" to result.relayListsInStore, + "authors_with_outbox" to result.authorsWithOutbox, + "relays" to result.relays, + "relays_ok" to result.relaysOk, + "relays_failed" to result.relaysFailed, + "relays_paged_fallback" to result.relaysPagedFallback, + "downloaded" to result.downloaded, + "uploaded" to result.uploaded, + "deletions_sent_up" to result.deletionsSentUp, + "deletions_applied_down" to result.deletionsAppliedDown, + "report_limit" to reportLimit, + "per_relay" to report, + ), + ) + return 0 + } + } + + /** + * Build + sign one kind:30382 [ContactCardEvent] per (target, rank), fanned + * out across CPU cores (id-hash + Schnorr sign is CPU-bound). The signed + * events are discarded — this only exists to time card generation. Returns + * the number signed. + */ + private suspend fun signCards( + cards: List>, + signer: NostrSigner, + ): Int { + if (cards.isEmpty()) return 0 + val cores = Runtime.getRuntime().availableProcessors().coerceAtLeast(1) + val chunkSize = ((cards.size + cores - 1) / cores).coerceAtLeast(1) + return coroutineScope { + cards + .chunked(chunkSize) + .map { chunk -> + async(Dispatchers.Default) { + for ((target, rank) in chunk) { + ContactCardEvent.create( + targetUser = target, + signer = signer, + publicInitializer = { add(RankTag.assemble(rank)) }, + ) + } + chunk.size + } + }.awaitAll() + .sum() + } + } + + /** + * `amy graperank operator [status | relay … | providers]` + * + * Manage the machine's operator keys used to sign trusted-assertion cards. + * - `status` (default): master pubkey, configured relay(s), provider count. + * - `relay …`: set the operator relay(s) the cards + retractions publish + * to; creates the operator master on first use. + * - `providers`: the observer -> provider-pubkey mapping learned so far. + */ + private fun operator( + dataDir: DataDir, + rest: Array, + ): Int { + val opKeys = dataDir.operatorKeys() + return when (rest.firstOrNull()) { + "relay" -> { + val urls = rest.drop(1).filter { it.isNotBlank() } + val normalized = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + if (normalized.isEmpty()) return Output.error("bad_args", "usage: amy graperank operator relay [ …]") + opKeys.setRelays(urls) + Output.emit(mapOf("master_pubkey" to opKeys.masterPubKey(), "relays" to normalized.map { it.url })) + 0 + } + + "providers" -> { + Output.emit( + mapOf( + "master_pubkey" to if (opKeys.exists()) opKeys.masterPubKey() else null, + "providers" to opKeys.providers().map { (observer, rec) -> mapOf("observer" to observer, "provider_pubkey" to rec.providerPubKey) }, + ), + ) + 0 + } + + null, "status" -> { + if (!opKeys.exists()) { + Output.emit(mapOf("initialized" to false)) + } else { + Output.emit( + mapOf( + "initialized" to true, + "master_pubkey" to opKeys.masterPubKey(), + "relays" to opKeys.operatorRelays().map { it.url }, + "providers" to opKeys.providers().size, + ), + ) + } + 0 + } + + else -> Output.error("bad_args", "unknown operator subcommand '${rest.first()}' (status | relay | providers)") + } + } + + /** + * `amy graperank register [PROVIDER] [--service KIND:TAG] [--relay URL] [--private]` + * + * Add a NIP-85 provider entry to the account's kind:10040 + * [TrustProviderListEvent] — the declaration a client reads to discover which + * key publishes which assertion, and where. Defaults to declaring *self* as + * the `30382:rank` provider at the account's first outbox relay, which is the + * self-advertisement a GrapeRank provider makes so its followers can find the + * cards it publishes. Fetches the freshest list first so existing providers + * are preserved. + */ + private suspend fun register( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val providerArg = args.positionalOrNull(0) ?: args.flag("provider") + val serviceArg = args.flag("service") + val relayArg = args.flag("relay") + val isPrivate = args.bool("private") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + val service = + serviceArg?.let { + ServiceType.parse(it) ?: return Output.error("bad_args", "--service must be KIND:TAG, e.g. 30382:rank") + } ?: ProviderTypes.rank + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val self = ctx.identity.pubKeyHex + val provider = providerArg?.let { ctx.requireUserHex(it) } ?: self + + val outbox = ctx.outboxRelays() + val relay = + relayArg?.let { RelayUrlNormalizer.normalizeOrNull(it) } + ?: outbox.firstOrNull() + ?: return Output.error("no_relays", "no relay hint; pass --relay URL or configure outbox relays") + + val latest = fetchLatestProviderList(ctx, self, outbox, timeoutMs) + val alreadyListed = + latest?.serviceProviders()?.any { + it.service == service && it.pubkey == provider && it.relayUrl == relay + } ?: false + + if (alreadyListed) { + Output.emit( + mapOf( + "service" to service.toValue(), + "provider" to provider, + "relay" to relay.url, + "changed" to false, + "based_on" to latest.id, + ), + ) + return 0 + } + + val tag = ServiceProviderTag(service, provider, relay) + val event = + if (latest == null) { + TrustProviderListEvent.create(tag, isPrivate = isPrivate, signer = ctx.signer) + } else { + TrustProviderListEvent.add(latest, tag, isPrivate = isPrivate, signer = ctx.signer) + } + + val ack = ctx.publish(event, outbox) + Output.emit( + mapOf( + "service" to service.toValue(), + "provider" to provider, + "relay" to relay.url, + "private" to isPrivate, + "changed" to true, + "event_id" to event.id, + "based_on" to latest?.id, + "published_to" to ack.filterValues { it }.keys.map { it.url }, + "rejected_by" to ack.filterValues { !it }.keys.map { it.url }, + ), + ) + return 0 + } + } + + /** + * `amy graperank providers [USER] [--refresh] [--timeout SECS]` + * + * List the NIP-85 trusted providers a user declares in their kind:10040 + * (default: the active account). Cache-first; falls back to a relay drain on + * a miss or with `--refresh`. For the active account, private (NIP-44) + * provider entries are decrypted and included too. + */ + private suspend fun providers( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val userArg = args.positionalOrNull(0) + val refresh = args.bool("refresh") + val timeoutMs = args.longFlag("timeout", 8L) * 1000 + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val user = userArg?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex + val isSelf = user == ctx.identity.pubKeyHex + + var event = if (refresh) null else providerListOf(ctx, user) + if (event == null) { + ctx.drain( + (ctx.bootstrapRelays() + Constants.eventFinderRelays).associateWith { + listOf(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(user), limit = 1)) + }, + timeoutMs, + ) + event = providerListOf(ctx, user) + } + + if (event == null) { + Output.emit(mapOf("user" to user, "found" to false, "providers" to emptyList())) + return 0 + } + + val public = event.serviceProviders() + val private = if (isSelf) event.privateTags(ctx.signer)?.serviceProviders().orEmpty() else emptyList() + + fun render( + tag: ServiceProviderTag, + scope: String, + ) = mapOf( + "service" to tag.service.toValue(), + "provider" to tag.pubkey, + "relay" to tag.relayUrl.url, + "scope" to scope, + ) + + Output.emit( + mapOf( + "user" to user, + "found" to true, + "event_id" to event.id, + "created_at" to event.createdAt, + "providers" to public.map { render(it, "public") } + private.map { render(it, "private") }, + ), + ) + return 0 + } + } + + /** Latest known kind:10040 provider list for [pubKey] from the local store. */ + private suspend fun providerListOf( + ctx: Context, + pubKey: HexKey, + ): TrustProviderListEvent? = + ctx.store + .query(Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1)) + .firstOrNull() as? TrustProviderListEvent + + /** + * Fetch the freshest kind:10040 for [pubKey] from [relays] so a register + * builds on top of the current provider set instead of clobbering it. + */ + private suspend fun fetchLatestProviderList( + ctx: Context, + pubKey: HexKey, + relays: Set, + timeoutMs: Long, + ): TrustProviderListEvent? { + if (relays.isEmpty()) return providerListOf(ctx, pubKey) + val filter = Filter(kinds = listOf(TrustProviderListEvent.KIND), authors = listOf(pubKey), limit = 1) + ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return providerListOf(ctx, pubKey) + } + + /** + * Feed reports into [builder], dropping any that a valid NIP-09 deletion has + * retracted. Uses quartz's [DeletionIndex] — the same indexer the Android + * app's LocalCache runs — which keys each deletion under the DELETER's pubkey, + * so `hasBeenDeleted(report)` is true only when the report's own author + * deleted it (NIP-09: a deletion is authoritative only from the event's + * author). It also honours created_at ordering. Returns how many were dropped. + */ + private suspend fun materializeReports( + ctx: Context, + builder: TrustGraphBuilder, + ): Int { + val reports = ctx.store.query(Filter(kinds = listOf(ReportEvent.KIND))).filterIsInstance() + if (reports.isEmpty()) return 0 + + // Everything in the store already passed verifyAndStore, so mark the + // deletions as verified and skip the redundant signature check. + val deletions = DeletionIndex() + for (ev in ctx.store.query(Filter(kinds = listOf(DeletionEvent.KIND)))) { + if (ev is DeletionEvent) deletions.add(ev, wasVerified = true) + } + + var dropped = 0 + for (r in reports) { + if (deletions.hasBeenDeleted(r)) { + dropped++ + continue + } + builder.addReports(r.pubKey, r.reportedAuthor().map { it.pubkey }) + } + if (dropped > 0) System.err.println("[graperank] dropped $dropped retracted reports (NIP-09 deletions)") + return dropped + } + + /** + * If the active account IS the observer (so we hold their key), publish/refresh + * their kind:10040 declaring `30382:rank` -> [providerPubkey] at [relay], to + * their own outbox relays — the NIP-85 pointer a client follows to find these + * cards. Returns the 10040 event id, or null when we don't hold the key (a + * third-party observer must add the provider to their 10040 out-of-band). + */ + private suspend fun maybePublishObserverProviderList( + ctx: Context, + observer: HexKey, + providerPubkey: HexKey, + relay: NormalizedRelayUrl, + ): String? { + if (observer != ctx.identity.pubKeyHex) return null + val service = ProviderTypes.rank + val outbox = ctx.outboxRelays() + val latest = fetchLatestProviderList(ctx, observer, outbox, 8_000) + val alreadyListed = + latest?.serviceProviders()?.any { + it.service == service && it.pubkey == providerPubkey && it.relayUrl == relay + } ?: false + if (alreadyListed) return latest.id + + val tag = ServiceProviderTag(service, providerPubkey, relay) + val event = + if (latest == null) { + TrustProviderListEvent.create(tag, isPrivate = false, signer = ctx.signer) + } else { + TrustProviderListEvent.add(latest, tag, isPrivate = false, signer = ctx.signer) + } + ctx.publish(event, outbox) + return event.id + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt index 22079d8634..549608c252 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/KeyCommands.kt @@ -77,7 +77,7 @@ object KeyCommands { Output.emit(mapOf("valid" to false)) return 0 } - val npub = hex!!.hexToByteArray().toNpub() + val npub = hex.hexToByteArray().toNpub() Output.emit(mapOf("valid" to true, "pubkey" to hex, "npub" to npub)) return 0 } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt new file mode 100644 index 0000000000..3312c8f61b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LogoffCommand.kt @@ -0,0 +1,167 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import java.io.File + +/** + * `amy logoff [--yes] [--keep-events]` — log off an account and clear its + * local data. + * + * "Logging off" a CLI with no server session means removing everything the + * account left on this machine: + * - the identity file and any backend-held secret (keychain / ncryptsec / + * plaintext) — via [DataDir.deleteIdentity], + * - the rest of the per-account directory `~/.amy//` (run-state + * cursors, aliases, cashu counters, all MLS/Marmot state), + * - the active-account pin at `~/.amy/current`, if it points here, + * - and the account's events in the SHARED store at + * `~/.amy/shared/events-store/`. + * + * The event store is shared across every account on the machine, so this + * does NOT wipe it wholesale — it deletes only the events that involve this + * account: those it authored (`authors`) plus those addressed to it via a + * `#p` tag (inbound gift wraps, nutzaps, reactions, mentions…). Other + * accounts' cached events are untouched. Pass `--keep-events` to leave the + * shared cache alone and only remove the identity + per-account state. + * + * The account is selected the normal way (the `--account` flag, the + * `current` pin, or the sole account) — when more than one account exists + * and none is pinned, [DataDir.resolve] already errors out asking the caller + * to disambiguate, so logoff never guesses which account to destroy. + * + * Reads the public key straight from `identity.json` (never unlocking the + * private key), so it needs no passphrase and pops no keychain prompt. + * + * Requires `--yes` to execute, because it is destructive and cannot be + * undone — the private key is gone with the identity file. Without `--yes` + * the command reports what it would delete and exits with code 2. + */ +object LogoffCommand { + suspend fun run( + dataDir: DataDir, + tail: Array, + ): Int { + val confirmed = tail.any { it == "--yes" || it == "-y" } + val keepEvents = tail.any { it == "--keep-events" } + + // Read the on-disk identity metadata only — no SecretStore round-trip, + // so we never prompt for a passphrase or trip a keychain dialog just + // to log off. + val idFile = + dataDir.loadIdentityFileOrNull() + ?: return Output.error( + "no_account", + "no identity at ${dataDir.identityFile.absolutePath}; nothing to log off", + ) + val pubkey = idFile.pubKeyHex + + val marker = File(DataDir.DEFAULT_ROOT, DataDir.CURRENT_MARKER_NAME) + val isPinned = marker.isFile && marker.readText().trim() == dataDir.accountName + + // Everything the account touched in the shared store: authored by it, + // or addressed to it via a #p tag (gift wraps, nutzaps, reactions…). + val involvedFilters = + listOf( + Filter(authors = listOf(pubkey)), + Filter(tags = mapOf("p" to listOf(pubkey))), + ) + + if (!confirmed) { + val eventCount = if (keepEvents) 0 else withStore(dataDir) { it.count(involvedFilters) } + Output.emit( + mapOf( + "dry_run" to true, + "account" to dataDir.accountName, + "npub" to idFile.npub, + "pubkey" to pubkey, + "account_dir" to dataDir.root.absolutePath, + "pinned" to isPinned, + "events_to_purge" to eventCount, + "keep_events" to keepEvents, + "detail" to "pass --yes to permanently delete this account's key, local state" + + (if (keepEvents) "" else ", and cached events"), + ), + ) + return 2 + } + + // 1. Purge the account's events from the shared store. + var purged = 0 + if (!keepEvents) { + withStore(dataDir) { store -> + val before = store.count(involvedFilters) + store.delete(involvedFilters) + purged = (before - store.count(involvedFilters)).coerceAtLeast(0) + } + } + + // 2. Remove the identity file and any backend-held secret. + dataDir.deleteIdentity() + + // 3. Wipe the rest of the per-account directory (run-state, aliases, + // cashu counters, Marmot/MLS state). The shared events-store lives + // outside this directory, so it is not affected. + val dirFullyRemoved = dataDir.root.deleteRecursively() + + // 4. Drop the active-account pin if it pointed at this account. + val clearedPin = isPinned && marker.delete() + + Output.emit( + mapOf( + "logoff" to true, + "account" to dataDir.accountName, + "npub" to idFile.npub, + "events_purged" to purged, + "removed_dir" to dataDir.root.absolutePath, + "dir_fully_removed" to dirFullyRemoved, + "cleared_pin" to clearedPin, + ), + ) + return 0 + } + + /** + * Open the shared [FsEventStore] directly — logoff needs the store but no + * identity, signer, or relays, so it skips [com.vitorpamplona.amethyst.cli.Context.open] + * (which requires a bootstrapped identity). Mirrors `StoreCommands.withStore`. + */ + private inline fun withStore( + dataDir: DataDir, + body: (FsEventStore) -> T, + ): T { + val store = + FsEventStore( + root = dataDir.eventsDir.toPath(), + eventToJson = JacksonMapper::toJsonPretty, + ) + try { + return body(store) + } finally { + store.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt index 457d1915c8..97d49b5b4e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -79,7 +79,7 @@ object NappletCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -134,7 +134,7 @@ object NappletCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -193,7 +193,7 @@ object NappletCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = extraRelays diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index 08ffe57337..a24ded7865 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -81,7 +81,7 @@ object NostrConnect { } } if (secret == null) return null - return Offer(clientPubkey, relays, secret!!, name) + return Offer(clientPubkey, relays, secret, name) } private fun buildOffer( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt index e00b767220..4780dec65e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -79,7 +79,7 @@ object NsiteCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -145,7 +145,7 @@ object NsiteCommands { val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) val relays = @@ -203,7 +203,7 @@ object NsiteCommands { val extraServers = StaticSiteFetch.commaList(args.flag("server")) val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val authorHex = ctx.requireUserHex(author) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt index 408f49704a..3fdcfc362d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OfferCommands.kt @@ -109,7 +109,7 @@ object OfferCommands { } /** Local decode of a `noffer` pointer — no network, no account needed. */ - private fun info(rest: Array): Int { + internal fun info(rest: Array): Int { val args = Args(rest) val offer = ClinkPointerParser.parse(args.positional(0, "noffer").trim()) as? NOffer diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt index 8545803da8..9d3459d08e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/OutboxCommand.kt @@ -44,7 +44,7 @@ object OutboxCommand { val refresh = args.bool("refresh") val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 8L) * 1000 - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val pubkey = ctx.requireUserHex(user) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt index 42fdfc1dc9..4e320e43b6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Podcast20Commands.kt @@ -219,7 +219,9 @@ object Podcast20Commands { ): Int { val args = Args(rest) val limit = args.intFlag("limit", 50) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (pass a USER to + // list someone else's episodes). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt index 098b83194d..9c91826ec1 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PodcastCommands.kt @@ -136,7 +136,9 @@ object PodcastCommands { ): Int { val args = Args(rest) val limit = args.intFlag("limit", 50) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (pass a USER to + // list someone else's podcasts). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val author = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex val relays = RawEventSupport.queryTargets(ctx, args) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt index a496cc1184..d70ae89b25 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ProfileCommands.kt @@ -63,7 +63,9 @@ object ProfileCommands { val args = Args(rest) val refresh = args.bool("refresh") val timeoutSecs = args.longFlag("timeout", 8L) - Context.open(dataDir).use { ctx -> + // Read-only: runs anonymously when there is no account (an explicit + // USER is then required, since there is no "own" profile to default to). + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val pubKey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt index 37da518132..c47b725a27 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/PublishCommand.kt @@ -55,7 +55,7 @@ object PublishCommand { return Output.error("invalid_event", "event id/signature does not verify — refusing to publish") } - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val targets = RawEventSupport.publishTargets(ctx, args) if (targets.isEmpty()) { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 1ea2698858..74f48c1506 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -237,7 +237,7 @@ object RelayCommands { val raw = args.positional(0, "relay-url") val normalized = raw.normalizeRelayUrlOrNull() - ?: return Output.error("bad_args", "invalid relay url: $raw") + ?: return Output.invalidRelayUrl(raw) val httpUrl = normalized.toHttp() val request = @@ -286,21 +286,21 @@ object RelayCommands { val self = ctx.identity.pubKeyHex when (verb) { "add" -> { - val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) val existing = flat.read(ctx, self) val added = existing.none { it.url == url.url } if (added) ctx.verifyAndStore(flat.build(ctx, existing + url)) Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "added" to added)) } "remove", "rm" -> { - val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) val existing = flat.read(ctx, self) val removed = existing.any { it.url == url.url } if (removed) ctx.verifyAndStore(flat.build(ctx, existing.filterNot { it.url == url.url })) Output.emit(mapOf("noun" to flat.noun, "kind" to flat.kind, "url" to url.url, "removed" to removed)) } "set" -> { - val relays = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url") + val relays = parseUrls(args.positional) ?: return badUrlIn(args.positional) if (relays.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${flat.noun} clear` to empty it") val signed = flat.build(ctx, relays) ctx.verifyAndStore(signed) @@ -335,7 +335,7 @@ object RelayCommands { when (verb) { "add", "remove", "rm" -> { val present = verb == "add" - val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) val changed = mutateNip65(ctx, self) { applyFacet(it, url, facet, present) } Output.emit( mapOf( @@ -352,7 +352,7 @@ object RelayCommands { if (verb == "clear") { emptyList() } else { - val parsed = parseUrls(args.positional) ?: return Output.error("bad_args", "invalid relay url") + val parsed = parseUrls(args.positional) ?: return badUrlIn(args.positional) if (parsed.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${facet.noun} clear` to empty it") parsed } @@ -388,7 +388,7 @@ object RelayCommands { ) } "remove", "rm" -> { - val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) val removed = mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } } Output.emit(mapOf("noun" to "nip65", "kind" to AdvertisedRelayListEvent.KIND, "url" to url.url, "removed" to removed)) } @@ -416,7 +416,7 @@ object RelayCommands { args: Args, add: Boolean, ): Int { - val url = parseUrl(args.positional(0, "url")) ?: return Output.error("bad_args", "invalid relay url") + val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) Context.open(dataDir).use { ctx -> val self = ctx.identity.pubKeyHex val changed = linkedMapOf() @@ -518,6 +518,9 @@ object RelayCommands { private fun parseUrl(raw: String): NormalizedRelayUrl? = raw.normalizeRelayUrlOrNull() + /** The single relay-URL argument every add/remove verb takes, or null if it doesn't parse. */ + private fun urlArg(args: Args): NormalizedRelayUrl? = parseUrl(args.positional(0, "url")) + /** Normalize + dedupe (order-preserving) a list of raw URLs, or null on any bad one. */ private fun parseUrls(raws: List): List? { val out = mutableListOf() @@ -525,6 +528,9 @@ object RelayCommands { return out.distinctBy { it.url } } + /** Error exit naming the first URL in [raws] that made [parseUrls] fail. */ + private fun badUrlIn(raws: List): Int = Output.invalidRelayUrl(raws.first { parseUrl(it) == null }) + private suspend fun readNip65( ctx: Context, self: HexKey, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt index 4e0bedbcbd..1dae1c9b52 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SearchCommand.kt @@ -145,7 +145,7 @@ object SearchCommand { timeoutMs: Long, render: (List) -> List>, ): Int { - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = SearchActions.resolveSearchRelays( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt new file mode 100644 index 0000000000..624bc1e9d1 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StatusCommand.kt @@ -0,0 +1,167 @@ +/* + * 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.cli.commands + +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.cli.RunState +import com.vitorpamplona.amethyst.cli.StoreStats +import com.vitorpamplona.amethyst.cli.secrets.IdentityFile +import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret +import java.io.File + +/** + * `amy status` — a single at-a-glance overview of everything amy is + * holding on disk under `~/.amy/`. Built for the returning user: "I + * haven't run this in months — what accounts do I have, which one is + * active, can they still sign, and how big is the local database?" + * + * Cross-account by design, so it dispatches *before* account resolution + * (like `use`) and never fails on "zero accounts" or "ambiguous account". + * It is strictly read-only and metadata-only: it parses the on-disk + * `identity.json` / `state.json` / `aliases.json` and walks the shared + * event store, but it never unlocks a private key (no keychain prompt, + * no NIP-49 passphrase) and never touches the network. + * + * Per account it reports the npub, how the key is stored (local keychain + * / ncryptsec / plaintext, a NIP-46 bunker, or read-only), whether it can + * sign, and the local footprint that account has accumulated: aliases, + * Marmot groups, a published KeyPackage bundle, a Cashu wallet, and the + * sync cursors that tell catch-up commands where they left off. + */ +object StatusCommand { + fun run(tail: Array): Int { + // `status` takes no positional args; tolerate an accidental one + // rather than erroring — it's a read-only inspection command. + val rootBase = DataDir.DEFAULT_ROOT + + val currentPin = + File(rootBase, DataDir.CURRENT_MARKER_NAME) + .takeIf { it.isFile } + ?.readText() + ?.trim() + ?.ifEmpty { null } + + val accountNames = DataDir.listAccounts(rootBase) + val accounts = accountNames.map { accountRow(File(rootBase, it), it, it == currentPin) } + + // The event store is shared across every account. + val store = StoreStats.of(File(rootBase, "shared/events-store").toPath()) + + Output.emit( + mapOf( + "root" to rootBase.absolutePath, + "current" to currentPin, + "account_count" to accounts.size, + "accounts" to accounts, + "store" to + mapOf( + "events" to store.events, + "distinct_kinds" to store.distinctKinds, + "disk_bytes" to store.diskBytes, + "oldest_at" to store.oldestAt, + "newest_at" to store.newestAt, + "root" to store.root.toString(), + ), + ), + ) + return 0 + } + + private fun accountRow( + accountRoot: File, + name: String, + isCurrent: Boolean, + ): Map { + val identity = readIdentity(File(accountRoot, "identity.json")) + val signer = classifySigner(identity) + + val marmotGroups = + File(accountRoot, "marmot/groups") + .listFiles { f -> f.name.endsWith(".state") } + ?.size ?: 0 + val hasKeyPackage = File(accountRoot, "marmot/keypackages.bundle").isFile + val hasCashuWallet = File(accountRoot, "cashu.json").isFile + val aliasCount = readAliases(File(accountRoot, "aliases.json")).size + val runState = readRunState(File(accountRoot, "state.json")) + + // LinkedHashMap so the text renderer prints fields in this order. + val row = LinkedHashMap() + row["name"] = name + row["current"] = isCurrent + row["npub"] = identity?.npub + row["hex"] = identity?.pubKeyHex + row["signer"] = signer.kind + row["key_storage"] = signer.storage + row["can_sign"] = signer.canSign + if (signer.bunkerRelays != null) row["bunker_relays"] = signer.bunkerRelays + row["aliases"] = aliasCount + row["marmot_groups"] = marmotGroups + row["key_package_published"] = hasKeyPackage + row["cashu_wallet"] = hasCashuWallet + row["dm_cursor_at"] = runState.giftWrapSince + row["marmot_group_cursors"] = runState.groupSince.size + return row + } + + /** + * How this account can sign, derived purely from the on-disk + * [IdentityFile] — never resolves the secret itself. + * - `local` — an on-device private key ([storage] says where). + * - `bunker` — a NIP-46 remote signer ([bunkerRelays] lists it). + * - `read-only` — imported from an npub/nprofile/NIP-05; cannot sign. + */ + private data class SignerInfo( + val kind: String, + val storage: String?, + val canSign: Boolean, + val bunkerRelays: List?, + ) + + private fun classifySigner(identity: IdentityFile?): SignerInfo { + if (identity == null) return SignerInfo("unknown", null, false, null) + identity.bunker?.let { bunker -> + return SignerInfo("bunker", secretStorageLabel(identity.secret), true, bunker.relays) + } + val storage = secretStorageLabel(identity.secret) + return when { + identity.secret != null -> SignerInfo("local", storage, true, null) + // Pre-secret-store data-dirs kept the key inline; still signable. + identity.privKeyHex != null || identity.nsec != null -> SignerInfo("local", "legacy-plaintext", true, null) + else -> SignerInfo("read-only", null, false, null) + } + } + + private fun secretStorageLabel(secret: IdentitySecret?): String? = + when (secret) { + is IdentitySecret.Keychain -> "keychain:${secret.backend}" + is IdentitySecret.Ncryptsec -> "ncryptsec" + is IdentitySecret.Plaintext -> "plaintext" + null -> null + } + + private fun readIdentity(file: File): IdentityFile? = if (file.isFile) runCatching { Output.mapper.readValue(file.readText()) }.getOrNull() else null + + private fun readAliases(file: File): Map = if (file.isFile) runCatching { Output.mapper.readValue>(file.readText()) }.getOrElse { emptyMap() } else emptyMap() + + private fun readRunState(file: File): RunState = if (file.isFile) runCatching { Output.mapper.readValue(file.readText()) }.getOrElse { RunState() } else RunState() +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt index bab4c94a55..16a84361d4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StoreCommands.kt @@ -22,9 +22,13 @@ package com.vitorpamplona.amethyst.cli.commands import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.amethyst.cli.StoreBackend +import com.vitorpamplona.amethyst.cli.StoreFactory +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import java.io.File import java.io.IOException import java.nio.file.Files import java.nio.file.Path @@ -33,19 +37,24 @@ import kotlin.io.path.exists /** * `amy store ` — direct introspection - * and maintenance of the file-backed event store at - * `/events-store/`. + * and maintenance of the shared event store under `/shared/`. * - * - `stat` total event count, kind histogram, disk bytes, - * mtime range — pure read, no relay traffic. + * The store backend is selected by `AMY_STORE` (SQLite by default, or the + * FS tree with `AMY_STORE=fs` — see [StoreFactory]); each verb adapts to + * whichever is active: + * + * - `stat` total event count, disk bytes, backend, plus (FS only) + * the per-kind histogram and mtime range — pure read, + * no relay traffic. * - `sweep-expired` delete events whose NIP-40 `expiration` tag has * passed (per the store's own sweep logic). Run * from cron / scheduler / `amy` periodically. - * - `scrub` rebuild every `idx/` entry from the canonical - * events. Recovers from partial-write crashes or - * external edits. - * - `compact` drop dangling `idx/` entries whose canonical is - * gone. Cheaper than scrub. + * - `scrub` FS: rebuild every `idx/` entry from the canonical + * events, recovering from partial-write crashes or + * external edits. SQLite: a no-op (indexes are updated + * transactionally and can't drift). + * - `compact` FS: drop dangling `idx/` entries whose canonical is + * gone. SQLite: `VACUUM` the database to reclaim space. * - `reindex-fts` wipe and rebuild only the NIP-50 full-text search * index from the stored events. Run after a quartz * upgrade that changes which kinds are searchable. @@ -68,7 +77,52 @@ object StoreCommands { ), ) - private fun stat(dataDir: DataDir): Int { + private suspend fun stat(dataDir: DataDir): Int = + when (StoreFactory.backend()) { + StoreBackend.SQLITE -> sqliteStat(dataDir) + StoreBackend.FS -> fsStat(dataDir) + } + + /** + * SQLite `stat`: total count via `COUNT(*)` and on-disk bytes from the + * DB file plus its `-wal`/`-shm` sidecars. The per-kind histogram and + * mtime range are FS-store concepts (they read the `idx/kind` tree and + * file mtimes), so they're omitted here. + */ + private suspend fun sqliteStat(dataDir: DataDir): Int { + val dbFile = dataDir.eventsDbFile + if (!dbFile.exists()) { + Output.emit( + mapOf( + "backend" to "sqlite", + "events" to 0, + "disk_bytes" to 0L, + "root" to dbFile.absolutePath, + ), + ) + return 0 + } + val count = + EventStore(dbName = dbFile.absolutePath, relay = null).use { store -> + store.count(Filter()) + } + val diskBytes = + listOf("", "-wal", "-shm").sumOf { suffix -> + val f = File(dbFile.absolutePath + suffix) + if (f.isFile) f.length() else 0L + } + Output.emit( + mapOf( + "backend" to "sqlite", + "events" to count, + "disk_bytes" to diskBytes, + "root" to dbFile.absolutePath, + ), + ) + return 0 + } + + private fun fsStat(dataDir: DataDir): Int { val storeRoot = dataDir.eventsDir.toPath() if (!storeRoot.exists()) { Output.emit( @@ -140,37 +194,65 @@ object StoreCommands { private suspend fun sweepExpired(dataDir: DataDir): Int = withStore(dataDir) { store -> - val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at") - val before = countEntries(expiresAtDir) - store.deleteExpiredEvents() - val after = countEntries(expiresAtDir) - Output.emit( - mapOf( - "swept" to (before - after).coerceAtLeast(0L), - "remaining" to after, - ), - ) + if (store is FsEventStore) { + // The FS store exposes its expiration index as a directory, + // so we can report exactly how many entries the sweep cleared. + val expiresAtDir = dataDir.eventsDir.toPath().resolve("idx/expires_at") + val before = countEntries(expiresAtDir) + store.deleteExpiredEvents() + val after = countEntries(expiresAtDir) + Output.emit( + mapOf( + "swept" to (before - after).coerceAtLeast(0L), + "remaining" to after, + ), + ) + } else { + store.deleteExpiredEvents() + Output.emit(mapOf("ok" to true)) + } 0 } - private fun scrub(dataDir: DataDir): Int = + private suspend fun scrub(dataDir: DataDir): Int = withStore(dataDir) { store -> - store.scrub() - Output.emit(mapOf("ok" to true)) + when (store) { + is FsEventStore -> { + store.scrub() + Output.emit(mapOf("ok" to true)) + } + // SQLite indexes are written in the same transaction as the + // event, so they can't drift the way the FS `idx/` tree can — + // there is nothing to rebuild. + else -> + Output.emit( + mapOf( + "ok" to true, + "note" to "scrub is a no-op for the sqlite backend (indexes update transactionally)", + ), + ) + } 0 } - private fun compact(dataDir: DataDir): Int = + private suspend fun compact(dataDir: DataDir): Int = withStore(dataDir) { store -> - store.compact() + when (store) { + // FS: drop dangling idx/ postings. SQLite: VACUUM to rebuild + // the file and hand freed pages back to the OS. + is FsEventStore -> store.compact() + is EventStore -> store.store.vacuum() + else -> Unit + } Output.emit(mapOf("ok" to true)) 0 } private suspend fun reindexFts(dataDir: DataDir): Int = withStore(dataDir) { store -> + val fsBacked = store is FsEventStore val ftsDir = dataDir.eventsDir.toPath().resolve("idx/fts") - val before = countEntries(ftsDir) + val before = if (fsBacked) countEntries(ftsDir) else 0L // Drive the resumable, batched path to completion so a huge // store is processed without holding the writer lock for the // whole pass. A real long-running caller would persist the @@ -184,35 +266,34 @@ object StoreCommands { processed += progress.processedThisBatch batches++ } while (!progress.done) - val after = countEntries(ftsDir) - Output.emit( - mapOf( + val out = + linkedMapOf( "ok" to true, "processed" to processed, "batches" to batches, - "tokens_before" to before, - "tokens_after" to after, - ), - ) + ) + if (fsBacked) { + // Token-file counts are an FS-store notion (idx/fts is a + // directory); the SQLite FTS index doesn't expose one. + out["tokens_before"] = before + out["tokens_after"] = countEntries(ftsDir) + } + Output.emit(out) 0 } /** * Maintenance verbs only need the store — not identity, not relays, - * not the signer. Skip [Context.open] (which throws if no identity - * has been bootstrapped) and construct the [FsEventStore] directly - * from [DataDir.eventsDir]. Pretty formatter matches what the rest - * of the CLI uses for inspection-friendly output. + * not the signer. Skip [Context.open] (which throws if no identity has + * been bootstrapped) and open the configured backend directly via + * [StoreFactory], so `amy store` acts on whichever store the rest of + * the CLI is using. */ - private inline fun withStore( + private suspend fun withStore( dataDir: DataDir, - body: (FsEventStore) -> Int, + body: suspend (IEventStore) -> Int, ): Int { - val store = - FsEventStore( - root = dataDir.eventsDir.toPath(), - eventToJson = JacksonMapper::toJsonPretty, - ) + val store = StoreFactory.open(dataDir) try { return body(store) } finally { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt index a00a9ce86a..0d58a3ee6f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SubscribeCommand.kt @@ -53,7 +53,7 @@ object SubscribeCommand { val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val relays = RawEventSupport.queryTargets(ctx, args) if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 9f878186d0..ff74354184 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -26,8 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DeletionSettleResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IdAndTime @@ -54,15 +56,29 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * - * Both directions are pipelined with the reconcile: need-id batches feed - * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single - * uploader, so downloads and uploads overlap the remaining reconcile rounds - * instead of waiting for the full diff. Every downloaded event funnels - * through `Context.drain`'s verify-and-store path, unchanged. + * Deletion propagation (on by default; disable with `--no-sync-deletions`) is a + * **second pass over the residual**, not per-event work in the content pass — so it + * costs the same whether the database is tiny or huge. After the content settle, a + * re-reconcile's leftover diff is (barring races) exactly the events a deletion kept + * from converging: * - * Thin assembly only: the windowing, streaming, and back-pressure live in - * quartz (`negentropyReconcile`); this file only routes ids to - * `Context.drain` / `Context.publish`. + * - a residual **need** (relay has it, we still lack it after `--down` tried to + * download) = we deleted it → publish OUR covering deletion up so the relay drops it; + * - a residual **have** (we have it, relay still lacks it after `--up` tried to upload) + * = the relay deleted it → pull the relay's covering kind-5 down and apply it locally. + * + * Coverage is any way a deletion reaches an event ([deletionsCovering]): a NIP-09 kind-5 + * by id (`e`) or address (`a`, cutoff-checked), or a NIP-62 vanish targeting this relay + * (up direction only — a pulled vanish is not auto-applied, its blast radius being the + * whole account). The residual is small (only real deletion mismatches), so only it is + * fetched — never the whole need set. The loop repeats until a round resolves nothing. + * So `amy sync` (default `--down`) makes the relay honor your deletions; `--up` makes + * your store honor the relay's; `--up --down` converges both ways. + * + * Content is pipelined with the reconcile: need-id batches feed [DOWNLOAD_WORKERS] + * concurrent by-id REQ drains and have-ids feed a single uploader. Thin assembly only: + * the windowing, streaming, and back-pressure live in quartz (`negentropyReconcile`); + * this file only routes ids to `Context.drain` / `Context.publish`. */ object SyncCommand { private const val ID_CHUNK = 500 @@ -78,6 +94,15 @@ object SyncCommand { /** Overlapped `created_at`-window reconciles after an over-cap split. */ private const val RECONCILE_CONCURRENCY = 2 + /** + * Cap on deletion-settle rounds. Each round resolves the residual it can and + * re-reconciles; a healthy sync converges in 1–2 (round N sends/applies, round + * N+1 confirms empty). The cap only bounds pathological non-convergence (e.g. a + * relay that refuses a deletion), which the "resolved nothing → stop" check + * normally catches first. + */ + private const val MAX_DELETION_ROUNDS = 4 + suspend fun run( dataDir: DataDir, rest: Array, @@ -88,14 +113,15 @@ object SyncCommand { ?: return Output.error("bad_args", "sync requires --relay URL") val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) - ?: return Output.error("bad_args", "invalid relay url: $relayUrl") + ?: return Output.invalidRelayUrl(relayUrl) val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 30L) * 1000 // Default direction is download; --up adds upload. val up = args.bool("up") val down = args.bool("down") || !up + val syncDeletions = !args.bool("no-sync-deletions") val filter = RawEventSupport.buildFilter(args) - Context.open(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { ctx -> ctx.prepare() val localEvents = ctx.store.query(filter) val localById = localEvents.associateBy { it.id } @@ -104,23 +130,22 @@ object SyncCommand { val downloaded = AtomicInteger(0) val uploaded = AtomicInteger(0) + // ── Pass 1: content settle — download needs, upload haves. No deletion + // logic, so a plain sync costs exactly what it always did. val result = try { coroutineScope { // needIds = relay has, we lack; haveIds = we have, relay lacks. - // Bounded so a slow download back-pressures the reconcile - // rounds instead of piling ids up in memory. val needBatches = Channel>(DOWNLOAD_WORKERS * 2) - // Unbounded is fine here: have-ids reference events we already - // hold locally, so memory is bounded by the local set. val haveBatches = Channel>(Channel.UNLIMITED) val downloaders = List(DOWNLOAD_WORKERS) { launch { for (batch in needBatches) { - val got = ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs) - downloaded.addAndGet(got.size) + // drain verifies + stores; anything we deleted is + // rejected by our own tombstone and stays a "need". + downloaded.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) } } } @@ -129,8 +154,7 @@ object SyncCommand { for (batch in haveBatches) { for (id in batch) { val ev = localById[id] ?: continue - val ack = ctx.publish(ev, setOf(relay)) - if (ack.values.any { it }) uploaded.incrementAndGet() + if (ctx.publish(ev, setOf(relay)).values.any { it }) uploaded.incrementAndGet() } } } @@ -160,6 +184,28 @@ object SyncCommand { return Output.error("sync_error", e.message ?: "negentropy sync failed") } + // ── Pass 2+: deletion settle. The reusable quartz accessory re-reconciles + // and resolves only the residual — send our deletions up for what we deleted + // (bounded by --down), apply the relay's kind-5 down for what it deleted + // (bounded by --up) — looping until stable. Cheap regardless of database size + // (see negentropySettleDeletions), and best-effort so it can't fail the sync. + val deletions = + if (syncDeletions) { + ctx.client.negentropySettleDeletions( + relay = relay, + filter = filter, + store = ctx.store, + sendUp = down, + applyDown = up, + batchSize = ID_CHUNK, + idleTimeoutMs = timeoutMs, + maxRounds = MAX_DELETION_ROUNDS, + reconcileConcurrency = RECONCILE_CONCURRENCY, + ) + } else { + DeletionSettleResult(0, 0, 0) + } + Output.emit( mapOf( "relay" to relay.url, @@ -169,6 +215,9 @@ object SyncCommand { "have" to result.haveCount, "downloaded" to downloaded.get(), "uploaded" to uploaded.get(), + "deletions_sent_up" to deletions.sentUp, + "deletions_applied_down" to deletions.appliedDown, + "deletion_rounds" to deletions.rounds, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt new file mode 100644 index 0000000000..c81ce32c4b --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt @@ -0,0 +1,225 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway +import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher +import com.vitorpamplona.amethyst.commons.wot.WoTService +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import java.util.Collections + +/** + * `amy wot ` — Web-of-Trust score queries. + * + * The score for a pubkey X is the count of accounts in the active user's + * kind-3 follow set who also follow X. `get` and `list` are read-only — + * they hydrate the score map from whatever kind-3 events already live in + * the local event store. `sync` pulls fresh kind-3 events from the + * configured relay pool so the next `get` / `list` is up to date. + */ +object WotCommand { + suspend fun dispatch( + dataDir: DataDir, + rest: Array, + ): Int { + val head = rest.firstOrNull() ?: return usage() + val tail = rest.drop(1).toTypedArray() + return when (head) { + "get" -> get(dataDir, tail) + "list" -> list(dataDir, tail) + "sync" -> sync(dataDir, tail) + else -> usage() + } + } + + private fun usage(): Int = Output.error("bad_args", "wot ") + + private suspend fun get( + dataDir: DataDir, + rest: Array, + ): Int { + if (rest.isEmpty()) return Output.error("bad_args", "wot get ") + val userArg = rest[0] + Context.open(dataDir).use { ctx -> + ctx.prepare() + val target = ctx.requireUserHex(userArg) + val (svc, scope) = buildHydratedService(ctx) + try { + val score = svc.scoresSnapshot()[target] ?: 0 + Output.emit(mapOf("pubkey" to target, "score" to score)) + return 0 + } finally { + scope.cancel() + } + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val threshold = args.flag("threshold")?.toIntOrNull() ?: 1 + val limit = args.flag("limit")?.toIntOrNull() ?: 50 + Context.open(dataDir).use { ctx -> + ctx.prepare() + val (svc, scope) = buildHydratedService(ctx) + try { + val entries = + svc + .scoresSnapshot() + .entries + .asSequence() + .filter { it.value >= threshold } + .sortedByDescending { it.value } + .take(limit) + .map { mapOf("pubkey" to it.key, "score" to it.value) } + .toList() + Output.emit(mapOf("count" to entries.size, "entries" to entries)) + return 0 + } finally { + scope.cancel() + } + } + } + + private suspend fun sync( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + // Overall timeout; per-relay budget is set by OutboxDispatcher's + // default (4s). `--timeout N` overrides the overall cap. + val overallTimeoutMs = args.flag("timeout")?.toLongOrNull()?.times(1000) ?: 8_000L + Context.open(dataDir).use { ctx -> + ctx.prepare() + val self = ctx.identity.pubKeyHex + val myKind3 = ctx.contactsOf(self) + val follows = + myKind3?.verifiedFollowKeySet()?.toSet() + ?: return Output.error("no_follows", "no kind-3 in local store; run `amy follow` first") + if (follows.isEmpty()) { + Output.emit(mapOf("synced" to 0, "detail" to "empty follow set")) + return 0 + } + val relays = ctx.indexRelays() + if (relays.isEmpty()) return Output.error("no_relays", "no index relays configured") + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + // Buffer discovered events; persist synchronously after + // the fetch. `store.insert` is suspending so we can't call + // it from the non-suspending gateway callbacks. This also + // keeps `insert` errors surfaceable in a single log line + // rather than swallowed into a race. + val buffered = Collections.synchronizedList(mutableListOf()) + val gateway = + object : OutboxCacheGateway { + override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = + // Amy's store lookup is suspending; can't do + // it here. The dispatcher then falls through + // to Phase 1 discovery for every author, which + // matches the old `amy wot sync` behaviour of + // always re-asking. A future optimisation + // could pre-populate a `Map` before dispatch. + null + + override fun onOutboxDiscovered( + event: AdvertisedRelayListEvent, + relay: NormalizedRelayUrl, + ) { + buffered.add(event) + } + + override fun onDiscoveredEvent( + event: Event, + relay: NormalizedRelayUrl, + ) { + buffered.add(event) + } + } + + val dispatcher = + OutboxDispatcher( + client = ctx.client, + scope = scope, + indexRelays = { relays }, + gateway = gateway, + overallTimeoutMs = overallTimeoutMs, + ) + + val result = dispatcher.fetchKind3Only(follows) + + // Persist to store so future `get` / `list` see them. + val eventsToPersist = synchronized(buffered) { buffered.toList() } + eventsToPersist.forEach { runCatching { ctx.store.insert(it) } } + + Output.emit( + mapOf( + "followers" to follows.size, + "authors_requested" to result.authorsRequested, + "kind10002_received" to result.kind10002Received, + "kind3_received" to result.kind3Received, + "outbox_covered_authors" to result.outboxCoveredAuthors, + "fallback_authors" to result.fallbackAuthors, + "persisted" to eventsToPersist.size, + ), + ) + return 0 + } finally { + scope.cancel() + } + } + } + + /** + * Build a [WoTService], populate it from the local event store, then + * return the (service, backing scope). Caller must cancel the scope + * when done. + */ + private suspend fun buildHydratedService(ctx: Context): Pair { + val self = ctx.identity.pubKeyHex + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined) + val myKind3 = ctx.contactsOf(self) + val follows: Set = myKind3?.verifiedFollowKeySet() ?: emptySet() + svc.onFollowSetChange(follows, self) + // Pull each follower's kind-3 from the store and feed into the service. + follows.forEach { follower -> + val followerKind3 = ctx.contactsOf(follower) ?: return@forEach + svc.applyKind3(follower, followerKind3.verifiedFollowKeySet()) + } + svc.markReadyOnce() + return svc to scope + } +} diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index bad5982cf2..b2a0f61f7a 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -4,3 +4,4 @@ dm/state-dm-headless/ nests/state/ clink/state-clink-headless/ relaygroup/state-relaygroup-headless/ +sync/state-sync-deletions/ diff --git a/cli/tests/sync/sync-deletions-headless.sh b/cli/tests/sync/sync-deletions-headless.sh new file mode 100755 index 0000000000..871649dc5e --- /dev/null +++ b/cli/tests/sync/sync-deletions-headless.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# sync-deletions-headless.sh — drives the real `amy` binary against a real +# `amy serve` relay to prove NIP-77 deletion propagation end-to-end. +# +# `amy sync` converges deletions in a second pass over the reconcile residual +# (see quartz `negentropySettleDeletions`). This exercises both directions plus +# the opt-out: +# +# T1 (up) — we deleted a note the relay still has → `amy sync` sends our +# kind-5 up and the relay drops the note. Verified by an ISOLATED +# third account whose store reads the relay only (no tombstone). +# T2 (off) — same setup with `--no-sync-deletions` → the relay keeps the note +# and nothing is sent. +# T3 (down) — the relay deleted a note we still hold → `amy sync --up` pulls the +# relay's kind-5 down and applies it locally (converges on re-sync). +# +# Each amy account gets its OWN $HOME so their file stores don't share (accounts +# under one $HOME share ~/.amy/shared/events-store). The relay (amy serve) keeps +# a separate store from any client store. +# +# Usage: ./sync-deletions-headless.sh [--port N] [--no-build] +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-sync-deletions" +LOG_DIR="$STATE_DIR/logs" +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" +RELAY_HOST="127.0.0.1" +RELAY_PORT="${RELAY_PORT:-7790}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +# Fresh state every run — stale per-account $HOME dirs from a prior run must not +# leak into this one. +rm -rf "$STATE_DIR" +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" + +# Leniently-trimmed equality assertion (assert helpers live in the DM-specific +# helpers.sh, which hardcodes its own amy wrappers — so define our own here). +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assert: $test_id \"$actual\" == \"$expected\"" + return 0 + fi + fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})" + record_result "$test_id" fail "${note:-mismatch}" + return 1 +} + +SERVE_PID="" +RELAY_HOME="" +cleanup() { + [[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null + trap - EXIT INT TERM HUP + print_summary +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +banner "amy sync — NIP-77 deletion propagation headless ($RUN_TS)" + +# ---- build ------------------------------------------------------------------ +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)…" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; } + +# ---- amy wrappers (one isolated $HOME per account) -------------------------- +strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:|MarmotManager|MlsGroup"; } +mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; } +# amy_run args... +amy_run() { + local home="$1" acct="$2"; shift 2 + HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip +} + +RELAY_HOME="$(mk_home)" +amy_run "$RELAY_HOME" a init >/dev/null + +step "Starting amy serve on $RELAY_URL…" +HOME="$RELAY_HOME" "$AMY_BIN" --account a --secret-backend plaintext \ + serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 & +SERVE_PID=$! + +# Wait for the relay to accept connections (poll the serve log). +for _ in $(seq 1 60); do + grep -q "relay up at" "$LOG_FILE" && break + sleep 0.5 +done +grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } + +# Isolated verifier: its own empty store, reads the relay only (no tombstone). +VERIFY_HOME="$(mk_home)" +amy_run "$VERIFY_HOME" v init >/dev/null +relay_count() { amy_run "$VERIFY_HOME" v fetch --id "$1" --relay "$RELAY_URL" | jq -r '.count // 0'; } + +# ============================================================================= +# T1 — up direction: we deleted it, the relay still has it → sync sends it up. +# ============================================================================= +banner "T1 — amy sync sends our deletion up (relay drops the note)" +NOTE="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t1" | jq -c '.event')" +NID="$(echo "$NOTE" | jq -r '.id')" +echo "$NOTE" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +before="$(relay_count "$NID")" +assert_eq "$before" "1" T1.setup "relay should hold the note before sync" \ + && record_result T1.setup pass "relay has the note" + +# Delete locally only (no --relay → stored, applied, not sent to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID\"]]" --content "" --publish >/dev/null + +SYNC="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL")" +info "sync: $SYNC" +sent="$(echo "$SYNC" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent" "1" T1.sent_up "sync should report one deletion sent up" \ + && record_result T1.sent_up pass "deletions_sent_up=1" + +sleep 1 +after="$(relay_count "$NID")" +assert_eq "$after" "0" T1.relay_dropped "relay must have removed the note after sync" \ + && record_result T1.relay_dropped pass "relay note count 1 → 0" + +# ============================================================================= +# T2 — opt-out: --no-sync-deletions leaves the relay untouched. +# ============================================================================= +banner "T2 — --no-sync-deletions propagates nothing" +NOTE2="$(amy_run "$RELAY_HOME" a event --kind 1 --content "keep-me-t2" | jq -c '.event')" +NID2="$(echo "$NOTE2" | jq -r '.id')" +echo "$NOTE2" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID2\"]]" --content "" --publish >/dev/null + +SYNC2="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL" --no-sync-deletions)" +info "sync: $SYNC2" +sent2="$(echo "$SYNC2" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent2" "0" T2.no_send "--no-sync-deletions must send nothing" \ + && record_result T2.no_send pass "deletions_sent_up=0" +sleep 1 +kept="$(relay_count "$NID2")" +assert_eq "$kept" "1" T2.relay_kept "relay must still hold the note" \ + && record_result T2.relay_kept pass "relay note untouched" + +# ============================================================================= +# T3 — down direction: the relay deleted it, we still hold it → sync --up pulls +# the relay's deletion down and applies it locally. +# ============================================================================= +banner "T3 — amy sync --up applies the relay's deletion locally" +BOB_HOME="$(mk_home)" +amy_run "$BOB_HOME" b init >/dev/null +NOTE3="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t3" | jq -c '.event')" +NID3="$(echo "$NOTE3" | jq -r '.id')" +echo "$NOTE3" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +# bob's isolated store learns the note from the relay… +amy_run "$BOB_HOME" b fetch --id "$NID3" --relay "$RELAY_URL" >/dev/null +# …then the relay deletes it (author pushes a kind-5 straight to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID3\"]]" --content "" | jq -c '.event' \ + | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +SYNC3="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +info "sync: $SYNC3" +applied="$(echo "$SYNC3" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied" "1" T3.applied_down "sync --up should apply one relay deletion locally" \ + && record_result T3.applied_down pass "deletions_applied_down=1" + +# Converged: a second --up sync finds nothing left to apply. +SYNC3B="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +applied2="$(echo "$SYNC3B" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied2" "0" T3.converged "re-sync applies nothing (converged)" \ + && record_result T3.converged pass "second sync stable" + +# print_summary runs from the cleanup trap; exit non-zero if any test failed. +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 diff --git a/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md b/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md new file mode 100644 index 0000000000..43a3ff19db --- /dev/null +++ b/commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md @@ -0,0 +1,695 @@ +--- +title: WoT fetch via outbox model + PR #3483 review fixes +type: fix +status: completed +date: 2026-07-06 +origin: PR https://github.com/vitorpamplona/amethyst/pull/3483 review comments (Vitor Pamplona, davotoula) +--- + +# WoT fetch via outbox model + PR #3483 review fixes + +## Overview + +PR #3483 (branch `feat/wot-shared-index-relays`) adds Web-of-Trust badges + shared +Index Relays + `amy wot` verbs. Two reviewers flagged issues: + +- **Vitor** (owner): stop broadcasting kind-0/kind-3 REQs to a static index-relay + list. Use the outbox model: index relays discover each author's kind-10002, + then kind-0/kind-3 REQs go to each author's declared write relays. +- **davotoula**: six correctness / perf / lifecycle bugs across + `DesktopLocalCache`, `WoTService`, and `FeedMetadataCoordinator` — some + Desktop-scoped, most in `commons/commonMain` so Android inherits them the + moment WoT gets wired there. + +This plan lands **both** in a single PR revision: + +- The outbox-model refactor for kind-0 / kind-3 fetching (Vitor's ask). +- All six correctness/perf/lifecycle fixes (davotoula's ask). +- A sweep confirming no production default references the dying + `relay.damus.io`. + +The scope is intentionally larger than a normal review-fix cycle because the +outbox refactor changes the same seams the bug-fixes touch — separating them +would produce a churny diff. + +## Problem Statement + +### 1. Index-relay broadcast is architecturally wrong for kind-0 / kind-3 + +Current flow (this branch): + +``` +Login (~350 follows) ─▶ Main.kt:1315 + └── FeedMetadataCoordinator.loadKind3Batched(follows) + └── REQ kinds=[3] authors=[follows chunked/100] + to *every* index relay in + PreferencesIndexRelays.effective() +``` + +Semantics: + +- Every kind-3 event is fetched from index relays whether or not the author + publishes there. +- Users who *only* publish to their own outbox (increasingly common on modern + Nostr) return no kind-3 — WoT signal is wrong (undercount). +- Index-relay operators absorb the entire follow set's worth of REQ authors, + even when other relays hold the data. +- The same anti-pattern exists for kind-0 profile metadata (via + `loadMetadataBatched`) and inside `amy wot sync` (which reimplements the same + broadcast in `WotCommand.sync`). + +Vitor's directive (quoting review): + +> Kind 0 and 3 must be downloaded from the outbox relay (10002, write) of each +> user. Basically, find all 10002 events via index relays (purple pages, etc), +> then parse them all to find a list of relays per author, invert the map to +> get a list of authors per relay, then use that list to download posts, kind +> 0 and contact lists from each author. + +### 2. Six correctness / perf / lifecycle bugs + +| # | File:line | Symptom | Severity | +|---|-----------|---------|----------| +| 1 | `DesktopLocalCache.kt:509-530` | `accountPubkey` race → self kind-3 stamped `lastContactListByAuthor` before self-check; later relay retry rejected by `createdAt <= prev`; empty follow view; `FollowAction.follow` calls `createFromScratch(...)` and **wipes real follow list** | **P0 (data loss)** | +| 2 | `commons/wot/WoTService.kt:162-190` | `handleFollowSet` sets `myFollows` before the `MAX_FOLLOWS` guard, guard doesn't return `myFollows` to empty, and `Main.kt:1561` still calls `loadKind3Batched` when over the cap — CPU/memory blow-up the PR description promised was skipped | P1 (perf regression on mega-follow accounts) | +| 3 | `commons/wot/WoTService.kt` | No `close()/dispose()` → writer coroutine + `Channel` leak on account switch; leaks compound over long sessions | P1 (leak) | +| 4 | `commons/wot/WoTService.kt:37-49, 149-160` | Doc claims "per-key subscriber isolation via `Snapshot.withMutableSnapshot`" — that's a mis-attribution. Per-key isolation is a `SnapshotStateMap` property, not a `withMutableSnapshot` property; the `withMutableSnapshot` on *every* op just batches writes. Any consumer that reads the map iteratively (size, keys) *will* invalidate on every mutation, which the comment claims won't happen. Future Android integrator will trust the comment. | P2 (misleading docs → landmine) | +| 5 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:320-368` | `queuedKind3Pubkeys` marks pubkeys sent, never reset on failure. If every index relay times out (mobile flake / cold-start), WoT stays empty for the entire session; `loadKind3Batched` will short-circuit thereafter. | P1 (silent WoT-empty session) | +| 6 | `commons/relayClient/assemblers/FeedMetadataCoordinator.kt:274, 338` | `eoseReceived: MutableSet` written from per-relay `Dispatchers.IO` `onEose` callbacks with no sync → race can drop an EOSE, blocking on the full 5 s timeout instead of firing early. Low ceiling but pre-existing pattern that this PR duplicates. | P2 (perf / responsiveness) | + +Additional owner-flagged item: +- `relay.damus.io` shutting down end of month. Confirmed: no production + default on this branch references it. Only commonTest fixtures do — leave + those alone (they're wire-format fixtures, not runtime relay lists). + +## Proposed Solution + +### Outbox refactor: two-phase discovery + +Replace the single "broadcast a kind-3 REQ to all index relays" flow with a +two-phase pipeline that reuses existing Quartz infrastructure. The pipeline +lives in `commons/commonMain` so **Desktop, Android (future), and `amy`** all +share it. + +``` + ┌────────────────────────────────────────────────────┐ + │ Phase 1 — kind-10002 discovery (index-relay REQ) │ + │ inputs: pubkeys[], indexRelays[] │ + │ emits: Map> │ + │ (author → declared write relays) │ + │ │ + │ • REQ kinds=[10002] authors=chunked-by-100 │ + │ to every index relay. │ + │ • Feed matching AdvertisedRelayListEvent into │ + │ LocalCache (so future lookups skip the REQ). │ + │ • Per-relay timeout (default 4s), NOT one global.│ + └────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ Phase 2a — RelayListRecommendationProcessor │ + │ inputs: authorMap from Phase 1 │ + │ emits: Set │ + │ (relay → author set, minimal cover) │ + │ │ + │ Reuses Quartz's existing algorithm which: │ + │ • builds relay → author set (transpose) │ + │ • greedily picks most-popular relay, removes │ + │ covered authors, repeats │ + │ • second pass to ensure ≥2-relay coverage per │ + │ author │ + │ • filters onion/localhost per config │ + └────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ Phase 2b — per-relay kind 0 + kind 3 REQ │ + │ For each RelayRecommendation: │ + │ REQ kinds=[0,3] authors=[recommendation.users] │ + │ with per-relay timeout, single subscription. │ + │ Events flow into LocalCache via existing │ + │ consume path. │ + └────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ Phase 3 — Fallback for authors without 10002 │ + │ Authors in the input set that never returned a │ + │ 10002 fall back to the current index-relay flow │ + │ (REQ kinds=[0,3] authors=[fallbackSet] on index │ + │ relays). Bounded; only fires when non-empty. │ + └────────────────────────────────────────────────────┘ + │ + ▼ + onEose() → WoTService.markReadyOnce() +``` + +Global 2 s startup fallback in `Main.kt` stays as the outermost safety net. + +### Bug fixes (correctness first, always) + +**Fix 1 — `DesktopLocalCache` accountPubkey race.** Make `accountPubkey` +either a constructor parameter or a required init that must resolve *before* +hydration starts. Reorder `Main.kt` so `localCache.accountPubkey = +account.pubKeyHex` runs before `localRelayStore.hydrate(localCache)`. Belt + +braces: inside `consumeContactList`, do not stamp `lastContactListByAuthor` +for events where `event.pubKey == accountPubkey` unless the self path +actually accepted the event. This eliminates the "poisoned stamp" for the +future relay retry even if a caller ever forgets to bind pubkey first. + +**Fix 2 — MAX_FOLLOWS guard bypass.** Two-part fix: +- In `WoTService.handleFollowSet`, when the follow set exceeds `MAX_FOLLOWS`, + set `myFollows = emptySet()` *and* flip a `disabled: Boolean` flag. Both + `handleKind3` and every future op must early-return on `disabled`. +- In the outbox driver's entrypoint (formerly `Main.kt:1561`), consult + `WoTService.isDisabled` (new StateFlow) or `follows.size <= + WoTService.MAX_FOLLOWS` before dispatching Phase 1. When over the cap: + skip Phase 1 + 2 entirely and call `markReadyOnce()` immediately. + +**Fix 3 — `WoTService.close()`.** Add: + +```kotlin +private val supervisor = SupervisorJob(scope.coroutineContext[Job]) +private val serviceScope = CoroutineScope(scope.coroutineContext + supervisor + writerDispatcher) + +fun close() { + ops.close() + supervisor.cancel() +} +``` + +Call from account-switch (Main.kt clear path) and from `DesktopIAccount` +disposal. Add an internal `AutoCloseable` implement so callers can lean on +`use { }`. + +**Fix 4 — Correct the misleading comments.** Rewrite `WoTService` KDoc to say: + +> Scores are exposed via a Compose-observable `SnapshotStateMap`. Consumers +> that read a *specific key* (`scores[pubkey]`) recompose only when that key +> changes — this is `SnapshotStateMap`'s per-key observation. Consumers that +> iterate the map or read its size will recompose on any mutation. +> +> Ops are serialized through a single-writer `Channel`. Coalescing writes +> inside `Snapshot.withMutableSnapshot { }` batches state commits so a +> multi-key op emits a single Compose invalidation instead of one per key. + +No behaviour change; the comment is the fix. + +**Fix 5 — `queuedKind3Pubkeys` retryable.** Convert the current mark-on-send +set into mark-on-EOSE: +- Track `inFlight: MutableSet` for de-duplication during a single call. +- On successful EOSE (or per-relay EOSE), move pubkeys into `succeeded` + (unchanged behaviour: skip future REQs). +- On global timeout with zero events for a pubkey, **do not** promote to + `succeeded`; keep them retryable on the next `loadKind3Batched` / + `loadKind3ViaOutbox` call. +- Cheap: same `Set` mechanics, just gated by outcome instead of intent. + +**Fix 6 — Synchronise `eoseReceived`.** Two options; pick (b): +- (a) Wrap in `Mutex` / `synchronized` — `synchronized` needs a JVM-only + path or an `expect/actual`. +- (b) **Use a single-writer coroutine**: replace the `MutableSet` + + `CompletableDeferred` handshake with a `Channel( + capacity = Channel.UNLIMITED)` + a launched consumer that increments a + local counter and completes the deferred when it hits `indexRelays.size`. + Same shape, zero shared mutable state across dispatchers. KMP-clean. + +Apply the same fix to both `loadKind3Batched` and `loadMetadataBatched` +because the pattern is duplicated. + +### Damus sweep + +Grep of the current branch found `relay.damus.io` only in test fixtures +(`FeedDefinitionSerializerTest`, `TorRelayEvaluationTest`, `RichTextParserTest`, +`ZapSplitResolverTest`). None are production defaults. `DEFAULT_INDEX_RELAYS` += `{nos.lol, nostr.wine, noswhere, primal.net}`; +`AmethystDefaults.DefaultIndexerRelayList` = `{purplepages, coracle, userkinds, +yabu, nostr1}`. Leave the test fixtures alone (they exercise URL parsing on +canonical example URLs — replacing them adds churn without protecting users). + +Include a one-line status note in the PR description so Vitor sees "checked". + +## Technical Approach + +### Architecture — where each piece lives + +Following the codebase-specific rule (`commons/ARCHITECTURE.md`): "protocol +in Quartz, business logic in commons, layouts in platform apps." + +``` +quartz/ (unchanged — reuse only) + nip65RelayList/AdvertisedRelayListEvent.kt — parser (existing) + nip65RelayList/RelayListRecommendationProcessor — transpose + cover (existing) + +commons/commonMain/ + wot/WoTService.kt — bug fixes 2/3/4 + wot/OutboxDispatcher.kt — NEW (Phase 1-3 driver) + wot/OutboxRelayLoader.kt — MOVED from amethyst/, + Flow> + relayClient/assemblers/FeedMetadataCoordinator.kt — bug fixes 5/6 + calls + into OutboxDispatcher when + configured + +desktopApp/jvmMain/ + Main.kt — reorder localCache init, + call OutboxDispatcher + cache/DesktopLocalCache.kt — bug fix 1 + — new consumeAdvertisedRelayList + path + +cli/ + commands/WotCommand.kt — amy wot sync via + OutboxDispatcher +``` + +### OutboxDispatcher API (draft) + +```kotlin +// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt +class OutboxDispatcher( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: () -> Set, // lazy — respects settings updates + private val cache: OutboxCacheGateway, // interface, actual = DesktopLocalCache + private val perRelayTimeoutMs: Long = 4_000, +) { + data class Result( + val kind10002Received: Int, + val kind3Received: Int, + val kind0Received: Int, + val fallbackAuthors: Int, + ) + + /** + * Fetch kind-3 and kind-0 for [authors] via each author's declared write + * relays (NIP-65). Falls back to [indexRelays] for authors with no 10002. + * + * Suspending — returns after every phase EOSEs or times out. Callers + * that need "return immediately, mark ready later" should wrap in + * [scope.launch]. + */ + suspend fun fetchKind0And3(authors: Set): Result + suspend fun fetchKind3Only(authors: Set): Result // WoT-specific +} + +interface OutboxCacheGateway { + /** Returns the cached kind-10002 for [pubkey] if the local store already has one. */ + fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? + /** Called for every 10002 that comes back — cache should stash it. */ + fun onOutboxDiscovered(event: AdvertisedRelayListEvent, relay: NormalizedRelayUrl) + /** Called for every kind-3 / kind-0 that comes back — cache should route through its consume path. */ + fun onDiscoveredEvent(event: Event, relay: NormalizedRelayUrl) +} +``` + +`DesktopLocalCache` implements `OutboxCacheGateway`; `amy` gets a minimal +implementation that writes into its local store. + +`OutboxRelayLoader` (moved from `amethyst/`) provides the *live* Flow-form for +reactive lookups; `OutboxDispatcher` uses it internally for the "check cache +first, only REQ what's missing" fast-path. + +### Reactivity for "new follow arriving" + +Current code (Main.kt:1559) collects `localCache.followedUsers` and calls +`loadKind3Batched(follows)` on every change. The dedup set means the diff +(only new pubkeys) actually flows through. + +Under the outbox model, the analogous flow is: + +``` +localCache.followedUsers.collect { follows -> + wotService.onFollowSetChange(follows, account.pubKeyHex) + if (wotService.isDisabled) { wotService.markReadyOnce(); return@collect } + launch { + val result = outboxDispatcher.fetchKind3Only(follows) // dedup inside + wotService.markReadyOnce() + } +} +``` + +`fetchKind3Only` internally consults `inFlight` + `succeeded` and only REQs +the diff. Test scenario "user follows one new person mid-session" trivially +covered because Phase 1 for a single-element authors set is a single index- +relay REQ, and Phase 2 is one per-outbox REQ. + +### `amy wot sync` under outbox + +Replace the manual `Filter/chunked/ctx.drain(...)` block in +`WotCommand.sync` with: + +```kotlin +val dispatcher = OutboxDispatcher(client, scope, ctx::indexRelays, AmyCacheGateway(store)) +val result = dispatcher.fetchKind3Only(follows.toSet()) +Output.emit("wot sync", + "10002=${result.kind10002Received} kind3=${result.kind3Received} " + + "fallback=${result.fallbackAuthors}") +``` + +The JSON schema for `--json` gains three new keys (`kind10002_received`, +`fallback_authors`, `kind3_received`) — additive, no rename. + +### Concurrency & KMP concerns + +- All new code targets `commonMain`. No `java.util.concurrent`, no + `synchronized {}` (needs jvmAndroid actual). Rely on `Channel`, `Mutex`, + `StateFlow`, and `Snapshot` — all KMP-safe. +- `Dispatchers.IO` isn't KMP either; use `Dispatchers.Default` in commonMain + and let platform code override if needed. +- Per-relay timeouts implemented via `withTimeoutOrNull(perRelayTimeoutMs)` + inside per-relay coroutines; overall EOSE gate uses a + `CompletableDeferred` that trips when either (a) all per-relay jobs + complete or (b) the outer `withTimeoutOrNull(overallCap)` fires. + +### Data flow: how discovered 10002s stop double-fetching + +Every `AdvertisedRelayListEvent` received during Phase 1 goes through +`OutboxCacheGateway.onOutboxDiscovered(event, relay)` → the platform cache's +`consume` path. Next call for the same author checks `cachedOutbox(pubkey)` +before dispatching Phase 1, so we never REQ the same 10002 twice within a +session (or across sessions, if the local relay store persists the 10002 — +which it does, since kind-10002 events are indexed like any other event). + +### Implementation Phases + +#### Phase 1 — Bug fixes (correctness first, self-contained) + +Ship-blockers, no outbox dependency, land these commits first so a revert +doesn't force rolling back the outbox refactor: + +1. `fix(desktop-cache): eliminate accountPubkey race in + consumeContactList` — reorder Main.kt so pubkey binds before hydrate; + gate `lastContactListByAuthor` stamp inside self branch. Test: + `DesktopLocalCacheHydrationTest` — reproduce the wipe by running + hydration before pubkey bind, assert follow set survives relay retry. +2. `fix(wot): clear myFollows + set disabled flag when MAX_FOLLOWS exceeded` + — plus a Main.kt short-circuit before dispatching Phase 1. Test: + `WoTServiceTest.overCapDisablesEverything`. +3. `refactor(wot): close()/dispose() + AutoCloseable, call from + account-switch` — Test: `WoTServiceLifecycleTest.closeCancelsWriter`. +4. `docs(wot): correct SnapshotStateMap isolation comments` — comment-only. +5. `fix(coordinator): mark queuedKind3Pubkeys only on EOSE, allow retry on + timeout` — Test: `FeedMetadataCoordinatorTest.timeoutRetryIsAllowed`. +6. `fix(coordinator): single-writer EOSE aggregator (KMP-safe)` — Test: + `FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks` + using a fake client that fires EOSE from multiple dispatchers. + +**Success criteria phase 1:** all six tests pass; `./gradlew :commons:jvmTest +:desktopApp:jvmTest :cli:test` green; `./gradlew spotlessApply` clean. + +#### Phase 2 — Outbox scaffolding (commons) + +7. `refactor(commons): move OutboxRelayLoader from amethyst/ to + commons/commonMain` — pure code motion; leave a re-export in the amethyst + package to avoid Android build breaks. Test: existing Android + `OutboxRelayLoaderTest` (if any) still passes. +8. `feat(commons): OutboxDispatcher two-phase kind-0/kind-3 fetcher` — + commonMain, plus jvmMain test that drives a fake `INostrClient` through + Phase 1/2/3 including the fallback path. +9. `feat(commons): OutboxCacheGateway interface + DesktopLocalCache impl` — + including a new `consumeAdvertisedRelayList(event, relay)` in + `DesktopLocalCache` that mirrors the existing `consumeContactList` pattern. + +**Success criteria phase 2:** `./gradlew :commons:jvmTest` green; +`OutboxDispatcherTest` covers "author with 10002", "author without 10002 → +fallback", "index relay times out on Phase 1", and "per-relay timeout on +Phase 2 doesn't cancel other relays". + +#### Phase 3 — Cutover (Main.kt + amy) + +10. `feat(desktop): route WoT kind-3 fetch through OutboxDispatcher` — + Main.kt uses OutboxDispatcher; delete the direct `loadKind3Batched` + call. Preserve the 2 s startup fallback for `markReadyOnce`. +11. `feat(desktop): also route stranger-avatar kind-0 through + OutboxDispatcher` — MetadataPreloader gets a hook that prefers outbox + when a 10002 exists for the author. +12. `feat(cli): amy wot sync via OutboxDispatcher` — rewrite the manual + filter/drain in `WotCommand.sync`. Update its `--json` schema (additive). + +**Success criteria phase 3:** manual testing sheet (Section: Test Plan) +passes end-to-end. `./gradlew test` green. + +#### Phase 4 — Documentation & PR description + +13. Update PR description's "Behaviour" section to reflect the outbox flow. +14. Add a top-level "Damus relay: production defaults verified clean" line + so Vitor doesn't have to look. + +## Alternative Approaches Considered + +**A. Do the outbox refactor in a follow-up PR.** Rejected by user: the same +files (WoTService, FeedMetadataCoordinator, Main.kt) also need the review +fixes, so a two-PR split would double the churn in the same seams. + +**B. Skip Phase 1 (10002 discovery) and read the local cache only.** Would +break for cold-start accounts with no cached 10002s. Only works for +"warm-cache" sessions, defeating Vitor's ask on first login. + +**C. Adopt `AmethystDefaults.DefaultIndexerRelayList` as the new index-relay +default.** The current branch keeps `{nos.lol, nostr.wine, noswhere, +primal.net}` for continuity. Adopting the Purple Pages / Coracle / etc. set +is a user-visible behaviour change deserving its own review. Deferred to a +follow-up ticket. Documented in `PreferencesIndexRelays.kt:85-92` already; +no action here. + +**D. Use `graperank` as Vitor idly mused in the follow-up comment.** Not +actionable in this PR — it's a musing about extending `amy`, not a review +change. Called out here so the item doesn't get lost, but leave for a +future ticket. + +## System-Wide Impact + +### Interaction Graph + +``` +Login + └─ Main.kt:1541 LaunchedEffect binds localCache.accountPubkey + └─ (Fix 1: must run BEFORE the block below) + └─ Main.kt:893 launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) } + └─ per-event: localCache.justConsumeMyOwnEvent → consumeContactList + └─ before fix: stamps lastContactListByAuthor with null accountPubkey + └─ after fix: stamp only inside self-branch, or after ordering guarantee + +Login (parallel) + └─ Main.kt:1559 collect(followedUsers) → + └─ WoTService.onFollowSetChange + └─ ops.trySend(FollowSet) → writerLoop → handleFollowSet + └─ Fix 2: MAX_FOLLOWS → clear myFollows + disabled=true, return + └─ if !disabled: OutboxDispatcher.fetchKind3Only(follows) + └─ Phase 1: REQ 10002 on index relays + └─ OutboxCacheGateway.onOutboxDiscovered → DesktopLocalCache.consumeAdvertisedRelayList + └─ Phase 2a: RelayListRecommendationProcessor.reliableRelaySetFor + └─ Phase 2b: per-relay REQ kind=[0,3] authors=[relay's users] + └─ OutboxCacheGateway.onDiscoveredEvent → LocalCache.consume path + └─ consumeContactList (fixed) → _contactListEvents.tryEmit + └─ WoTService.applyKind3 → handleKind3 → updateScore + └─ Phase 3: fallback for missing-10002 authors → index-relay REQ + └─ WoTService.markReadyOnce → _isReady.value = true → badge composables recompose + +Account switch + └─ Main.kt:874 localCache.clear() → resets lastContactListByAuthor + └─ Fix 3: WoTService.close() → cancels writer, drops ops channel + └─ OutboxDispatcher scope cancels → in-flight REQs unsubscribe +``` + +### Error & Failure Propagation + +- `client.subscribe` failure inside `OutboxDispatcher` → swallowed at the + per-relay coroutine level, logged, moves on. The overall `withTimeoutOrNull` + ensures the caller never blocks past its budget. +- `AdvertisedRelayListEvent.writeRelaysNorm()` returning null (author has a + 10002 but empty write list) → falls through to Phase 3 fallback. +- Cache consume path errors (e.g. corrupt event) → existing `LocalCache` + behavior; not new. + +### State Lifecycle Risks + +- Between `WoTService.close()` and `OutboxDispatcher` scope cancel there's a + small window where a pending REQ EOSE could arrive at a torn-down service. + Mitigation: `OutboxCacheGateway.onDiscoveredEvent` and + `WoTService.applyKind3` must be null-guarded against the "already-closed" + state — WoTService's writerLoop naturally handles this (channel closed → + loop exits). +- Fix 1 requires Main.kt reordering; if the reorder is done wrong and pubkey + bind is *later* than hydration, the bug recurs silently. Test: + `DesktopLocalCacheHydrationTest.regressionOrderingProtection`. + +### API Surface Parity + +`amy wot sync` and Desktop login both consume the same `OutboxDispatcher`, +so any protocol change propagates. Android is a future consumer — the +plan intentionally lives in commons/commonMain so wiring Android on top +is a Main.kt-equivalent + gateway impl. + +### Integration Test Scenarios + +1. Cold-start login, well-connected account (~350 follows, ~90% with 10002): + Phase 1 completes, Phase 2 fetches only from write relays, Phase 3 kicks + in for the ~10% no-10002 authors, WoT ready < 5 s, badges render. +2. Cold-start login, ~4000-follow account: MAX_FOLLOWS trips → dispatcher + skipped, `markReadyOnce()` immediately, no badges, no REQ traffic. +3. Cold-start login, all index relays unreachable: overall timeout fires, + `markReadyOnce()`; on next `followedUsers` emission, `inFlight` is empty + (thanks to fix 5) so a retry happens. +4. Mid-session follow: single-author `fetchKind3Only({newPubkey})` uses cache + hit if `cachedOutbox(newPubkey) != null`, else does one Phase-1 REQ. +5. Account switch: `WoTService.close()` runs; opening the same account again + creates a fresh instance without leaking the previous writer coroutine. +6. `amy wot sync` on a headless VM with only the OS event store: writes + 10002 + kind 3 events to disk; second run of `amy wot get ` returns + the correct hydrated score. + +## Acceptance Criteria + +### Functional + +- [ ] `DesktopLocalCache.consumeContactList` no longer stamps + `lastContactListByAuthor` for the self path unless `accountPubkey` is + set and the event matches. Regression test exists. +- [ ] `WoTService` exposes `isDisabled: StateFlow`; caller + (Main.kt) skips OutboxDispatcher when disabled. +- [ ] `WoTService` implements `AutoCloseable`; account-switch path calls + `close()`. +- [ ] `FeedMetadataCoordinator.loadKind3Batched` and + `loadMetadataBatched` retry on timeout (pubkeys not promoted to + `succeeded`). +- [ ] Both `loadKind3Batched` and `loadMetadataBatched` use single-writer + EOSE aggregation (no `MutableSet` shared across dispatchers). +- [ ] `OutboxDispatcher.fetchKind3Only` and `fetchKind0And3` exist in + commons/commonMain with test coverage for the four scenarios in + "Integration Test Scenarios". +- [ ] `Main.kt` login path uses `OutboxDispatcher` for kind-3 seeding + (WoT + follow-set metadata). +- [ ] `amy wot sync` uses `OutboxDispatcher`; `--json` output additively + gains `kind10002_received`, `kind3_received`, `fallback_authors`. + +### Non-functional + +- [ ] `./gradlew test` green. +- [ ] `./gradlew spotlessApply` clean before commit. +- [ ] No production default relay list on this branch references + `relay.damus.io` (already verified; keep it verified after refactor). +- [ ] No use of `java.util.concurrent` / JVM-only `synchronized {}` in + `commons/commonMain/`. +- [ ] KDoc for `WoTService` accurately describes SnapshotStateMap + per-key isolation. + +### Quality Gates + +- [ ] Manual regression sheet covering integration scenarios 1-6 above. +- [ ] `amy wot sync --json` sample output attached to PR description. +- [ ] Follow-list-wipe regression covered by an automated test that + hydrates a cached kind-3 before binding pubkey and asserts nothing is + poisoned. + +## Success Metrics + +- WoT badge coverage on real accounts (Vitor's expected win): jump from + "index-relay-published authors only" to "any author with a 10002" — + measured by running the desktop app before/after and diffing the badge + count on a fixed follow-set. +- Zero follow-list-wipe reports in the two weeks after merge (davotoula + finding 1 was worst-case data loss). +- Zero index-relay REQ traffic for kind-0/kind-3 authors that publish a + 10002. Measurable by wireshark on a test build. + +## Dependencies & Prerequisites + +- Quartz `AdvertisedRelayListEvent` + `RelayListRecommendationProcessor` — + already exist, reused verbatim. +- `INostrClient.subscribe(subId, filters, listener)` — already exists. +- `DesktopLocalCache` needs a new `consumeAdvertisedRelayList(event, relay)` + method — mirrors existing `consumeContactList` structure. +- `OutboxRelayLoader` — moved from `amethyst/` to `commons/commonMain`; + Android continues to compile because it only depends on things + already in commons/quartz. + +No new third-party libraries introduced. No `libs.versions.toml` change. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|------|------------|--------|------------| +| Outbox refactor changes badge counts on live user accounts unexpectedly | Med | Med | Keep the Phase-3 fallback path so no author gets *worse* coverage than today. Manual A/B test on maintainer's account before merge. | +| `RelayListRecommendationProcessor.reliableRelaySetFor` picks pathologically many relays for a fragmented follow set | Low | Low | Algorithm already caps by second-pass "at least 2 relays per author" rule. Add a hard `MAX_RELAYS_PER_FETCH` (say 40) as a belt-and-braces guard. | +| Concurrent EOSE handshake rewrite introduces a new bug | Low | High | Test `FeedMetadataCoordinatorTest.eoseReadyUnderConcurrentCallbacks` with a fake client firing EOSE from three dispatchers 1000× to catch ordering assumptions. | +| Bug fix 1 (Main.kt reordering) breaks another consumer that read localCache before accountPubkey bind | Med | Med | Grep for all `localCache.accountPubkey` reads; verify none pre-date the bind. If any, thread the pubkey through as a parameter. | +| Adopting `AutoCloseable` on `WoTService` misleads callers into thinking it's `use`-scoped | Low | Low | Comment on `close()` says "call from account-switch/dispose only; instance lives for the account session". | +| `amy wot sync --json` schema change breaks downstream scripts | Med | Low | Additive fields only, no renames. Document in `cli/plans/*` if a plan exists there. | + +## Resource Requirements + +- One engineer, ~2-3 days including tests + manual regression. +- Test-relay access: can use `wss://nos.lol` and Purple Pages for real + Phase 1 verification. +- Access to a mega-follow test account (>2000 follows) to verify Fix 2. +- Access to an account with a well-populated 10002 network to verify + Phase 2 does what we think. + +## Future Considerations + +- Android wiring: `AndroidApp` currently doesn't wire WoTService. When it + does, it can lean on the same `OutboxDispatcher` — expected diff is + Main-equivalent + a `LocalCache` gateway. +- Graperank scoring (Vitor's follow-up musing): if `amy wot` grows a scoring + strategy plugin API, `OutboxDispatcher` remains unchanged; only the + post-fetch aggregation layer inside `WoTService` changes. +- Adopting `AmethystDefaults.DefaultIndexerRelayList`: separate ticket. + Deserves its own review because it's a user-visible behaviour change. + +## Documentation Plan + +- Update this plan's status to `completed` post-merge; write a short + "solutions" note if the accountPubkey race surprised us elsewhere. +- Update PR description "Behaviour" section to reflect outbox path. +- Update `commons/ARCHITECTURE.md` "where does my code go?" section with a + one-line entry for `OutboxDispatcher`. + +## Sources & References + +### Origin + +- **PR review comments:** + https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892248528 (davotoula, bugs 1+2) + https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892272000 (davotoula, bugs 3-6 impact on Android) + https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892302009 (vitorpamplona, outbox directive) + https://github.com/vitorpamplona/amethyst/pull/3483#issuecomment-4892686911 (vitorpamplona, Graperank musing — out of scope) + +### Internal References + +- Kind-10002 parser: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListEvent.kt` +- Relay-cover algorithm: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/RelayListRecommendationProcessor.kt` +- Existing Android outbox loader: `amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/OutboxRelayLoader.kt` +- WoT service (bugs 2-4): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt` +- Feed metadata coordinator (bugs 5-6): `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt` +- Cache race (bug 1): `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:509-530` +- Main wiring: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:1541-1568, 764-768, 863-874` +- amy WoT: `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt` +- Index relay persistence: `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt` +- Baseline plan the PR extended: `desktopApp/plans/2026-07-01-feat-desktop-wot-score-plan.md` + +### External References + +- NIP-65 (Relay List Metadata): https://github.com/nostr-protocol/nips/blob/master/65.md +- Original plan for the current PR: `docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md` + +### Related Work + +- PR #3483 (this PR): https://github.com/vitorpamplona/amethyst/pull/3483 +- Prior WoT badge PR (base for this branch): `feat/desktop-wot-score` + +## Unanswered questions + +- Per-relay timeout budget — 4 s picked from thin air. Real number? +- Should the fallback in Phase 3 also hit the account's own home/search + relays, matching Android's `pickRelaysToLoadUsers` cascade? Or index-only? +- WoTService.close() called from account-switch — what's the canonical + disposal hook on Desktop? DesktopIAccount teardown? +- amy wot sync `--json` schema — is `fallback_authors` the right name, or + match Android naming? +- Should `OutboxDispatcher` be a per-account singleton (like WoTService) or + short-lived per fetch? Leaning singleton for the dedup set. +- Do we want to persist the "author has no 10002" fact so we skip Phase 1 + for them on next login? Requires a small persistent map — worth it? +- Should Fix 1 (accountPubkey race) be split into its own hotfix commit + before the outbox refactor lands, so backporters have a clean cherry-pick? diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/DefaultDmIndexerRelays.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/DefaultDmIndexerRelays.kt new file mode 100644 index 0000000000..cc62930367 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/DefaultDmIndexerRelays.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.defaults + +/** + * Curated indexer relays for resolving NIP-17 inbox lookups (kind:10050). + * + * Used by [com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver] + * via a SEPARATE unauthenticated NostrClient — these queries MUST NOT carry an + * AUTH event back to the user's identity key (security review F-01: an + * authenticated indexer fan-out turns "indexer learns we queried for pubkey X" + * into "indexer learns Amethyst user U queried for pubkey X"). + * + * Set selected for known kind:10050 indexing coverage; `purplepag.es` is + * deliberately excluded (metadata indexer, weak kind:10050 coverage). + */ +object DefaultDmIndexerRelays { + val RELAYS: List = + listOf( + "wss://relay.nos.social", + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.nostr.band", + "wss://purplerelay.com", + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index fb465ea3ae..991be61e4a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -114,6 +114,24 @@ class User( fun dmInboxRelays() = dmInboxRelayList()?.relays()?.ifEmpty { null } ?: inboxRelays() + /** + * Strict variant of [dmInboxRelays] that returns ONLY the user's NIP-17 + * inbox relays (kind:10050) and never falls back to the NIP-65 read + * marker (kind:10002). + * + * Per NIP-17 §Publishing, gift wraps MUST land on relays advertised in + * the recipient's kind:10050 — the NIP-65 read fallback in + * [dmInboxRelays] is a UI-convenience heuristic that leaks the DM + * metadata to relays the recipient did not designate for DMs. Any code + * that decides "can I actually deliver a NIP-17 wrap to this user" + * should call this strict variant; UI hints and probe-time bootstrap + * paths may continue to use the lenient one. + * + * Returns `null` when the recipient has no published kind:10050 (or an + * empty one) — callers treat this as "unreachable via NIP-17". + */ + fun dmInboxRelaysStrict() = dmInboxRelayList()?.relays()?.ifEmpty { null } + fun bestRelayHint() = authorRelayList()?.writeRelaysNorm()?.firstOrNull() ?: mostUsedNonLocalRelay() fun allUsedRelaysOrNull() = relays?.allOrNull() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt index fd213ba1bd..621aaf3149 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt @@ -185,6 +185,7 @@ class NostrSignerPermissionLedger( * Deliberately conservative: when a kind's blast radius is unclear, it is left out so the user * is asked rather than surprised. */ + @Suppress("DEPRECATION") // TorrentCommentEvent is deprecated (NIP-22) but still a reasonable sign kind val REASONABLE_SIGN_KINDS: Set = setOf( TextNoteEvent.KIND, // 1 — short text notes & replies diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt new file mode 100644 index 0000000000..260e0d9fc2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.privacylock + +/** + * Routes gated by the privacy lock. + * + * A single master `PrivacyLockSettings.lockEnabled` flag protects all scopes + * together, but each scope keeps its own [PrivacyLockState] so that unlock, + * idle-timer, and leave-route transitions apply independently per route. + */ +enum class LockScope { Messages, Wallet } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt index 39901fd790..4ffc77f88f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockSettings.kt @@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.StateFlow interface PrivacyLockSettings { val lockEnabled: StateFlow val inactivityTimer: StateFlow - val redactionLevel: StateFlow + val dmRedactionLevel: StateFlow val firstRunCardSeen: StateFlow /** @@ -68,7 +68,7 @@ interface PrivacyLockSettings { fun setInactivityTimer(timer: InactivityTimer) - fun setRedactionLevel(level: DmRedactionLevel) + fun setDmRedactionLevel(level: DmRedactionLevel) fun setFirstRunCardSeen(seen: Boolean) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt similarity index 74% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt index a4cfab66e6..23648af539 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockState.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.privacylock +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.compositionLocalOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -33,19 +35,25 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch /** - * App-global state holder for the Messages privacy lock. + * App-global state holder for a single privacy-lock [scope]. + * + * One instance per gated route (Messages, Wallet, …) is provided via + * [LocalPrivacyLockState] at the App composition root. All instances share + * the same [PrivacyLockSettings] — one master `lockEnabled` flag enables + * every scope together — but each scope keeps its own [LockState] and its + * own idle-timer [Job] so unlock, leave-route, and inactivity transitions + * apply independently per route. * - * - Single instance per app, provided via [LocalMessagesLockState] at the - * App composition root. * - Initial value is seeded synchronously from [settings.lockEnabled.value] * so the first composition sees [LockState.Locked] without flashing * content (deep-link race fix, plan §Security Hardening H1). * - The underlying StateFlow is hot (`MutableStateFlow`); notification path * can read `state.value` synchronously without subscribing. */ -class MessagesLockState( +class PrivacyLockState( + val scope: LockScope, private val settings: PrivacyLockSettings, - private val scope: CoroutineScope, + private val coroutineScope: CoroutineScope, ) { private val seed: LockState = if (settings.lockEnabled.value) LockState.Locked else LockState.Disabled @@ -64,12 +72,12 @@ class MessagesLockState( } else if (mutableState.value is LockState.Disabled) { mutableState.value = LockState.Locked } - }.launchIn(scope) + }.launchIn(coroutineScope) combine(settings.lockEnabled, settings.inactivityTimer) { enabled, timer -> enabled to timer } .onEach { _ -> if (mutableState.value is LockState.Unlocked) restartIdleTimer() - }.launchIn(scope) + }.launchIn(coroutineScope) } /** Resets the inactivity timer. No-op unless currently Unlocked. */ @@ -90,7 +98,7 @@ class MessagesLockState( * Mark the session as authenticated. Transitions from either * [LockState.Locked] (normal unlock path) or [LockState.Disabled] * (first-run banner path — enabling the lock while the user is - * actively in Messages should NOT flash the lock screen). + * actively in a gated route should NOT flash the lock screen). * No-op if already [LockState.Unlocked]. Starts the idle timer. */ fun onUnlockSuccess() { @@ -105,7 +113,8 @@ class MessagesLockState( /** * Triggered when biometric / OS credential is permanently unavailable. - * Auto-disables the lock so the user can keep accessing Messages. + * Auto-disables the lock (flips every scope to [LockState.Disabled] + * via the shared setting) so the user can keep accessing gated routes. */ fun onCredentialUnavailable() { cancelIdleTimer() @@ -118,6 +127,10 @@ class MessagesLockState( * [PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES] failures: base 30 s, * doubling each further failure, capped at 5 min. * + * Backoff state is shared across scopes — a mistyped password on the + * Wallet gate locks out the Messages gate too (and vice versa). This is + * intentional anti-brute-force behaviour. + * * @param nowMs current epoch millis (injected for testability). * @return the new [PrivacyLockSettings.lockedUntilEpochMs] value, or * null when no lockout yet applies. @@ -148,7 +161,7 @@ class MessagesLockState( cancelIdleTimer() val millis = settings.inactivityTimer.value.millis ?: return idleTimerJob = - scope.launch { + coroutineScope.launch { delay(millis) if (mutableState.value is LockState.Unlocked) { mutableState.value = LockState.Locked @@ -162,8 +175,22 @@ class MessagesLockState( } } -/** Provided once at the App composition root. */ -val LocalMessagesLockState = - compositionLocalOf { - error("LocalMessagesLockState not provided — wrap App() with CompositionLocalProvider") +/** + * Provided once at the App composition root. Map keyed by [LockScope]; every + * scope must have an entry (see [lockStateFor] which throws when missing). + */ +val LocalPrivacyLockState = + compositionLocalOf> { + error("LocalPrivacyLockState not provided — wrap App() with CompositionLocalProvider") } + +/** + * Convenience accessor used inside gate composables. Reads the map from the + * ambient [LocalPrivacyLockState] and returns the state holder for [scope]. + * Throws if the scope was not registered at the App root. + */ +@Composable +@ReadOnlyComposable +fun lockStateFor(scope: LockScope): PrivacyLockState = + LocalPrivacyLockState.current[scope] + ?: error("PrivacyLockState for $scope not registered at App root") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt index d0ef61e5b1..659ca97dcd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt @@ -33,13 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.Volatile /** * Coordinates metadata and reactions loading for feed items. @@ -73,6 +76,15 @@ class FeedMetadataCoordinator( private val queuedPubkeys = mutableSetOf() private val queuedNoteIds = mutableSetOf() private val queuedBoostedIds = mutableSetOf() + private val queuedKind3Pubkeys = mutableSetOf() + + // Batched paths only — pubkeys currently in-flight in a batched REQ. + // Prevents rapid re-fire of the same batch. Distinct from queuedPubkeys + // and queuedKind3Pubkeys (which record "asked and at least one relay + // returned EOSE") so a batch that times out with zero events can be + // retried on the next call — see PR #3483 review finding 5. + private val inFlightBatchedMetadata = mutableSetOf() + private val inFlightBatchedKind3 = mutableSetOf() /** * Start processing the subscription queue. @@ -251,14 +263,24 @@ class FeedMetadataCoordinator( /** * Fast-path: batched metadata subscription for visible-viewport authors. * Bypasses rate limiter. Single filter with all authors. Closes after EOSE. + * + * Pubkeys are moved into [queuedPubkeys] (dedup) only after at least one + * relay EOSE'd. On timeout with zero EOSE (index relays all unreachable) + * they roll out of [inFlightBatchedMetadata] so a subsequent call can + * retry — see PR #3483 review finding 5. */ fun loadMetadataBatched( pubkeys: List, timeoutMs: Long = 5_000L, ) { - val newPubkeys = pubkeys.filter { it !in queuedPubkeys }.distinct() + val newPubkeys = + pubkeys + .asSequence() + .filter { it !in queuedPubkeys && it !in inFlightBatchedMetadata } + .distinct() + .toList() if (newPubkeys.isEmpty()) return - queuedPubkeys.addAll(newPubkeys) + inFlightBatchedMetadata.addAll(newPubkeys) scope.launch { val filter = @@ -269,8 +291,7 @@ class FeedMetadataCoordinator( ) val filterMap = indexRelays.associateWith { listOf(filter) } val subId = newSubId() - val eoseReceived = mutableSetOf() - val allEose = CompletableDeferred() + val gate = BatchEoseGate(scope, target = indexRelays.size) val listener = object : SubscriptionListener { @@ -287,16 +308,96 @@ class FeedMetadataCoordinator( relay: NormalizedRelayUrl, forFilters: List?, ) { - eoseReceived.add(relay) - if (eoseReceived.size >= indexRelays.size) { - allEose.complete(Unit) - } + gate.notifyEose(relay) } } client.subscribe(subId, filterMap, listener) - withTimeoutOrNull(timeoutMs) { allEose.await() } + val eosedRelays = gate.awaitAll(timeoutMs) client.unsubscribe(subId) + + if (eosedRelays > 0) { + queuedPubkeys.addAll(newPubkeys) + } + inFlightBatchedMetadata.removeAll(newPubkeys.toSet()) + } + } + + /** + * Batched kind-3 (follow list) subscription. Used by the WoT service + * to fetch the follow lists of every account the active user follows, + * so friends-of-friends counts can be computed. + * + * Chunks authors into ≤100 per Filter within a single subscription + * so relays with per-filter author caps (nostr-rs-relay defaults to + * ~100) don't silently truncate the batch. Aggregates EOSE across + * chunks and calls [onEose] once (or after [timeoutMs]). + * + * Pubkeys are moved into [queuedKind3Pubkeys] (dedup) only after at + * least one relay EOSE'd. On timeout with zero EOSE (index relays all + * unreachable — common on flaky mobile networks) they roll out of + * [inFlightBatchedKind3] so the next `loadKind3Batched` call retries + * — see PR #3483 review finding 5. + */ + fun loadKind3Batched( + pubkeys: Collection, + timeoutMs: Long = 5_000L, + onEose: () -> Unit = {}, + ) { + val newPubkeys = + pubkeys + .asSequence() + .filter { it !in queuedKind3Pubkeys && it !in inFlightBatchedKind3 } + .distinct() + .toList() + if (newPubkeys.isEmpty()) { + onEose() + return + } + inFlightBatchedKind3.addAll(newPubkeys) + + scope.launch { + val filters = + newPubkeys.chunked(100).map { chunk -> + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = chunk, + limit = chunk.size, + ) + } + val filterMap = indexRelays.associateWith { filters } + val subId = newSubId() + val gate = BatchEoseGate(scope, target = indexRelays.size) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + this@FeedMetadataCoordinator.onEvent?.invoke(event, relay) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gate.notifyEose(relay) + } + } + + client.subscribe(subId, filterMap, listener) + val eosedRelays = gate.awaitAll(timeoutMs) + client.unsubscribe(subId) + + if (eosedRelays > 0) { + queuedKind3Pubkeys.addAll(newPubkeys) + } + inFlightBatchedKind3.removeAll(newPubkeys.toSet()) + + onEose() } } @@ -307,5 +408,53 @@ class FeedMetadataCoordinator( priorityQueue.clear() queuedPubkeys.clear() queuedNoteIds.clear() + queuedKind3Pubkeys.clear() + inFlightBatchedMetadata.clear() + inFlightBatchedKind3.clear() + } + + /** + * Aggregates EOSE notifications from per-relay `onEose` callbacks + * (which the client may dispatch on `Dispatchers.IO`) via a + * [Channel]. The consumer coroutine is the sole reader/writer of the + * `seen` set, eliminating the race the previous `mutableSetOf` + + * shared-state check had — see PR #3483 review finding 6. + * + * [awaitAll] blocks up to [timeoutMs] and returns the number of + * relays that EOSE'd (may be less than [target] on timeout). The + * count feeds the retry decision in the batched loaders. + */ + private class BatchEoseGate( + private val scope: CoroutineScope, + private val target: Int, + ) { + private val incoming = Channel(Channel.UNLIMITED) + private val done = CompletableDeferred() + + @Volatile private var lastCount = 0 + + fun notifyEose(relay: NormalizedRelayUrl) { + incoming.trySend(relay) + } + + suspend fun awaitAll(timeoutMs: Long): Int { + if (target <= 0) return 0 + val consumer = + scope.launch { + val seen = mutableSetOf() + for (relay in incoming) { + if (seen.add(relay)) { + lastCount = seen.size + if (seen.size >= target && !done.isCompleted) { + done.complete(Unit) + } + } + } + } + withTimeoutOrNull(timeoutMs) { done.await() } + incoming.close() + consumer.join() + return lastCount + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt new file mode 100644 index 0000000000..7d9205fffa --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalBanner.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.auth + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +/** + * Inline AUTH approval banner. + * + * Renders one row per pending tier-2 NIP-42 AUTH challenge with three + * actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve] + * with the user's choice, which the parent (typically a coordinator) + * uses to complete the underlying [PendingAuthApproval.decision] + * deferred and persist the scope. + * + * Stacks up to 3 entries inline; the rest collapse into a `+N more` row + * (a future iteration may expand them on click — keep simple for now). + * + * The component is platform-agnostic and lives in `commons` so Android + * and Desktop can render the same UX once the wire-up is built on each + * platform. + */ +@Composable +fun AuthApprovalBanner( + pending: List, + onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = pending.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = modifier, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + val visible = pending.take(3) + val hidden = pending.size - visible.size + + visible.forEach { approval -> + AuthApprovalRow(approval = approval, onResolve = onResolve) + } + + if (hidden > 0) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + } + } + } +} + +@Composable +private fun AuthApprovalRow( + approval: PendingAuthApproval, + onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f), + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = approval.relayUrl.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = + if (approval.pendingCount > 1) { + "requires authentication for ${approval.pendingCount} messages" + } else { + "requires authentication to deliver this message" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f), + ) + } + Spacer(Modifier.width(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) { + Text("Once", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) { + Text("Always", style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) { + Text("Never", style = MaterialTheme.typography.labelMedium) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicy.kt new file mode 100644 index 0000000000..e700e2ec80 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicy.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Persisted scope for an AUTH approval decision. + * + * `ONCE` is in-memory only — never written to disk. `ALWAYS` and `BLOCKED` + * persist via [AuthApprovalStore]. + */ +enum class AuthApprovalScope { + /** Approve this session; don't persist. */ + ONCE, + + /** Approve indefinitely (or until the store's TTL expires the row). */ + ALWAYS, + + /** Reject indefinitely. Future AUTH challenges from this relay are silently dropped. */ + BLOCKED, +} + +/** + * The classifier verdict for a single AUTH challenge. + * + * `Allow` and `Block` are immediate. `Pending` means the user needs to decide; + * the policy hands back a [CompletableDeferred] that the UI banner completes + * once the user picks `[Once] [Always] [Never]`. + */ +sealed interface AuthApprovalDecision { + /** Auto-sign the AUTH event for this relay. */ + data object Allow : AuthApprovalDecision + + /** Silently drop the AUTH challenge. */ + data object Block : AuthApprovalDecision + + /** + * Suspend the signer until the user resolves the prompt. + * + * @property pending populated with the user's choice when the banner is + * actioned. The signer awaits this deferred; if it resolves to + * [AuthApprovalScope.BLOCKED] the AUTH is dropped, otherwise signed. + */ + data class Pending( + val pending: CompletableDeferred, + ) : AuthApprovalDecision +} + +/** + * A pending tier-2 AUTH approval surfaced to the user. + * + * Created when the policy decides a challenge needs user consent. Subscribers + * (an `AccountAuthApprovals` ViewModel — wired in P2.5) render a banner with + * `[Once] [Always] [Never]` buttons that resolve [decision] via `complete()`. + * + * `pendingCount` lets the banner coalesce multiple challenges from the same + * relay into one row (`" requires authentication for 3 messages"`) + * rather than stacking duplicate banners. + */ +data class PendingAuthApproval( + val relayUrl: NormalizedRelayUrl, + val decision: CompletableDeferred, + val pendingCount: Int = 1, +) + +/** + * Per-account approval store. Implementations persist `ALWAYS` / `BLOCKED` + * grants (typically to a SQLite `auth_approvals` table, wired in P2.4). + * + * `getScope` returns `null` if no decision is recorded for the relay. + */ +interface AuthApprovalStore { + /** Returns the persisted decision for `relayUrl`, or `null` if unknown. */ + suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? + + /** + * Record a user decision. `ONCE` decisions are NOT persisted by contract — + * the policy caches them in-memory for the current session only. + */ + suspend fun setScope( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ) + + /** Wipe all persisted approvals. Called on account delete / logout. */ + suspend fun clear() +} + +/** + * In-memory [AuthApprovalStore] used as a development scaffold and as the + * `ONCE` cache layer on top of a persistent store. Tier-2 banner approvals + * with `ONCE` scope live here for the session and are dropped on logout. + */ +class InMemoryAuthApprovalStore : AuthApprovalStore { + private val scopes = mutableMapOf() + private val lock = Mutex() + + override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? = lock.withLock { scopes[relayUrl] } + + override suspend fun setScope( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ) { + lock.withLock { scopes[relayUrl] = scope } + } + + override suspend fun clear() { + lock.withLock { scopes.clear() } + } +} + +/** + * The classifier between the relay client's `signWithAllLoggedInUsers` lambda + * and the actual signer. + * + * Two tiers: + * + * - **Tier 1 (auto-allow):** the relay is in the user's own outbox or + * NIP-17 DM-inbox set, or has a persisted `ALWAYS` grant. Sign immediately, + * no prompt. These are relays the user has already declared they trust. + * - **Tier 2 (prompt):** anything else, with the exception of relays that + * carry a persisted `BLOCKED` grant. Surface a [PendingAuthApproval] via + * [onPromptRequired] and suspend until the user resolves the + * [CompletableDeferred]. If `ONCE`, cache for this session; if `ALWAYS` or + * `BLOCKED`, persist via the store. + * + * No tier-3: every challenge is either auto-allowed, blocked by a persisted + * decision, or surfaced to the user. There is no silent third path. + * + * @property selfApprovedRelays the union of own outbox + DM-inbox + any + * account-level pre-approval. Recomputed by the caller on Account state + * changes. Tier 1 if the challenger is in this set. + * @property store persistence layer (SQLite-backed in production, in-memory in + * tests). + * @property onPromptRequired called when a [PendingAuthApproval] needs to be + * surfaced to the UI. The UI subscribes to this side-channel and completes + * the contained [CompletableDeferred] with the user's pick. + */ +class AuthApprovalPolicy( + val selfApprovedRelays: () -> Set, + val store: AuthApprovalStore, + val onPromptRequired: (PendingAuthApproval) -> Unit, +) { + /** + * Decide what to do with an AUTH challenge from `relayUrl`. + * + * @return [AuthApprovalDecision.Allow] for tier-1 / persisted-ALWAYS, + * [AuthApprovalDecision.Block] for persisted-BLOCKED, + * [AuthApprovalDecision.Pending] (and emits to [onPromptRequired]) for + * unknown relays. + */ + suspend fun classify(relayUrl: NormalizedRelayUrl): AuthApprovalDecision { + // Persisted decision wins over tier-1: if user explicitly blocked a + // relay that happens to also be in their outbox, respect the block. + when (store.getScope(relayUrl)) { + AuthApprovalScope.ALWAYS -> return AuthApprovalDecision.Allow + AuthApprovalScope.BLOCKED -> return AuthApprovalDecision.Block + AuthApprovalScope.ONCE -> return AuthApprovalDecision.Allow + null -> Unit + } + + if (relayUrl in selfApprovedRelays()) { + return AuthApprovalDecision.Allow + } + + val deferred = CompletableDeferred() + onPromptRequired(PendingAuthApproval(relayUrl, deferred)) + return AuthApprovalDecision.Pending(deferred) + } + + /** Persist (or cache) the user's choice from a [PendingAuthApproval] resolution. */ + suspend fun recordDecision( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ) { + // `ONCE` lives in-memory only (the InMemoryAuthApprovalStore handles + // this transparently). `ALWAYS` and `BLOCKED` persist via the store. + store.setScope(relayUrl, scope) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt new file mode 100644 index 0000000000..301ebd7e98 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolver.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm + +import com.vitorpamplona.quartz.marmot.RecipientRelayFetcher +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Resolves a recipient's NIP-17 inbox relays (kind:10050) for DM delivery. + * + * Three-layer lookup, in order: + * + * 1. **LocalCache hit** — the caller has already seen the user's kind:10050 + * via the normal feed subscription pipeline. Cheapest; no I/O. + * 2. **In-memory LRU cache** — a prior resolve() succeeded for this pubkey + * within the TTL. Avoids re-querying indexers when the user opens a + * conversation list and clicks several recipients in sequence. + * 3. **Indexer fan-out** — query a curated set of indexer relays for the + * user's kind:10050 via [RecipientRelayFetcher]. The client passed in + * here MUST be an **unauthenticated** instance (no [RelayAuthenticator] + * attached) — otherwise an indexer's AUTH challenge would extract an + * identity-key signature from the user, turning the metadata leak + * "indexer learns who we want to DM" into "indexer learns user U wants + * to DM pubkey X". + * + * Filters to **kind:10050 only**. Per NIP-17 §Publishing, gift wraps MUST + * land on relays in the recipient's kind:10050; this resolver never + * substitutes the NIP-65 read marker as a fallback, because doing so leaks + * DMs to relays the recipient did not explicitly designate for DMs. + * + * Empty result is the canonical "we don't know where to send" signal — the + * caller should refuse to publish rather than fall back to its own relays + * (see [com.vitorpamplona.amethyst.desktop.model.DesktopIAccount.resolveDmInboxRelaysStrict]). + * + * @property unauthenticatedClient NostrClient WITHOUT a RelayAuthenticator + * attached. Use a dedicated instance — do NOT pass the app's primary + * client. + * @property indexerRelays Curated indexer set. Typically + * [com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays]. + * @property localLookup Callback the resolver invokes first to check the + * LocalCache — returns the user's current kind:10050 list or null if + * unknown. Allows commons/headless callers to plug in a CLI-safe lookup. + * @property cacheTtlMs LRU cache TTL. 1h matches the brainstorm's open + * question; configurable here for tests. + * @property cacheSize LRU bound. 100 entries × ~200 bytes each is trivial + * memory; matches typical active-conversation count for power users. + */ +class DmInboxRelayResolver( + private val unauthenticatedClient: INostrClient, + private val indexerRelays: Set, + private val localLookup: (HexKey) -> List?, + private val cacheTtlMs: Long = 60 * 60 * 1_000L, + private val cacheSize: Int = 100, + private val nowMs: () -> Long = { + kotlin.time.Clock.System + .now() + .toEpochMilliseconds() + }, +) { + private data class Entry( + val relays: List, + val expiresAtMs: Long, + ) + + private val cache = linkedMapOf() + private val mutex = Mutex() + + /** + * Resolve `pubkey`'s NIP-17 inbox relays. Returns empty list if neither + * the LocalCache nor the indexer fan-out yielded a kind:10050. + */ + suspend fun resolve(pubkey: HexKey): List { + localLookup(pubkey)?.takeIf { it.isNotEmpty() }?.let { return it } + + val now = nowMs() + mutex.withLock { + cache[pubkey]?.let { entry -> + if (entry.expiresAtMs > now) { + // Refresh LRU order on hit + cache.remove(pubkey) + cache[pubkey] = entry + return entry.relays + } else { + cache.remove(pubkey) + } + } + } + + if (indexerRelays.isEmpty()) return emptyList() + + val lists = RecipientRelayFetcher.fetchRelayLists(unauthenticatedClient, pubkey, indexerRelays) + // Strict: kind:10050 ONLY. No NIP-65 fallback. Empty = canonical + // "unreachable" signal; caller refuses to publish. + val relays = lists.dmInbox + + mutex.withLock { + cache[pubkey] = Entry(relays, now + cacheTtlMs) + while (cache.size > cacheSize) { + cache.remove(cache.keys.iterator().next()) + } + } + return relays + } + + /** Evict a specific entry — e.g. when LocalCache observes a fresh kind:10050. */ + suspend fun invalidate(pubkey: HexKey) { + mutex.withLock { cache.remove(pubkey) } + } + + /** Wipe the entire cache — e.g. on account switch. */ + suspend fun clear() { + mutex.withLock { cache.clear() } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt index 1006585991..9a559ebde8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme @@ -62,6 +64,10 @@ data class ProfilePictureUrl( * @param loadProfilePicture Whether to load the profile picture (false = show robohash only) * @param loadRobohash Whether to generate robohash (false = show generic icon) * @param useThumbnailCache Whether to use the thumbnail disk cache for faster repeated loads + * @param badge Optional overlay drawn on top of the avatar (bottom-right by + * convention). Used by Desktop for the WoT trust-score chip; Android call + * sites leave it null. When null the avatar renders as before (no extra + * `Box` wrapper). */ @Composable fun UserAvatar( @@ -73,7 +79,26 @@ fun UserAvatar( loadProfilePicture: Boolean = true, loadRobohash: Boolean = true, useThumbnailCache: Boolean = false, + badge: @Composable (BoxScope.() -> Unit)? = null, ) { + if (badge != null) { + Box(modifier = modifier.size(size)) { + UserAvatar( + userHex = userHex, + pictureUrl = pictureUrl, + size = size, + modifier = Modifier, + contentDescription = contentDescription, + loadProfilePicture = loadProfilePicture, + loadRobohash = loadRobohash, + useThumbnailCache = useThumbnailCache, + badge = null, + ) + badge() + } + return + } + val avatarModifier = remember(size, modifier) { modifier diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index 60fe3b5531..bd7e82f54f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -46,12 +47,17 @@ import org.jetbrains.compose.resources.stringResource /** * A card displaying user search result with avatar, name, and nip05/pubkey. * Shared between Android and Desktop search screens. + * + * @param badge Optional overlay drawn on top of the avatar (bottom-right + * by convention). Used by Desktop for the WoT trust-score chip; Android + * call sites leave it null. Forwarded to [UserAvatar]. */ @Composable fun UserSearchCard( user: User, onClick: () -> Unit, modifier: Modifier = Modifier, + badge: @Composable (BoxScope.() -> Unit)? = null, ) { Card( modifier = @@ -73,6 +79,7 @@ fun UserSearchCard( pictureUrl = user.profilePicture(), size = 40.dp, contentDescription = stringResource(Res.string.accessibility_user_avatar), + badge = badge, ) Column(modifier = Modifier.weight(1f)) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt index a0c57d50a7..a2c33a8583 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/CredentialPrompter.kt @@ -55,7 +55,7 @@ enum class PromptResult { /** * Credential surface permanently unavailable on this device — caller - * should invoke [com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState.onCredentialUnavailable]. + * should invoke [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onCredentialUnavailable]. */ Unavailable, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt index 91adb428a2..710dcbe97e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/IdleTimerModifier.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.privacylock import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput -import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState /** * Observes pointer events on the Initial pass — does NOT consume them, so @@ -35,7 +35,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.MessagesLockState * since they're not user input — preserves the "walked-away-from-desk" * protection per brainstorm resolved Q. */ -fun Modifier.resetIdleOnInteraction(state: MessagesLockState): Modifier = +fun Modifier.resetIdleOnInteraction(state: PrivacyLockState): Modifier = this.pointerInput(state) { awaitPointerEventScope { while (true) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt new file mode 100644 index 0000000000..d02c93176f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor +import kotlinx.coroutines.launch + +/** + * Shared lock-screen surface used by [MessagesLockGate] and [WalletLockGate]. + * Runs the async [CredentialPrompter] path (biometric / OS credential on + * Android + iOS). Desktop platforms use a password-input inline lock screen + * instead — see `DesktopMessagesLockGate` / `DesktopWalletLockGate`. + * + * Kept `internal` so the only public entry points are the per-scope Gates. + */ +@Composable +internal fun LockScreen( + scope: LockScope, + title: String, + subtitle: String, + unlockLabel: String, +) { + val lockState = lockStateFor(scope) + val prompter = LocalCredentialPrompter.current + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(prompter) { + if (!prompter.available) { + lockState.onCredentialUnavailable() + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Box(modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Box(modifier = Modifier.size(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(32.dp)) + Button( + onClick = { + coroutineScope.launch { + when (prompter.prompt()) { + PromptResult.Success -> lockState.onUnlockSuccess() + PromptResult.Unavailable -> lockState.onCredentialUnavailable() + else -> Unit + } + } + }, + enabled = prompter.available, + ) { + Text(text = unlockLabel) + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt index ebc43a69a8..04f5db93c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt @@ -20,40 +20,21 @@ */ package com.vitorpamplona.amethyst.commons.ui.privacylock -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState -import kotlinx.coroutines.launch +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** * Wraps the Messages route and gates entry behind the credential prompt. * * Branch selection happens SYNCHRONOUSLY in composition — no - * [LaunchedEffect] guard — so the chat content composable never enters - * composition while [LockState.Locked]. Closes the deep-link race - * (plan §Security Hardening H1). + * [androidx.compose.runtime.LaunchedEffect] guard — so the chat content + * composable never enters composition while [LockState.Locked]. Closes the + * deep-link race (plan §Security Hardening H1). * * The gate is an overlay, NOT a wrapper that disposes content. While * locked, the [content] lambda is not invoked at all; on unlock, the @@ -61,12 +42,14 @@ import kotlinx.coroutines.launch * `rememberSaveable` survive a lock cycle (SavedStateRegistry-backed). * For plain `remember` state, drafts are cleared — accept this trade-off. * - * The gate also fires [MessagesLockState.onLeaveRoute] from its - * [DisposableEffect.onDispose] block, so navigating away locks immediately. + * The gate also fires + * [com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockState.onLeaveRoute] + * from its [DisposableEffect.onDispose] block, so navigating away locks + * immediately. */ @Composable fun MessagesLockGate(content: @Composable () -> Unit) { - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() DisposableEffect(lockState) { @@ -74,70 +57,13 @@ fun MessagesLockGate(content: @Composable () -> Unit) { } when (current) { - is LockState.Locked -> LockScreen() + is LockState.Locked -> + LockScreen( + scope = LockScope.Messages, + title = "Messages locked", + subtitle = "Unlock to read or send messages.", + unlockLabel = "Unlock", + ) else -> content() } } - -@Composable -private fun LockScreen() { - val lockState = LocalMessagesLockState.current - val prompter = LocalCredentialPrompter.current - val scope = rememberCoroutineScope() - - LaunchedEffect(prompter) { - if (!prompter.available) { - lockState.onCredentialUnavailable() - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = - Modifier - .fillMaxSize() - .padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Box(modifier = Modifier.size(16.dp)) - Text( - text = "Messages locked", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Box(modifier = Modifier.size(8.dp)) - Text( - text = "Unlock to read or send messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(32.dp)) - Button( - onClick = { - scope.launch { - when (prompter.prompt()) { - PromptResult.Success -> lockState.onUnlockSuccess() - PromptResult.Unavailable -> lockState.onCredentialUnavailable() - else -> Unit - } - } - }, - enabled = prompter.available, - ) { - Text(text = "Unlock") - } - } - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt new file mode 100644 index 0000000000..5198855bc6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/WalletLockGate.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.privacylock + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.LockState +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * Wraps the Wallet route and gates entry behind the credential prompt. + * + * Behaviour mirrors [MessagesLockGate] — see that composable's KDoc for the + * deep-link race, draft persistence, and leave-route semantics. Only the + * [LockScope] and the lock-screen copy differ. + * + * Desktop apps use the platform-specific `DesktopWalletLockGate` (password + * input inline, no async CredentialPrompter round-trip); Android + iOS + * front ends use this composable directly. + */ +@Composable +fun WalletLockGate(content: @Composable () -> Unit) { + val lockState = lockStateFor(LockScope.Wallet) + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> + LockScreen( + scope = LockScope.Wallet, + title = "Wallet locked", + subtitle = "Unlock to see your balance and send or receive sats.", + unlockLabel = "Unlock", + ) + else -> content() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt index 8001a68b43..8e8d821fab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningAwareButton.kt @@ -43,7 +43,7 @@ fun SigningAwareButton( tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { when (signingState.state) { - is SigningOpState.Pending -> { + is SigningOpState.Pending, is SigningOpState.Progress -> { Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator( modifier = Modifier.size(16.dp), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt index d76efe5b99..6bc82cb1bb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningState.kt @@ -37,11 +37,29 @@ sealed class SigningOpState { data object Pending : SigningOpState() + /** + * Signing is in flight AND has a known step count — typically a NIP-17 + * group send via a remote signer (bunker), where the UI can usefully show + * "Encrypting via remote signer ([current] of [total])". + * + * Treated as Pending for all is-pending checks via [isPending] below; + * existing callers that branch on `is Pending` keep working unchanged. + * New callers can render the counter when [SigningOpState] is `Progress`. + */ + data class Progress( + val current: Int, + val total: Int, + val label: String? = null, + ) : SigningOpState() + data class Error( val message: String, ) : SigningOpState() } +/** True when signing is in flight, regardless of whether step counts are known. */ +fun SigningOpState.isPending(): Boolean = this is SigningOpState.Pending || this is SigningOpState.Progress + /** * Global signing status — any [SigningState] instance updates this when signing starts/ends. * Observe [globalState] from a screen-level composable to show a persistent status bar. @@ -86,7 +104,7 @@ class SigningState { private set suspend fun execute(block: suspend () -> T): T? { - if (state is SigningOpState.Pending) return null + if (state.isPending()) return null state = SigningOpState.Pending GlobalSigningStatus.onPending() errorMessage = null @@ -114,6 +132,23 @@ class SigningState { } } + /** + * Update the in-flight signing state with a progress counter. Use during + * multi-step operations (NIP-17 group send via bunker, batch zaps) to + * show the user how far the in-flight op has progressed. + * + * Only takes effect while [state] is Pending or Progress — no-op + * otherwise so callers don't have to gate on Idle/Error themselves. + */ + fun updateProgress( + current: Int, + total: Int, + label: String? = null, + ) { + if (!state.isPending()) return + state = SigningOpState.Progress(current, total, label) + } + private fun setError(message: String) { errorMessage = message state = SigningOpState.Error(message) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt index 2c6abd43b2..2ac91ec022 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/signing/SigningStatusBar.kt @@ -87,6 +87,21 @@ fun SigningStatusBar( } } + is SigningOpState.Progress -> { + Snackbar( + shape = RoundedCornerShape(8.dp), + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + val label = opState.label ?: "Signing" + Text( + text = "$label (${opState.current} of ${opState.total})", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + is SigningOpState.Error -> { Snackbar( shape = RoundedCornerShape(8.dp), diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt index e75f2371b5..0a9608313b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -25,6 +25,8 @@ import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip10Notes.content.findHashtags @@ -41,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** * Slim shared state for DM message composition. @@ -56,6 +59,19 @@ class ChatNewMessageState( val account: IAccount, val cache: ICacheProvider, val scope: CoroutineScope, + /** + * Optional resolver for probing kind:10050 via curated indexer relays + * when a peer's DM inbox isn't in the local cache. When provided, + * [updateRecipientRelayStatus] falls through to the resolver on a cache + * miss so the pre-send UI stops falsely reporting "recipient has no DM + * relay list" for accounts whose 10050 sits on an indexer we haven't + * subscribed to yet. + * + * When null (default, e.g. Android's ChatNewMessageViewModel which + * hasn't been wired to a resolver yet), behaviour matches the + * cache-only strict check. + */ + private val dmInboxResolver: (suspend (HexKey) -> List?)? = null, ) { private val _message = MutableStateFlow(TextFieldValue("")) val message: StateFlow = _message.asStateFlow() @@ -86,20 +102,66 @@ class ChatNewMessageState( } /** - * Check if all recipients have DM relay lists. - * Messages can only be sent via NIP-17, so recipients must have - * either a DM inbox relay list (kind 10050) or NIP-65 inbox relays. + * Check whether every participant in the current room is reachable via + * NIP-17 — i.e. has a published kind:10050 that lists at least one + * relay. + * + * Uses the **strict** check ([com.vitorpamplona.amethyst.commons.model.User.dmInboxRelaysStrict], + * kind:10050 only) instead of the lenient [dmInboxRelays] that falls + * back to the NIP-65 read marker — the send path also uses strict + * resolution (see `DesktopIAccount.resolveDmInboxRelaysStrict`), and + * disagreement here caused the pre-send UI to green-light sends that + * would then fail at send time. + * + * Two-phase check: + * + * 1. Synchronous cache check — every peer's kind:10050 sits in + * [cache]. If all present with at least one relay, unblock + * immediately. + * 2. Async resolver probe (only when [dmInboxResolver] is provided) — + * for peers whose 10050 isn't cached, kick off a curated-indexer + * fan-out. If any peer's relays turn up, update the flag to + * unblock the composer without requiring the user to restart the + * conversation view. + * + * The blocking flag is set optimistically during the probe so the + * user still sees the warning until we've confirmed the peer is + * genuinely unreachable via NIP-17. This preserves the "don't allow + * silent-fail sends" invariant. */ fun updateRecipientRelayStatus() { val currentRoom = _room.value - if (currentRoom != null) { - _recipientsMissingDmRelays.value = - currentRoom.users.any { hexKey -> - val user = cache.getOrCreateUser(hexKey) - user?.dmInboxRelays().isNullOrEmpty() - } - } else { + if (currentRoom == null) { _recipientsMissingDmRelays.value = false + return + } + + val missing = + currentRoom.users.filter { hexKey -> + val user = cache.getOrCreateUser(hexKey) + user?.dmInboxRelaysStrict().isNullOrEmpty() + } + if (missing.isEmpty()) { + _recipientsMissingDmRelays.value = false + return + } + + // Cache miss → block optimistically, then probe indexers for the + // missing peers. If any of them turn up a kind:10050, unblock. + _recipientsMissingDmRelays.value = true + val resolver = dmInboxResolver ?: return + + scope.launch { + val stillMissing = + missing.any { hexKey -> + val fanOut = resolver(hexKey) + fanOut.isNullOrEmpty() + } + // The room may have changed while we were probing; only apply + // the result if we're still looking at the same conversation. + if (_room.value == currentRoom) { + _recipientsMissingDmRelays.value = stillMissing + } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt new file mode 100644 index 0000000000..767687a343 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/LocalWoTService.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf + +/** + * Compose-observable score service. Provided by the Desktop app at App + * root when a user is logged in. Left null on Android and while logged + * out — leaf composables branch on `LocalWoTService.current == null` to + * skip the WoT rendering path. + * + * The badge-hide predicates (self, already-followed) are read from + * `commons.moderation.LocalSpamExemptKeys` — the same set already + * provided by the hashtag-spam filter. + */ +val LocalWoTService: ProvidableCompositionLocal = + compositionLocalOf { null } + +/** + * Whether the WoT service has finished its initial batch fetch (or the + * 2s startup timeout has elapsed). Read once at the App root via + * [WoTService.isReady] and provided down as a scalar so leaf composables + * don't each spawn a Flow collector. + */ +val LocalWoTReady: ProvidableCompositionLocal = + compositionLocalOf { false } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt new file mode 100644 index 0000000000..a9c451f05f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxCacheGateway.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Platform-agnostic interface between [OutboxDispatcher] and the platform's + * event cache. Desktop and `amy` each provide their own implementation — + * DesktopLocalCache on the app side, a minimal in-memory adapter over the + * amy local store on the CLI side. + * + * The dispatcher only needs three capabilities: + * + * 1. Peek at what kind-10002 events are already stored so it can skip + * Phase-1 discovery for authors whose write-relay list is already + * known (from hydration or a previous session's fetch). + * 2. Ingest a kind-10002 that just came back from an index relay so + * subsequent lookups don't re-fetch it. + * 3. Ingest a kind-0 or kind-3 that just came back from an outbox + * relay so the platform cache/UI can pick it up through the usual + * consume path. + * + * Every method must be idempotent — the dispatcher may re-fire the same + * event through the gateway if two relays happen to return the same + * addressable event. + */ +interface OutboxCacheGateway { + /** + * Returns the currently-cached kind-10002 event for [pubkey], or null + * if the platform cache doesn't have one yet. + */ + fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? + + /** + * Called for every kind-10002 the dispatcher receives during Phase 1. + * The gateway should route it through its normal consume path so the + * event is stored, deduped by createdAt, and picked up by any state + * holders observing the addressable-notes cache. + */ + fun onOutboxDiscovered( + event: AdvertisedRelayListEvent, + relay: NormalizedRelayUrl, + ) + + /** + * Called for every kind-0 (metadata) or kind-3 (contact list) the + * dispatcher receives during Phase 2 or Phase 3. The gateway should + * route it through its normal consume path — this is how new profile + * metadata and follow lists reach downstream consumers like the WoT + * service and the UI. + */ + fun onDiscoveredEvent( + event: Event, + relay: NormalizedRelayUrl, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt new file mode 100644 index 0000000000..24b6401cb3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcher.kt @@ -0,0 +1,495 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.RelayListRecommendationProcessor +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.Volatile + +/** + * Fetches kind-0 (profile metadata) and kind-3 (contact list) events for a + * set of authors using the NIP-65 **outbox model**: + * + * 1. **Phase 1 — discover.** Ask the configured index relays (Purple Pages, + * Coracle, nos.lol, …) for the kind-10002 of each author. Merge with + * already-cached 10002s from [OutboxCacheGateway]. + * + * 2. **Phase 2 — pick + fetch.** Feed the author → write-relays map into + * [RelayListRecommendationProcessor.reliableRelaySetFor] to get a + * minimal, popularity-based set of relays that covers every author. + * Open one subscription per recommended relay, filtered to that + * relay's authors, for kind-0 and/or kind-3. + * + * 3. **Phase 3 — fallback.** For any author whose kind-10002 the network + * never returned, fall back to the index-relay REQ (preserves the + * current behaviour so a coldish account doesn't lose signal). + * + * The dispatcher is single-scoped (one instance per account) so its dedup + * set survives across follow-set diffs. Call [clear] on account switch. + * + * @param client shared [INostrClient] used for every subscription + * @param scope account-lifetime scope; cancelling it cancels in-flight REQs + * @param indexRelays lazy accessor so a change through the settings UI + * takes effect on next fetch without recreating the + * dispatcher + * @param gateway platform-specific cache adapter (see [OutboxCacheGateway]) + * @param perRelayTimeoutMs how long each REQ waits for its EOSE. Under + * the plan (2026-07-06): 4 s. + * @param overallTimeoutMs cap on the whole two-phase fetch. Belt against + * a phase getting stuck. Under the plan: 8 s. + * @param maxOutboxRelaysPerAuthor bound author → write-relays to the first N + * relays after the [RelayListRecommendationProcessor] + * chooses them, to keep fan-out predictable + */ +class OutboxDispatcher( + private val client: INostrClient, + private val scope: CoroutineScope, + private val indexRelays: () -> Set, + private val gateway: OutboxCacheGateway, + private val perRelayTimeoutMs: Long = 4_000L, + private val overallTimeoutMs: Long = 20_000L, + @Suppress("UNUSED_PARAMETER") maxOutboxRelaysPerAuthor: Int = 5, +) { + /** + * Pubkeys we've already successfully fetched kind-3 for this session + * (Phase 1 or Phase 2 returned events for them). Skipping a second + * fetch is safe because a churn event from a subsequent kind-3 + * republication still reaches [OutboxCacheGateway.onDiscoveredEvent] + * via other subscriptions (feed, notifications). + */ + private val kind3Succeeded = mutableSetOf() + + /** + * Pubkeys we've already successfully fetched kind-0 for this session. + */ + private val kind0Succeeded = mutableSetOf() + + /** + * Currently-in-flight authors — prevents rapid re-fire of the same + * fetch. Distinct from [kind3Succeeded]/[kind0Succeeded]: a zero-EOSE + * timeout rolls out of this set (allowing retry) instead of + * permanently marking the pubkey as done. + */ + private val kind3InFlight = mutableSetOf() + private val kind0InFlight = mutableSetOf() + + /** + * Outcome counters. All values are aggregated across every phase of + * one [fetchKind3Only] / [fetchKind0And3] call. Callers log them for + * observability; `amy wot sync --json` also emits them so a caller + * can measure whether the outbox path is doing the work vs the + * fallback path. + */ + data class Result( + val authorsRequested: Int, + val kind10002Received: Int, + val kind3Received: Int, + val kind0Received: Int, + val outboxCoveredAuthors: Int, + val fallbackAuthors: Int, + ) + + /** + * Fetch kind-3 for every pubkey in [authors] via each author's outbox + * relay when known, falling back to index relays otherwise. Suspends + * until every phase EOSEs or times out. + */ + suspend fun fetchKind3Only(authors: Set): Result = run(authors, includeKind0 = false, includeKind3 = true) + + /** + * Fetch kind-3 AND kind-0 for every pubkey in [authors]. Same phase + * pipeline; a single per-outbox-relay subscription pulls both kinds + * so we don't double the connection count. + */ + suspend fun fetchKind0And3(authors: Set): Result = run(authors, includeKind0 = true, includeKind3 = true) + + /** + * Fetch kind-0 only. Used by the metadata preloader when it decides + * to bypass the index-relay batch for a specific author (e.g. a + * profile screen visit where the author's outbox is already cached). + */ + suspend fun fetchKind0Only(authors: Set): Result = run(authors, includeKind0 = true, includeKind3 = false) + + /** + * Drop every dedup marker. Call on account switch so a fresh account + * doesn't inherit the previous account's "already fetched" state. + */ + fun clear() { + kind3Succeeded.clear() + kind0Succeeded.clear() + kind3InFlight.clear() + kind0InFlight.clear() + } + + private suspend fun run( + authors: Set, + includeKind0: Boolean, + includeKind3: Boolean, + ): Result { + if (authors.isEmpty()) return zeroResult(0) + + val newForKind3 = + if (includeKind3) authors.filter { it !in kind3Succeeded && it !in kind3InFlight }.toSet() else emptySet() + val newForKind0 = + if (includeKind0) authors.filter { it !in kind0Succeeded && it !in kind0InFlight }.toSet() else emptySet() + + if (newForKind3.isEmpty() && newForKind0.isEmpty()) { + Log.d("OutboxDispatcher") { "skip: all authors deduped (succeeded or in-flight)" } + return zeroResult(authors.size) + } + + kind3InFlight.addAll(newForKind3) + kind0InFlight.addAll(newForKind0) + + return try { + val result = + withTimeoutOrNull(overallTimeoutMs) { + doRun(authors, newForKind3, newForKind0, includeKind0, includeKind3) + } + if (result == null) { + Log.w("OutboxDispatcher") { "overall timeout ${overallTimeoutMs}ms exceeded — returning zero result" } + zeroResult(authors.size) + } else { + result + } + } finally { + kind3InFlight.removeAll(newForKind3) + kind0InFlight.removeAll(newForKind0) + } + } + + private suspend fun doRun( + allAuthors: Set, + newForKind3: Set, + newForKind0: Set, + includeKind0: Boolean, + includeKind3: Boolean, + ): Result { + val relayCounts = FetchCounters() + val relaysConfigured = indexRelays() + val newTargets = (newForKind3 + newForKind0) + + // Split into "have cached 10002" vs "need Phase 1". + val cachedOutbox = mutableMapOf>() + val toDiscover = mutableSetOf() + for (author in newTargets) { + val write = + gateway + .cachedOutbox(author) + ?.writeRelaysNorm() + .orEmpty() + .toSet() + if (write.isNotEmpty()) cachedOutbox[author] = write else toDiscover.add(author) + } + + Log.d("OutboxDispatcher") { + "start authors=${allAuthors.size} newKind3=${newForKind3.size} newKind0=${newForKind0.size} " + + "cachedOutbox=${cachedOutbox.size} toDiscover=${toDiscover.size} " + + "indexRelays=${relaysConfigured.size}" + } + + // Phase 1 — discover kind-10002 on the index relays. runPhase1 + // returns pubkey → list of (event, relay) so we can pick the + // newest event (some relays return outdated 10002s). + val discovered = mutableMapOf>() + if (toDiscover.isNotEmpty() && relaysConfigured.isNotEmpty()) { + val (phase1Events, phase1EosedCount) = runPhase1(toDiscover, relaysConfigured) + phase1Events.forEach { (pubkey, results) -> + val newest = results.maxByOrNull { it.first.createdAt } ?: return@forEach + gateway.onOutboxDiscovered(newest.first, newest.second) + val write = + newest.first + .writeRelaysNorm() + .orEmpty() + .toSet() + if (write.isNotEmpty()) discovered[pubkey] = write + } + relayCounts.kind10002 += phase1Events.values.sumOf { it.size } + Log.d("OutboxDispatcher") { + "phase1 done eosed=$phase1EosedCount/${relaysConfigured.size} " + + "10002-events=${relayCounts.kind10002} discovered=${discovered.size}" + } + } + + val outboxMap = cachedOutbox + discovered + val authorsWithOutbox = outboxMap.keys + val fallbackAuthors = newTargets - authorsWithOutbox + + // Phase 2 — per-outbox-relay REQ, kind-3 and/or kind-0. All + // recommended relays are subscribed in a single call so the pool + // fans out in parallel; a per-relay 4 s timeout bounds the wait + // regardless of how many relays the recommendation set contains. + val kind3BeforePhase2 = relayCounts.kind3 + val kind0BeforePhase2 = relayCounts.kind0 + if (outboxMap.isNotEmpty() && (includeKind0 || includeKind3)) { + val recommendations = RelayListRecommendationProcessor.reliableRelaySetFor(outboxMap) + val phase2FilterMap = + recommendations + .mapNotNull { rec -> + val authorsForThisRelay = + rec.users.intersect( + if (includeKind0 && includeKind3) { + newTargets + } else if (includeKind3) { + newForKind3 + } else { + newForKind0 + }, + ) + if (authorsForThisRelay.isEmpty()) return@mapNotNull null + val kinds = + buildList { + if (includeKind0 && authorsForThisRelay.any { it in newForKind0 }) add(MetadataEvent.KIND) + if (includeKind3 && authorsForThisRelay.any { it in newForKind3 }) add(ContactListEvent.KIND) + } + if (kinds.isEmpty()) return@mapNotNull null + rec.relay to + authorsForThisRelay.chunked(100).map { chunk -> + Filter( + kinds = kinds, + authors = chunk, + limit = chunk.size * kinds.size, + ) + } + }.toMap() + + Log.d("OutboxDispatcher") { "phase2 recommendations=${recommendations.size} relays-with-work=${phase2FilterMap.size}" } + if (phase2FilterMap.isNotEmpty()) { + runPhase2Or3(phase2FilterMap, counters = relayCounts) + } + } + + Log.d("OutboxDispatcher") { + "phase2 done kind3=${relayCounts.kind3 - kind3BeforePhase2} kind0=${relayCounts.kind0 - kind0BeforePhase2}" + } + + // Phase 3 — index-relay fallback for authors with no 10002. + val kind3BeforePhase3 = relayCounts.kind3 + val kind0BeforePhase3 = relayCounts.kind0 + if (fallbackAuthors.isNotEmpty() && relaysConfigured.isNotEmpty()) { + val kinds = + buildList { + if (includeKind0 && fallbackAuthors.any { it in newForKind0 }) add(MetadataEvent.KIND) + if (includeKind3 && fallbackAuthors.any { it in newForKind3 }) add(ContactListEvent.KIND) + } + if (kinds.isNotEmpty()) { + Log.d("OutboxDispatcher") { "phase3 fallback authors=${fallbackAuthors.size} kinds=$kinds relays=${relaysConfigured.size}" } + val filters = + fallbackAuthors.chunked(100).map { chunk -> + Filter( + kinds = kinds, + authors = chunk, + limit = chunk.size * kinds.size, + ) + } + val phase3FilterMap = relaysConfigured.associateWith { filters } + runPhase2Or3(phase3FilterMap, counters = relayCounts) + Log.d("OutboxDispatcher") { + "phase3 done kind3=${relayCounts.kind3 - kind3BeforePhase3} kind0=${relayCounts.kind0 - kind0BeforePhase3}" + } + } + } + + // Promote to succeeded — a completed run means we've asked; even if + // an author had no publishable data we don't need to keep pounding + // relays every follow-set change. + kind3Succeeded.addAll(newForKind3) + kind0Succeeded.addAll(newForKind0) + + return Result( + authorsRequested = allAuthors.size, + kind10002Received = relayCounts.kind10002, + kind3Received = relayCounts.kind3, + kind0Received = relayCounts.kind0, + outboxCoveredAuthors = authorsWithOutbox.size, + fallbackAuthors = fallbackAuthors.size, + ) + } + + // ------------------------------------------------------------------ + + private class FetchCounters { + var kind10002 = 0 + var kind3 = 0 + var kind0 = 0 + } + + /** + * Phase 1 helper. Returns a map of pubkey → list of (event, relay) so + * caller can pick the newest, plus a boolean-per-relay EOSE indicator + * (currently ignored but recorded for future retry telemetry). + */ + private suspend fun runPhase1( + pubkeys: Set, + relays: Set, + ): Pair>>, Int> { + val filters = + pubkeys.chunked(100).map { chunk -> + Filter( + kinds = listOf(AdvertisedRelayListEvent.KIND), + authors = chunk, + limit = chunk.size, + ) + } + val filterMap = relays.associateWith { filters } + + val received = mutableMapOf>>() + val gate = BatchEoseGate(scope, target = relays.size) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is AdvertisedRelayListEvent && event.pubKey in pubkeys) { + received + .getOrPut(event.pubKey) { mutableListOf() } + .add(event to relay) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gate.notifyEose(relay) + } + } + + val subId = newSubId() + client.subscribe(subId, filterMap, listener) + val eosedCount = gate.awaitAll(perRelayTimeoutMs) + client.unsubscribe(subId) + + return received to eosedCount + } + + /** + * Phase 2 or Phase 3 helper. Opens a single subscription that + * fans out to every relay in [filterMap] (Phase 2 uses per-outbox- + * relay filters; Phase 3 uses the index-relay set with a shared + * fallback filter). All relays are subscribed in parallel — the + * per-relay timeout bounds the total wait regardless of relay count. + */ + private suspend fun runPhase2Or3( + filterMap: Map>, + counters: FetchCounters, + ) { + val gate = BatchEoseGate(scope, target = filterMap.size) + + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + when (event.kind) { + MetadataEvent.KIND -> counters.kind0++ + ContactListEvent.KIND -> counters.kind3++ + } + gateway.onDiscoveredEvent(event, relay) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + gate.notifyEose(relay) + } + } + + val subId = newSubId() + client.subscribe(subId, filterMap, listener) + gate.awaitAll(perRelayTimeoutMs) + client.unsubscribe(subId) + } + + private fun zeroResult(requested: Int) = + Result( + authorsRequested = requested, + kind10002Received = 0, + kind3Received = 0, + kind0Received = 0, + outboxCoveredAuthors = 0, + fallbackAuthors = 0, + ) + + /** + * KMP-safe EOSE aggregator (same as FeedMetadataCoordinator's local + * one — duplicated locally instead of exported to keep the fix scope + * minimal). Per-relay `onEose` callbacks may run on any dispatcher + * (typically `Dispatchers.IO`) so we funnel them through a Channel + * and let a single consumer coroutine own the `seen` set. + */ + private class BatchEoseGate( + private val scope: CoroutineScope, + private val target: Int, + ) { + private val incoming = Channel(Channel.UNLIMITED) + private val done = CompletableDeferred() + + @Volatile private var lastCount = 0 + + fun notifyEose(relay: NormalizedRelayUrl) { + incoming.trySend(relay) + } + + suspend fun awaitAll(timeoutMs: Long): Int { + if (target <= 0) return 0 + val consumer = + scope.launch { + val seen = mutableSetOf() + for (relay in incoming) { + if (seen.add(relay)) { + lastCount = seen.size + if (seen.size >= target && !done.isCompleted) { + done.complete(Unit) + } + } + } + } + withTimeoutOrNull(timeoutMs) { done.await() } + incoming.close() + consumer.join() + return lastCount + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt new file mode 100644 index 0000000000..4f5a01075b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.runtime.snapshots.SnapshotStateMap +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * Friends-of-friends trust score computed from the active user's follow + * graph. For every pubkey X the score is the count of accounts in the + * active user's follow set who also follow X. + * + * ## Reactivity model + * + * Scores are exposed via a Compose-observable [SnapshotStateMap]. Consumers + * that read a **single key** (`scores[pubkey]`) recompose only when that + * key changes — this is `SnapshotStateMap`'s built-in per-key observation + * and applies whether or not the writer wraps in a snapshot block. + * Consumers that iterate the map or read `size` recompose on **any** + * mutation. + * + * The writer wraps each op in [Snapshot.withMutableSnapshot] to *coalesce* + * an op's writes into a single Compose commit — so a Kind3 op that + * touches N reverse-index targets emits one invalidation, not N. It does + * not confer additional per-key isolation on top of `SnapshotStateMap`'s + * own semantics. + * + * ## Concurrency + * + * All internal state is mutated from a single writer coroutine + * ([writerLoop]) on [writerDispatcher] (default [Dispatchers.Default]), so + * concurrent [applyKind3] / [onFollowSetChange] / [markReadyOnce] calls + * from different threads are serialized without extra locking. + * + * ## Lifecycle + * + * Call [close] on account switch / logout so the writer coroutine exits + * and the ops channel is released. Post-close ops are silently dropped. + */ +@Stable +class WoTService( + private val scope: CoroutineScope, + /** Dispatcher for the internal writer coroutine. Tests override with `Dispatchers.Unconfined` for synchronous behavior. */ + private val writerDispatcher: CoroutineDispatcher = Dispatchers.Default, +) : AutoCloseable { + /** + * Sparse per-pubkey score map. Entries with count 0 are removed + * (not stored as 0) to keep the Compose subscriber tracking tight. + * Callers should read as `scores[pubkey] ?: 0`. + */ + private val _scores: SnapshotStateMap = mutableStateMapOf() + val scores: SnapshotStateMap get() = _scores + + // Reverse index: target pubkey → set of my-follows who follow them. + private val reverseIndex = HashMap>() + + // Per-follower cached follow set (excluding self / follower itself). + // Enables diff-based updates when a follower republishes their kind-3. + private val perFollowerSnapshot = HashMap>() + + private var myFollows: Set = emptySet() + private var selfPubkey: HexKey? = null + private var readyMarked = false + private var disabled = false + + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady.asStateFlow() + + private val _isDisabled = MutableStateFlow(false) + + /** + * True when the active user's follow set exceeds [MAX_FOLLOWS] and WoT + * scoring has been shut off. Callers that dispatch the batch kind-3 + * REQ must gate on this — a disabled service silently accepts and + * ignores all subsequent [applyKind3] calls, so a caller that keeps + * flooding kind-3s wastes bandwidth for nothing. + */ + val isDisabled: StateFlow = _isDisabled.asStateFlow() + + private val ops = Channel(capacity = Channel.UNLIMITED) + + init { + scope.launch(writerDispatcher) { writerLoop() } + } + + /** Update the active user's follow set (and self pubkey). */ + fun onFollowSetChange( + newFollows: Set, + newSelf: HexKey?, + ) { + ops.trySend(Op.FollowSet(newFollows, newSelf)) + } + + /** + * Ingest a kind-3 event for a followed pubkey. Ignored when the + * event's author isn't in the current follow set. Follow lists are + * capped at [MAX_FOLLOWS_PER_EVENT] to bound CPU cost against a + * hostile publisher. + */ + fun applyKind3( + follower: HexKey, + follows: Set, + ) { + val bounded = + if (follows.size > MAX_FOLLOWS_PER_EVENT) { + follows.take(MAX_FOLLOWS_PER_EVENT).toSet() + } else { + follows + } + ops.trySend(Op.Kind3(follower, bounded)) + } + + /** + * Mark the service as ready to render badges. Idempotent — subsequent + * calls are no-ops. Trigger from the first EOSE on the batch kind-3 + * REQ, or from a startup-timeout fallback, whichever fires first. + */ + fun markReadyOnce() { + ops.trySend(Op.MarkReady) + } + + /** Clear all state. Used on logout / account switch. */ + fun clear() { + ops.trySend(Op.Clear) + } + + /** + * Returns a plain [Map] snapshot of current scores for headless + * callers (e.g. the amy CLI) that don't run inside a Compose + * composition. O(N) copy from the underlying [SnapshotStateMap]. + */ + fun scoresSnapshot(): Map = HashMap(_scores) + + private sealed interface Op { + data class FollowSet( + val newFollows: Set, + val newSelf: HexKey?, + ) : Op + + data class Kind3( + val follower: HexKey, + val follows: Set, + ) : Op + + data object MarkReady : Op + + data object Clear : Op + } + + private suspend fun writerLoop() { + for (op in ops) { + Snapshot.withMutableSnapshot { + when (op) { + is Op.FollowSet -> handleFollowSet(op.newFollows, op.newSelf) + is Op.Kind3 -> handleKind3(op.follower, op.follows) + Op.MarkReady -> handleMarkReady() + Op.Clear -> handleClear() + } + } + } + } + + private fun handleFollowSet( + newFollows: Set, + newSelf: HexKey?, + ) { + // Guardrail — massive follow lists don't produce a useful WoT signal. + // Do this BEFORE assigning myFollows so applyKind3's `follower in + // myFollows` gate doesn't accidentally credit anyone once the + // caller keeps pumping kind-3s in (a caller that fails to gate on + // isDisabled would otherwise fully repopulate reverseIndex/_scores + // and defeat the guardrail — see PR #3483 review finding 2). + if (newFollows.size > MAX_FOLLOWS) { + reverseIndex.clear() + perFollowerSnapshot.clear() + _scores.clear() + myFollows = emptySet() + selfPubkey = newSelf + disabled = true + _isDisabled.value = true + handleMarkReady() + return + } + + val removed = myFollows - newFollows + myFollows = newFollows + selfPubkey = newSelf + // Follow set is back within limits (or was already) — re-enable if + // we had previously flipped disabled=true. + if (disabled) { + disabled = false + _isDisabled.value = false + } + + // Uncredit any follower we're no longer following. + removed.forEach { follower -> + val prevFollows = perFollowerSnapshot.remove(follower) ?: return@forEach + prevFollows.forEach { target -> + val set = reverseIndex[target] ?: return@forEach + set.remove(follower) + if (set.isEmpty()) reverseIndex.remove(target) + updateScore(target) + } + } + // Added followers will be credited when their kind-3 arrives via applyKind3. + } + + private fun handleKind3( + follower: HexKey, + follows: Set, + ) { + if (disabled) return + if (follower !in myFollows) return + + val old = perFollowerSnapshot[follower] ?: emptySet() + val excluded = setOfNotNull(follower, selfPubkey) + val effective = follows - excluded + val added = effective - old + val removed = old - effective + perFollowerSnapshot[follower] = effective + + added.forEach { target -> + reverseIndex.getOrPut(target) { hashSetOf() }.add(follower) + updateScore(target) + } + removed.forEach { target -> + val set = reverseIndex[target] ?: return@forEach + set.remove(follower) + if (set.isEmpty()) reverseIndex.remove(target) + updateScore(target) + } + } + + private fun handleMarkReady() { + if (!readyMarked) { + readyMarked = true + _isReady.value = true + } + } + + private fun handleClear() { + reverseIndex.clear() + perFollowerSnapshot.clear() + _scores.clear() + myFollows = emptySet() + selfPubkey = null + readyMarked = false + _isReady.value = false + disabled = false + _isDisabled.value = false + } + + /** + * Cancel the writer coroutine and release the ops channel. Call from + * account-switch / logout paths. Post-close [applyKind3] / [onFollowSetChange] + * / [markReadyOnce] / [clear] calls are silently dropped (the `trySend` + * on a closed [Channel] fails without throwing). + * + * Idempotent; safe to call multiple times. + */ + override fun close() { + ops.close() + } + + private fun updateScore(target: HexKey) { + val n = reverseIndex[target]?.size ?: 0 + if (n > 0) _scores[target] = n else _scores.remove(target) + } + + companion object { + /** Skip WoT entirely for accounts following more than this many pubkeys. */ + const val MAX_FOLLOWS = 2000 + + /** Cap follows per kind-3 event to bound CPU cost against a hostile publisher. */ + const val MAX_FOLLOWS_PER_EVENT = 5000 + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt similarity index 67% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt index d3828ab2e6..b6f44c35f7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockStateTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PrivacyLockStateTest.kt @@ -29,25 +29,27 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) -class MessagesLockStateTest { +class PrivacyLockStateTest { private class FakeSettings( lockEnabled: Boolean = false, timer: InactivityTimer = InactivityTimer.OneMin, + password: String? = null, ) : PrivacyLockSettings { private val mutableLockEnabled = MutableStateFlow(lockEnabled) private val mutableTimer = MutableStateFlow(timer) private val mutableRedaction = MutableStateFlow(DmRedactionLevel.DEFAULT) private val mutableFirstRunSeen = MutableStateFlow(false) - private val mutablePasswordHashed = MutableStateFlow(null) + private val mutablePasswordHashed = MutableStateFlow(password) private val mutableFailedAttempts = MutableStateFlow(0) private val mutableLockedUntil = MutableStateFlow(null) override val lockEnabled: StateFlow = mutableLockEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() - override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() + override val dmRedactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() @@ -61,7 +63,7 @@ class MessagesLockStateTest { mutableTimer.value = timer } - override fun setRedactionLevel(level: DmRedactionLevel) { + override fun setDmRedactionLevel(level: DmRedactionLevel) { mutableRedaction.value = level } @@ -71,6 +73,8 @@ class MessagesLockStateTest { override fun setPasswordHashed(saltAndHash: String?) { mutablePasswordHashed.value = saltAndHash + // Mirror the production cascade — no credential means no gate. + if (saltAndHash == null && mutableLockEnabled.value) mutableLockEnabled.value = false } override fun setFailedUnlockAttempts(count: Int) { @@ -86,7 +90,7 @@ class MessagesLockStateTest { fun cold_start_with_lock_enabled_seeds_to_locked() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Locked, state.state.value) } @@ -94,7 +98,7 @@ class MessagesLockStateTest { fun cold_start_with_lock_disabled_seeds_to_disabled() = runTest { val settings = FakeSettings(lockEnabled = false) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Disabled, state.state.value) } @@ -102,7 +106,7 @@ class MessagesLockStateTest { fun unlock_success_transitions_to_unlocked_and_idle_timer_fires() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) advanceTimeBy(InactivityTimer.OneMin.millis!! + 1_000L) @@ -113,7 +117,7 @@ class MessagesLockStateTest { fun leave_route_locks_immediately() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneHour) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) state.onLeaveRoute() @@ -124,7 +128,7 @@ class MessagesLockStateTest { fun toggling_lock_off_transitions_to_disabled() = runTest(UnconfinedTestDispatcher()) { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) settings.setLockEnabled(false) @@ -135,7 +139,7 @@ class MessagesLockStateTest { fun never_timer_does_not_fire() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.Never) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() advanceTimeBy(InactivityTimer.OneHour.millis!! * 2) assertEquals(LockState.Unlocked, state.state.value) @@ -145,7 +149,7 @@ class MessagesLockStateTest { fun user_interaction_resets_idle_timer() = runTest { val settings = FakeSettings(lockEnabled = true, timer = InactivityTimer.OneMin) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onUnlockSuccess() advanceTimeBy(InactivityTimer.OneMin.millis!! - 1_000L) state.onUserInteraction() @@ -159,7 +163,7 @@ class MessagesLockStateTest { fun credential_unavailable_disables_lock() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) state.onCredentialUnavailable() assertEquals(LockState.Disabled, state.state.value) assertEquals(false, settings.lockEnabled.value) @@ -169,11 +173,11 @@ class MessagesLockStateTest { fun unlock_success_from_disabled_transitions_to_unlocked() = runTest { // First-run banner path: user enables lock + sets password while - // already viewing Messages. State is Disabled at that moment, and - // we want to stay Unlocked so the user isn't kicked to the lock + // already viewing a gated route. State is Disabled at that moment, + // and we want to stay Unlocked so the user isn't kicked to the lock // screen right after enabling. val settings = FakeSettings(lockEnabled = false) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) assertEquals(LockState.Disabled, state.state.value) state.onUnlockSuccess() assertEquals(LockState.Unlocked, state.state.value) @@ -183,7 +187,7 @@ class MessagesLockStateTest { fun failed_attempts_below_threshold_do_not_trip_lockout() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES - 1) { assertEquals(null, state.onFailedUnlockAttempt(now)) @@ -199,7 +203,7 @@ class MessagesLockStateTest { fun fifth_failure_trips_base_lockout() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) @@ -212,7 +216,7 @@ class MessagesLockStateTest { fun lockout_doubles_and_caps_at_maximum() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L // 5th failure → base (30s) repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } @@ -230,7 +234,7 @@ class MessagesLockStateTest { fun unlock_success_clears_backoff_state() = runTest { val settings = FakeSettings(lockEnabled = true) - val state = MessagesLockState(settings, backgroundScope) + val state = PrivacyLockState(LockScope.Messages, settings, backgroundScope) val now = 1_000_000L repeat(PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES) { state.onFailedUnlockAttempt(now) } assertTrue(settings.lockedUntilEpochMs.value != null) @@ -239,4 +243,64 @@ class MessagesLockStateTest { assertEquals(null, settings.lockedUntilEpochMs.value) assertEquals(0, settings.failedUnlockAttempts.value) } + + // ---- Wallet-lock reuse additions ---- + + @Test + fun two_scopes_have_independent_lock_state() = + runTest(UnconfinedTestDispatcher()) { + val settings = FakeSettings(lockEnabled = true) + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + messages.onUnlockSuccess() + assertEquals(LockState.Unlocked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + messages.onLeaveRoute() + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + } + + @Test + fun failed_unlock_counter_is_shared_across_scopes() = + runTest { + val settings = FakeSettings(lockEnabled = true) + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + val now = 1_000_000L + // Three failures on Messages, two on Wallet → shared counter hits 5 + repeat(3) { messages.onFailedUnlockAttempt(now) } + repeat(2) { wallet.onFailedUnlockAttempt(now) } + assertEquals( + PrivacyLockSettings.LOCKOUT_TRIP_AFTER_FAILURES, + settings.failedUnlockAttempts.value, + ) + // The 5th failure trips the base lockout regardless of which scope + // it came from — either scope now sees the countdown. + assertEquals( + now + PrivacyLockSettings.LOCKOUT_BASE_MS, + settings.lockedUntilEpochMs.value, + ) + } + + @Test + fun clearing_password_cascades_to_disable_the_master_lock() = + runTest(UnconfinedTestDispatcher()) { + val settings = FakeSettings(lockEnabled = true, password = "salt\$hash") + val messages = PrivacyLockState(LockScope.Messages, settings, backgroundScope) + val wallet = PrivacyLockState(LockScope.Wallet, settings, backgroundScope) + assertEquals(LockState.Locked, messages.state.value) + assertEquals(LockState.Locked, wallet.state.value) + + // User clears the password from Settings → cascade fires + settings.setPasswordHashed(null) + + assertEquals(false, settings.lockEnabled.value) + assertEquals(LockState.Disabled, messages.state.value) + assertEquals(LockState.Disabled, wallet.state.value) + assertNull(settings.passwordHashed.value) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalEndToEndTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalEndToEndTest.kt new file mode 100644 index 0000000000..f2eaaf7eeb --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalEndToEndTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +/** + * End-to-end exercise of the AUTH stack: policy classification + + * RelayAuthEvent.build template + real NostrSignerInternal signing. + * + * This is the lambda-level shape that [DesktopAuthCoordinator]'s + * `signWithAllLoggedInUsers` calls into. It isolates the policy/signer + * round-trip from the live websocket layer (which has its own coverage + * in `geode/.../KtorRelayTest.kt` against a Ktor mock relay). + * + * Together with the existing AuthApprovalPolicyTest (classifier), + * PoolEventOutboxStateTest (auth-required carve-out), and + * GiftWrapRelayHintTest (NIP-17 hint placement), this covers the AUTH + * pipeline at unit granularity — the geode Ktor tests handle the + * websocket-level round-trip. + */ +class AuthApprovalEndToEndTest { + private val signer = NostrSignerInternal(KeyPair()) + private val ownInbox = NormalizedRelayUrl("wss://own.inbox/") + private val unknown = NormalizedRelayUrl("wss://unknown.relay/") + private val challenge = "test-challenge-abc123" + + private fun newPolicy( + ownSet: Set = setOf(ownInbox), + onPrompt: (PendingAuthApproval) -> Unit = {}, + ): Pair { + val store = InMemoryAuthApprovalStore() + return AuthApprovalPolicy( + selfApprovedRelays = { ownSet }, + store = store, + onPromptRequired = onPrompt, + ) to store + } + + /** + * Coordinator's lambda shape, distilled. Returns the signed AUTH event + * (or null on Block / not-signed-by-policy). + */ + private suspend fun signWithPolicy( + relay: NormalizedRelayUrl, + policy: AuthApprovalPolicy, + ): RelayAuthEvent? { + val template = RelayAuthEvent.build(relay, challenge) + val relayFromTemplate = template.tags.firstNotNullOfOrNull(RelayTag::parse) + assertEquals(relay, relayFromTemplate, "RelayAuthEvent.build must round-trip via RelayTag.parse") + return when (val decision = policy.classify(relay)) { + AuthApprovalDecision.Allow -> signer.sign(template) + AuthApprovalDecision.Block -> null + is AuthApprovalDecision.Pending -> { + val resolved = decision.pending.await() + if (resolved != AuthApprovalScope.ONCE) policy.recordDecision(relay, resolved) + if (resolved == AuthApprovalScope.BLOCKED) null else signer.sign(template) + } + } + } + + @Test + fun tier1OwnInboxAutoSignsValidAuthEvent() = + runTest { + val (policy, _) = newPolicy() + val signed = signWithPolicy(ownInbox, policy) + assertNotNull(signed) + assertEquals(RelayAuthEvent.KIND, signed.kind) + assertEquals(signer.pubKey, signed.pubKey) + assertEquals(challenge, signed.challenge()) + assertEquals(ownInbox, signed.relay()) + } + + @Test + fun tier2UnknownPromptsAndOnceResolutionSigns() = + runTest { + var prompted: PendingAuthApproval? = null + val (policy, _) = newPolicy(onPrompt = { prompted = it }) + + coroutineScope { + // Concurrent: lambda suspends inside policy.classify; we + // resolve the deferred from outside as the banner UI would. + val deferred = async { signWithPolicy(unknown, policy) } + yieldUntilNotNull { prompted } + prompted!!.decision.complete(AuthApprovalScope.ONCE) + + val signed = deferred.await() + assertNotNull(signed) + assertEquals(unknown, signed.relay()) + } + } + + @Test + fun tier2BlockedResolutionReturnsNullAndPersists() = + runTest { + var prompted: PendingAuthApproval? = null + val (policy, store) = newPolicy(onPrompt = { prompted = it }) + + coroutineScope { + val deferred = async { signWithPolicy(unknown, policy) } + yieldUntilNotNull { prompted } + prompted!!.decision.complete(AuthApprovalScope.BLOCKED) + + val signed = deferred.await() + assertNull(signed) + assertEquals(AuthApprovalScope.BLOCKED, store.getScope(unknown)) + } + } + + @Test + fun tier2AlwaysPersistsAndSkipsPromptNextTime() = + runTest { + var promptCount = 0 + val (policy, store) = + newPolicy(onPrompt = { + it.decision.complete(AuthApprovalScope.ALWAYS) + promptCount++ + }) + + // First call: prompts and resolves to ALWAYS. + val first = signWithPolicy(unknown, policy) + assertNotNull(first) + assertEquals(1, promptCount) + assertEquals(AuthApprovalScope.ALWAYS, store.getScope(unknown)) + + // Second call: should NOT prompt again. + val second = signWithPolicy(unknown, policy) + assertNotNull(second) + assertEquals(1, promptCount, "ALWAYS persisted — no second prompt") + } +} + +private suspend inline fun yieldUntilNotNull(crossinline supplier: () -> T?): T { + repeat(100) { + supplier()?.let { return it } + yield() + } + error("supplier never produced a value within 100 yields") +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicyTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicyTest.kt new file mode 100644 index 0000000000..9862dba858 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/AuthApprovalPolicyTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.auth + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class AuthApprovalPolicyTest { + private val ownOutbox = NormalizedRelayUrl("wss://own.outbox/") + private val unknown = NormalizedRelayUrl("wss://unknown.relay/") + private val blockedRelay = NormalizedRelayUrl("wss://blocked.relay/") + + private fun newPolicy( + ownSet: Set = setOf(ownOutbox), + onPrompt: (PendingAuthApproval) -> Unit = {}, + ): Pair { + val store = InMemoryAuthApprovalStore() + val policy = + AuthApprovalPolicy( + selfApprovedRelays = { ownSet }, + store = store, + onPromptRequired = onPrompt, + ) + return policy to store + } + + @Test + fun tier1OwnOutboxRelayIsAutoAllowed() = + runTest { + val (policy, _) = newPolicy() + val decision = policy.classify(ownOutbox) + assertSame(AuthApprovalDecision.Allow, decision) + } + + @Test + fun unknownRelayPromptsAndReturnsPending() = + runTest { + val prompts = mutableListOf() + val (policy, _) = newPolicy(onPrompt = { prompts += it }) + + val decision = policy.classify(unknown) + + assertIs(decision) + assertEquals(1, prompts.size) + assertEquals(unknown, prompts.first().relayUrl) + assertSame(decision.pending, prompts.first().decision) + } + + @Test + fun persistedAlwaysIsAutoAllowed() = + runTest { + val (policy, store) = newPolicy() + store.setScope(unknown, AuthApprovalScope.ALWAYS) + val decision = policy.classify(unknown) + assertSame(AuthApprovalDecision.Allow, decision) + } + + @Test + fun persistedBlockedIsAutoBlockedEvenForOwnOutbox() = + runTest { + // Explicit user `[Never]` overrides tier-1 — if the user blocked a relay + // that happens to be in their outbox, respect that. + val (policy, store) = newPolicy() + store.setScope(ownOutbox, AuthApprovalScope.BLOCKED) + val decision = policy.classify(ownOutbox) + assertSame(AuthApprovalDecision.Block, decision) + } + + @Test + fun recordDecisionPersistsAndChangesSubsequentClassification() = + runTest { + var promptCount = 0 + val (policy, _) = newPolicy(onPrompt = { promptCount++ }) + + // First call prompts. + val first = policy.classify(unknown) + assertIs(first) + assertEquals(1, promptCount) + + // User picks `[Always]`. + policy.recordDecision(unknown, AuthApprovalScope.ALWAYS) + + // Subsequent calls return Allow without prompting. + val second = policy.classify(unknown) + assertSame(AuthApprovalDecision.Allow, second) + assertEquals(1, promptCount, "should not prompt again after Always grant") + } + + @Test + fun blockedDecisionPersistsAndStaysBlocked() = + runTest { + var promptCount = 0 + val (policy, _) = newPolicy(onPrompt = { promptCount++ }) + + // First call prompts. + policy.classify(blockedRelay) + assertEquals(1, promptCount) + + // User picks `[Never]`. + policy.recordDecision(blockedRelay, AuthApprovalScope.BLOCKED) + + // Subsequent classify returns Block without prompting. + val decision = policy.classify(blockedRelay) + assertSame(AuthApprovalDecision.Block, decision) + assertEquals(1, promptCount, "should not prompt again after Never") + } + + @Test + fun selfApprovedRelaysIsReevaluatedPerCall() = + runTest { + // Account state changes (user adds a relay to their outbox) must take + // effect immediately — the policy reads the supplier per classify. + var ownSet = setOf() + val policy = + AuthApprovalPolicy( + selfApprovedRelays = { ownSet }, + store = InMemoryAuthApprovalStore(), + onPromptRequired = {}, + ) + + assertIs(policy.classify(ownOutbox)) + + ownSet = setOf(ownOutbox) + assertSame(AuthApprovalDecision.Allow, policy.classify(ownOutbox)) + } + + @Test + fun storeClearWipesAllApprovals() = + runTest { + val store = InMemoryAuthApprovalStore() + store.setScope(unknown, AuthApprovalScope.ALWAYS) + store.setScope(blockedRelay, AuthApprovalScope.BLOCKED) + + store.clear() + + // Both relays now unknown → fresh classification prompts. + assertTrue(store.getScope(unknown) == null) + assertTrue(store.getScope(blockedRelay) == null) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverTest.kt new file mode 100644 index 0000000000..c119c2973b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/nip17Dm/DmInboxRelayResolverTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.nip17Dm + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DmInboxRelayResolverTest { + private val peer: HexKey = "0".repeat(64) + private val cachedRelay = NormalizedRelayUrl("wss://cached.relay/") + private val indexer = NormalizedRelayUrl("wss://indexer.example/") + + private fun newResolver( + localLookup: (HexKey) -> List?, + indexers: Set = setOf(indexer), + now: () -> Long = { 0L }, + ttlMs: Long = 60_000L, + ) = DmInboxRelayResolver( + unauthenticatedClient = EmptyNostrClient(), + indexerRelays = indexers, + localLookup = localLookup, + cacheTtlMs = ttlMs, + cacheSize = 4, + nowMs = now, + ) + + @Test + fun localLookupHitShortCircuitsIndexerFanOut() = + runTest { + var indexerCalled = false + // The indexer would only run if RecipientRelayFetcher.fetchRelayLists ran. EmptyNostrClient returns no events, + // so even if it did, we'd get an empty list — but assert indirectly via the result. + val resolver = newResolver(localLookup = { listOf(cachedRelay) }) + val result = resolver.resolve(peer) + assertEquals(listOf(cachedRelay), result) + assertTrue(!indexerCalled) // we never set this; LocalLookup returns first + } + + @Test + fun emptyIndexerSetReturnsEmpty() = + runTest { + val resolver = newResolver(localLookup = { null }, indexers = emptySet()) + val result = resolver.resolve(peer) + assertEquals(emptyList(), result) + } + + @Test + fun emptyLocalAndEmptyIndexerYieldsEmpty() = + runTest { + // EmptyNostrClient.fetchAll returns no events → resolver yields empty. + val resolver = newResolver(localLookup = { null }) + val result = resolver.resolve(peer) + assertEquals(emptyList(), result) + } + + @Test + fun cacheHitWithinTtlSkipsIndexer() = + runTest { + // First call: localLookup returns null, indexer empty → caches [] for peer. + // Second call: same peer within TTL → returns cached [], no new indexer call. + var localLookupCalls = 0 + val resolver = + newResolver( + localLookup = { + localLookupCalls++ + null + }, + ) + resolver.resolve(peer) + resolver.resolve(peer) + // localLookup is invoked on every resolve (cheap), but the indexer + // fan-out + cache write only happens once. Hard to assert directly + // on RecipientRelayFetcher without a mock client; cache TTL behaviour + // is exercised below. + assertEquals(2, localLookupCalls) + } + + @Test + fun cacheExpiryTriggersFreshIndexerCall() = + runTest { + var nowMs = 0L + val ttl = 1_000L + val resolver = newResolver(localLookup = { null }, now = { nowMs }, ttlMs = ttl) + + resolver.resolve(peer) // caches [] with expiresAt = ttl + nowMs = ttl + 1 // past expiry + val second = resolver.resolve(peer) + assertEquals(emptyList(), second) // still empty from EmptyNostrClient — but went through the indexer path again + } + + @Test + fun clearWipesAllEntries() = + runTest { + val resolver = newResolver(localLookup = { null }) + resolver.resolve(peer) + resolver.clear() + // No way to introspect cache directly; assert through the resolve API + // continuing to work (would NPE if internal state were corrupt). + val result = resolver.resolve(peer) + assertEquals(emptyList(), result) + } + + @Test + fun invalidateRemovesNamedEntry() = + runTest { + val resolver = newResolver(localLookup = { null }) + resolver.resolve(peer) + resolver.invalidate(peer) + val result = resolver.resolve(peer) + assertEquals(emptyList(), result) + } + + @Test + fun localLookupReturningEmptyListFallsThroughToCacheAndIndexer() = + runTest { + // Subtle: localLookup must return null OR a non-empty list. An EMPTY + // list from localLookup means "I know this user has no 10050" — but + // we want "I don't know" to fall through. The resolver guards with + // `takeIf { it.isNotEmpty() }`. + var localLookupCalls = 0 + val resolver = + newResolver( + localLookup = { + localLookupCalls++ + emptyList() + }, + ) + val result = resolver.resolve(peer) + assertEquals(emptyList(), result) + assertEquals(1, localLookupCalls) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt index 5622ee9863..da4246109e 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/privacylock/PreferencesPrivacyLockSettings.kt @@ -63,7 +63,7 @@ class PreferencesPrivacyLockSettings( override val lockEnabled: StateFlow = mutableEnabled.asStateFlow() override val inactivityTimer: StateFlow = mutableTimer.asStateFlow() - override val redactionLevel: StateFlow = mutableRedaction.asStateFlow() + override val dmRedactionLevel: StateFlow = mutableRedaction.asStateFlow() override val firstRunCardSeen: StateFlow = mutableFirstRunSeen.asStateFlow() override val passwordHashed: StateFlow = mutablePasswordHashed.asStateFlow() override val failedUnlockAttempts: StateFlow = mutableFailedAttempts.asStateFlow() @@ -77,7 +77,7 @@ class PreferencesPrivacyLockSettings( // "locked UI / leaking notifications" anti-pattern). if (enabled && mutableRedaction.value == DmRedactionLevel.Full) { val userPickedFull = prefs.getBoolean("redaction_user_set", false) - if (!userPickedFull) setRedactionLevel(DmRedactionLevel.Generic) + if (!userPickedFull) setDmRedactionLevel(DmRedactionLevel.Generic) } } @@ -86,7 +86,7 @@ class PreferencesPrivacyLockSettings( prefs.putInt(KEY_INACTIVITY_TIMER, timer.ordinal) } - override fun setRedactionLevel(level: DmRedactionLevel) { + override fun setDmRedactionLevel(level: DmRedactionLevel) { mutableRedaction.value = level prefs.putInt(KEY_REDACTION_LEVEL, level.ordinal) prefs.putBoolean("redaction_user_set", true) @@ -99,7 +99,15 @@ class PreferencesPrivacyLockSettings( override fun setPasswordHashed(saltAndHash: String?) { mutablePasswordHashed.value = saltAndHash - if (saltAndHash == null) prefs.remove(KEY_PASSWORD_HASHED) else prefs.put(KEY_PASSWORD_HASHED, saltAndHash) + if (saltAndHash == null) { + prefs.remove(KEY_PASSWORD_HASHED) + // A lock without a credential is not a valid state — cascade so the + // toggle can't stay on with nothing to verify against. Every gated + // scope transitions to Disabled via the shared `lockEnabled` flag. + if (mutableEnabled.value) setLockEnabled(false) + } else { + prefs.put(KEY_PASSWORD_HASHED, saltAndHash) + } } override fun setFailedUnlockAttempts(count: Int) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayLatencyTracker.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayLatencyTracker.kt index 44272f6535..e43652b0f1 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayLatencyTracker.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayLatencyTracker.kt @@ -164,6 +164,18 @@ class RelayLatencyTracker( /** * Expires pending entries older than the configured TTLs and records the TTL value as the * sample (per the brainstorm: "punish silent relays"). Idempotent and cheap. + * + * The per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — their + * individual reads and writes are thread-safe, but iteration is NOT: per + * `Collections.synchronizedMap` javadoc, the caller MUST hold the returned map's + * monitor while iterating. Directly iterating triggers a + * `ConcurrentModificationException` when a producer thread (network dispatcher) + * mutates the map while the sweep is walking it — reliably reproduced on macOS + * during any relay-add on Amethyst Desktop as of 2026-07-06. + * + * Fix: iterate under `synchronized(pending)` blocks so the network dispatcher + * waits until sweep releases the monitor. The sweep is O(pending), typically + * ~single-digit entries per relay, so the hold time is negligible. */ override fun sweep(nowMs: Long) { // Per-relay pending maps are `Collections.synchronizedMap(LinkedHashMap)` — the diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt index 00772efcec..72c1985045 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/keystorage/SecureKeyStorage.kt @@ -324,7 +324,7 @@ actual class SecureKeyStorage private actual constructor() { } else { // Fallback for non-interactive environments (testing, etc.) print("Enter master password: ") - readLine() ?: throw SecureStorageException("Password required for fallback storage") + readlnOrNull() ?: throw SecureStorageException("Password required for fallback storage") } } return fallbackPassword!! diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt new file mode 100644 index 0000000000..ba6d768f6a --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays.index + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.prefs.Preferences + +/** + * User-configurable set of relays used to fetch profile metadata + * (kind 0) and follow lists (kind 3) — the "index relays" set passed + * to `FeedMetadataCoordinator` in the Desktop app and to `wot sync` + * in `amy`. + * + * Backed by [java.util.prefs.Preferences] at a fixed node + * `com/vitorpamplona/amethyst/relays/index` (JVM-user-scoped). The + * shared node means Desktop and `amy` running as the same OS user + * observe the same setting without extra plumbing — the same trick + * `PreferencesHashtagSpamSettings` uses for the hashtag-spam filter. + * + * Not per-account: users typically have a single preferred set of + * index relays regardless of which account is currently logged in. + * If per-account overrides become necessary later, layer a per-user + * key on top; this class stays the base. + * + * CSV serialisation for the persisted value matches what + * `DesktopAccountRelays` uses for its categories — no JSON dep, no + * `Serializable` contract. URLs are normalised via + * [RelayUrlNormalizer.normalizeOrNull] at both write and read time so + * malformed entries never enter the effective set. + */ +class PreferencesIndexRelays( + private val prefs: Preferences = Preferences.userRoot().node(NODE_NAME), +) { + private val mutableRelays: MutableStateFlow> = + MutableStateFlow(parse(prefs.get(KEY_URLS, ""))) + + /** + * Current user override. Empty when the user has not configured + * anything — callers should route through [effective] to get the + * defaults-fallback resolved set. + */ + val relays: StateFlow> = mutableRelays.asStateFlow() + + fun setRelays(new: Set) { + mutableRelays.value = new + prefs.put(KEY_URLS, new.joinToString(",") { it.url }) + } + + /** + * Resolves the set the relay client should actually use — the user + * override when non-empty, otherwise [DEFAULT_INDEX_RELAYS]. Never + * returns empty (unless the caller has explicitly reset both the + * override and the defaults to empty, which would require a code + * change here). + */ + fun effective(): Set = mutableRelays.value.ifEmpty { DEFAULT_INDEX_RELAYS } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index" + const val KEY_URLS = "urls" + + /** + * Byte-for-byte identical to `DefaultRelays.RELAYS` at + * `desktopApp/.../network/RelayStatus.kt`. Preserves current + * behaviour for users who never open the settings UI. + * + * Note: `commons/AmethystDefaults.kt` also has + * `DefaultIndexerRelayList` (Purple Pages, Coracle …) which is + * more purpose-built for indexing. Adopting it is a separate + * ticket — see the plan's "Out of Scope" section. + */ + val DEFAULT_INDEX_RELAYS: Set = + listOf( + "wss://nos.lol", + "wss://nostr.wine", + "wss://relay.noswhere.com", + "wss://relay.primal.net", + ).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + + internal fun parse(csv: String): Set = + csv + .split(",") + .mapNotNull { it.trim().takeIf(String::isNotEmpty) } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt new file mode 100644 index 0000000000..c0f8a609d4 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinatorTest.kt @@ -0,0 +1,297 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.assemblers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Regression tests for PR #3483 review findings on FeedMetadataCoordinator: + * + * - Finding 5: `queuedKind3Pubkeys` was marked-on-send, so if every index + * relay timed out the pubkeys were permanently marked and subsequent + * calls short-circuited — WoT stayed empty for the whole session. + * Fix: pubkeys land in `queuedKind3Pubkeys` only after ≥1 EOSE; on + * zero-EOSE timeout they roll out of `inFlightBatchedKind3` for retry. + * + * - Finding 6: `eoseReceived: MutableSet` was mutated from per-relay + * `onEose` callbacks running on `Dispatchers.IO` with no sync. Fix: + * `BatchEoseGate` funnels EOSE notifications through a `Channel` so a + * single consumer coroutine is the sole reader/writer of the `seen` + * set. + */ +class FeedMetadataCoordinatorTest { + private lateinit var scope: CoroutineScope + private val relay1 = NormalizedRelayUrl("wss://relay1.test/") + private val relay2 = NormalizedRelayUrl("wss://relay2.test/") + private val relay3 = NormalizedRelayUrl("wss://relay3.test/") + private val indexRelays = setOf(relay1, relay2, relay3) + + @Before + fun setup() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + + @After + fun teardown() { + scope.cancel() + } + + private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0') + + /** + * Fake client that captures subscribe/unsubscribe and lets the test + * drive EOSE notifications on any dispatcher we choose. + */ + private class ControllableClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + val subscriptions = mutableMapOf() + val subscribeCalls = mutableListOf>>() + var unsubscribeCallCount = 0 + private set + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscriptions[subId] = listener + subscribeCalls.add(filters) + } + + override fun unsubscribe(subId: String) { + subscriptions.remove(subId) + unsubscribeCallCount++ + } + + fun fireEose(relay: NormalizedRelayUrl) { + subscriptions.values.filterNotNull().forEach { it.onEose(relay, forFilters = null) } + } + } + + @Test + fun `loadKind3Batched retries after zero-EOSE timeout`() = + runBlocking { + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + ) + + val pubkeys = listOf(pubkey(1), pubkey(2), pubkey(3)) + + // Call 1 — no relay EOSEs; must time out. + coordinator.loadKind3Batched(pubkeys, timeoutMs = 200) + delay(350) // exceed the timeout + + // Call 2 — the same pubkeys must be re-subscribed since call 1 + // never got a successful EOSE. The old code would silently + // short-circuit here. + coordinator.loadKind3Batched(pubkeys, timeoutMs = 200) + delay(50) // let the launcher run + + assertEquals( + "Zero-EOSE timeout must not permanently dedup pubkeys", + 2, + client.subscribeCalls.size, + ) + assertEquals( + "Second call must re-request the same author set", + pubkeys.size, + client.subscribeCalls[1] + .values + .first() + .first() + .authors!! + .size, + ) + } + + @Test + fun `loadKind3Batched short-circuits after successful EOSE`() = + runBlocking { + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + ) + + val pubkeys = listOf(pubkey(1), pubkey(2)) + + coordinator.loadKind3Batched(pubkeys, timeoutMs = 1_000) + // Give the launcher time to register the listener before we fire. + delay(50) + indexRelays.forEach(client::fireEose) + delay(200) // let the coordinator finish + promote to queued + + coordinator.loadKind3Batched(pubkeys, timeoutMs = 200) + delay(50) + + assertEquals( + "Successful call must dedup subsequent identical calls", + 1, + client.subscribeCalls.size, + ) + } + + @Test + fun `loadKind3Batched promotes even when only some relays EOSE`() = + runBlocking { + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + ) + + val pubkeys = listOf(pubkey(1)) + + coordinator.loadKind3Batched(pubkeys, timeoutMs = 300) + delay(30) + // Only 1 of 3 EOSEs — timeout still fires but we made progress. + client.fireEose(relay1) + delay(400) + + coordinator.loadKind3Batched(pubkeys, timeoutMs = 200) + delay(50) + + assertEquals( + "≥1 EOSE = progress = promote to queued (avoid re-asking)", + 1, + client.subscribeCalls.size, + ) + } + + /** + * Regression for finding 6 — pumps EOSE from many dispatchers in + * parallel. The old MutableSet-based code could drop entries or throw + * ConcurrentModificationException on the internal HashSet iterator. + * BatchEoseGate must aggregate every distinct relay exactly once. + */ + @Test + fun `EOSE aggregator is safe under concurrent per-relay callbacks`() = + runBlocking { + val bigIndexSet = + (0..19).map { NormalizedRelayUrl("wss://relay$it.test/") }.toSet() + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = bigIndexSet, + ) + + coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 2_000) + delay(50) // wait for subscription + + // Fire EOSEs concurrently from many dispatchers. + val jobs = + bigIndexSet.map { relay -> + scope.launch(Dispatchers.IO) { + client.fireEose(relay) + } + } + jobs.forEach { it.join() } + + // The 2nd call must short-circuit — every relay EOSE'd, so + // pubkey(1) is now in queuedKind3Pubkeys. + delay(100) + coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200) + delay(50) + + assertEquals( + "Under concurrent EOSE from all relays, aggregator must reach target", + 1, + client.subscribeCalls.size, + ) + } + + @Test + fun `loadMetadataBatched follows the same retry semantics`() = + runBlocking { + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + ) + + val pubkeys = listOf(pubkey(1), pubkey(2)) + + // Call 1 — zero EOSE, timeout. + coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200) + delay(350) + // Call 2 — must re-subscribe. + coordinator.loadMetadataBatched(pubkeys, timeoutMs = 200) + delay(50) + + assertTrue( + "Metadata batch also retries on zero-EOSE timeout", + client.subscribeCalls.size >= 2, + ) + } + + @Test + fun `clear releases in-flight dedup so a fresh call always fires`() = + runBlocking { + val client = ControllableClient() + val coordinator = + FeedMetadataCoordinator( + client = client, + scope = scope, + indexRelays = indexRelays, + ) + + coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200) + delay(50) + // clear() must drop the in-flight tracker even mid-request. + coordinator.clear() + delay(300) // let call 1 finish + roll back + + coordinator.loadKind3Batched(listOf(pubkey(1)), timeoutMs = 200) + delay(50) + + assertTrue(client.subscribeCalls.size >= 2) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt new file mode 100644 index 0000000000..0024dfc31f --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelaysTest.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relays.index + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.prefs.Preferences + +class PreferencesIndexRelaysTest { + private val testNode = "com/vitorpamplona/amethyst/test/relays/index_${System.currentTimeMillis()}" + + private fun prefs(): Preferences = Preferences.userRoot().node(testNode) + + @Before + fun setup() { + prefs().clear() + } + + @After + fun teardown() { + prefs().removeNode() + } + + @Test + fun defaultsWhenPreferencesUnset() { + val store = PreferencesIndexRelays(prefs()) + assertTrue(store.relays.value.isEmpty()) + assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective()) + } + + @Test + fun setRelaysPersistsAcrossInstances() { + val store = PreferencesIndexRelays(prefs()) + val urls = + listOf("wss://relay.example", "wss://index.example") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + store.setRelays(urls) + assertEquals(urls, store.relays.value) + + val reloaded = PreferencesIndexRelays(prefs()) + assertEquals(urls, reloaded.relays.value) + assertEquals(urls, reloaded.effective()) + } + + @Test + fun effectiveFallsBackWhenOverrideCleared() { + val store = PreferencesIndexRelays(prefs()) + val urls = + listOf("wss://relay.example") + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + store.setRelays(urls) + store.setRelays(emptySet()) + assertEquals(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS, store.effective()) + } + + @Test + fun emptyEntriesInCsvAreSkipped() { + // Plant a URL list with empty tokens (extra commas). The + // parser should skip blanks silently. + prefs().put(PreferencesIndexRelays.KEY_URLS, "wss://good.example,,wss://also-good.example,") + val store = PreferencesIndexRelays(prefs()) + // Both good URLs should be present; no blank / empty entry. + assertEquals(2, store.relays.value.size) + assertTrue(store.relays.value.none { it.url.isBlank() }) + } + + @Test + fun defaultSetIsNotEmpty() { + // Guardrail against a future refactor accidentally clearing the constant. + assertTrue(PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.isNotEmpty()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt new file mode 100644 index 0000000000..9238b5ce1d --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/OutboxDispatcherTest.kt @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Coverage for the outbox pipeline defined in + * `commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md`. + * Scenarios: + * + * 1. Author has a cached kind-10002 → Phase 1 skipped, Phase 2 REQs + * the author's write relay directly. + * 2. Author has no cached 10002 → Phase 1 discovers, Phase 2 uses the + * discovered write relays. + * 3. Author with no 10002 anywhere → Phase 3 fallback to index relays. + * 4. Per-relay timeout on Phase 1 doesn't cancel Phase 2 for authors + * that already had a cached outbox. + * 5. clear() releases dedup so a fresh call always re-runs. + */ +class OutboxDispatcherTest { + private lateinit var scope: CoroutineScope + + private val indexRelay1 = NormalizedRelayUrl("wss://index1.test/") + private val indexRelay2 = NormalizedRelayUrl("wss://index2.test/") + private val indexRelays = setOf(indexRelay1, indexRelay2) + + private val outboxAlice = NormalizedRelayUrl("wss://alice-outbox.test/") + private val outboxBob = NormalizedRelayUrl("wss://bob-outbox.test/") + + private val alice = pubkey(1) + private val bob = pubkey(2) + private val charlie = pubkey(3) + + @Before + fun setup() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + } + + @After + fun teardown() { + scope.cancel() + } + + private fun pubkey(seed: Int): HexKey = seed.toString(16).padStart(64, '0') + + private fun dummySig() = "0".repeat(128) + + private fun outboxEventFor( + author: HexKey, + writeRelays: List, + createdAt: Long = 1_700_000_000, + ): AdvertisedRelayListEvent { + val tags = writeRelays.map { arrayOf("r", it.url, "write") }.toTypedArray() + return AdvertisedRelayListEvent( + id = "out-$author".take(64).padEnd(64, '0'), + pubKey = author, + createdAt = createdAt, + tags = tags, + content = "", + sig = dummySig(), + ) + } + + private fun kind3For( + author: HexKey, + follows: List, + ) = ContactListEvent( + id = "k3-$author".take(64).padEnd(64, '0'), + pubKey = author, + createdAt = 1_700_000_100, + tags = follows.map { arrayOf("p", it) }.toTypedArray(), + content = "", + sig = dummySig(), + ) + + private class RecordingGateway : OutboxCacheGateway { + val cache = mutableMapOf() + val discoveredOutbox = mutableListOf>() + val discoveredEvents = mutableListOf>() + + override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = cache[pubkey] + + override fun onOutboxDiscovered( + event: AdvertisedRelayListEvent, + relay: NormalizedRelayUrl, + ) { + cache[event.pubKey] = event + discoveredOutbox.add(event to relay) + } + + override fun onDiscoveredEvent( + event: Event, + relay: NormalizedRelayUrl, + ) { + discoveredEvents.add(event to relay) + } + } + + /** + * Fake INostrClient that replays a scripted set of events + auto-EOSEs + * per relay when [subscribe] is called. The script is keyed by the + * REQ's `(kinds, relay)` pair so tests can seed different responses + * for Phase-1 and Phase-2 subs. + */ + private class ScriptedClient( + private val delegate: INostrClient = EmptyNostrClient(), + ) : INostrClient by delegate { + // (kind, relay) → list of events to return + private val script = mutableMapOf, List>() + private val eoseNever = mutableSetOf() + val allSubscribeCalls = mutableListOf>>() + + fun scriptEvent( + kind: Int, + relay: NormalizedRelayUrl, + events: List, + ) { + script[kind to relay] = events + } + + fun neverEose(relay: NormalizedRelayUrl) { + eoseNever.add(relay) + } + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + allSubscribeCalls.add(filters) + filters.forEach { (relay, filterList) -> + filterList.forEach { filter -> + filter.kinds?.forEach { kind -> + script[kind to relay]?.forEach { event -> + listener?.onEvent(event, isLive = false, relay = relay, forFilters = null) + } + } + } + if (relay !in eoseNever) { + listener?.onEose(relay, forFilters = null) + } + } + } + + override fun unsubscribe(subId: String) { /* no-op */ } + } + + @Test + fun `cached outbox skips Phase 1 and fetches directly from write relay`() = + runBlocking { + val client = ScriptedClient() + val gateway = RecordingGateway() + gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice)) + client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob)))) + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = gateway, + perRelayTimeoutMs = 400, + overallTimeoutMs = 2_000, + ) + + val result = dispatcher.fetchKind3Only(setOf(alice)) + + assertEquals(1, result.kind3Received) + assertEquals(1, result.outboxCoveredAuthors) + assertEquals(0, result.fallbackAuthors) + assertTrue( + "Phase 2 must REQ from Alice's own outbox relay", + client.allSubscribeCalls.any { call -> outboxAlice in call.keys }, + ) + assertTrue( + "No Phase 1 REQ should be sent to index relays when 10002 is cached", + client.allSubscribeCalls.none { call -> indexRelays.any { it in call.keys } }, + ) + } + + @Test + fun `Phase 1 discovers 10002 then Phase 2 fetches from the discovered write relay`() = + runBlocking { + val client = ScriptedClient() + val gateway = RecordingGateway() + val bobOutbox = outboxEventFor(bob, listOf(outboxBob)) + + indexRelays.forEach { rel -> + client.scriptEvent(AdvertisedRelayListEvent.KIND, rel, listOf(bobOutbox)) + } + client.scriptEvent(ContactListEvent.KIND, outboxBob, listOf(kind3For(bob, listOf(alice)))) + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = gateway, + perRelayTimeoutMs = 400, + overallTimeoutMs = 2_000, + ) + + val result = dispatcher.fetchKind3Only(setOf(bob)) + + assertTrue("Discovered 10002 count > 0", result.kind10002Received > 0) + assertEquals(1, result.kind3Received) + assertEquals(1, result.outboxCoveredAuthors) + assertEquals(0, result.fallbackAuthors) + assertTrue( + "Gateway was told about the discovered 10002", + gateway.discoveredOutbox.any { it.first.pubKey == bob }, + ) + } + + @Test + fun `author with no 10002 falls back to index-relay REQ`() = + runBlocking { + val client = ScriptedClient() + val gateway = RecordingGateway() + + // No 10002 anywhere. Charlie's kind-3 sits only on the index relays. + indexRelays.forEach { rel -> + client.scriptEvent(ContactListEvent.KIND, rel, listOf(kind3For(charlie, listOf(alice)))) + } + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = gateway, + perRelayTimeoutMs = 400, + overallTimeoutMs = 2_000, + ) + + val result = dispatcher.fetchKind3Only(setOf(charlie)) + + assertEquals(1, result.fallbackAuthors) + assertEquals(0, result.outboxCoveredAuthors) + assertTrue( + "Fallback path receives the kind-3", + result.kind3Received >= 1, + ) + } + + @Test + fun `cached-outbox author still fetched when Phase 1 for other authors times out`() = + runBlocking { + val client = ScriptedClient() + val gateway = RecordingGateway() + + // Alice has cached outbox — Phase 2 must fetch from her write relay. + gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice)) + client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob)))) + + // Bob has no cached outbox and index relays never EOSE for Phase 1. + indexRelays.forEach(client::neverEose) + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = gateway, + perRelayTimeoutMs = 200, + overallTimeoutMs = 2_000, + ) + + val result = dispatcher.fetchKind3Only(setOf(alice, bob)) + + // Alice was covered by cached outbox; Bob wasn't but Phase 1 timed + // out, so he became a fallback candidate. + assertEquals( + "Alice always covered by cached outbox", + 1, + result.outboxCoveredAuthors, + ) + assertTrue(result.kind3Received >= 1) + } + + @Test + fun `clear releases dedup so a subsequent identical call refetches`() = + runBlocking { + val client = ScriptedClient() + val gateway = RecordingGateway() + gateway.cache[alice] = outboxEventFor(alice, listOf(outboxAlice)) + client.scriptEvent(ContactListEvent.KIND, outboxAlice, listOf(kind3For(alice, listOf(bob)))) + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = gateway, + perRelayTimeoutMs = 400, + overallTimeoutMs = 2_000, + ) + + dispatcher.fetchKind3Only(setOf(alice)) + val subCountAfterFirst = client.allSubscribeCalls.size + + // Second call without clear() — should short-circuit. + dispatcher.fetchKind3Only(setOf(alice)) + assertEquals(subCountAfterFirst, client.allSubscribeCalls.size) + + // After clear(), the same call re-runs Phase 2. + dispatcher.clear() + dispatcher.fetchKind3Only(setOf(alice)) + assertTrue(client.allSubscribeCalls.size > subCountAfterFirst) + } + + /** + * BatchEoseGate stress — inside OutboxDispatcher this is a private + * class but the observable effect (Phase 1 completes when all index + * relays EOSE, and stays within the timeout budget) is what matters. + */ + @Test + fun `EOSE aggregation is safe with many concurrent index-relay callbacks`() = + runBlocking { + val bigIndexSet = (0..15).map { NormalizedRelayUrl("wss://index$it.test/") }.toSet() + val client = ScriptedClient() + val gateway = RecordingGateway() + + val dispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { bigIndexSet }, + gateway = gateway, + perRelayTimeoutMs = 1_000, + overallTimeoutMs = 3_000, + ) + + // Kick off a fetch and race the subscribe call. ScriptedClient + // fires EOSE inline; we simulate concurrent per-relay EOSE by + // launching multiple dispatchers as a smoke test. + val fetchJob = scope.launch { dispatcher.fetchKind3Only(setOf(alice, bob, charlie)) } + + // Give the launcher a moment to enter Phase 1's subscribe. + delay(50) + fetchJob.join() + // No CME thrown, no hang past the timeout budget. + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt new file mode 100644 index 0000000000..1ac29587fb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTServiceTest.kt @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.wot + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class WoTServiceTest { + private lateinit var scope: CoroutineScope + private lateinit var svc: WoTService + + // Fixed test pubkeys for readability. + private val me = "self".padEnd(64, '0') + private val a = "aaaa".padEnd(64, '0') + private val b = "bbbb".padEnd(64, '0') + private val c = "cccc".padEnd(64, '0') + private val d = "dddd".padEnd(64, '0') + private val e = "eeee".padEnd(64, '0') + + @Before + fun setup() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + svc = WoTService(scope, writerDispatcher = Dispatchers.Unconfined) + } + + @After + fun teardown() { + scope.cancel() + } + + /** + * With `Dispatchers.Unconfined` + `Channel.UNLIMITED`, `trySend` from the + * test thread synchronously resumes the writer coroutine — so no explicit + * wait is needed. This helper is a no-op we keep for future scheduler + * changes. + */ + private fun drain() = Unit + + @Test + fun emptyGraphYieldsEmptyScores() { + svc.onFollowSetChange(emptySet(), me) + drain() + assertEquals(emptyMap(), svc.scoresSnapshot()) + } + + @Test + fun singleFollowerCreditsTargets() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c, d)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + assertEquals(1, svc.scoresSnapshot()[d]) + } + + @Test + fun overlappingFollowersSumScores() { + svc.onFollowSetChange(setOf(a, b), me) + svc.applyKind3(a, setOf(c, d)) + svc.applyKind3(b, setOf(c, e)) + drain() + assertEquals(2, svc.scoresSnapshot()[c]) + assertEquals(1, svc.scoresSnapshot()[d]) + assertEquals(1, svc.scoresSnapshot()[e]) + } + + @Test + fun removingFollowerDecrementsAllContributions() { + svc.onFollowSetChange(setOf(a, b), me) + svc.applyKind3(a, setOf(c, d)) + svc.applyKind3(b, setOf(c, e)) + drain() + // A drops out. + svc.onFollowSetChange(setOf(b), me) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + // d had only A crediting it — should be gone. + assertFalse(c in svc.scoresSnapshot() && d in svc.scoresSnapshot() && svc.scoresSnapshot()[d] == null) + assertEquals(null, svc.scoresSnapshot()[d]) + assertEquals(1, svc.scoresSnapshot()[e]) + } + + @Test + fun kind3ChurnAppliesDiff() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c, d)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + assertEquals(1, svc.scoresSnapshot()[d]) + // A republishes with a different set — d removed, e added. + svc.applyKind3(a, setOf(c, e)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + assertEquals(null, svc.scoresSnapshot()[d]) + assertEquals(1, svc.scoresSnapshot()[e]) + } + + @Test + fun selfInclusionInKind3IsExcluded() { + svc.onFollowSetChange(setOf(a), me) + // A's kind-3 includes self (me) — must not inflate self's score. + svc.applyKind3(a, setOf(c, me)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + assertEquals(null, svc.scoresSnapshot()[me]) + } + + @Test + fun followerSelfInclusionIsExcluded() { + svc.onFollowSetChange(setOf(a), me) + // A's kind-3 includes A itself — must not inflate A's own score. + svc.applyKind3(a, setOf(c, a)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + assertEquals(null, svc.scoresSnapshot()[a]) + } + + @Test + fun kind3FromNonFollowerIsIgnored() { + svc.onFollowSetChange(setOf(a), me) + // e is NOT in my follow set — their kind-3 shouldn't credit anyone. + svc.applyKind3(e, setOf(c, d)) + drain() + assertEquals(emptyMap(), svc.scoresSnapshot()) + } + + @Test + fun sparseMapDropsZeroCounts() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c)) + drain() + assertTrue(c in svc.scoresSnapshot()) + // A republishes with an empty follow set. + svc.applyKind3(a, emptySet()) + drain() + // c dropped to 0 → removed from map, not stored as 0. + assertFalse(c in svc.scoresSnapshot()) + } + + private fun fakePubkey(seed: Int): String = seed.toString(16).padStart(64, '0') + + @Test + fun guardrailSkipsHugeFollowSets() { + val hugeFollows = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet() + svc.onFollowSetChange(hugeFollows, me) + drain() + assertEquals(emptyMap(), svc.scoresSnapshot()) + assertTrue(runBlocking { svc.isReady.first() }) + assertTrue(runBlocking { svc.isDisabled.first() }) + } + + /** + * Regression for PR #3483 review finding 2: even after the guardrail + * trips, applyKind3 for a follower in the huge follow set used to + * repopulate reverseIndex/_scores because myFollows had already been + * assigned. Fix clears myFollows AND sets a disabled flag; both gate + * handleKind3 so the guardrail actually holds under sustained pump. + */ + @Test + fun guardrailIgnoresApplyKind3AfterTrip() { + val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet() + svc.onFollowSetChange(huge, me) + drain() + + val anyFollower = huge.first() + svc.applyKind3(anyFollower, setOf(c, d, e)) + drain() + + assertEquals( + "Guardrail must block score repopulation via applyKind3", + emptyMap(), + svc.scoresSnapshot(), + ) + } + + @Test + fun guardrailReleasesWhenFollowSetShrinksBack() { + val huge = (0..WoTService.MAX_FOLLOWS + 1).map { fakePubkey(it) }.toSet() + svc.onFollowSetChange(huge, me) + drain() + assertTrue(runBlocking { svc.isDisabled.first() }) + + // User trims their follow list — dispatcher should re-engage. + svc.onFollowSetChange(setOf(a, b), me) + drain() + assertFalse(runBlocking { svc.isDisabled.first() }) + + // And WoT scoring resumes normally. + svc.applyKind3(a, setOf(c, d)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + } + + @Test + fun closeStopsAcceptingOps() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c)) + drain() + assertEquals(1, svc.scoresSnapshot()[c]) + + svc.close() + drain() + + // Post-close writes are dropped silently. + svc.applyKind3(a, setOf(d)) + drain() + assertEquals(null, svc.scoresSnapshot()[d]) + // State observed before close remains readable. + assertEquals(1, svc.scoresSnapshot()[c]) + } + + @Test + fun closeIsIdempotent() { + svc.close() + svc.close() // should not throw + } + + @Test + fun maxFollowsPerEventCap() { + svc.onFollowSetChange(setOf(a), me) + val huge = (0..WoTService.MAX_FOLLOWS_PER_EVENT + 100).map { fakePubkey(it) }.toSet() + svc.applyKind3(a, huge) + drain() + // Cap kicks in after MAX_FOLLOWS_PER_EVENT — no crash, score map bounded. + assertTrue(svc.scoresSnapshot().size <= WoTService.MAX_FOLLOWS_PER_EVENT) + } + + @Test + fun markReadyOnceFiresReady() { + assertFalse(runBlocking { svc.isReady.first() }) + svc.markReadyOnce() + drain() + assertTrue(runBlocking { svc.isReady.first() }) + } + + @Test + fun clearResetsEverything() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c, d)) + svc.markReadyOnce() + drain() + svc.clear() + drain() + assertEquals(emptyMap(), svc.scoresSnapshot()) + assertFalse(runBlocking { svc.isReady.first() }) + } + + @Test + fun scoresSnapshotIsHashMapCopy() { + svc.onFollowSetChange(setOf(a), me) + svc.applyKind3(a, setOf(c)) + drain() + val snap = svc.scoresSnapshot() + assertEquals(1, snap[c]) + // Modifying the snapshot must not affect the service. + (snap as MutableMap).clear() + assertEquals(1, svc.scoresSnapshot()[c]) + } +} diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 23c43ccd43..10ca2b3d48 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -98,7 +98,7 @@ dependencies { testImplementation(libs.okhttp) // Compose UI testing (createComposeRule / onNodeWithText / etc.) - testImplementation(compose.desktop.uiTestJUnit4) + testImplementation(libs.jetbrains.compose.ui.test.junit4) } compose.desktop { diff --git a/desktopApp/packaging/homebrew/amethyst-nostr.rb b/desktopApp/packaging/homebrew/amethyst-nostr.rb new file mode 100644 index 0000000000..c63eef6ec1 --- /dev/null +++ b/desktopApp/packaging/homebrew/amethyst-nostr.rb @@ -0,0 +1,38 @@ +# Reference Homebrew Cask for the Amethyst desktop app. +# +# This file is NOT consumed by any build in this repo. Submit it to +# Homebrew/homebrew-cask (as `Casks/a/amethyst-nostr.rb`) or drop it into a +# personal tap (`Casks/amethyst-nostr.rb`) for an instant +# `brew install --cask /amethyst-nostr`. +# +# The release matrix (.github/workflows/create-release.yml) builds an +# Apple-Silicon DMG only (no Intel DMG), so this cask is arm64-only. +# +# version + sha256 below track the published +# `amethyst-desktop--macos-arm64.dmg`. Once the cask exists upstream, +# bump-homebrew.yml keeps the live copy current on each stable release. To +# refresh this reference by hand: +# curl -fsSL -o amethyst.dmg \ +# https://github.com/vitorpamplona/amethyst/releases/download/vX.Y.Z/amethyst-desktop-X.Y.Z-macos-arm64.dmg +# shasum -a 256 amethyst.dmg +cask "amethyst-nostr" do + version "1.12.6" + sha256 "69882e83ebcec6723e1ad5655ec2c9d1fa151b9d1a8ae51b869a9d62feabf093" + + url "https://github.com/vitorpamplona/amethyst/releases/download/v#{version}/amethyst-desktop-#{version}-macos-arm64.dmg", + verified: "github.com/vitorpamplona/amethyst/" + name "Amethyst" + desc "Nostr client for desktop" + homepage "https://github.com/vitorpamplona/amethyst" + + livecheck do + url :url + strategy :github_latest + end + + depends_on arch: :arm64 + + app "Amethyst.app" + + zap trash: "~/.amethyst" +end diff --git a/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md b/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md new file mode 100644 index 0000000000..4f8286c61b --- /dev/null +++ b/desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md @@ -0,0 +1,73 @@ +# Manual testing sheet — Desktop Web-of-Trust Score Badges + +Plan: `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md` + +Run with `./gradlew :desktopApp:run`. Sign in with an account that has a +follow list (WoT is meaningless without one). + +## Pre-flight + +- **Followed authors' kind-3 events** need to be reachable via the + configured index relays. On first launch, badges may take a couple of + seconds to appear while the batch REQ completes. +- Hashtag-spam PR (#3431) providers must be live — WoTBadgedAvatar reads + `LocalSpamExemptKeys` for the self+follows hide predicate. + +## Scenarios + +| # | Test | Expected | +|---|------|----------| +| **T1** | **Cold start with ≥50 follows.** Log in, watch avatars for 2 s. | Batch REQ fires once; badges appear on some strangers within ~2 s. | +| **T2** | **No badge on self.** Open own profile in a Profile column. | Own header avatar has no chip regardless of any follower kind-3s. | +| **T3** | **No badge on followed authors.** Any note by a person you follow. | Card renders with clean avatar, no chip. | +| **T4** | **Badge on a stranger who's followed by 2 of your follows.** Find such a note (or contrive one). | Small chip showing "2" bottom-right of avatar. | +| **T5** | **Tooltip on hover.** Hover the badge for ~1 s. | Plain tooltip: "N of the people you follow follow this person". | +| **T6** | **99+ overflow.** Simulate a pubkey scored 200+ (e.g. a well-connected celebrity in your graph). | Badge shows `99+`. | +| **T7** | **No layout shift.** Scroll a busy column while badges arrive mid-scroll. | Avatar sizes don't jump — badge overlays the existing avatar bounds. | +| **T8** | **Kind-3 churn.** Watch a followed author's kind-3 update mid-session (or manually publish one from another client). | Their contribution to affected pubkeys' scores diffs correctly; no double-counting. | +| **T9** | **Follow someone new.** Follow a fresh pubkey via the UI. | Their kind-3 is fetched via a subsequent `loadKind3Batched`; anyone they follow gets their score incremented once their kind-3 arrives. | +| **T10** | **Unfollow someone.** Unfollow an existing follow via the UI. | Every pubkey they were crediting has their score decremented; some may drop to 0 and lose their badge. | +| **T11** | **Guardrail (mega-follow account).** Log in with an account following ≥ 2 000 pubkeys. | `LocalWoTReady` becomes true immediately; no badges anywhere; no batch REQ fires. | +| **T12** | **Empty graph.** Log in with an account following 0 people. | `LocalWoTReady` becomes true after the 2 s fallback timeout; no badges. | +| **T13** | **`consumeContactList` prerequisite fix.** From another client, watch a kind-3 event from a follower arrive while you're logged in. | Your own `_followedUsers` (used by feed filters, sidebar) stays unchanged. Verify by opening a filtered feed and confirming it hasn't broken. | +| **T14** | **Account switch.** Switch to a different logged-in account. | Old scores gone; new account's follow set drives new WoT map. No leaked badges. | +| **T15** | **amy wot get.** `./gradlew :cli:installDist && cli/build/install/cli/bin/amy wot get ` (against an account with kind-3 events in `~/.amy/shared/events-store/`). | Output: `pubkey= score=` or JSON with `--json`. | +| **T16** | **amy wot list.** `amy wot list --threshold 3 --limit 20 --json`. | JSON of top-20 pubkeys with score ≥ 3, sorted desc. | +| **T17** | **amy wot sync.** `amy wot sync` (with follows in local store). | Runs a chunked kind-3 REQ against outbox relays, stores fresh events. Subsequent `amy wot get` reflects the update. | + +## Known v1 limitations + +- **Notification-tab avatars are not badged.** Notifications use a custom + 56 dp compact card that doesn't route through `NoteCard` / `UserAvatar`. + Deferred to v2. +- **Search-result "person" cards** (via `UserSearchCard`) are not badged + in v1 — they use a different composable. +- **Cross-column reveal state doesn't matter** (WoT is stateless per + render; no reveal to persist). +- **No filter/threshold gating of feeds or notifications v1** — badges + are display-only. v2 will add the threshold Setting. +- **`amy wot sync` uses outbox/inbox relays**, not index relays. The + Desktop app uses `indexRelays`. If the two disagree, results may + differ slightly. Follow-up ticket: unified `indexRelays` for both. + +## Sign-off + +- [ ] T1 Cold start +- [ ] T2 No self badge +- [ ] T3 No badge on follows +- [ ] T4 Stranger scored 2 +- [ ] T5 Tooltip +- [ ] T6 99+ overflow +- [ ] T7 No layout shift +- [ ] T8 Kind-3 churn +- [ ] T9 New follow +- [ ] T10 Unfollow +- [ ] T11 Guardrail +- [ ] T12 Empty graph +- [ ] T13 Cache prerequisite fix +- [ ] T14 Account switch +- [ ] T15 amy wot get +- [ ] T16 amy wot list +- [ ] T17 amy wot sync + +Tester: ________________ Date: ________________ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index b909baedb5..d537406c78 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -71,22 +71,28 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull +import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady +import com.vitorpamplona.amethyst.commons.wot.LocalWoTService import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.auth.DesktopAuthCoordinator import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories -import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup @@ -123,10 +129,12 @@ import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.amethyst.desktop.ui.settings.ImageCompressionSettings import com.vitorpamplona.amethyst.desktop.ui.settings.MediaServerSettings import com.vitorpamplona.amethyst.desktop.ui.settings.NamecoinSettingsSection +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener 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.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent @@ -699,14 +707,17 @@ fun App( com.vitorpamplona.amethyst.commons.privacylock .PreferencesPrivacyLockSettings() } - val messagesLockState = + val privacyLockStates = remember(privacyLockSettings) { - com.vitorpamplona.amethyst.commons.privacylock - .MessagesLockState(privacyLockSettings, appScope) + val scopes = com.vitorpamplona.amethyst.commons.privacylock.LockScope.entries + scopes.associateWith { scope -> + com.vitorpamplona.amethyst.commons.privacylock + .PrivacyLockState(scope, privacyLockSettings, appScope) + } } CompositionLocalProvider( - com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState provides messagesLockState, + com.vitorpamplona.amethyst.commons.privacylock.LocalPrivacyLockState provides privacyLockStates, com.vitorpamplona.amethyst.desktop.security.LocalPrivacyLockSettings provides privacyLockSettings, ) { AppInner( @@ -841,6 +852,17 @@ private fun AppInner( // node so the `amy` CLI binary observes the same toggle. val hashtagSpamSettings = remember { PreferencesHashtagSpamSettings() } + // Index-relay preference — user-configurable set used to fetch profile + // metadata (kind 0) and follow lists (kind 3). Persisted in a shared + // java.util.prefs node so `amy wot sync` reads from the same source of + // truth. Falls back to PreferencesIndexRelays.DEFAULT_INDEX_RELAYS when + // the user hasn't configured anything. + val indexRelaysStore = + remember { + com.vitorpamplona.amethyst.commons.relays.index + .PreferencesIndexRelays() + } + // Local relay store — persists events to SQLite per account val localRelayStore = remember { @@ -908,6 +930,39 @@ private fun AppInner( } val nip11Fetcher = remember { Nip11Fetcher() } + // Dedicated unauthenticated NostrClient for kind:10050 lookups against + // curated indexer relays. MUST NOT have a RelayAuthenticator attached — + // an authenticated indexer query would extract identity-key signatures + // and turn "indexer learns who we want to DM" into "indexer learns user + // U wants to DM pubkey X" (security review F-01). + val indexerClient = + remember(httpClient) { + NostrClient(BasicOkHttpWebSocket.Builder(httpClient::getHttpClient)).also { it.connect() } + } + DisposableEffect(indexerClient) { + onDispose { indexerClient.disconnect() } + } + + // Resolver consults LocalCache first, then its own LRU, then the indexer + // client. Strict kind:10050 only — no NIP-65 read-marker fallback. + val dmInboxResolver = + remember(indexerClient, localCache) { + DmInboxRelayResolver( + unauthenticatedClient = indexerClient, + indexerRelays = + DefaultDmIndexerRelays.RELAYS + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet(), + localLookup = { pubkey -> + // Strict kind:10050 only — the lenient dmInboxRelays() falls + // back to NIP-65 read relays, which this fast-path would + // return before the strict indexer fan-out ran, leaking DM + // metadata to relays the recipient never designated for DMs. + localCache.getUserIfExists(pubkey)?.dmInboxRelaysStrict() + }, + ) + } + // Start 1Hz metrics snapshot for relay dashboard LaunchedEffect(relayManager) { relayManager.startMetricsSnapshot(this) @@ -925,28 +980,36 @@ private fun AppInner( } } - // Subscriptions coordinator — uses default relay URLs for metadata indexing. - // Feed subscriptions (inside MainContent) drive actual relay pool connections. + // Subscriptions coordinator — uses the user's configured index relays + // (or PreferencesIndexRelays.DEFAULT_INDEX_RELAYS as fallback) for + // metadata + follow-list REQs. Changes made via the settings UI take + // effect on next relaunch — the coordinator snapshots the set here. val subscriptionsCoordinator = - remember(relayManager, localCache) { + remember(relayManager, localCache, indexRelaysStore) { DesktopRelaySubscriptionsCoordinator( client = relayManager.client, scope = scope, - indexRelays = - DefaultRelays.RELAYS - .mapNotNull { - RelayUrlNormalizer.normalizeOrNull(it) - }.toSet(), + indexRelays = indexRelaysStore.effective(), localCache = localCache, ).also { it.startCleanupLoop() } } + // NIP-42 AUTH coordinator — wires relay-auth challenges through the + // tier classifier so own DM-inbox relays auto-sign and unknown relays + // surface a tier-2 banner approval via authCoordinator.pendingApprovals. + val authCoordinator = + remember(relayManager, localCache) { + DesktopAuthCoordinator(relayManager, localCache, scope) + } + // Clear cache and subscriptions on logout or account switch var previousAccountPubKey by remember { mutableStateOf(null) } LaunchedEffect(accountState) { when (val state = accountState) { is AccountState.LoggedOut -> { + authCoordinator.onLogout() subscriptionsCoordinator.clear() + localCache.accountPubkey = null localCache.clear() localRelayMaintenance.stop() localRelayStore.close() @@ -957,18 +1020,32 @@ private fun AppInner( val currentPubKey = state.pubKeyHex if (previousAccountPubKey != null && previousAccountPubKey != currentPubKey) { // Account switched — clear old data so new feed loads fresh + authCoordinator.onLogout() subscriptionsCoordinator.clear() + localCache.accountPubkey = null localCache.clear() localRelayMaintenance.stop() localRelayStore.close() subscriptionsCoordinator.start() } + // Bind the active-user pubkey BEFORE hydration launches. The + // hydration coroutine below reads the local relay store on + // Dispatchers.IO and calls consumeContactList; without this + // ordering, a cached self kind-3 would be stamped without + // updating _followedUsers, and a later relay retry of the + // same event would be rejected by the createdAt gate, + // leaving the follow list empty and FollowAction.follow + // publishing a fresh kind-3 that wipes the real one. + // See commons/plans/2026-07-06-fix-wot-outbox-model-and-review-fixes-plan.md + // (Fix 1). + localCache.accountPubkey = currentPubKey // Open local relay store for the current account and hydrate cache localRelayStore.openForAccount(currentPubKey) localRelayMaintenance.start() scope.launch(Dispatchers.IO) { localRelayStore.hydrate(localCache) } + authCoordinator.onLogin(state) previousAccountPubKey = currentPubKey } @@ -1246,31 +1323,53 @@ private fun AppInner( LocalNamecoinService provides namecoinService, LocalSpamExemptKeys provides spamExemptKeys, ) { - MainContent( - layoutMode = layoutMode, - deckState = deckState, - workspaceManager = workspaceManager, - singlePaneState = singlePaneState, - pinnedNavBarState = pinnedNavBarState, - relayManager = relayManager, - localCache = localCache, - accountManager = accountManager, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - nip11Fetcher = nip11Fetcher, - appScope = scope, - torStatus = currentTorStatus, - onShowComposeDialog = onShowComposeDialog, - onShowReplyDialog = onShowReplyDialog, - onShowAppDrawer = onShowAppDrawer, - onOpenFeedsDrawer = { - appDrawerInitialTab = - com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS - onShowAppDrawer() - }, - onShowImportFollowListDialog = onShowImportFollowListDialog, - ) + val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + // On macOS the window uses `apple.awt.fullWindowContent` + // (see [applyNativeWindowChrome]), so the traffic-light + // buttons sit over the top-left corner of content. Clear + // that zone so the banner text/icon aren't occluded. + val bannerModifier = + if (PlatformInfo.isMacOS) { + Modifier.padding(start = 80.dp, top = 8.dp, end = 8.dp, bottom = 4.dp) + } else { + Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + } + AuthApprovalBanner( + pending = pendingAuthApprovals.values.toList(), + onResolve = { url, scope -> authCoordinator.resolve(url, scope) }, + modifier = bannerModifier, + ) + Box(modifier = Modifier.weight(1f)) { + MainContent( + layoutMode = layoutMode, + deckState = deckState, + workspaceManager = workspaceManager, + singlePaneState = singlePaneState, + pinnedNavBarState = pinnedNavBarState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + indexRelaysStore = indexRelaysStore, + nip11Fetcher = nip11Fetcher, + dmInboxResolver = dmInboxResolver, + appScope = scope, + torStatus = currentTorStatus, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onShowAppDrawer = onShowAppDrawer, + onOpenFeedsDrawer = { + appDrawerInitialTab = + com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS + onShowAppDrawer() + }, + onShowImportFollowListDialog = onShowImportFollowListDialog, + ) + } + } // Import Follow List dialog (triggered from File menu / // Cmd+Shift+I). Rendered inside this CompositionLocalProvider @@ -1380,7 +1479,9 @@ fun MainContent( account: AccountState.LoggedIn, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + indexRelaysStore: com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays, nip11Fetcher: Nip11Fetcher, + dmInboxResolver: DmInboxRelayResolver, appScope: CoroutineScope, torStatus: com.vitorpamplona.amethyst.commons.tor.TorServiceStatus, onShowComposeDialog: () -> Unit, @@ -1407,10 +1508,18 @@ fun MainContent( } val iAccount = - remember(account, localCache, relayManager, dmSendTracker, accountRelays) { - DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays) + remember(account, localCache, relayManager, dmSendTracker, accountRelays, dmInboxResolver) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope, accountRelays, dmInboxResolver) } + // When iAccount is replaced (account switch), the previous WoTService's + // internal writer coroutine + ops Channel would otherwise leak — the + // outer `scope` lives for the whole session. Close the previous + // instance on dispose so account-switch is a clean teardown. + DisposableEffect(iAccount) { + onDispose { iAccount.wotService.close() } + } + // Follow Packs state — single per-account holder for Discover + sidebar + naddr cards val followPacksState = remember(iAccount, localCache, relayManager, scope) { @@ -1423,13 +1532,14 @@ fun MainContent( ) } - // Aggregated relay categories (feed, notifications, search, DM) + // Aggregated relay categories (feed, notifications, search, DM, index) val relayCategories = - remember(iAccount.nip65RelayList, accountRelays, relayManager) { + remember(iAccount.nip65RelayList, accountRelays, relayManager, indexRelaysStore) { DesktopRelayCategories( nip65State = iAccount.nip65RelayList, accountRelays = accountRelays, connectedRelays = relayManager.connectedRelays, + indexRelaysStore = indexRelaysStore, scope = scope, ) } @@ -1674,6 +1784,71 @@ fun MainContent( relayHealthStore.scanNow() } + // Web-of-Trust: pubkey is already bound by the outer LaunchedEffect + // that also gates hydration ordering (see the LoggedIn branch above). + // This effect re-asserts the binding to cover the (rare) case where + // MainContent's `account` diverges from the outer accountState mid- + // recomposition; it's idempotent when they already match. + LaunchedEffect(localCache, account.pubKeyHex) { + localCache.accountPubkey = account.pubKeyHex + } + val wotReady by iAccount.wotService.isReady.collectAsState() + LaunchedEffect( + iAccount.wotService, + localCache, + subscriptionsCoordinator, + account.pubKeyHex, + ) { + // Fan-in of every accepted kind-3 event from the local cache. + launch { + localCache.contactListEvents.collect { evt -> + iAccount.wotService.applyKind3(evt.pubKey, evt.verifiedFollowKeySet()) + } + } + // React to changes in the active user's follow set. Under the + // outbox model (PR #3483 review directive from Vitor) kind-3 + // fetch goes to each author's declared write relays instead of a + // static index-relay broadcast — the OutboxDispatcher does the + // NIP-65 discovery, transposes with RelayListRecommendationProcessor + // and issues per-outbox-relay REQs. Falls back to index relays + // for authors that never returned a 10002. + launch { + localCache.followedUsers.collect { follows -> + iAccount.wotService.onFollowSetChange(follows, account.pubKeyHex) + when { + iAccount.wotService.isDisabled.value -> { + // Guardrail — mega-follow accounts skip WoT + // entirely so we don't dispatch a batch that + // would be discarded anyway. + iAccount.wotService.markReadyOnce() + } + follows.isEmpty() -> { + iAccount.wotService.markReadyOnce() + } + else -> { + launch { + val result = subscriptionsCoordinator.loadKind3ViaOutbox(follows) + Log.d("WotOutbox") { + "fetchKind3Only authors=${result.authorsRequested} " + + "covered=${result.outboxCoveredAuthors} " + + "fallback=${result.fallbackAuthors} " + + "kind10002=${result.kind10002Received} " + + "kind3=${result.kind3Received}" + } + iAccount.wotService.markReadyOnce() + } + } + } + } + } + // Safety net: mark ready after 2s regardless of REQ progress so + // avatars stop suppressing badges even if index relays never EOSE. + launch { + kotlinx.coroutines.delay(2_000) + iAccount.wotService.markReadyOnce() + } + } + CompositionLocalProvider( LocalRelayCategories provides relayCategories, com.vitorpamplona.amethyst.desktop.ui.relay.LocalAccountRelays provides accountRelays, @@ -1682,6 +1857,8 @@ fun MainContent( com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayHealthStore provides relayHealthStore, com.vitorpamplona.amethyst.desktop.ui.deck.LocalRelayListMutator provides relayListMutator, com.vitorpamplona.amethyst.desktop.ui.deck.LocalFollowPacksState provides followPacksState, + LocalWoTService provides iAccount.wotService, + LocalWoTReady provides wotReady, ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt new file mode 100644 index 0000000000..0f6de969a9 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/DesktopAuthCoordinator.kt @@ -0,0 +1,186 @@ +/* + * 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.desktop.auth + +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalDecision +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalPolicy +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore +import com.vitorpamplona.amethyst.commons.relayClient.auth.PendingAuthApproval +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** + * Desktop NIP-42 AUTH wiring. + * + * Today the desktop has NO AUTH wiring — relays demanding AUTH from desktop + * users get silently ignored. This coordinator closes that gap, but does it + * the security-conscious way: + * + * - **Tier 1 (auto-allow):** the relay is in the active account's NIP-17 DM + * inbox set (kind:10050). Sign immediately, no prompt. + * - **Tier 2 (prompt):** anything else. Surface a [PendingAuthApproval] on + * [pendingApprovals]; the (forthcoming) inline AUTH banner reads from + * there and calls [resolve] with the user's `[Once] [Always] [Never]` + * pick. + * + * **Until the banner UI lands**, tier-2 approvals accumulate in + * [pendingApprovals] but nothing resolves them — so tier-2 relays don't get + * an AUTH response. Behaviour-wise that's the same outcome as the pre-this- + * commit world (no AUTH at all). The improvement is tier-1: own DM-inbox + * relays now AUTH automatically. + * + * Persisted `ALWAYS` / `BLOCKED` decisions are scoped per-account via + * [PreferencesAuthApprovalStore]. + * + * Lifecycle: bind to [AccountState] from the host (Main.kt) — call [onLogin] + * when an account becomes [AccountState.LoggedIn] and [onLogout] on logout / + * account-switch. Each call tears down the prior [RelayAuthenticator] and + * cancels any pending deferreds. + */ +class DesktopAuthCoordinator( + private val relayManager: RelayConnectionManager, + private val localCache: DesktopLocalCache, + private val scope: CoroutineScope, +) { + private val lock = Any() + + @Volatile + private var active: ActiveAuth? = null + + private val _pendingApprovals = MutableStateFlow>(persistentMapOf()) + + /** + * Tier-2 AUTH challenges awaiting the user's `[Once] [Always] [Never]` + * decision. The banner UI subscribes and calls [resolve] to settle each. + */ + val pendingApprovals: StateFlow> = _pendingApprovals.asStateFlow() + + /** Wire AUTH for a newly logged-in account. Idempotent. */ + fun onLogin(account: AccountState.LoggedIn) { + synchronized(lock) { + if (active?.pubKeyHex == account.pubKeyHex) return + tearDownLocked() + val store = PreferencesAuthApprovalStore(account.pubKeyHex) + val policy = + AuthApprovalPolicy( + selfApprovedRelays = { selfApprovedRelaysFor(account.pubKeyHex) }, + store = store, + onPromptRequired = { pending -> + _pendingApprovals.update { it.put(pending.relayUrl, pending) } + }, + ) + val authenticator = + RelayAuthenticator( + client = relayManager.client, + scope = scope, + signWithAllLoggedInUsers = { relayUrl, template -> + val signed = signWithPolicy(account, relayUrl, template, policy) + signed?.let { listOf(it) } ?: emptyList() + }, + ) + active = ActiveAuth(account.pubKeyHex, store, policy, authenticator) + Log.d("DesktopAuthCoordinator") { "AUTH wired for ${account.pubKeyHex.take(8)}" } + } + } + + /** Tear down AUTH on logout / account switch. */ + fun onLogout() { + synchronized(lock) { tearDownLocked() } + } + + /** + * Resolve a tier-2 [PendingAuthApproval] from the banner UI. + * + * Removes the entry from [pendingApprovals] before completing the + * deferred, so the suspended signer wakes up exactly once. + */ + fun resolve( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ) { + val pending = _pendingApprovals.value[relayUrl] ?: return + _pendingApprovals.update { it.remove(relayUrl) } + pending.decision.complete(scope) + } + + private fun tearDownLocked() { + val prev = active ?: return + prev.authenticator.destroy() + // Cancel any in-flight tier-2 prompts so suspended signers wake up. + _pendingApprovals.value.values.forEach { it.decision.complete(AuthApprovalScope.BLOCKED) } + _pendingApprovals.value = persistentMapOf() + active = null + } + + private fun selfApprovedRelaysFor(pubKeyHex: String): Set { + // Tier-1 = the user's own NIP-17 DM-inbox (kind:10050). Strict + // by design — write/read relays (NIP-65 kind:10002) are NOT included, + // because the user may have read-only relays they don't intend to + // identify themselves to via AUTH. + // + // MUST use dmInboxRelaysStrict (kind:10050 only) rather than the + // lenient dmInboxRelays helper, which falls back to NIP-65 read + // relays and would silently expand tier-1 to include every relay + // in the user's outbox. That defeats the tier-2 prompt for any + // relay in the user's normal read set. + val user = localCache.getOrCreateUser(pubKeyHex) + return user.dmInboxRelaysStrict()?.toSet() ?: emptySet() + } + + private suspend fun signWithPolicy( + account: AccountState.LoggedIn, + relayUrl: NormalizedRelayUrl, + template: EventTemplate, + policy: AuthApprovalPolicy, + ): RelayAuthEvent? = + when (val decision = policy.classify(relayUrl)) { + AuthApprovalDecision.Allow -> account.signer.sign(template) + AuthApprovalDecision.Block -> null + is AuthApprovalDecision.Pending -> { + val resolved = decision.pending.await() + if (resolved != AuthApprovalScope.ONCE) { + policy.recordDecision(relayUrl, resolved) + } + if (resolved == AuthApprovalScope.BLOCKED) null else account.signer.sign(template) + } + } + + private data class ActiveAuth( + val pubKeyHex: String, + val store: AuthApprovalStore, + val policy: AuthApprovalPolicy, + val authenticator: RelayAuthenticator, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt new file mode 100644 index 0000000000..ff47f1d38d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/auth/PreferencesAuthApprovalStore.kt @@ -0,0 +1,81 @@ +/* + * 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.desktop.auth + +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalScope +import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalStore +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import java.util.prefs.Preferences + +/** + * Desktop persistence backend for [AuthApprovalStore] using + * `java.util.prefs.Preferences`. + * + * Trade-offs vs the full SQLite `auth_approvals` table proposed in the plan: + * + * - **Pro**: zero new dependencies, no schema migration, already proven for + * other small desktop settings (per memory: `SearchHistoryStore`, + * `DesktopPreferences`). + * - **Con**: flat key/value, no transactions, no native TTL. Acceptable here + * because the approval set per account is small (≪50 relays for any user) + * and the read pattern is "look up before signing AUTH" — once per relay + * per session, easily cached in memory by the [AuthApprovalPolicy] layer. + * + * Per-account scoping is by Preferences node: each account gets its own node + * at `/com/vitorpamplona/amethyst/desktop/auth//`. Logout calls + * [clear] which `removeNode()`s the per-account subtree. + * + * `ONCE` scope is never persisted — that's the in-memory contract enforced + * by the [AuthApprovalStore] interface. This implementation only writes + * `ALWAYS` and `BLOCKED`. + */ +class PreferencesAuthApprovalStore( + private val accountPubKeyHex: String, +) : AuthApprovalStore { + private val node: Preferences = + Preferences.userRoot().node( + "/com/vitorpamplona/amethyst/desktop/auth/$accountPubKeyHex", + ) + + override suspend fun getScope(relayUrl: NormalizedRelayUrl): AuthApprovalScope? { + val raw = node.get(relayUrl.url, null) ?: return null + return runCatching { AuthApprovalScope.valueOf(raw) }.getOrNull() + } + + override suspend fun setScope( + relayUrl: NormalizedRelayUrl, + scope: AuthApprovalScope, + ) { + if (scope == AuthApprovalScope.ONCE) { + // ONCE is the session-only contract from AuthApprovalStore — must + // not touch the persistent store, otherwise it would silently + // upgrade to "until next clear()". + return + } + node.put(relayUrl.url, scope.name) + node.flush() + } + + override suspend fun clear() { + node.removeNode() + node.flush() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index d32ba9093f..fc762421e9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -56,9 +56,11 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.DualCase import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow @@ -66,6 +68,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import java.util.concurrent.ConcurrentHashMap @@ -91,6 +94,27 @@ class DesktopLocalCache : ICacheProvider { private val _followedUsers = MutableStateFlow>(emptySet()) val followedUsers: StateFlow> = _followedUsers.asStateFlow() + /** + * Active user's pubkey (hex). Set from Main.kt on login. When set, only + * kind-3 events from this pubkey update [_followedUsers] and + * [lastContactListEvent]. Other users' kind-3 events still flow through + * [contactListEvents] for consumers like the WoT service. + */ + @Volatile + var accountPubkey: HexKey? = null + + /** + * Fires for every accepted kind-3 event (both the active user's and + * other users' — filtered downstream). Buffered so slow consumers don't + * block the consume path. + */ + private val _contactListEvents = + MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val contactListEvents: SharedFlow = _contactListEvents.asSharedFlow() + /** Increments on each metadata update — observe to recompose when user names change. */ private val _metadataVersion = MutableStateFlow(0L) val metadataVersion: StateFlow = _metadataVersion.asStateFlow() @@ -281,11 +305,43 @@ class DesktopLocalCache : ICacheProvider { consumeComment(event, relay) } + is AdvertisedRelayListEvent -> { + consumeAdvertisedRelayList(event, relay) + } + else -> { false } } + /** + * Consumes a kind 10002 (NIP-65) advertised relay list event. Stores + * the newest per-author copy in [addressableNotes] so the outbox + * dispatcher can look up each follow's declared write relays without + * a fresh REQ. Emits nothing to the event stream — the UI doesn't + * render kind 10002s directly. + */ + private fun consumeAdvertisedRelayList( + event: AdvertisedRelayListEvent, + relay: NormalizedRelayUrl?, + ): Boolean { + val addressableNote = getOrCreateAddressableNote(event.address()) + val existing = addressableNote.event + if (existing != null && existing.createdAt >= event.createdAt) return false + val author = getOrCreateUser(event.pubKey) + addressableNote.loadEvent(event, author, emptyList()) + relay?.let { addressableNote.addRelay(it) } + return false + } + + /** + * Returns the cached kind-10002 event for [pubkey], if any. Used by the + * outbox dispatcher to skip a Phase-1 REQ for authors whose write-relay + * list is already in the store (from a previous session's local relay + * hydration or an in-session discovery). + */ + fun cachedAdvertisedRelayList(pubkey: HexKey): AdvertisedRelayListEvent? = addressableNotes.get(AdvertisedRelayListEvent.createAddress(pubkey).toValue())?.event as? AdvertisedRelayListEvent + /** * Consumes a kind 1 text note event. * Creates/updates Note in cache and links reply relationships. @@ -472,19 +528,47 @@ class DesktopLocalCache : ICacheProvider { /** * Consumes a kind 3 contact list event (replaceable). - * Updates the cached followedUsers set. + * + * Tracks the newest kind-3 per author (not a single global scalar) so + * ingesting other users' follow lists (e.g. for WoT scoring) doesn't + * corrupt the active user's state. Only the active user's kind-3 updates + * [_followedUsers] / [lastContactListEvent]. Every accepted event fans + * out on [_contactListEvents] for downstream consumers. */ - private var lastContactListCreatedAt = 0L + private val lastContactListByAuthor = ConcurrentHashMap() var lastContactListEvent: ContactListEvent? = null private set private fun consumeContactList(event: ContactListEvent): Boolean { - // Replaceable event — only accept newer contact lists - if (event.createdAt <= lastContactListCreatedAt) return false - lastContactListCreatedAt = event.createdAt - lastContactListEvent = event - _followedUsers.value = event.verifiedFollowKeySet() + // Replaceable event — only accept newer contact lists per author. + val prev = lastContactListByAuthor[event.pubKey] ?: 0L + if (event.createdAt <= prev) return false + + // Stamp lastContactListByAuthor *only* on branches where we know + // whether this event is the active user's own kind-3. If accountPubkey + // hasn't been bound yet (login/hydration ordering window), skip the + // stamp entirely so a later relay retry — after Main.kt binds + // accountPubkey — is not rejected by the createdAt gate. The + // _followedUsers state remains untouched in that case; downstream + // consumers still get the fan-out via _contactListEvents (WoT etc). + val currentAccountPubkey = accountPubkey + when { + event.pubKey == currentAccountPubkey -> { + lastContactListEvent = event + _followedUsers.value = event.verifiedFollowKeySet() + lastContactListByAuthor[event.pubKey] = event.createdAt + } + currentAccountPubkey != null -> { + // Known-not-self: safe to stamp. + lastContactListByAuthor[event.pubKey] = event.createdAt + } + else -> { + // accountPubkey not bound yet — cannot tell if this is self. + // Defer stamping so the relay retry that arrives after bind + // will still populate _followedUsers. + } + } // Store in addressableNotes too — Kind3FollowListState.getFollowListEvent // reads from getOrCreateAddressableNote(...) and would otherwise see a @@ -493,6 +577,8 @@ class DesktopLocalCache : ICacheProvider { val addressableNote = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) addressableNote.loadEvent(event, author, emptyList()) + + _contactListEvents.tryEmit(event) return true } @@ -626,6 +712,7 @@ class DesktopLocalCache : ICacheProvider { * @param relay The relay this event came from * @return true if event was processed, false if no matching request */ + @OptIn(DelicateCoroutinesApi::class) fun consume( event: LnZapPaymentResponseEvent, relay: NormalizedRelayUrl?, @@ -786,7 +873,9 @@ class DesktopLocalCache : ICacheProvider { followerCounts.clear() followingCounts.clear() notesByAuthor.clear() - lastContactListCreatedAt = 0L + lastContactListByAuthor.clear() + lastContactListEvent = null + accountPubkey = null } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index a76b64b18b..e85c610398 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -31,10 +31,13 @@ import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListRepository import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.DmInboxRelayResolver import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -71,6 +74,7 @@ class DesktopIAccount( val dmSendTracker: DmSendTracker, private val scope: CoroutineScope, private val accountRelays: DesktopAccountRelays? = null, + val dmInboxResolver: DmInboxRelayResolver? = null, ) : IAccount { override val signer: NostrSigner = NostrSignerWithClientTag(accountState.signer, CLIENT_TAG_NAME) @@ -93,6 +97,14 @@ class DesktopIAccount( }, ) + /** + * Friends-of-friends trust score. Populated by Main.kt's login flow + * from batch kind-3 fetches on the active user's follow set. + */ + val wotService = + com.vitorpamplona.amethyst.commons.wot + .WoTService(scope) + val nip65RelayList = Nip65RelayListState( signer, @@ -179,7 +191,8 @@ class DesktopIAccount( override suspend fun sendNip17PrivateMessage(template: EventTemplate) { if (!isWriteable()) return - val result = NIP17Factory().createMessageNIP17(template, signer) + val hints = recipientRelayHints(template.tags) + val result = NIP17Factory().createMessageNIP17(template, signer, recipientRelayHints = { hints[it] }) // Optimistic local add — use the inner ChatMessageEvent, not the wraps val innerMsg = result.msg as ChatMessageEvent @@ -189,18 +202,7 @@ class DesktopIAccount( val batch = result.wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } @@ -210,7 +212,8 @@ class DesktopIAccount( override suspend fun sendNip17EncryptedFile(template: EventTemplate) { if (!isWriteable()) return - val result = NIP17Factory().createEncryptedFileNIP17(template, signer) + val hints = recipientRelayHints(template.tags) + val result = NIP17Factory().createEncryptedFileNIP17(template, signer, recipientRelayHints = { hints[it] }) // Optimistic local add val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent @@ -220,18 +223,7 @@ class DesktopIAccount( val batch = result.wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } @@ -242,24 +234,69 @@ class DesktopIAccount( val batch = wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } scope.launch { dmSendTracker.sendBatch(batch) } } + /** + * NIP-17 inbox-relay resolution, strict variant — no fallback to the + * user's connected relays. + * + * Per NIP-17 §Publishing, a gift wrap MUST only land on relays advertised + * in the recipient's kind:10050. Falling back to the sender's connected + * relays when 10050 is missing publishes the wrap to relays the recipient + * does NOT consult — at best the message never arrives, at worst it leaks + * the conversation metadata (recipient pubkey + send timestamp) to relays + * outside the recipient's chosen inbox. + * + * Three-layer lookup when a [dmInboxResolver] is injected (default in + * Main.kt): + * 1. LocalCache hit (fast, no I/O) + * 2. Resolver's in-memory LRU cache + * 3. Curated indexer fan-out via an unauthenticated NostrClient + * + * Without a resolver (legacy / tests), falls back to LocalCache-only. + * + * Empty result means the wrap will not be sent; [DmSendTracker.sendBatch] + * surfaces this as a "No relays available" failure to the user. + */ + private suspend fun resolveDmInboxRelaysStrict(recipientKey: HexKey?): Set = resolveDmInboxRelaysStrictOrdered(recipientKey).toSet() + + /** + * Ordered variant of [resolveDmInboxRelaysStrict]. Preserves the relay + * order declared in the recipient's kind:10050 so the first element is the + * recipient's *primary* DM inbox — used as the NIP-17 gift-wrap `p`-tag + * relay hint. The unordered [resolveDmInboxRelaysStrict] derives from this. + */ + private suspend fun resolveDmInboxRelaysStrictOrdered(recipientKey: HexKey?): List { + if (recipientKey == null) return emptyList() + val resolver = dmInboxResolver + return if (resolver != null) { + resolver.resolve(recipientKey) + } else { + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelaysStrict() + ?.ifEmpty { null } + ?: emptyList() + } + } + + /** + * Per-recipient primary DM-inbox relay, keyed by recipient pubkey, for the + * NIP-17 gift-wrap `p`-tag hint (`["p", , ]`). Built + * from the recipient `p` tags on the outgoing message template. A recipient + * with no resolvable kind:10050 maps to `null`, which keeps the historical + * 2-element `p` tag for that recipient. + */ + private suspend fun recipientRelayHints(tags: Array>): Map { + val recipients = tags.mapNotNull { if (it.size >= 2 && it[0] == "p") it[1] else null }.toSet() + return recipients.associateWith { resolveDmInboxRelaysStrictOrdered(it).firstOrNull() } + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt index b7fc38fa52..586c7b1922 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.model import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState +import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn /** @@ -47,6 +49,13 @@ class DesktopRelayCategories( accountRelays: DesktopAccountRelays, /** Reactive connected relay set — used as fallback when NIP-65 is empty */ connectedRelays: StateFlow>, + /** + * Shared index-relay preference — app-global, backed by + * [PreferencesIndexRelays] and visible to `amy` via the same + * Preferences node. Used by [indexRelays] and by + * `Main.kt` when constructing the subscriptions coordinator. + */ + private val indexRelaysStore: PreferencesIndexRelays, scope: CoroutineScope, ) { /** Default relays — ALWAYS populated, used as stateIn initial value */ @@ -99,6 +108,26 @@ class DesktopRelayCategories( .distinctUntilChanged() .stateIn(scope, SharingStarted.Eagerly, defaultRelays) + /** + * Index relays: user override → [PreferencesIndexRelays.DEFAULT_INDEX_RELAYS]. + * + * Unlike [feedRelays] / [notificationRelays] / [dmRelays] this + * category does *not* combine with connected/NIP-65 sets — it's a + * curated user choice about where to look up metadata and follow + * lists, not a "what's actually reachable right now" derived set. + * No debounce needed: writes are gated by settings-screen UI, not + * fanned in from a subscription pipeline. + */ + val indexRelays: StateFlow> = + indexRelaysStore.relays + .map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } } + .distinctUntilChanged() + .stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective()) + + fun setIndexRelays(new: Set) { + indexRelaysStore.setRelays(new) + } + companion object { val DEFAULT_SEARCH_RELAYS = DefaultSearchRelayList } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt new file mode 100644 index 0000000000..81bdebb9d5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopLockScreen.kt @@ -0,0 +1,213 @@ +/* + * 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.desktop.security + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor +import kotlinx.coroutines.delay + +/** + * Shared desktop lock-screen surface. Used by + * [DesktopMessagesLockGate] and [DesktopWalletLockGate] with per-scope + * copy passed in as [title] and [subtitle]. + * + * When no password is set — an edge case that only happens if the user + * cleared the password while a lock toggle was still active — the screen + * offers [onNoPasswordAction] (typically deep-linking to Settings so the + * user can set a new one). Falls back to a "Disable lock" button when + * [onNoPasswordAction] is null. + * + * Enforces exponential backoff after repeated failed attempts (5 fails → + * 30 s, doubling, capped at 5 min). Backoff state persists across restarts + * and is shared across every gated scope (anti-brute-force property). + */ +@Composable +internal fun DesktopLockScreen( + scope: LockScope, + title: String, + subtitle: String, + onNoPasswordAction: (() -> Unit)? = null, + noPasswordButtonLabel: String = "Open Settings", +) { + val lockState = lockStateFor(scope) + val settings = LocalPrivacyLockSettings.current + val stored by settings.passwordHashed.collectAsState() + val lockedUntil by settings.lockedUntilEpochMs.collectAsState() + + var input by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + var remainingMs by remember { mutableStateOf(lockoutRemainingMs(lockedUntil)) } + + LaunchedEffect(lockedUntil) { + while (true) { + val r = lockoutRemainingMs(lockedUntil) + remainingMs = r + if (r <= 0) break + delay(500) + } + } + + val submit: () -> Unit = { + if (remainingMs <= 0) { + val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true + if (ok) { + input = "" + showError = false + lockState.onUnlockSuccess() + } else { + showError = true + lockState.onFailedUnlockAttempt(System.currentTimeMillis()) + } + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Box(modifier = Modifier.size(16.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Box(modifier = Modifier.size(8.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(24.dp)) + if (stored == null) { + Text( + text = "No password is set yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(16.dp)) + if (onNoPasswordAction != null) { + Button(onClick = onNoPasswordAction) { + Text(noPasswordButtonLabel) + } + } else { + Button(onClick = { lockState.onCredentialUnavailable() }) { + Text("Disable lock") + } + } + } else { + OutlinedTextField( + value = input, + onValueChange = { + input = it + showError = false + }, + label = { Text("Password") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + enabled = remainingMs <= 0, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions(onDone = { submit() }), + isError = showError, + supportingText = + when { + remainingMs > 0 -> { + { Text("Too many attempts. Try again in ${formatLockoutCountdown(remainingMs)}.") } + } + showError -> { + { Text("Wrong password") } + } + else -> null + }, + modifier = Modifier.widthIn(max = 320.dp), + ) + Box(modifier = Modifier.size(16.dp)) + Button( + onClick = submit, + enabled = input.isNotEmpty() && remainingMs <= 0, + ) { + Text("Unlock") + } + } + } + } +} + +private fun lockoutRemainingMs(untilEpochMs: Long?): Long { + val until = untilEpochMs ?: return 0 + val diff = until - System.currentTimeMillis() + return if (diff > 0) diff else 0 +} + +private fun formatLockoutCountdown(millis: Long): String { + val totalSeconds = (millis + 999) / 1000 + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt index e4337d986e..c37f62554c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt @@ -20,59 +20,33 @@ */ package com.vitorpamplona.amethyst.desktop.security -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope import com.vitorpamplona.amethyst.commons.privacylock.LockState -import kotlinx.coroutines.delay +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** - * Desktop equivalent of `MessagesLockGate`. Uses password verification - * synchronously — no async CredentialPrompter round-trip needed. - * - * Renders content when Disabled / Unlocked; renders an inline password - * input when Locked. If no password has been set, prompts the user to set - * one first (this fires from the settings toggle in normal flow, so the - * fallback exists only as a safety net). + * Desktop equivalent of `MessagesLockGate`. Uses synchronous password + * verification (no async CredentialPrompter round-trip needed) via the + * shared [DesktopLockScreen] surface. * * Branch selection is SYNCHRONOUS in composition — no LaunchedEffect * guard — closing the deep-link race (plan §Security Hardening H1). * - * Enforces exponential backoff after repeated failed attempts (5 fails → - * 30 s, doubling, capped at 5 min). Backoff state persists across restarts. + * @param onOpenSettings optional deep-link into the Settings screen used + * when the user cleared the master password while the Messages lock was + * still toggled on. When null, the fallback "Disable lock" button is + * offered instead. */ @Composable -fun DesktopMessagesLockGate(content: @Composable () -> Unit) { - val lockState = LocalMessagesLockState.current +fun DesktopMessagesLockGate( + onOpenSettings: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val lockState = lockStateFor(LockScope.Messages) val current by lockState.state.collectAsState() DisposableEffect(lockState) { @@ -80,138 +54,13 @@ fun DesktopMessagesLockGate(content: @Composable () -> Unit) { } when (current) { - is LockState.Locked -> DesktopLockScreen() + is LockState.Locked -> + DesktopLockScreen( + scope = LockScope.Messages, + title = "Messages locked", + subtitle = "Enter your privacy-lock password to view messages.", + onNoPasswordAction = onOpenSettings, + ) else -> content() } } - -@Composable -private fun DesktopLockScreen() { - val lockState = LocalMessagesLockState.current - val settings = LocalPrivacyLockSettings.current - val stored by settings.passwordHashed.collectAsState() - val lockedUntil by settings.lockedUntilEpochMs.collectAsState() - - var input by remember { mutableStateOf("") } - var showError by remember { mutableStateOf(false) } - var remainingMs by remember { mutableStateOf(lockoutRemaining(lockedUntil)) } - - LaunchedEffect(lockedUntil) { - while (true) { - val r = lockoutRemaining(lockedUntil) - remainingMs = r - if (r <= 0) break - delay(500) - } - } - - val submit: () -> Unit = { - if (remainingMs <= 0) { - val ok = stored?.let { PasswordHasher.verify(input.toCharArray(), it) } == true - if (ok) { - input = "" - showError = false - lockState.onUnlockSuccess() - } else { - showError = true - lockState.onFailedUnlockAttempt(System.currentTimeMillis()) - } - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Column( - modifier = Modifier.fillMaxSize().padding(32.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - symbol = MaterialSymbols.Lock, - contentDescription = null, - modifier = Modifier.size(64.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Box(modifier = Modifier.size(16.dp)) - Text( - text = "Messages locked", - style = MaterialTheme.typography.headlineSmall, - textAlign = TextAlign.Center, - ) - Box(modifier = Modifier.size(8.dp)) - Text( - text = "Enter your privacy-lock password to view messages", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(24.dp)) - if (stored == null) { - Text( - text = "No password is set yet. Open Settings → Privacy lock to set one.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - textAlign = TextAlign.Center, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(16.dp)) - Button(onClick = { lockState.onCredentialUnavailable() }) { - Text("Disable lock") - } - } else { - OutlinedTextField( - value = input, - onValueChange = { - input = it - showError = false - }, - label = { Text("Password") }, - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - enabled = remainingMs <= 0, - keyboardOptions = - KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(onDone = { submit() }), - isError = showError, - supportingText = - when { - remainingMs > 0 -> { - { Text("Too many attempts. Try again in ${formatCountdown(remainingMs)}.") } - } - showError -> { - { Text("Wrong password") } - } - else -> null - }, - modifier = Modifier.widthIn(max = 320.dp), - ) - Box(modifier = Modifier.size(16.dp)) - Button( - onClick = submit, - enabled = input.isNotEmpty() && remainingMs <= 0, - ) { - Text("Unlock") - } - } - } - } -} - -private fun lockoutRemaining(untilEpochMs: Long?): Long { - val until = untilEpochMs ?: return 0 - val diff = until - System.currentTimeMillis() - return if (diff > 0) diff else 0 -} - -private fun formatCountdown(millis: Long): String { - val totalSeconds = (millis + 999) / 1000 - val minutes = totalSeconds / 60 - val seconds = totalSeconds % 60 - return if (minutes > 0) "${minutes}m ${seconds}s" else "${seconds}s" -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt new file mode 100644 index 0000000000..7f032bf28e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt @@ -0,0 +1,69 @@ +/* + * 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.desktop.security + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.LockState +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * Desktop equivalent of `WalletLockGate`. Mirrors [DesktopMessagesLockGate] + * behaviour with wallet-scoped copy. + * + * Reads its lock state from `lockStateFor(LockScope.Wallet)` — a separate + * instance from Messages, so an unlocked Messages session does NOT + * auto-unlock the Wallet, and vice-versa. Both scopes share the same + * password, failed-attempt counter, and lockout schedule via the + * ambient [PrivacyLockSettings]. + * + * @param onOpenSettings optional deep-link into the Settings screen used + * when the user cleared the master password while the Wallet lock was + * still toggled on. Per plan Q5: prefer deep-link over the plain + * "Disable lock" fallback so the user can immediately set a new + * password rather than blindly disabling the feature. + */ +@Composable +fun DesktopWalletLockGate( + onOpenSettings: (() -> Unit)? = null, + content: @Composable () -> Unit, +) { + val lockState = lockStateFor(LockScope.Wallet) + val current by lockState.state.collectAsState() + + DisposableEffect(lockState) { + onDispose { lockState.onLeaveRoute() } + } + + when (current) { + is LockState.Locked -> + DesktopLockScreen( + scope = LockScope.Wallet, + title = "Wallet locked", + subtitle = "Enter your privacy-lock password to view the wallet.", + onNoPasswordAction = onOpenSettings, + ) + else -> content() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt index 6950bb366c..e06d0239e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/LocalPrivacyLockSettings.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.desktop.security import androidx.compose.runtime.compositionLocalOf import com.vitorpamplona.amethyst.commons.privacylock.PrivacyLockSettings -/** Provided once at the Desktop App root alongside LocalMessagesLockState. */ +/** Provided once at the Desktop App root alongside LocalPrivacyLockState. */ val LocalPrivacyLockSettings = compositionLocalOf { error("LocalPrivacyLockSettings not provided — wrap App() with CompositionLocalProvider") diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt index 6af1cd1efa..8f82724342 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/MessagesFirstRunBanner.kt @@ -47,7 +47,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor /** * One-time discovery banner at the top of the Desktop Messages column. @@ -64,7 +65,7 @@ import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState @Composable fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { val settings = LocalPrivacyLockSettings.current - val lockState = LocalMessagesLockState.current + val lockState = lockStateFor(LockScope.Messages) val enabled by settings.lockEnabled.collectAsState() val seen by settings.firstRunCardSeen.collectAsState() var showDialog by remember { mutableStateOf(false) } @@ -92,11 +93,13 @@ fun MessagesFirstRunBanner(onSaved: (String) -> Unit = {}) { ) Column(modifier = Modifier.weight(1f)) { Text( - text = "Lock the Messages tab?", + text = "Lock Messages and Wallet?", style = MaterialTheme.typography.titleSmall, ) Text( - text = "Require a password before Messages shows. Feed and profile stay open.", + text = + "Require your password before the Messages and Wallet columns show. " + + "Feed, profile, and search stay open.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt new file mode 100644 index 0000000000..fc1be6642c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/PrivacyLockBlurModifier.kt @@ -0,0 +1,49 @@ +/* + * 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.desktop.security + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp + +/** + * Blur the modified node when the privacy lock is enabled AND the desktop + * window is currently unfocused. + * + * Per plan Q4: applies only to sensitive text nodes (balance amount, invoice + * strings, addresses, NWC URIs, transaction memos) — NOT to card + * containers, icons, or layout structure. This preserves the visual + * skeleton for a passer-by while hiding the meaningful values. + * + * Uses Compose Desktop's built-in [LocalWindowInfo.isWindowFocused] — no + * Swing WindowListener plumbing required. + */ +@Composable +fun Modifier.privacyLockBlurWhenUnfocused(): Modifier { + val settings = LocalPrivacyLockSettings.current + val enabled by settings.lockEnabled.collectAsState() + val focused = LocalWindowInfo.current.isWindowFocused + return if (enabled && !focused) this.then(Modifier.blur(16.dp)) else this +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt index 80840cd6d6..8b9d08f0a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/SetPasswordDialog.kt @@ -62,7 +62,8 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.privacylock.LocalMessagesLockState +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor import kotlinx.coroutines.delay /** Enforced minimum length for a new/rotated password. */ @@ -103,7 +104,7 @@ fun SetPasswordDialog( val submit: () -> Unit = { val currentOk = !isChange || - (existingHash != null && PasswordHasher.verify(current.toCharArray(), existingHash)) + PasswordHasher.verify(current.toCharArray(), existingHash) when { !currentOk -> { currentError = "Wrong password" @@ -232,7 +233,10 @@ fun RemovePasswordDialog( onDismiss: () -> Unit, onConfirm: () -> Unit, ) { - val lockState = LocalMessagesLockState.current + // Remove-password only runs from Settings; the Messages state instance is + // as good as any — both scopes read the same shared lockedUntilEpochMs and + // failedUnlockAttempts, so backoff bookkeeping is scope-agnostic. + val lockState = lockStateFor(LockScope.Messages) val settings = LocalPrivacyLockSettings.current val lockedUntil by settings.lockedUntilEpochMs.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt new file mode 100644 index 0000000000..3d0b0db801 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/WalletFirstRunBanner.kt @@ -0,0 +1,131 @@ +/* + * 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.desktop.security + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.privacylock.LockScope +import com.vitorpamplona.amethyst.commons.privacylock.lockStateFor + +/** + * One-time discovery banner at the top of the Desktop Wallet column. + * Mirrors [MessagesFirstRunBanner] — same state (`firstRunCardSeen`) and + * same enable-with-password flow, only the visual anchor changes so users + * who never open the Messages tab still learn about the feature. + * + * Because the master `firstRunCardSeen` flag is shared, dismissing this + * banner also hides the Messages banner (and vice versa). Enabling the + * lock from either banner locks both routes. + */ +@Composable +fun WalletFirstRunBanner(onSaved: (String) -> Unit = {}) { + val settings = LocalPrivacyLockSettings.current + val lockState = lockStateFor(LockScope.Wallet) + val enabled by settings.lockEnabled.collectAsState() + val seen by settings.firstRunCardSeen.collectAsState() + var showDialog by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = !enabled && !seen, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + symbol = MaterialSymbols.Lock, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Lock the Wallet and Messages?", + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = + "Require your password before the Wallet and Messages columns show. " + + "Feed, profile, and search stay open.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = { settings.setFirstRunCardSeen(true) }) { + Text("Not now") + } + Button(onClick = { showDialog = true }) { + Text("Enable") + } + } + } + } + + if (showDialog) { + SetPasswordDialog( + existingHash = null, + onDismiss = { showDialog = false }, + onConfirm = { newHash -> + settings.setPasswordHashed(newHash) + settings.setLockEnabled(true) + settings.setFirstRunCardSeen(true) + // Keep the user Unlocked — don't kick them to the lock screen + // right after they just entered the password. + lockState.onUnlockSuccess() + showDialog = false + onSaved("Privacy lock enabled") + }, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index afa616fb00..33afa1921d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -25,6 +25,8 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoo import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter import com.vitorpamplona.amethyst.commons.service.BasicBundledInsert +import com.vitorpamplona.amethyst.commons.wot.OutboxCacheGateway +import com.vitorpamplona.amethyst.commons.wot.OutboxDispatcher import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.quartz.nip01Core.core.Event @@ -33,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -98,6 +101,51 @@ class DesktopRelaySubscriptionsCoordinator( }, ) + /** + * Bridges [OutboxDispatcher] to [DesktopLocalCache]. Every event the + * dispatcher receives goes through [DesktopLocalCache.consume] so it + * lands in the same code path as events arriving from feed + * subscriptions — kind-10002 caches into `addressableNotes`; kind-0 + * updates the user metadata; kind-3 fans out through + * `_contactListEvents` for the WoT service. + */ + private val outboxGateway = + object : OutboxCacheGateway { + override fun cachedOutbox(pubkey: HexKey): AdvertisedRelayListEvent? = localCache.cachedAdvertisedRelayList(pubkey) + + override fun onOutboxDiscovered( + event: AdvertisedRelayListEvent, + relay: NormalizedRelayUrl, + ) { + localCache.consume(event, relay) + } + + override fun onDiscoveredEvent( + event: Event, + relay: NormalizedRelayUrl, + ) { + localCache.consume(event, relay) + } + } + + /** + * NIP-65 outbox model for kind-0 and kind-3 fetching. Per PR #3483 + * review directive from Vitor: index relays discover each author's + * write-relay list, then kind-0/kind-3 REQs go to that author's + * declared write relays. See [OutboxDispatcher] for the pipeline. + * + * Kept as a val (not lazy) because [clear] must reset its dedup + * markers on account switch. The dispatcher itself is stateless + * across accounts as long as `clear()` is called. + */ + val outboxDispatcher = + OutboxDispatcher( + client = client, + scope = scope, + indexRelays = { indexRelays }, + gateway = outboxGateway, + ) + // Event bundler: batches consumed notes before emitting to SharedFlow // 250ms for desktop (Android uses 1000ms to save battery) private val eventBundler = @@ -264,6 +312,19 @@ class DesktopRelaySubscriptionsCoordinator( feedMetadata.loadMetadataBatched(pubkeys) } + /** + * Batched kind-3 (follow list) fetch. Used by the WoT service to + * build friends-of-friends counts. Chunks authors into ≤100 per + * Filter within one subscription. [onEose] fires once all chunks + * finish (or after the 5s internal timeout). + */ + fun loadKind3Batched( + pubkeys: Collection, + onEose: () -> Unit = {}, + ) { + feedMetadata.loadKind3Batched(pubkeys, onEose = onEose) + } + // -- DM Subscription Support -- /** Active DM subscription IDs for cleanup */ @@ -374,10 +435,20 @@ class DesktopRelaySubscriptionsCoordinator( unsubscribeFromDms() feedMetadata.clear() + outboxDispatcher.clear() rateLimiter.reset() cleanupJob?.cancel() } + /** + * Fetch kind-3 (follow lists) for [pubkeys] via each author's outbox + * relay per NIP-65 (see [OutboxDispatcher]). Suspends until every + * phase EOSEs or times out. Callers typically launch this on a + * scope-owned coroutine and mark the WoT service ready in the + * continuation. Returns per-phase counters for observability. + */ + suspend fun loadKind3ViaOutbox(pubkeys: Set): OutboxDispatcher.Result = outboxDispatcher.fetchKind3Only(pubkeys) + // ----- Memory Cleanup ----- private val memoryBean = ManagementFactory.getMemoryMXBean() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt index bb9377f958..9ab82738b8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt @@ -27,7 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.utils.TimeUtils /** * Filter builders for DM subscriptions on desktop. @@ -116,20 +115,18 @@ object FilterDMs { * Creates a filter for NIP-59 gift-wrapped events TO the user. * Gift wraps (kind 1059) contain encrypted NIP-17 DMs. * - * The since is adjusted back by 2 days because gift wrap created_at - * timestamps are randomized within a 2-day window for privacy. + * No `since` is exposed: per NIP-17, seal (kind 13) and gift wrap (kind 1059) + * `created_at` are randomized up to 2 days in the past for privacy. Any + * `since` window applied here silently drops wraps whose randomized + * timestamp predates it — losing real DMs and suppressing the unread badge. + * DMs are low-volume, so subscribing without a `since` is safe. * * @param userPubKeyHex The user's public key (hex) - * @param since Optional since timestamp (will be adjusted -2 days) */ - fun giftWrapsToMe( - userPubKeyHex: HexKey, - since: Long? = null, - ): Filter = + fun giftWrapsToMe(userPubKeyHex: HexKey): Filter = Filter( kinds = listOf(GiftWrapEvent.KIND, EphemeralGiftWrapEvent.KIND), tags = mapOf("p" to listOf(userPubKeyHex)), - since = since?.minus(TimeUtils.twoDays()), ) } @@ -184,11 +181,13 @@ fun createNip04DmOutboxSubscription( /** * Creates a subscription config for NIP-59 gift-wrapped DMs TO the user. * Subscribes on DM/inbox relays. + * + * No `since` parameter: see [FilterDMs.giftWrapsToMe] for why NIP-17 wraps + * cannot use a `since` window without dropping legitimate messages. */ fun createGiftWrapSubscription( relays: Set, userPubKeyHex: HexKey, - since: Long? = null, onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, ): SubscriptionConfig? { @@ -196,7 +195,7 @@ fun createGiftWrapSubscription( return SubscriptionConfig( subId = generateSubId("giftwrap-${userPubKeyHex.take(8)}"), - filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex, since)), + filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex)), relays = relays, onEvent = onEvent, onEose = onEose, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index c240c645d2..ec07c0e06b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -99,7 +99,6 @@ import com.vitorpamplona.amethyst.commons.search.QuerySerializer import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip @@ -136,6 +135,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender +import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar import com.vitorpamplona.amethyst.desktop.ui.relay.LocalRelayCategories import com.vitorpamplona.amethyst.desktop.ui.relay.Nip65RelayEditor import com.vitorpamplona.amethyst.desktop.ui.search.SearchResultsList @@ -290,14 +290,14 @@ private fun FeedNoteCardBody( ) { GenericRepostLayout( baseAuthorPicture = { - UserAvatar( + WoTBadgedAvatar( userHex = event.pubKey, pictureUrl = reposterUser?.profilePicture(), size = 35.dp, ) }, repostAuthorPicture = { - UserAvatar( + WoTBadgedAvatar( userHex = originalEvent.pubKey, pictureUrl = originalUser?.profilePicture(), size = 35.dp, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index 83806d8395..7a52fb1d08 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -818,6 +819,7 @@ fun BoostsPopup( /** * Fetches metadata for multiple users in a single subscription. */ +@OptIn(DelicateCoroutinesApi::class) private suspend fun fetchMetadataForUsers( pubKeys: List, relayManager: DesktopRelayConnectionManager, @@ -1584,6 +1586,7 @@ private fun openLightningUri(bolt11: String) { * Fetches user metadata on-demand to get lightning address. * Returns the lightning address if found, null otherwise. */ +@OptIn(DelicateCoroutinesApi::class) private suspend fun fetchUserLightningAddress( pubKey: String, relayManager: DesktopRelayConnectionManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index a5288551ea..8540b85045 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -77,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.profile.ui.ProfileBroadcastBanner import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -90,6 +89,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createContactListSubscri import com.vitorpamplona.amethyst.desktop.subscriptions.generateSubId import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.amethyst.desktop.ui.media.LightboxOverlay +import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar import com.vitorpamplona.amethyst.desktop.ui.profile.EditProfileDialog import com.vitorpamplona.amethyst.desktop.ui.profile.GalleryTab import com.vitorpamplona.amethyst.desktop.viewmodels.DesktopFeedViewModel @@ -682,7 +682,7 @@ fun UserProfileScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, ) { - UserAvatar( + WoTBadgedAvatar( userHex = pubKeyHex, pictureUrl = picture, size = 56.dp, @@ -1155,7 +1155,7 @@ fun UserProfileScreen( } Spacer(Modifier.width(4.dp)) } - UserAvatar( + WoTBadgedAvatar( userHex = pubKeyHex, pictureUrl = picture, size = 28.dp, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 8b89c582db..8823c6197f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -246,7 +246,15 @@ private fun CompactMessagesContent( } val messageState = remember(currentRoom) { - ChatNewMessageState(account, cacheProvider, scope) + ChatNewMessageState( + account, + cacheProvider, + scope, + dmInboxResolver = + (account as? DesktopIAccount)?.dmInboxResolver?.let { resolver -> + { hexKey -> resolver.resolve(hexKey) } + }, + ) } val broadcastStatus = if (account is DesktopIAccount) { @@ -342,7 +350,15 @@ private fun SplitMessagesContent( } val messageState = remember(currentRoom) { - ChatNewMessageState(account, cacheProvider, scope) + ChatNewMessageState( + account, + cacheProvider, + scope, + dmInboxResolver = + (account as? DesktopIAccount)?.dmInboxResolver?.let { resolver -> + { hexKey -> resolver.resolve(hexKey) } + }, + ) } val broadcastStatus = if (account is DesktopIAccount) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt index 909b97e26d..cc6f77a3f5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -222,8 +222,13 @@ fun NewDmDialog( val userResults = bech32Results.filterIsInstance() items(userResults) { result -> + // getOrCreateUser (not getUserIfExists): a DM recipient is + // identified purely by pubkey, so a valid npub must be + // selectable even when we have no metadata (kind:0) cached + // for them yet. Otherwise pasting the npub of anyone the + // cache hasn't seen renders a dead, non-clickable row. val user = - cacheProvider.getUserIfExists(result.pubKeyHex) + cacheProvider.getOrCreateUser(result.pubKeyHex) if (user != null) { UserSearchCard( user = user, @@ -231,7 +236,8 @@ fun NewDmDialog( modifier = selectedModifier(isSelected(user)), ) } else { - // Minimal card for unloaded users + // Only reached if the key itself can't be resolved + // (malformed) — show a non-selectable hint. Surface( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.surfaceVariant, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index 9f317693b9..12865e4f61 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -394,7 +394,9 @@ internal fun RootContent( } DeckColumnType.Messages -> { - com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate { + com.vitorpamplona.amethyst.desktop.security.DesktopMessagesLockGate( + onOpenSettings = onNavigateToRelays, + ) { DesktopMessagesScreen( account = iAccount, cacheProvider = localCache, @@ -500,15 +502,19 @@ internal fun RootContent( } DeckColumnType.Wallet -> { - com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( - account = account, - accountManager = accountManager, - relayManager = relayManager, - localCache = localCache, - nwcConnection = nwcConnection, - appScope = appScope, - onZapFeedback = onZapFeedback, - ) + com.vitorpamplona.amethyst.desktop.security.DesktopWalletLockGate( + onOpenSettings = onNavigateToRelays, + ) { + com.vitorpamplona.amethyst.desktop.ui.wallet.WalletColumnScreen( + account = account, + accountManager = accountManager, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } } DeckColumnType.Relays -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt index 299e5afa5b..5648051598 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/LocalFeedProvider.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.feeds.custom.defaultFeeds import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -38,6 +39,7 @@ private val feedPrefs: Preferences by lazy { Preferences.userRoot().node("amethyst/feeds") } +@OptIn(DelicateCoroutinesApi::class) private val defaultRepository by lazy { val repo = FeedDefinitionRepository(GlobalScope) @@ -66,6 +68,7 @@ val LocalFeedRepository = defaultRepository } +@OptIn(DelicateCoroutinesApi::class) val LocalFeedScope = compositionLocalOf { GlobalScope diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 565a9b2156..b23adb506c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.commons.model.ImmutableListOfLists import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.UrlParser -import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.commons.ui.note.ReplyContext import com.vitorpamplona.amethyst.commons.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache @@ -69,6 +68,7 @@ import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState import com.vitorpamplona.amethyst.desktop.ui.media.isAnimatedGifUrl +import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadgedAvatar import com.vitorpamplona.amethyst.desktop.ui.toNoteDisplayData import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent @@ -259,7 +259,7 @@ fun NoteCard( }, ), ) { - UserAvatar( + WoTBadgedAvatar( userHex = note.pubKeyHex, pictureUrl = note.profilePictureUrl, size = 32.dp, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt new file mode 100644 index 0000000000..7db95ed012 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt @@ -0,0 +1,83 @@ +/* + * 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.desktop.ui.note + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** + * Compact trust-score chip drawn on top of a [UserAvatar]. Shows the raw + * count of accounts in the active user's follow set who also follow this + * pubkey. Clamps display to `"99+"` for counts over 99 to keep the chip + * width predictable. + * + * Wrapped in a Material3 [TooltipBox] so hovering the badge shows a + * plain tooltip explaining what the number means. `isPersistent = true` + * fixes the Compose Multiplatform default of tooltips dismissing too + * quickly for mouse users (JB compose-multiplatform#3539). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WoTBadge( + count: Int, + modifier: Modifier = Modifier, +) { + if (count <= 0) return + val display = if (count > 99) "99+" else count.toString() + val state = rememberTooltipState(isPersistent = true) + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above, 4.dp), + tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } }, + state = state, + ) { + Box( + modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .semantics { contentDescription = "Followed by $count of your contacts" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = display, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt new file mode 100644 index 0000000000..9e91f361b1 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt @@ -0,0 +1,91 @@ +/* + * 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.desktop.ui.note + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady +import com.vitorpamplona.amethyst.commons.wot.LocalWoTService + +/** + * Drop-in replacement for [UserAvatar] that overlays a Web-of-Trust + * score chip when four gates all pass: + * + * 1. `LocalWoTService.current` is non-null (Desktop provides it; Android + * leaves it null and this composable falls back to a plain avatar). + * 2. `LocalWoTReady.current == true` (initial batch fetch complete OR + * startup timeout elapsed). + * 3. `userHex !in LocalSpamExemptKeys.current` — same set the hashtag-spam + * filter uses; contains the active user's pubkey plus everyone they + * follow. Skips self-badge and already-trusted accounts in one check. + * + * The score is read as a plain snapshot access from + * `WoTService.scores` — Compose tracks the read per key, so avatars only + * recompose when their own score changes. + */ +@Composable +fun WoTBadgedAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadProfilePicture: Boolean = true, + loadRobohash: Boolean = true, + useThumbnailCache: Boolean = false, +) { + val service = LocalWoTService.current + val ready = LocalWoTReady.current + val exemptKeys = LocalSpamExemptKeys.current + + val score = + if (service != null && ready && userHex !in exemptKeys) { + service.scores[userHex] ?: 0 + } else { + 0 + } + + UserAvatar( + userHex = userHex, + pictureUrl = pictureUrl, + size = size, + modifier = modifier, + contentDescription = contentDescription, + loadProfilePicture = loadProfilePicture, + loadRobohash = loadRobohash, + useThumbnailCache = useThumbnailCache, + badge = + if (score > 0) { + { + WoTBadge( + count = score, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } + } else { + null + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt new file mode 100644 index 0000000000..2767d08630 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/IndexRelaysEditor.kt @@ -0,0 +1,222 @@ +/* + * 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.desktop.ui.relay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.relays.index.PreferencesIndexRelays +import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Editor for the shared "index relays" — the set used by + * `FeedMetadataCoordinator` (Desktop) and `amy wot sync` (CLI) to fetch + * profile metadata (kind 0) and follow lists (kind 3). + * + * Matches the buffered-Save UX of the sibling relay editors + * (Search / DM / Blocked). Add/Remove mutate a local buffer; Save + * commits the buffer to `PreferencesIndexRelays`. Reset restores the + * built-in defaults into the buffer (still requires Save to persist). + * + * The running Desktop coordinator continues using its constructor-time + * snapshot until the app is relaunched, so persisted changes take effect + * on next launch. + */ +@Composable +fun IndexRelaysEditor( + categories: DesktopRelayCategories, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val persisted by categories.indexRelays.collectAsState() + val localRelays = remember { mutableStateListOf() } + var newRelayUrl by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var savedMessage by remember { mutableStateOf(null) } + + LaunchedEffect(persisted) { + localRelays.clear() + localRelays.addAll(persisted.sortedBy { it.url }) + } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + "Relays queried for profile metadata and follow lists (Web-of-Trust). Changes take effect on next relaunch. Shared with the `amy` CLI.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = newRelayUrl, + onValueChange = { + newRelayUrl = it + error = null + }, + label = { Text("wss://relay.example.com") }, + singleLine = true, + isError = error != null, + supportingText = error?.let { { Text(it) } }, + modifier = + Modifier + .weight(1f) + .onPreviewKeyEvent { event -> + if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) { + error = tryAddSimpleRelay(newRelayUrl, localRelays) + if (error == null) newRelayUrl = "" + true + } else { + false + } + }, + ) + + Spacer(Modifier.width(8.dp)) + + IconButton( + onClick = { + error = tryAddSimpleRelay(newRelayUrl, localRelays) + if (error == null) newRelayUrl = "" + }, + ) { + Icon(MaterialSymbols.Add, contentDescription = "Add relay") + } + } + + if (localRelays.isNotEmpty()) { + Text( + "${localRelays.size} relay(s) configured", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + localRelays.toList().forEach { url -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + url.displayUrl(), + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { localRelays.remove(url) }, modifier = Modifier.size(28.dp)) { + Icon( + MaterialSymbols.Close, + contentDescription = "Remove", + modifier = Modifier.size(16.dp), + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { + if (newRelayUrl.isNotBlank()) { + val addError = tryAddSimpleRelay(newRelayUrl, localRelays) + if (addError != null) { + error = addError + return@Button + } + newRelayUrl = "" + } + if (localRelays.isEmpty()) { + error = "Add at least one relay before saving (or Reset to defaults)" + return@Button + } + categories.setIndexRelays(localRelays.toSet()) + scope.launch { + savedMessage = "Saved ${localRelays.size} relay(s) — restart to apply" + delay(3000) + savedMessage = null + } + }, + ) { + Text("Save") + } + + OutlinedButton( + onClick = { + localRelays.clear() + localRelays.addAll( + PreferencesIndexRelays.DEFAULT_INDEX_RELAYS.sortedBy { it.url }, + ) + error = null + }, + ) { + Text("Reset to defaults") + } + + savedMessage?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt index 82448c2c05..7865f991be 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt @@ -162,6 +162,18 @@ fun RelayConfigTab( }, ) } + + Spacer(Modifier.height(16.dp)) + + // 6. Index Relays — app-global (not per-account); shared with `amy wot sync`. + CollapsibleSection( + title = "Index Relays", + description = "Where the Web-of-Trust and profile lookups fetch kind 0/3 events", + ) { + IndexRelaysEditor( + categories = LocalRelayCategories.current, + ) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt index 4b35e4d95c..68bd2e8fbc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt @@ -55,12 +55,16 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.SearchSortOrder import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady +import com.vitorpamplona.amethyst.commons.wot.LocalWoTService import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.ui.note.NoteCard import com.vitorpamplona.amethyst.desktop.ui.note.SpamCheckedNoteRender +import com.vitorpamplona.amethyst.desktop.ui.note.WoTBadge import com.vitorpamplona.amethyst.desktop.ui.rememberDisplayData import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -116,6 +120,7 @@ fun SearchResultsList( UserSearchCard( user = user, onClick = { onNavigateToProfile(user.pubkeyHex) }, + badge = wotBadgeFor(user.pubkeyHex), ) } if (people.size > 5) { @@ -126,6 +131,7 @@ fun SearchResultsList( UserSearchCard( user = user, onClick = { onNavigateToProfile(user.pubkeyHex) }, + badge = wotBadgeFor(user.pubkeyHex), ) } } @@ -286,6 +292,34 @@ fun SearchResultsList( } } +/** + * Returns a WoT-badge lambda for the given pubkey, or null when the + * badge should be hidden. Same gates as [WoTBadgedAvatar]: + * - WoT service is provided + * - initial batch fetch has finished (or 2 s startup timeout fired) + * - the pubkey is not exempt (self or already followed) + * - the score is > 0 + * Inlined here (rather than wrapped in a new composable) because it's + * only used at the two SearchResultsList person-result call sites. + */ +@Composable +private fun wotBadgeFor(userHex: String): (@Composable androidx.compose.foundation.layout.BoxScope.() -> Unit)? { + val service = LocalWoTService.current + val ready = LocalWoTReady.current + val exempt = LocalSpamExemptKeys.current + val score = + if (service != null && ready && userHex !in exempt) { + service.scores[userHex] ?: 0 + } else { + 0 + } + return if (score > 0) { + { WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) } + } else { + null + } +} + @Composable private fun SortableHeader( title: String, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt index 113c90debb..3cf49de2a3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt @@ -102,7 +102,7 @@ private fun LockToggleCard( var showRemovePassword by remember { mutableStateOf(false) } var pendingEnable by remember { mutableStateOf(false) } - SettingsCard(title = "Lock the Messages tab") { + SettingsCard(title = "Enable privacy lock") { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -110,8 +110,8 @@ private fun LockToggleCard( ) { Text( text = - "Require a password before the Messages column shows. " + - "The rest of the app stays open.", + "Require your password before the Messages and Wallet columns show. " + + "Feed, profile, and search stay open.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -195,7 +195,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) { verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Re-lock Messages after this much inactivity.", + text = "Re-lock Messages and Wallet after this much inactivity.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -220,7 +220,7 @@ private fun InactivityCard(settings: PrivacyLockSettings) { @Composable private fun RedactionCard(settings: PrivacyLockSettings) { val enabled by settings.lockEnabled.collectAsState() - val level by settings.redactionLevel.collectAsState() + val level by settings.dmRedactionLevel.collectAsState() if (!enabled) return SettingsCard(title = "DM notification preview") { @@ -232,8 +232,8 @@ private fun RedactionCard(settings: PrivacyLockSettings) { ) { Text( text = - "When lock is on, DM notifications hide sender + message. " + - "Change to Full to show them.", + "When the lock is on, DM notifications hide sender + message. " + + "Change to Full to show them. Wallet has no notifications yet.", style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) @@ -245,7 +245,7 @@ private fun RedactionCard(settings: PrivacyLockSettings) { DropdownMenuItem( text = { Text(entry.label()) }, onClick = { - settings.setRedactionLevel(entry) + settings.setDmRedactionLevel(entry) expanded = false }, ) @@ -276,7 +276,8 @@ private fun LimitationsCard() { ) Text( text = - "This lock hides the Messages column on an unattended device. " + + "This lock hides the Messages and Wallet columns on an unattended device. " + + "Wallet balance and invoice text blur when the window loses focus. " + "It does NOT protect against: filesystem access, memory dumps, " + "attached debuggers, or screen-recording apps you've granted access. " + "Your Nostr private key is still stored as it is today.", diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt index eb2c16e705..3027d35da4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt @@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler +import com.vitorpamplona.amethyst.desktop.security.privacyLockBlurWhenUnfocused import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback import com.vitorpamplona.amethyst.desktop.ui.auth.QrCodeCanvas import com.vitorpamplona.quartz.lightning.LnInvoiceUtil @@ -134,107 +135,112 @@ fun WalletColumnScreen( } } - Box(modifier = Modifier.fillMaxSize()) { - if (nwcConnection == null) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - NoWalletContent(onConnect = { showConnectDialog = true }) - } - } else { - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = Modifier.widthIn(max = 360.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + Column(modifier = Modifier.fillMaxSize()) { + com.vitorpamplona.amethyst.desktop.security.WalletFirstRunBanner( + onSaved = { message -> scope.launch { snackbarHostState.showSnackbar(message) } }, + ) + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + if (nwcConnection == null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, ) { - WalletBalanceCard( - balanceSats = balanceSats, - isLoading = isLoadingBalance, - onRefresh = { - isLoadingBalance = true - scope.launch { - when (val result = paymentHandler.getBalance(nwcConnection)) { - is NwcPaymentHandler.BalanceResult.Success -> { - balanceSats = result.balanceMsats / 1000 - } - - is NwcPaymentHandler.BalanceResult.Error -> { - snackbarHostState.showSnackbar("Balance error: ${result.message}") - } - - is NwcPaymentHandler.BalanceResult.Timeout -> { - snackbarHostState.showSnackbar("Balance request timed out") - } - } - isLoadingBalance = false - } - }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + NoWalletContent(onConnect = { showConnectDialog = true }) + } + } else { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Button( - onClick = { showSendDialog = true }, - modifier = Modifier.weight(1f), + WalletBalanceCard( + balanceSats = balanceSats, + isLoading = isLoadingBalance, + onRefresh = { + isLoadingBalance = true + scope.launch { + when (val result = paymentHandler.getBalance(nwcConnection)) { + is NwcPaymentHandler.BalanceResult.Success -> { + balanceSats = result.balanceMsats / 1000 + } + + is NwcPaymentHandler.BalanceResult.Error -> { + snackbarHostState.showSnackbar("Balance error: ${result.message}") + } + + is NwcPaymentHandler.BalanceResult.Timeout -> { + snackbarHostState.showSnackbar("Balance request timed out") + } + } + isLoadingBalance = false + } + }, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Send") + Button( + onClick = { showSendDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowUpward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Send") + } + OutlinedButton( + onClick = { showReceiveDialog = true }, + modifier = Modifier.weight(1f), + ) { + Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Receive") + } } - OutlinedButton( - onClick = { showReceiveDialog = true }, - modifier = Modifier.weight(1f), - ) { - Icon(symbol = MaterialSymbols.ArrowDownward, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Receive") + + HorizontalDivider() + + Text( + text = "Connected Wallet", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Relay: ${nwcConnection.relayUri}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + TextButton(onClick = { + appScope.launch { + accountManager.clearNwcConnection(account.npub) + balanceSats = null + } + }) { + Text("Disconnect", color = MaterialTheme.colorScheme.error) } } - - HorizontalDivider() - - Text( - text = "Connected Wallet", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Relay: ${nwcConnection.relayUri}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "Wallet: ${nwcConnection.pubKeyHex.take(8)}...${nwcConnection.pubKeyHex.takeLast(8)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - TextButton(onClick = { - appScope.launch { - accountManager.clearNwcConnection(account.npub) - balanceSats = null - } - }) { - Text("Disconnect", color = MaterialTheme.colorScheme.error) - } } } - } - SnackbarHost( - hostState = snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter), - ) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } } // -- Dialogs -- @@ -369,6 +375,7 @@ private fun WalletBalanceCard( style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.privacyLockBlurWhenUnfocused(), ) } else { Text( @@ -557,11 +564,7 @@ private fun SendDialog( kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { httpClient.newCall(request).execute() } - val body = response.body?.string() - if (body == null) { - sendState = SendState.Error("Failed to reach payment server", SendState.Idle) - return@LaunchedEffect - } + val body = response.body.string() val json = mapper.readTree(body) val callback = json.get("callback")?.asText()?.ifBlank { null } if (callback == null) { @@ -604,11 +607,7 @@ private fun SendDialog( kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { httpClient.newCall(request).execute() } - val body = response.body?.string() - if (body == null) { - sendState = SendState.Error("Failed to fetch invoice", SendState.Idle) - return@LaunchedEffect - } + val body = response.body.string() val json = mapper.readTree(body) val pr = json.get("pr")?.asText()?.ifBlank { null } if (pr != null) { @@ -875,7 +874,10 @@ private fun ReceiveDialog( "${formatSats(amount.toLongOrNull() ?: 0)} sats", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .privacyLockBlurWhenUnfocused(), ) if (description.isNotBlank()) { Spacer(Modifier.height(4.dp)) @@ -889,10 +891,13 @@ private fun ReceiveDialog( Spacer(Modifier.height(16.dp)) - // QR code + // QR code — sensitive, blur when window unfocused QrCodeCanvas( data = generatedInvoice!!, - modifier = Modifier.align(Alignment.CenterHorizontally), + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .privacyLockBlurWhenUnfocused(), size = 240.dp, ) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index aefb0bb1ee..a43b3758e6 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -152,7 +152,7 @@ class CoordinatorPipelineTest { fun `consumeEvent routes text note into cache and triggers ViewModel update`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) @@ -191,7 +191,7 @@ class CoordinatorPipelineTest { fun `consumeEvent updates lastEventAt timestamp`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) assertTrue(coordinator.lastEventAt.value == null, "lastEventAt should be null initially") @@ -217,7 +217,7 @@ class CoordinatorPipelineTest { fun `contact list consumed via coordinator updates followedUsers`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) val contactEvent = @@ -244,7 +244,7 @@ class CoordinatorPipelineTest { fun `following feed shows notes after contact list and text notes arrive via coordinator`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) // Step 1: Contact list arrives @@ -294,7 +294,7 @@ class CoordinatorPipelineTest { fun `following feed remains empty when no contact list has been consumed`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) // No contact list consumed — followedUsers is empty @@ -334,7 +334,7 @@ class CoordinatorPipelineTest { fun `duplicate events are not double-counted in feed`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, _) = createCoordinator(cache, scope) val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) @@ -372,7 +372,7 @@ class CoordinatorPipelineTest { fun `requestInteractions opens subscription on client`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, client) = createCoordinator(cache, scope) val noteIds = listOf("n1".padEnd(64, '0')) @@ -393,7 +393,7 @@ class CoordinatorPipelineTest { fun `requestInteractions with empty noteIds returns without opening subscription`() = runBlocking { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val (coordinator, client) = createCoordinator(cache, scope) coordinator.requestInteractions(emptyList(), setOf(relayUrl)) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 457a9a1fc1..5760661c5b 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -124,7 +124,7 @@ class DesktopCachePipelineTest { @Test fun `consume text note creates Note in cache`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = textNote("note1".padEnd(64, '0'), userPubKey) val consumed = cache.consume(event, relayUrl, wasVerified = true) @@ -137,7 +137,7 @@ class DesktopCachePipelineTest { @Test fun `consume same note twice returns false`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = textNote("note1".padEnd(64, '0'), userPubKey) cache.consume(event, relayUrl, wasVerified = true) @@ -148,7 +148,7 @@ class DesktopCachePipelineTest { @Test fun `consume contact list updates followedUsers`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey)) cache.consume(event, relayUrl, wasVerified = true) @@ -158,7 +158,7 @@ class DesktopCachePipelineTest { @Test fun `newer contact list replaces older`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) val newer = contactList( @@ -176,7 +176,7 @@ class DesktopCachePipelineTest { @Test fun `older contact list is rejected`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200) val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) @@ -192,7 +192,7 @@ class DesktopCachePipelineTest { @Test fun `consume reaction links to target note`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val noteId = "note1".padEnd(64, '0') val note = textNote(noteId, userPubKey) val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId) @@ -204,6 +204,69 @@ class DesktopCachePipelineTest { assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event") } + // ----------------------------------------------------------------------- + // 1b. accountPubkey race regression (PR #3483 review finding 1) + // + // Reproduces the "hydration before pubkey bind" data-loss race: if a + // self kind-3 arrives while accountPubkey is null (e.g. from disk during + // login), the cache used to stamp lastContactListByAuthor without + // populating _followedUsers. Then the same event arriving from a relay + // AFTER pubkey binding was rejected by the createdAt gate, leaving the + // follow set empty. FollowAction.follow then called createFromScratch + // and wiped the real follow list. Fix: skip the stamp when + // accountPubkey is null so the later relay retry can populate cleanly. + // ----------------------------------------------------------------------- + + @Test + fun `self kind-3 hydrated before pubkey bind does not poison later relay retry`() { + val cache = DesktopLocalCache() // accountPubkey deliberately unset + val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100) + + // Phase A — hydration path: consume with accountPubkey unbound. + cache.consume(event, relayUrl, wasVerified = true) + assertEquals( + emptySet(), + cache.followedUsers.value, + "Follow set stays empty until accountPubkey is bound", + ) + + // Phase B — Main.kt binds accountPubkey. + cache.accountPubkey = userPubKey + + // Phase C — relay replay of the SAME event. Must NOT be rejected by + // the createdAt gate; must populate _followedUsers. + cache.consume(event, relayUrl, wasVerified = true) + assertEquals( + setOf(followedPubKey), + cache.followedUsers.value, + "Later relay retry of same self kind-3 must populate follow set", + ) + } + + @Test + fun `non-self kind-3 hydrated before pubkey bind still stamps and does not touch followedUsers`() { + val cache = DesktopLocalCache() + val other = contactList("cl2".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey), createdAt = 100) + + cache.consume(other, relayUrl, wasVerified = true) + cache.accountPubkey = userPubKey + + // followedUsers is for the active user only; a non-self kind-3 + // should never touch it. followedUsers must stay empty. + assertEquals(emptySet(), cache.followedUsers.value) + + // And the newer version of the same non-self kind-3 must still be + // accepted (stamping happened in the known-not-self branch would + // reject; here we skipped stamping when pubkey was null so a + // newer replay lands cleanly). + val newer = contactList("cl2b".padEnd(64, '0'), followedPubKey, listOf(unfollowedPubKey, userPubKey), createdAt = 200) + cache.consume(newer, relayUrl, wasVerified = true) + // No direct assertion on internal state; the fact that this + // returns without throwing + does not affect followedUsers is + // the invariant. The next line documents intent. + assertEquals(emptySet(), cache.followedUsers.value) + } + // ----------------------------------------------------------------------- // 2. Event stream emission // ----------------------------------------------------------------------- @@ -211,7 +274,7 @@ class DesktopCachePipelineTest { @Test fun `consume emits to eventStream`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val collected = mutableListOf>() val job = @@ -240,7 +303,7 @@ class DesktopCachePipelineTest { @Test fun `GlobalFeedFilter includes all text notes`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val filter = DesktopGlobalFeedFilter(cache) // Add notes from different authors @@ -254,7 +317,7 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter only includes notes from followed users`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true) @@ -269,7 +332,7 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter returns empty when no follows`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true) val filter = DesktopFollowingFeedFilter(cache) { emptySet() } @@ -280,7 +343,7 @@ class DesktopCachePipelineTest { @Test fun `ProfileFeedFilter only shows notes from target pubkey`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true) cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true) @@ -293,7 +356,7 @@ class DesktopCachePipelineTest { @Test fun `ThreadFilter returns root and replies`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val rootId = "root".padEnd(64, '0') val replyId = "reply".padEnd(64, '0') @@ -308,7 +371,7 @@ class DesktopCachePipelineTest { @Test fun `NotificationFeedFilter shows events tagging user`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val noteId = "note1".padEnd(64, '0') cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true) @@ -333,7 +396,7 @@ class DesktopCachePipelineTest { @Test fun `ViewModel starts in Loading then transitions to Loaded after refresh`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true) val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) @@ -352,7 +415,7 @@ class DesktopCachePipelineTest { @Test fun `ViewModel shows Empty when cache has no matching notes`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) waitForBundler() @@ -365,7 +428,7 @@ class DesktopCachePipelineTest { @Test fun `ViewModel updates when new notes arrive via eventStream`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache) waitForBundler() @@ -388,7 +451,7 @@ class DesktopCachePipelineTest { @Test fun `Following ViewModel only shows followed users notes via eventStream`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } @@ -418,7 +481,7 @@ class DesktopCachePipelineTest { @Test fun `Following ViewModel feed is empty when followedUsers is empty`() = runBlocking { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } // No contact list consumed — followedUsers remains empty val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) @@ -441,7 +504,7 @@ class DesktopCachePipelineTest { @Test fun `clear resets all cache state`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true) cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) @@ -458,7 +521,7 @@ class DesktopCachePipelineTest { @Test fun `global feed is sorted newest first`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true) cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true) cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true) @@ -476,7 +539,7 @@ class DesktopCachePipelineTest { @Test fun `consumeMetadata updates user info`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val metadata = com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( id = "meta1".padEnd(64, '0'), @@ -501,7 +564,7 @@ class DesktopCachePipelineTest { @Test fun `GlobalFeedFilter applyFilter only accepts TextNoteEvents`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val filter = DesktopGlobalFeedFilter(cache) // Create a text note @@ -522,7 +585,7 @@ class DesktopCachePipelineTest { @Test fun `FollowingFeedFilter applyFilter respects follow set`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } @@ -547,7 +610,7 @@ class DesktopCachePipelineTest { @Test fun `profile follower count is cached and survives clear of note cache`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } assertEquals(0, cache.getCachedFollowerCount(userPubKey)) @@ -561,7 +624,7 @@ class DesktopCachePipelineTest { @Test fun `profile following count is cached`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.cacheFollowingCount(userPubKey, 150) assertEquals(150, cache.getCachedFollowingCount(userPubKey)) @@ -569,7 +632,7 @@ class DesktopCachePipelineTest { @Test fun `clear resets profile count caches`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } cache.cacheFollowerCount(userPubKey, 42) cache.cacheFollowingCount(userPubKey, 150) @@ -581,7 +644,7 @@ class DesktopCachePipelineTest { @Test fun `metadata is available from cache after consumption`() { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = userPubKey } val metadata = com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent( id = "meta1".padEnd(64, '0'), diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt index 3bd92f908a..f039e7deb7 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/relay/LocalRelayStoreHydrationTest.kt @@ -140,7 +140,7 @@ class LocalRelayStoreHydrationTest { @Test fun hydratingAnEmptyDatabaseSucceedsAndLeavesCacheEmpty() = runTest { - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() try { store.hydrate(cache) @@ -165,7 +165,7 @@ class LocalRelayStoreHydrationTest { // empty when phase 2 ran and the metadata would never load. seedDatabase(listOf(followeeMetadata, contactList)) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() // Pin a strong reference to the followee's User for the duration of the @@ -206,7 +206,7 @@ class LocalRelayStoreHydrationTest { val recentNote = makeTextNote(author, "recent", createdAt = nowSeconds() - 3600) seedDatabase(listOf(recentNote)) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() try { store.hydrate(cache) @@ -226,7 +226,7 @@ class LocalRelayStoreHydrationTest { val oldNote = makeTextNote(author, "stale", createdAt = eightDaysAgo) seedDatabase(listOf(oldNote)) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() try { store.hydrate(cache) @@ -256,7 +256,7 @@ class LocalRelayStoreHydrationTest { val note = makeTextNote(author, "round-trip") seedDatabase(listOf(note)) - val cache = DesktopLocalCache() + val cache = DesktopLocalCache().apply { accountPubkey = ownerPubKey } val store = newStore() try { store.hydrate(cache) diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index fb54ca3c7c..9a2b0643a3 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -140,6 +140,12 @@ "German" ] }, + { + "user": "StellarStoic", + "languages": [ + "Slovenian" + ] + }, { "user": "anthony-robin", "languages": [ @@ -158,12 +164,6 @@ "Chinese Simplified" ] }, - { - "user": "StellarStoic", - "languages": [ - "Slovenian" - ] - }, { "user": "BitByBit21", "languages": [ diff --git a/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md b/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md new file mode 100644 index 0000000000..3046cbb196 --- /dev/null +++ b/docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md @@ -0,0 +1,703 @@ +--- +title: Desktop DM Reliability +type: feat +status: active +date: 2026-06-10 +origin: docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md +--- + +# ✨ Desktop DM Reliability + +## Overview + +Two-track program to close the reliability gap between Amethyst Desktop's NIP-17 DMs and the reference clients **wisp.mobile** (Kotlin/Compose, github.com/barrydeen/wisp) and **nospeak.chat** (SvelteKit, github.com/psic4t/nospeak): + +1. **Track A — Reliability plumbing.** Port the publish-path, AUTH, subscription, and discovery patterns that make wisp/nospeak feel reliable. Most are small surgical fixes; together they close the "messages silently disappear" failure modes. +2. **Track B — Bunker speed.** Spec + implement a NIP-46 `get_conversation_keys` batch RPC so bunker users decrypt N gift wraps in 1 round-trip instead of N — Vitor's stated direction, replacing the rejected NIP-4E path. + +Carried forward from brainstorm: +- Explicitly out of scope: NIP-4E adoption, NIP-29 group chats, NIP-04 cleanup, new DM UX features (typing/read receipts, attachments redesign) +- NIP-04 stays visible with `legacy` badge (brainstorm Q1) +- Bunker SEND latency shown as live progress (brainstorm Q3) — see Deepening §6 +- Tier-2 AUTH consent = inline chat-column banner `[Once] [Always] [Never]` (brainstorm Q5) +- Self-copy wrap → remote DM relays only, not local relay (brainstorm Q7) +- Desktop-first; Android inherits `commons/` changes (brainstorm Q8) + +## Deepening Synthesis (2026-06-10) + +Eleven parallel review passes (skills + reviewers) revealed substantial corrections. **Six P0 security blockers, ~30% scope compression, plus architectural fixes.** Apply BEFORE `/ce:work`. + +### Desktop-only scope (2026-06-10 amendment) + +**This plan ships desktop-only.** Android may incidentally benefit from `commons/` and `quartz/` changes (it shares those modules), but no Android-specific code changes, no Android UI work, no Android-side audits, no Android tests in acceptance. If a `commons/` change has Android-visible behavior change, that's a side effect — not a goal — and we don't gate this plan on Android validation. + +**Removed from scope:** +- ~~Android `AccountGiftWrapsEoseManager.kt:55-61` `since` fix~~ — defer to Android pass +- ~~Android `Account.kt:1156-1167` security-fix audit~~ — same Android pass +- "Android inherits" framing in acceptance criteria +- Cross-platform `User.dmInboxRelays()` audit beyond desktop callers (still touch the commons helper; just don't validate Android consumers) +- Splitting `RetryQueueCoordinator` into commons-interface + desktop-impl — desktop-only, single file in `desktopApp/` +- Splitting `AccountAuthApprovals` for Android inheritance — keep desktop-side if simpler; if natural to put in commons it stays there but no Android UI ships + +### Phase restructure (simplicity + scope) +- **R1 collapses to verification + KDoc.** Desktop already passes no `since` (`DesktopRelaySubscriptionsCoordinator.kt:345`). Add a regression test confirming wraps with `created_at = now - 1.5d` arrive; drop the `since` parameter from `FilterDMs.giftWrapsToMe` to lock the invariant. No longer a phase — one item under Phase 2. +- **Cut R6 (proactive window-focus re-AUTH)** as a separate coordinator. Replace with: use Compose-native `LocalWindowInfo.isWindowFocused` + `snapshotFlow`; let AUTH heal *lazily* on next `auth-required:` via the existing `RelayAuthenticator.checkAuthResults → syncFilters` path. **The plan's "force AUTH via benign kind:0 sub" trick is wrong** — most relays only AUTH-challenge on restricted REQs. +- **R10 (self-copy)**: already half-implemented via `BaseDMGroupEvent.groupMembers() = recipients.plus(pubKey)`. On desktop, port the pre-consume + alias-note pattern (Android has it; we replicate the *technique* in `DesktopIAccount`, not import the Android code) + route self-wrap to `account.dmInboxRelays()`, not `connectedRelays`. +- **R11 (relay hint on p-tag)**: a one-line change in `GiftWrapEvent.create:117-122`; keep but no separate sub-phase. +- **R12**: drop as standalone scope item — compress to a single regression test under Phase 5. +- **Phase 6 decouple**: spec PR + bunker batch RPC has external coordination dependencies (nsec.app/Amber/Keychat). Spin into its own plan file; Phases 1–5 ship independently. +- **Manual relay-entry dialog → simple error message** (Phase 4): replace `DmInboxRelayMissingDialog` UI with a Snackbar "Can't find DM relays for ``. They need to publish their NIP-17 inbox first." Only re-introduce manual entry if security validation requirements (F-02) are met. + +### Cross-cutting corrections + +**P0 security blockers (must fix before merge):** + +| ID | Issue | Fix | +|---|---|---| +| F-01 | Indexer fan-out uses authenticated client → identity-key leak to `purplepag.es` etc. | Open a dedicated `NostrClient` with `RelayAuthenticator` NOT attached; use it for all `RecipientRelayFetcher` calls. Add unit test: indexer sends AUTH → client sends NO AUTH event. | +| F-02 | Manual relay-entry has no URL validation | If we ship manual entry at all: hard-reject non-`wss://`; Levenshtein-1 typosquat warning vs curated set; "this DM will be visible to this relay operator" confirmation interstitial. **Default: drop the dialog entirely** per simplicity reviewer; use a Snackbar error. | +| F-03 | Current `RelayAuthenticator.authenticate()` (`quartz/.../auth/RelayAuthenticator.kt:81-94`) auto-signs **every** challenge with no rate limit + no tier check + across **all** logged-in accounts (multi-account linkage leak) | `shouldAutoAuth` must **REPLACE** the unconditional path, not be added in front. Per-account scoping. Rate-limit: max 1 AUTH/relay/60s, max M AUTHs/min account-wide. Default = do NOT sign unless tier-1. | +| F-04 | Retry queue stores plaintext recipient pubkey + relay URL + timestamp + last_error → social graph leak via disk forensics | Account-delete purges `retry_queue WHERE account_pubkey = ?`; 24h hard TTL on `created_at`; verify directory perms 0700; or store under `DesktopAccountStorage` AES-GCM wrapper. | +| F-07 | Plan says relay hint on "seal's p tag" — seal has NO p tag. NIP-17 spec puts hint on wrap (`["p", recipientPubkey, relay-url]`, GiftWrapEvent kind:1059). | Update plan to "wrap's p tag per NIP-17 spec"; regression test asserting it's there. (Security agent argued for rumor; but the rumor is encrypted, so other devices can't read the hint until AFTER decrypt — defeats its purpose. Spec is correct.) | +| F-10 | NIP-46 batch RPC response untrusted | Spec PR mandates: `result.length == request.pubkeys.length`, positions match, MAC self-test on first decrypt, bunker echoes `request.id`. Client validates on every call. | + +**Architecture corrections (move/rename, no behavior change):** + +| Subject | Plan says | Correct | +|---|---|---| +| Indexer-relay set | `commons/.../relayClient/dm/` | `commons/defaults/` | +| `relayClient/dm/` package | `dm/` | `nip17Dm/` (match siblings) | +| `AccountAuthApprovals` ViewModel | `commons/.../viewmodels/` | `commons/.../relayClient/auth/` (colocated with feature) | +| `ConversationKeyCache` | `commons/.../service/cache/` (path doesn't exist) | `quartz/.../nip46RemoteSigner/cache/` | +| `shouldAutoAuth` tier classifier | Quartz `RelayAuthenticator` | `commons/.../relayClient/auth/AuthApprovalPolicy.kt`; Quartz takes a `Set` of pre-approved relays via existing `signWithAllLoggedInUsers` lambda seam | +| `RetryQueueCoordinator` | `desktopApp/...` only | Split: `commons/.../service/RetryQueueCoordinator` (interface + no-op default for Android) + `desktopApp/.../SqliteRetryQueueCoordinator` (impl) | +| AUTH state shape | `MutableStateFlow>` (status is a mutable holder — won't emit on inner change) | `MutableStateFlow>` (immutable snapshot, identity changes on update) | +| `authCompleted` event | New `SharedFlow`/`Channel` | Derive from `authStatusFlow.scan` transitions; no new primitive needed | +| Path key | `pubkey8` (8-hex prefix, collision risk) | Full 64-hex pubkey; one-time rename migration | +| SQLite tables location | `LocalRelayStore.kt` events.db | **Sibling `outbox.db`** with `PRAGMA synchronous = NORMAL` (events.db has `synchronous = OFF` — unsafe for "durable send" semantics) | + +**Data-integrity schema rewrite** (apply to Phase 3 + Phase 2): + +```sql +-- ~/.amethyst/accounts//outbox.db (sibling to events.db) +-- synchronous = NORMAL; journal_mode = WAL; foreign_keys = OFF + +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +-- bind file to account: INSERT meta('account_pubkey', '') + +CREATE TABLE auth_approvals ( + account_pubkey TEXT NOT NULL, + relay_url TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('always','blocked')), + granted_at INTEGER NOT NULL, + expires_at INTEGER, + PRIMARY KEY (account_pubkey, relay_url), + CHECK (length(account_pubkey) = 64), + CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%') +) WITHOUT ROWID; +CREATE INDEX auth_approvals_expiry ON auth_approvals(expires_at) WHERE expires_at IS NOT NULL; + +CREATE TABLE retry_queue ( + account_pubkey TEXT NOT NULL, + gift_wrap_id TEXT NOT NULL, + relay_url TEXT NOT NULL, + rumor_id TEXT NOT NULL, + event_json TEXT NOT NULL, + attempt INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 12, -- raised from 8; backoff seq 1,2,4,8,16,32,64,128,256,512,600,600s + next_attempt_at INTEGER NOT NULL, + last_error TEXT, + created_at INTEGER NOT NULL, + PRIMARY KEY (account_pubkey, gift_wrap_id, relay_url), + CHECK (length(account_pubkey) = 64), + CHECK (length(gift_wrap_id) = 64), + CHECK (length(rumor_id) = 64), + CHECK (attempt >= 0 AND attempt <= max_attempts), + CHECK (max_attempts > 0 AND max_attempts <= 32), + CHECK (length(event_json) < 200000), + CHECK (relay_url LIKE 'wss://%' OR relay_url LIKE 'ws://%') +) WITHOUT ROWID; +CREATE INDEX retry_queue_due ON retry_queue(account_pubkey, next_attempt_at) WHERE attempt < max_attempts; +``` + +`LocalRelayMaintenance.kt` must purge expired AUTH approvals + dead-letter retry_queue rows older than 30d. + +**Performance corrections (apply throughout):** + +- **Per-rumor `StateFlow`, not global map** (Phase 3). Replace `MutableStateFlow>` with `LargeCache>` — each bubble subscribes to its own flow; 50 visible bubbles × global map churn = ~75k unnecessary recompositions otherwise. Non-optional. +- **Delete-bundler or periodic sweep for retry_queue** (Phase 3). Per-OK DELETEs fsync individually → 100 OKs = 100 transactions ≈ 5s of IO. Either add a 250ms bundler or have the coordinator sweep `attempt=0 AND created_at > 60s` every 30s. +- **Bunker concurrency cap** (until Phase 6 ships). Wrap `NIP17Factory.createWraps`' `mapNotNullAsync` in a `Semaphore(4)` when `signer is NostrSignerRemote`. Today: 5 recipients × 2 calls = 10 concurrent bunker round-trips saturate the bunker socket. +- **Indexer fan-out: first-result + pre-warm + persistent cache** (Phase 4). 8s `fetchAll` timeout → first-message latency 1-3s. Short-circuit on first non-empty result after 2s; pre-warm on conversation-list render; persist LRU cache across restart. +- **Retry-queue triggers reactive, not 1s polling** (Phase 3). `Channel(CONFLATED)` + `select { wake.onReceive(); onTimeout(nextDue) }`. Triggered by enqueue, authCompleted, network reconnect. +- **`withTimeout(15s)` on `client.publish`** inside retry coordinator — wedged-socket protection. + +**NIP-17 protocol corrections:** + +- `User.dmInboxRelays()` at `commons/.../model/User.kt:115` **silently falls back to `inboxRelays()` (NIP-65 read marker)** when kind:10050 missing. This is the FIRST silent leak layer (before `DesktopIAccount.connectedRelays` fallback). Cross-platform bug. Fix: add `dmInboxRelaysStrict()` returning null on missing; audit all 4 callers. **Android `Account.kt:1156-1167` has the same fallback bug** — security fix applies cross-platform, NOT desktop-only. +- `RecipientRelayFetcher.fetchRelayLists` returns kind:10050 + 10051 + 10002. `DmInboxRelayResolver` must use **`lists.dmInbox` only**, NOT `dmInboxOrFallback` (which falls through to NIP-65 read). +- Shared `rumor.created_at` applies to rumor (kind 14) ONLY; seal (13) and wrap (1059) `created_at` MUST stay independently randomized per NIP-17 §"randomized up to 2 days back." +- Drop `purplepag.es` from indexer set (not authoritative for kind:10050); add `purplerelay.com`. Curated set: `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`. +- Multi-indexer agreement: only trust a kind:10050 if ≥2 indexers return the same `event.id`. One compromised indexer can otherwise mass-redirect DMs. + +**NIP-46 batch RPC corrections (Phase 6):** + +- Method name: **`nip44_get_conversation_keys`** (consistent with `nip44_encrypt`/`nip44_decrypt`), not `get_conversation_keys`. +- Request params: variadic `[pk1, pk2, ..., pkN]` (matches existing `nip44_encrypt` shape), NOT one stringified JSON blob. +- Response: `result = JSON.stringify(["base64key1", ...])` (NIP-46 mandates single-string result). Errors = all-or-nothing; client falls back to per-call. +- **Drop `result.capabilities` mechanism.** Use optimistic probe + per-bunker-pubkey negative-cache for the session. Adding capabilities expands the spec PR surface. +- **2 sequential round-trips** (wrap layer keys → peel wraps → seal layer keys), not 1 parallel. Acceptance criterion: `≤4` round-trips for 200 wraps (100-pubkey spec cap). +- Two-tier cache: NO cache for ephemeral wrap pubkeys (single-use), LRU 1000 for sender-identity seal keys. Cache key = `(selfPubkey, peerPubkey)`. Wipe on logout AND account-switch. +- Bunker validation: assert `result.length == params.length`, position binding, MAC self-test on first decrypt. + +### Open questions resolved during deepening + +- Tier-3 silent drop → **dropped from design**. Only 2 tiers: auto / prompt (user picks `[Once|Always|Never]`). +- Indexer-relay Settings UI → **dropped**. Hardcoded curated 5; override via system property `-Damethyst.dmIndexers=...` if needed. +- Bunker progress "N of M" → **simplified to spinner + "Encrypting…"**. Counter requires extending `SigningOpState` with `current,total` fields; net UX win is small. +- `account_pubkey` in SQLite schema → **kept** but constraint-bound to `meta('account_pubkey')` for defense-in-depth. +- Conversation-key cache TTL → **session-only**, wipe on logout/switch, no disk. +- Self-copy fallback → **own DM relays only**; no NIP-65 fallback (avoids the same leak class). + +## Problem Statement + +The Amethyst lead's prompt cited cross-client NIP-17 working reliably between nospeak.chat ↔ wisp.mobile and asked whether NIP-4E was the missing piece for bunker users. Research showed three things: + +1. **Neither nospeak nor wisp uses bunker, and neither implements NIP-4E.** Their reliability comes from publish-path semantics + AUTH retry + idempotency. +2. **Vitor (Amethyst maintainer) NACKs both NIP-4E PRs** (#1647, #2361) on five technical grounds — trial-decryption pathology, custody downgrade, no rotation story, nsec loses recovery, fragmentation across own devices. His counter is the NIP-46 batch RPC. +3. **Amethyst Desktop's DM path has concrete reliability gaps** the survey identified: + - AUTH-walled subscriptions die silently when the 3-try cap in `PoolEventOutboxState.Tries.isDone()` is hit + - No persistent retry queue — `DmSendTracker` 10s-timeouts and resets + - **Security bug**: `DesktopIAccount.sendNip17PrivateMessage` falls back to `connectedRelays.value` when recipient has no kind:10050 — leaks DM to non-inbox relays (violates the 2026-04-20 "block DM fallback" decision) + - No bubble-level per-message delivery feedback (DmSendTracker is global to the composer, not keyed by EventId) + - Android's kind:1059 sub still passes `since`, silently dropping wraps with randomized 2-day-past timestamps + - 10050 lookup never fans out to indexer relays — if user's `LocalCache` doesn't have the recipient's 10050, it falls through to the buggy fallback above + +Goal: send a DM and have visible confirmation it landed (or visible reason it didn't), survive bunker timeouts and AUTH-walled relays without silent drops, and let bunker users open a 200-wrap inbox in seconds instead of minutes. + +## Proposed Solution + +Six phases. Phases 1–5 are Track A (Reliability), Phase 6 is Track B (Bunker speed, parallel). Each phase is independently shippable. + +``` +Phase 1 — Receive resilience (R1) ┐ +Phase 2 — AUTH end-to-end (R2,R3,R5,R6) │ Track A +Phase 3 — Send visibility (R7,R8 + bunker UI)│ (sequential) +Phase 4 — Discovery hardening + security fix │ + (R4,R11 + 10050 fallback) │ +Phase 5 — Correctness (R9,R10,R12) ┘ + +Phase 6 — NIP-46 batch RPC (spec + impl) — Track B (parallel) +``` + +## Technical Approach + +### Architecture + +The bulk of the change lives in `quartz/.../nip01Core/relay/client/` (publish path, AUTH state) and `commons/.../relayClient/` (filter assemblers, subscriptions). UI hooks are in `desktopApp/` (window focus listener, AUTH banner, bubble delivery indicator). Persistence lands in `desktopApp/.../desktop/relay/LocalRelayStore.kt` (existing SQLite, add tables). + +**Key reusable existing infrastructure** (survey-discovered): +- `RelayAuthenticator.kt` already calls `syncFilters` on AUTH success — re-publishes pending outbox + re-sends REQs. The path exists; we extend it. +- `RecipientRelayFetcher` (`quartz/.../marmot/`) already fans out kind:10050/10002 lookups against a relay set — wire it into the DM send path. +- `LocalRelayStore` (`~/.amethyst/accounts//events.db`) + `BasicBundledInsert` (250ms batching) — host the retry queue table. +- `SigningState` pattern (shipped 2026-03-20) — reuse for bunker SEND progress UI. +- `RelayInsertConfirmationCollector` — pattern for per-OK aggregation; lift into a per-message delivery `StateFlow`. +- `geode/.../KtorRelayTest.kt:208,254` — mock Ktor relay with real `auth-required:` round-trip support. Reusable test infra. + +### Phase 1 — Receive resilience (R1) + +**Goal:** stop silently dropping inbound gift wraps. + +**Scope:** +- Verify desktop kind:1059 sub does not pass `since` (already true — `DesktopRelaySubscriptionsCoordinator.kt:345` passes nothing → `FilterDMs.giftWrapsToMe(userPubKeyHex)` default `since=null`). +- Fix Android: `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` currently passes `since?.get(relay)?.time`. Replace with `since=null` (or relax to a wide window — e.g. `since - 30 days` for users that want bounded backfill). +- Document the invariant in code: add a KDoc on `FilterDMs.giftWrapsToMe` explaining that seal timestamps are randomized up to 2 days back per NIP-17, so `since` is unsafe. +- **Belt-and-braces**: change `FilterDMs.giftWrapsToMe` signature to drop the `since` parameter entirely. Forces all callers to be explicit. + +**Files:** +- `desktopApp/.../desktop/subscriptions/FilterDMs.kt:125-133` — remove `since` param +- `amethyst/.../service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsEoseManager.kt:55-61` — drop `since` arg +- `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` — same + +**Acceptance:** +- [ ] `FilterDMs.giftWrapsToMe` has no `since` parameter +- [ ] All call sites updated; build green +- [ ] Add unit test: subscribe to kind:1059 → server returns wrap with `created_at = now - 1.5 days` → wrap is received +- [ ] Add KDoc explaining the NIP-17 randomized-timestamp invariant + +### Phase 2 — AUTH end-to-end (R2, R3, R5, R6) + +**Goal:** AUTH-walled relays never silently drop messages, user-consents to tier-2 relays once and remembers. + +**Scope:** + +1. **Lift the 3-try cap for `auth-required:` responses.** In `PoolEventOutboxState.kt:64-93 newResponse`, if the response message starts with `auth-required:`, do NOT count it toward the `Tries.isDone()` budget. The retry happens once `RelayAuthenticator.checkAuthResults` → `syncFilters` fires. + +2. **Expose AUTH state as a public `StateFlow`.** Convert `RelayAuthenticator.authStatusCache: LargeCache` into a `MutableStateFlow>` so UI can subscribe. Add a flow event `authCompleted(relayUrl)` for downstream wakeups (re-subscribe to kind:1059 explicitly, refresh retry queue, etc.). + +3. **Tiered AUTH classification** in `RelayAuthenticator.shouldAutoAuth(relayUrl, account)`: + - **Tier 1 (auto-sign):** relay is in `account.outboxRelays` OR `account.dmInboxRelays` (own NIP-17 inbox) OR was previously approved. + - **Tier 2 (prompt):** relay is marked `dmDeliveryTarget` (set by `DesktopIAccount.sendNip17PrivateMessage` before publish — mirrors wisp's `markDmDeliveryTarget`) OR was never seen before. + - **Tier 3 (silent drop):** anything else. + - Persist tier-2 approvals: new SQLite table `auth_approvals(account_pubkey TEXT, relay_url TEXT, scope TEXT, expires_at INT)` in `LocalRelayStore`. Scope `"once"` is in-memory only; `"always"` rows persist. + +4. **Inline AUTH banner UX** (desktop only — Android inherits classification, separate UI pass later): + - Add `AccountAuthApprovals` ViewModel exposing `MutableStateFlow>`. + - In `ChatPane` (the right pane of `DesktopMessagesScreen`), render an inline banner above the message list when an approval is pending: `" requires authentication to deliver this message. [Once] [Always] [Never]"`. + - `[Once]` → grants for the current session, no persistence. `[Always]` → writes `auth_approvals(scope="always")`. `[Never]` → writes `auth_approvals(scope="blocked")`, drops the wrap. + - **Survey precedent**: check current behavior in Coracle, Damus, Primal, 0xchat before final mockup (open question Q5 in brainstorm). + +5. **Re-subscribe to kind:1059 on `authCompleted`** (R3): + - Wire `RelayAuthenticator.checkAuthResults` to emit `authCompleted(relayUrl)` after a successful AUTH-OK. + - The desktop `DesktopRelaySubscriptionsCoordinator` already calls `syncFilters` indirectly via outbox sync. Verify the kind:1059 REQ is re-sent on that relay specifically. Add an integration test using `geode/KtorRelayTest.kt` pattern: client connects → relay sends `AUTH challenge` → client sends `AUTH event` → relay OKs → relay sends gift wrap → client receives it. + +6. **Proactive re-AUTH on window focus** (R6 desktop-only): + - Register `WindowFocusListener` on the `ComposeWindow` in `desktopApp/.../Main.kt:316`. + - On `windowGainedFocus`, push to `MutableStateFlow(focused)`. + - A coordinator (e.g. `DesktopFocusReAuthCoordinator` under `desktopApp/.../desktop/coordinators/`) collects this flow; on `false → true` transition, calls `relayManager.client.reconnect(true)` (forces reconnect of dead sockets) AND for each AUTHENTICATED relay older than 5 min, triggers a no-op AUTH challenge (subscribe to a benign `kind:0` filter on that relay — relays re-issue AUTH challenges on subsequent REQs). + +**Files (touch list):** +- `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt` — `newResponse` skip `auth-required:` from try budget +- `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt` — convert cache to StateFlow, add `authCompleted` event, add `shouldAutoAuth` tier logic +- `quartz/.../nip01Core/relay/client/auth/RelayAuthStatus.kt` — extend with `lastAuthSuccessAt` +- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — new `auth_approvals` table + helpers +- `desktopApp/.../desktop/Main.kt` — wire `WindowFocusListener` +- `desktopApp/.../desktop/coordinators/DesktopFocusReAuthCoordinator.kt` — new file +- `desktopApp/.../ui/chats/ChatPane.kt` — inline AUTH banner +- `commons/.../viewmodels/AccountAuthApprovals.kt` — new ViewModel (commons, so Android inherits later) +- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-208` — call `relayPool.markDmDeliveryTarget(url)` before publish + +**Acceptance:** +- [ ] Test: mock relay returns `auth-required:` → client signs AUTH → publishes → message accepted on retry. Outbox tries counter not incremented by `auth-required:`. +- [ ] Test: account has 3 outbox relays, 1 DM-inbox relay. Sending to a recipient whose DM relay is unknown → AUTH banner appears. `[Always]` persists across app restart. +- [ ] Test: AUTH state flow emits `Authenticated(url)` when AUTH-OK received. Subscriber on kind:1059 receives a wrap delivered to that relay after AUTH. +- [ ] Test: window unfocused → focused. Stale connections reconnect. Verify via mock-relay log. +- [ ] No regression: nsec-local user can still send + receive in <1s end-to-end (no extra round-trips introduced). + +### Phase 3 — Send-path visibility (R7, R8 + bunker progress UI) + +**Goal:** every outgoing message has a visible delivery state per relay; no fire-and-forget; persistent retry across app restart. + +**Scope:** + +1. **Per-message delivery state** (R8): + - Replace transient `DmSendTracker` with a persistent `MutableStateFlow>` keyed by **rumor id** (not gift-wrap id — multiple wraps share one rumor). `MessageDeliveryState` = `{relayDeliverySet: Map, sentAt, lastAttemptAt, error?}`. + - In `quartz/.../accessories/RelayInsertConfirmationCollector.kt`, add a `collectByRumor(rumorId)` overload that aggregates OKs across all gift wraps for a rumor. + - Surface in `DmConversationViewModel` so chat bubble subscribes per-bubble: `messageBubbleState(rumorId): StateFlow`. + - Bubble UI shows: `✓` (≥1 relay accepted), `✓✓` (all relays accepted), `⟳` (in flight), `⚠` (zero relays accepted after retry exhausted). + +2. **Persistent retry queue** (R7): + - New SQLite table in `LocalRelayStore`: + ```sql + CREATE TABLE retry_queue ( + id TEXT PRIMARY KEY, -- gift_wrap_event_id || ":" || relay_url + account_pubkey TEXT NOT NULL, + rumor_id TEXT NOT NULL, + event_json TEXT NOT NULL, -- serialized GiftWrapEvent + relay_url TEXT NOT NULL, + attempt INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 8, + next_attempt_at INT NOT NULL, + last_error TEXT, + created_at INT NOT NULL + ); + CREATE INDEX retry_queue_next_attempt ON retry_queue(next_attempt_at); + ``` + - `RetryQueueCoordinator` (new, `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt`): on app start, scan retry_queue for `next_attempt_at < now`; for each, attempt `client.publish(event, listOf(relayUrl))`. On OK → delete row. On AUTH-required → wait for AUTH (Phase 2's flow already handles). On other rejection → exp-backoff `next_attempt_at = now + min(30s, 2^attempt seconds)`, `attempt++`. On `attempt >= max_attempts` → delete row, surface to UI as permanent failure. + - Enqueue path: `DesktopIAccount.sendNip17PrivateMessage` calls `retryQueue.enqueue(wrap, relays)` BEFORE `client.publish` so we don't lose anything if the app dies between send and confirmation. + - On OK from publish path → `retryQueue.confirm(wrapId, relayUrl)`. + +3. **Bunker SEND progress UI** (brainstorm Q3 = "Live progress in send button"): + - Reuse existing `SigningState` pattern from 2026-03-20 plan. + - In `DesktopIAccount.sendNip17PrivateMessage`, wrap each `signer.sign()` call with progress emission: `SigningState.InProgress(current=2, total=5, label="Encrypting via remote signer")`. + - Compose-side: send button shows linear progress + label when state is `InProgress`. + - Only active when `signer is NostrSignerRemote`; nsec users see no change. + +**Files:** +- `quartz/.../accessories/RelayInsertConfirmationCollector.kt` — add `collectByRumor` +- `commons/.../viewmodels/DmConversationViewModel.kt` — expose `messageBubbleState(rumorId)` +- `desktopApp/.../desktop/relay/LocalRelayStore.kt` — `retry_queue` table + DAO methods +- `desktopApp/.../desktop/relay/RetryQueueCoordinator.kt` — new +- `desktopApp/.../desktop/model/DesktopIAccount.kt` — enqueue → publish → confirm pattern, signing-state emission +- `desktopApp/.../ui/chats/ChatMessageBubble.kt` — render delivery indicators +- `desktopApp/.../ui/chats/MessageComposer.kt` — bunker progress + +**Acceptance:** +- [ ] Send DM, kill app mid-publish (during bunker sign). Restart → retry queue drains → message lands. +- [ ] Send DM to 3 relays; relay #2 returns `auth-required:` while #1 and #3 OK. Bubble shows `✓ 2/3` immediately; after AUTH completes, updates to `✓ 3/3`. +- [ ] Bunker user sends to 5 recipients. Send button shows "Encrypting via remote signer (3 of 5)" until last signature lands. +- [ ] No retry-queue table growth in nsec-local mode under normal conditions. +- [ ] Retry queue respects per-account isolation (multi-account users don't see each other's queued sends). + +### Phase 4 — Discovery hardening + security fix (R4, R11) + +**Goal:** kind:10050 lookup is robust against missing/stale data; stop the silent metadata-leak fallback. + +**Scope:** + +1. **Indexer-relay fan-out for kind:10050** (R4): + - New `DmInboxRelayResolver` (commons, so Android inherits). API: `suspend fun resolveDmInboxRelays(pubkey: HexKey): Result>`. + - Wraps `RecipientRelayFetcher` (already in `quartz/.../marmot/`). Configures it with a discovery set (curated indexer relays). + - LRU cache (100 entries, TTL 1h) on `(pubkey → relays)` results. + - Decoupled from `relayManager.connectedRelays` — uses ephemeral connections to indexers. + +2. **Security fix: stop falling back to `connectedRelays.value`** in `DesktopIAccount.sendNip17PrivateMessage:179` and twin methods (sendNip17EncryptedFile, sendGiftWraps). + - New flow: `dmInboxRelays()` → if null → `DmInboxRelayResolver.resolveDmInboxRelays(recipient)` → if empty → **block send and surface UI prompt** "Could not find DM relays for . [Enter manually] [Cancel]". + - Never silently fall back to user's connected relays for DMs (matches 2026-04-20 Relay Power Tools decision). + - The "[Enter manually]" path is a one-shot dialog with relay-URL chips; user-entered relays are NOT persisted to recipient's 10050 (we don't publish on their behalf), only used for this send. + +3. **Indexer-relay set** (open question Q2 in brainstorm — decided here): + - Hardcoded curated list in `commons/.../relayClient/dm/DefaultIndexerRelays.kt`: + - `wss://purplepag.es` + - `wss://relay.nos.social` + - `wss://relay.damus.io` + - `wss://nos.lol` + - `wss://relay.nostr.band` + - Configurable in Settings → DMs → "Inbox-relay discovery" (advanced). Default = curated list. + +4. **Relay hint on `p` tags** (R11): + - In `NIP17Factory.createWraps`, when building the seal's `p` tag for the recipient, include the recipient's primary DM relay URL: `["p", recipientPubkey, primaryDmRelay]`. + - "Primary" = first relay from `resolveDmInboxRelays(recipient)` result. Empty string if unknown. + +**Files:** +- `commons/.../relayClient/dm/DmInboxRelayResolver.kt` — new +- `commons/.../relayClient/dm/DefaultIndexerRelays.kt` — new +- `desktopApp/.../desktop/model/DesktopIAccount.kt:179-261` — three send methods updated +- `desktopApp/.../ui/chats/DmInboxRelayMissingDialog.kt` — new +- `quartz/.../nip17Dm/NIP17Factory.kt` — relay hint on `p` tag +- `desktopApp/.../ui/settings/DmSettingsScreen.kt` — new (indexer-relay config) + +**Acceptance:** +- [ ] Recipient has no kind:10050 in our cache and indexers return nothing → user sees "Enter manually" dialog. No silent send to non-inbox relays. +- [ ] Recipient has 10050 in cache → no indexer call. Cache TTL respected (1h). +- [ ] Recipient has no 10050 in cache, indexers return [r1, r2] → cache populated, send proceeds. +- [ ] `p` tag in seal contains recipient's primary DM relay URL when known. +- [ ] Settings allows custom indexer set. +- [ ] **Security regression test**: send DM where recipient has no 10050 anywhere → verify zero outbound traffic to user's own outbox/general relays. + +### Phase 5 — Correctness (R9, R10, R12) + +**Goal:** group DMs, cross-device sync, and dedupe behave correctly. + +**Scope:** + +1. **Shared `rumorCreatedAt` across recipient wraps** (R9): + - In `NIP17Factory.createWraps` (currently `quartz/.../nip17Dm/NIP17Factory.kt:43-72`), compute `rumorCreatedAt = TimeUtils.now()` once before the per-recipient `mapNotNullAsync` loop. Pass into every `SealedRumorEvent.create(...)` so all seals encode the same rumor (same `rumor.id`). + - Same `rumorId` becomes the dedupe anchor + receipt target across all recipients of a group message. + +2. **Self-copy gift wrap to own DM relays** (R10): + - In each `DesktopIAccount.sendNip17*` method, after building wraps for all recipients, also build one wrap addressed to self. + - Route to `account.dmInboxRelays` (or write relays as fallback per wisp's pattern). **NOT to local relay** (brainstorm Q7). + - Pre-mark `LocalCache.seenGiftWraps[selfWrap.id]` (or equivalent) to avoid double-render when it loops back from the relay. + +3. **Persistent seen-index** (R12) — verify, don't re-build: + - The existing `LocalCache.consume()` + write-through to `LocalRelayStore` (`DesktopLocalCache.kt:216-219`) already provides on-disk dedupe across restart. + - Add a regression test: kill desktop app with N gift wraps in-cache; on restart, re-deliver same wraps from a mock relay; verify they're rejected as duplicates at `LocalCache.consume` (no decryption attempt → no bunker round-trip). + +**Files:** +- `quartz/.../nip17Dm/NIP17Factory.kt:43-78` — shared rumor created_at +- `desktopApp/.../desktop/model/DesktopIAccount.kt` — add self-copy in three send methods +- `commons/.../service/LocalCache.kt` (or `desktopApp/.../desktop/cache/DesktopLocalCache.kt`) — pre-mark seenGiftWraps if not already supported +- Tests in `desktopApp/.../jvmTest/` and `quartz/.../commonTest/` + +**Acceptance:** +- [ ] Group DM to 4 recipients: all seals share one rumor.id. Reaction event targeting that rumor.id by recipient #2 is correctly received by sender and other recipients. +- [ ] Send DM from desktop install A; open same account on desktop install B. Self-copy arrives via 10050 → conversation appears on B. +- [ ] Kill app with 50 wraps in cache. Mock relay re-broadcasts same 50 wraps. App restart → decryption attempted 0 times (verified via signer-call counter). + +### Phase 6 — NIP-46 batch RPC (parallel track) + +**Goal:** bunker users receive N gift wraps in 1–2 round-trips instead of N. + +**Spec proposal:** +- File NIP-46 PR in `nostr-protocol/nips` proposing method `get_conversation_keys`: + ``` + Request: { id, method: "get_conversation_keys", params: [pubkeys_json_array] } + Response: { id, result: keys_json_array, error?: string } + ``` +- `pubkeys_json_array` = JSON-encoded array of hex pubkeys. Result is parallel array of base64-encoded 32-byte NIP-44 conversation keys (same key the bunker would derive for the corresponding `nip44_encrypt`/`nip44_decrypt`). +- Bunker MAY rate-limit or reject (e.g. if more than 100 pubkeys). Client falls back to per-call `nip44_decrypt` if `get_conversation_keys` returns error or capability not advertised. +- Capability advertised via NIP-46 `connect` response: `result.capabilities: ["get_conversation_keys"]` (or via a `get_capabilities` method if spec evolves). + +**Coordination:** +- Open NIPs PR + cross-post to bunker maintainers: + - **nsec.app** (Yegor) — github.com/nostrband/nsec.app + - **Amber** (greenart7c3) — github.com/greenart7c3/Amber + - **Keychat** — github.com/keychat-io +- Resolve open semantics question (brainstorm Q4): + - For NIP-17, the receiver needs `ecdh(self, ephemeral_pubkey_in_each_wrap)` for the wrap layer, AND `ecdh(self, sender_pubkey)` for the seal layer. + - Wrap layer: N ephemeral pubkeys → N keys. Pass all in one batch call. + - Seal layer: M unique sender pubkeys (often M << N). Pass all in one batch call. + - Net: 2 bunker calls instead of 2N. Confirmed acceptable shape. + +**Amethyst-side implementation:** +- Capability probe: on bunker `connect`, parse `result.capabilities` (or fall back to a feature-flag pref). +- `RemoteSignerManager.getConversationKeys(pubkeys: List): List` — new method. Sends one NIP-46 request, awaits response, parses keys. +- Wire into NIP-17 receive path: when `LocalCache.consume` ingests a batch of kind:1059 events, before decrypting, collect all unique ephemeral pubkeys + sender pubkeys, call `getConversationKeys` once, then decrypt locally with the returned keys. +- Wire into NIP-17 send path: per-recipient conversation key fetched once (cached), used for seal encryption locally. +- Cache conversation keys in-memory (LRU 500); wipe on logout. Conversation keys are NOT persisted — re-derivable from bunker on next session. + +**Files:** +- `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt` — add `getConversationKeys` +- `quartz/.../nip46RemoteSigner/dto/` — new request/response DTOs +- `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` — expose batch path +- `quartz/.../nip17Dm/NIP17Factory.kt` — switch to batch path when signer is remote and capability available +- `quartz/.../nip17Dm/Nip17Receiver.kt` or wherever wraps are decrypted (likely under `commons/.../service/`) — batch-decrypt path +- `commons/.../service/cache/ConversationKeyCache.kt` — new LRU +- `quartz/.../commonTest/.../GetConversationKeysTest.kt` — round-trip test with mock bunker + +**Acceptance (gated on spec PR being open at least; impl can land behind capability flag):** +- [ ] NIPs PR opened with discussion-ready spec. +- [ ] Mock bunker test: client calls `get_conversation_keys([10 pubkeys])` → receives 10 keys → uses them to decrypt 10 wraps with 0 further bunker calls. +- [ ] Capability fallback: bunker doesn't advertise capability → client falls back to per-call `nip44_decrypt`. No regression. +- [ ] Inbox-load benchmark: 200 wraps via bunker. Without batch RPC: ~200 round-trips. With batch RPC: ≤2 round-trips. Measured via signer-call counter. + +## Alternative Approaches Considered + +| Alternative | Why rejected | +|---|---| +| **Implement NIP-4E (PRs #1647/#2361)** | Vitor (maintainer) NACKed both with 5 technical objections. Externalizes trial-decryption cost on every legacy peer. Politically infeasible. | +| **Read-side NIP-4E compat only** (honor peers' kind:10044 + n-tag on receive) | Spec contested, no merge in sight. Adds receive-path complexity for unclear win — Jumble + Coop are small. Defer until spec lands. | +| **Migrate to MLS/Marmot for DMs** | Larger orthogonal program. Marmot already in tree (per `quartz/.../marmot/`). Separate track. Doesn't solve NIP-17 reliability for users on non-MLS peers. | +| **Drop bunker support for DMs entirely** | wisp + nospeak do this; works but regresses Amethyst's bunker UX. Better path is to make bunker fast (Phase 6) than to drop it. | +| **Per-relay outbox max-tries config without auth-required carve-out** | Half-measure; doesn't solve the silent-drop case where AUTH succeeds AFTER 3 retries exhausted. The carve-out is required regardless. | +| **In-memory only retry queue** | Loses messages on app crash / kill. Persistent SQLite is cheap given `LocalRelayStore` already exists. | +| **Self-copy wrap to embedded local relay** (instead of remote DM relays) | Brainstorm Q7: rejected to match wisp behavior + keep local relay's "cache only" role. | + +## System-Wide Impact + +### Interaction Graph + +**Outgoing DM (post-Phase 3):** +``` +ComposeUI(send button click) + → DmConversationViewModel.send(text) + → DesktopIAccount.sendNip17PrivateMessage(text, recipients) + → DmInboxRelayResolver.resolveDmInboxRelays(recipient) ── Phase 4 + → RecipientRelayFetcher.fetch([indexer relays]) + → NIP17Factory.createWraps(text, recipients, signer) ── shared rumor_created_at (Phase 5) + → for each recipient (parallel): + → SealedRumorEvent.create(rumor, recipient, signer) + → signer.sign(seal) (bunker round-trip if Remote) ── batch via Phase 6 capability + → signer.nip44Encrypt(rumor, recipient) (bunker round-trip)── batch via Phase 6 capability + → GiftWrapEvent.create(seal, recipient, ephemeralKey) + → for each wrap: + → retryQueue.enqueue(wrap, relays) ── Phase 3 + → client.publish(wrap, relays) + → relay returns OK → retryQueue.confirm(wrapId, relayUrl) + → relay returns auth-required → RelayAuthenticator handles ── Phase 2 + → relay rejects → retryQueue.scheduleRetry(wrapId, relayUrl) + → self-copy wrap published to own DM relays ── Phase 5 +ChatBubble subscribes to messageBubbleState(rumorId) ── Phase 3 + → renders ✓ ✓✓ ⟳ ⚠ based on state changes +``` + +**Incoming DM (post-Phase 6):** +``` +RelayConnection.onIncomingMessage(EventMessage(kind=1059)) + → LocalCache.consume(wrap) + → if seen → drop ── Phase 5 (already exists) + → batch collected by ingestion buffer (250ms window) + → batch decrypt path: + → collect unique sender pubkeys from wraps in batch + → if signer is Remote AND batch capability: getConversationKeys() ── Phase 6 + → for each wrap: decrypt locally with cached key + → on rumor decrypted → DesktopMessagesScreen.conversationFlow updates +``` + +### Error & Failure Propagation + +| Layer | Error class | Today | Post-plan | +|---|---|---|---| +| Relay socket | `WebSocketDisconnected` | reconnect attempt, in-flight events tries-counted | reconnect, queue preserves event, retries on reconnect | +| Relay OK | `auth-required:` | counts toward 3-try cap, often silently dropped | NOT counted; held until AUTH completes; user sees banner if tier-2 | +| Relay OK | `pow:` / `replaced:` / `invalid:` | discarded (correct) | unchanged | +| Bunker RPC | `BunkerTimeout` (65s) | request continuation removed; late response discarded | retry queue re-attempts on next coordinator tick; bubble shows ⚠ | +| Bunker RPC | `DecryptCache` poisoning (per 2026-05-04 plan) | permanent cache poison until app restart | unchanged this plan — covered by prior plan | +| Bunker RPC | `get_conversation_keys` not supported | N/A | fall back to per-call `nip44_decrypt` | +| 10050 lookup | recipient has no 10050 anywhere | **falls back to user's connected relays (metadata leak)** | Phase 4: blocks send + prompts user for manual relay entry | +| Signer | `signer.sign` returns null | DmSendTracker → Failed → resets in 3s | retry queue keeps the event, scheduler retries; bubble stays ⟳ | + +### State Lifecycle Risks + +1. **Retry queue rows must be deleted on permanent failure or success, never orphaned.** Coordinator deletes on `attempt >= max_attempts` even if no UI sees it. Alternative: archive to `retry_queue_dead_letter` table for diagnostics. +2. **AUTH-approvals table must be account-scoped.** Multi-account users share the SQLite store across accounts but each row carries `account_pubkey`. Test: account A approves relay X "always"; account B sending to relay X gets tier-2 prompt independently. +3. **Indexer cache invalidation.** If recipient publishes a new 10050, our 1h TTL hides it. Mitigation: on receiving a fresh kind:10050 event for any user via the normal relay feed, eagerly update the cache. +4. **Self-copy wrap can race the original.** If self-copy lands first, recipient #1's wrap arrives second and we already have the rumor — dedupe at rumor.id should handle it. Test. +5. **Retry queue + AUTH banner can dual-drive UI** — if a wrap is queued AND the relay's AUTH is pending, we don't want two notifications. Coordinator suppresses retry attempts on relays in `AUTHENTICATING` state. +6. **Conversation-key cache (Phase 6) lives in memory only.** On logout / account switch, must wipe to prevent cross-account leakage. + +### API Surface Parity + +| Surface | Effect | +|---|---| +| Desktop NIP-17 send | full plan | +| Android NIP-17 send | inherits all `commons/` + `quartz/` changes. Android-specific UI (AUTH banner) NOT in this plan — separate Android pass. Android keeps current AUTH-prompt-less behavior until then; the underlying classifier still works (silent drops for tier-3, auto for tier-1, **silent drop for tier-2** — Android users with bunker won't see banner; safer than current). | +| CLI (`amy`) | `commons/` changes apply. CLI doesn't render banners. Tier-2 AUTH approvals via a config file (out of scope, file follow-up). | +| Marmot/MLS DMs | unaffected (separate event kinds + path) | +| NIP-04 legacy DMs | unaffected (no AUTH retry, no retry queue — legacy path stays as-is per brainstorm Q1) | + +### Integration Test Scenarios + +1. **AUTH retry across restart.** Send DM to relay R that demands AUTH. Sign + send AUTH. Kill app before AUTH-OK arrives. Restart. Verify retry queue resumes, AUTH handshake completes, original wrap accepted. +2. **Tier-2 prompt persistence.** Recipient has 10050 pointing to a relay user has never seen. Send → banner appears → user clicks `[Always]`. Send another DM to same recipient → no banner; relay AUTH'd silently using stored approval. +3. **No-10050 security path.** Recipient has no kind:10050 in our cache and on indexers. Send → dialog shows "Enter manually". Cancel → zero outbound traffic to general relays. Verify via mock-relay sniffer. +4. **Bunker batch RPC inbox load.** 200-wrap inbox, bunker user. Pre-plan: ~200 round-trips (~minutes). Post-plan with capability: ≤2 round-trips (~seconds). Measured via signer-call counter. +5. **Group DM rumor coherence.** Send to [A, B, C]. Each receives a wrap. A reacts to message → reaction `e` tag references shared `rumorId`. B and C see the reaction associated with the right message. Sender sees it too. +6. **Self-copy cross-device.** Account on desktop install X sends DM to recipient. Open same account on install Y (cold cache). Y's first 10050 fetch returns sender's own DM relays → self-copy wrap arrives → conversation pre-populates. +7. **Window-focus re-AUTH.** Mac sleeps 1h. Wake → focus desktop app → mock relay's AUTH challenges fire → client AUTHs all stale connections within 5s. No user input required. + +## Acceptance Criteria + +### Functional + +- [ ] Phase 1: kind:1059 sub on both Desktop and Android passes no `since` (or a 30-day default at most). Wraps with timestamps 2 days in the past arrive. +- [ ] Phase 2: All five sub-items shipping (tier classifier, persisted approvals, banner, re-sub-on-auth-completed, focus re-AUTH). +- [ ] Phase 3: Per-message bubble delivery indicator. Persistent retry queue. Bunker progress UI. +- [ ] Phase 4: No silent fallback to user's connected relays for DMs. Indexer fan-out + manual entry dialog. +- [ ] Phase 5: Shared rumor.created_at. Self-copy wrap. Persistent dedupe verified. +- [ ] Phase 6: NIPs PR open. Capability negotiation + fallback. Batch decrypt wired for receive path. + +### Non-functional + +- [ ] No regression for nsec-local users: end-to-end DM round-trip stays under 1s on healthy relays. +- [ ] Bunker inbox load (200 wraps): post-Phase 6 ≤10s vs current ≥120s. +- [ ] Retry queue size stays under 100 rows under normal use (i.e. high-success-rate publish path keeps it empty most of the time). +- [ ] No new secrets persisted: AUTH approvals carry no key material; only relay URLs + scope flags. + +### Quality gates + +- [ ] All new code passes `./gradlew spotlessApply` + `./gradlew test`. +- [ ] Integration test count: ≥1 per phase, ≥7 total. +- [ ] Mock relay infra (`geode/.../KtorRelayTest.kt` pattern) reused where possible; new mock-bunker for Phase 6. +- [ ] No new uses of `runBlocking` in publish path. +- [ ] Code-review pass with `compose-expert`, `relay-client`, `auth-signers`, `nostr-expert` skills before merge per phase. + +## Success Metrics + +| Metric | Pre-plan baseline | Target | +|---|---|---| +| Silent message drops on AUTH-walled relays | unknown (likely common) | 0 | +| Inbox load time, 200 wraps via bunker | ~2 min | ≤10 s | +| Successful delivery rate on first try (nsec, healthy network) | ~95% (estimated) | ≥99% | +| Successful delivery rate including retry queue, 24h window | unknown | ≥99.5% | +| User reports of "DM never arrived" / "DM never sent" | baseline TBD | 50% reduction over 3 months | +| Crash/ANR rate on DM screen | baseline TBD | no regression | + +## Dependencies & Prerequisites + +- **Phase 6 blocked on NIPs PR consensus.** Spec authors (nsec.app/Amber/Keychat) need to weigh in. If consensus stalls, Phase 6 implementation can still land **behind a feature flag** as a discussion prototype. +- Phases 1–5 are sequential within Track A but each is independently shippable. +- Phase 4's manual-entry dialog needs design pass (no Figma assumed; brainstorm spec is the source). +- Mock bunker test infra for Phase 6 — small new utility, no external deps. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Phase 2 AUTH classifier wrongly tier-3-drops a legit relay | M | High (silent drop) | Default tier-3 to "log warning" not "silently drop" during rollout; flip later. Tier-1 + tier-2 catch the common cases. | +| Retry queue grows unbounded (e.g. dead relay) | M | M | `max_attempts=8` + exp-backoff caps total relay-time per event to ~10 min. Dead-letter table for inspection. UI surfaces "permanent failure" ⚠. | +| Phase 6 NIPs PR rejected | M | M | Implement Vitor's design as a discussion prototype regardless; if PR rejected, ship as Amethyst↔nsec.app/Amber bilateral capability negotiation (slightly worse interop, equivalent end-user outcome). | +| Security fix (Phase 4) breaks existing users who relied on the leaky fallback | L | L | Surface dialog + provide manual-entry fallback. Document migration in release notes. Add telemetry for "no-10050" send attempts in 1.0 to size the affected population. | +| Window-focus listener leaks on close | L | L | Standard `addWindowFocusListener` / `removeWindowFocusListener` pairing; unit-test via headless ComposeWindow. | +| Conversation-key cache (Phase 6) leaks across account switch | L | High (cross-account decryption) | Wipe `ConversationKeyCache` in `AccountStateHolder.onAccountChanged`. Unit test. | +| Persistent AUTH approvals get out of sync with relay's actual AUTH state | L | L | TTL of 30 days on `auth_approvals.expires_at`; re-prompt after expiry. | +| Android inheriting `commons/` changes regresses Android DM screens | M | M | Run full Android test suite after each phase; manual smoke test on Android emulator before merge. | +| Spec change in NIP-46 mid-implementation | M | M | Capability negotiation isolates Amethyst from spec churn; fallback path always works. | + +## Resource Requirements + +- Solo engineer; estimated 4–6 weeks of focused work for Phases 1–5, plus indefinite coordination for Phase 6. +- No new infrastructure / hosting. +- New dev-dep on a mock-bunker test utility (small, in-tree). + +## Future Considerations + +- **Receipts UX**: read receipts in wisp + nospeak use a "high water mark" per conversation. Out of scope here, natural follow-up plan. +- **NIP-04 visibility cleanup**: brainstorm Q1 said "keep legacy badge"; revisit when NIP-17 adoption hits a threshold. +- **Marmot/MLS DMs**: separate program. The reliability plumbing (AUTH, retry queue) is reusable — Phase 6's batch RPC concept does NOT apply (MLS uses different keying). +- **WoT inbox relays** (e.g. pyramid.fiatjaf.com/inbox) — open question Q5 in brainstorm. AUTH plumbing makes us publishable. Surface relay rejection messages to UI as toast for diagnostics. No special WoT machinery needed for now. +- **Android UI parity** for AUTH banner and bunker progress — separate Android-only pass, plan TBD. +- **Telemetry** — add anonymous metric `dm.delivery.outcome = {ok, retry, dropped}` (opt-in only, behind Settings flag). + +## Documentation Plan + +- Update `desktopApp/.../README.md` (if any) with new DM reliability features. +- Update `MEMORY.md` summary at end of work. +- Add `commons/ARCHITECTURE.md` entries for `DmInboxRelayResolver` and `RetryQueueCoordinator`. +- KDoc on new public surfaces: `RelayAuthenticator.authCompleted`, `DesktopIAccount.messageBubbleState`, `RemoteSignerManager.getConversationKeys`. +- Release-notes entries per phase (`docs/release-notes/`?). User-facing: "DMs now show per-relay delivery status", "AUTH-walled relays handled automatically", "Bunker users can open large inboxes much faster". + +## Sources & References + +### Origin + +- **Brainstorm document**: [docs/brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md](../brainstorms/2026-06-10-desktop-dm-reliability-brainstorm.md). + Key decisions carried forward: two-track umbrella (reliability + bunker speed), R1–R12 inventory, NIP-04 stays legacy, bunker progress UI, AUTH inline banner, self-copy → remote only, desktop-first Android-inherits. + +### Internal references + +| Concern | File:line | +|---|---| +| Desktop kind:1059 sub site | `desktopApp/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt:338-349` | +| Android kind:1059 sub site (needs fix) | `amethyst/.../AccountGiftWrapsEoseManager.kt:55-61` | +| Filter assembler (commons) | `commons/.../relayClient/nip17Dm/FilterGiftWrapsToPubkey.kt:31-49` | +| Filter assembler (desktop) | `desktopApp/.../subscriptions/FilterDMs.kt:125-133` | +| Publish path entry | `quartz/.../nip01Core/relay/client/NostrClient.kt:233-245` | +| Per-event outbox | `quartz/.../nip01Core/relay/client/pool/PoolEventOutboxState.kt:64-108` | +| AUTH state cache | `quartz/.../nip01Core/relay/client/auth/RelayAuthenticator.kt:57-104` | +| AUTH event builder | `quartz/.../nip42RelayAuth/RelayAuthEvent.kt` | +| Bunker signer manager | `quartz/.../nip46RemoteSigner/signer/RemoteSignerManager.kt:44-102` | +| NIP-17 factory | `quartz/.../nip17Dm/NIP17Factory.kt:43-78` | +| Recipient-relay fetcher | `quartz/.../marmot/RecipientRelayFetcher.kt:38-114` | +| Desktop send path (security bug) | `desktopApp/.../model/DesktopIAccount.kt:179-261` | +| DmSendTracker (to replace) | `desktopApp/.../ui/chats/DmSendTracker.kt:32-86` | +| Window state on desktop | `desktopApp/.../Main.kt:250-316` | +| LocalRelayStore (retry queue host) | `desktopApp/.../desktop/relay/LocalRelayStore.kt` | +| Mock-relay AUTH test infra | `geode/.../KtorRelayTest.kt:208,254` | +| Server-side AUTH test | `quartz/.../commonTest/.../nip01Core/relay/server/NostrServerAuthTest.kt` | + +### Related prior plans (carry constraints / reuse infra) + +- **2026-04-20 Relay Power Tools** — shipped "block DM fallback to all relays" decision (Phase 4 enforces). `desktopApp/.../docs/plans/2026-04-20-feat-relay-power-tools-plan.md`. +- **2026-05-04 Bunker Timeouts & Decryption** — shipped DecryptCache poisoning fix. Retry queue (Phase 3) inherits the lesson: persist request state across timeouts. `docs/plans/2026-05-04-fix-bunker-timeouts-and-decryption-plan.md`. +- **2026-05-09 Embedded Local Relay** — shipped `LocalRelayStore` SQLite + `BasicBundledInsert`. Phase 3 retry queue uses the same store. `desktopApp/plans/2026-05-09-embedded-local-relay-plan.md`. +- **2026-03-20 Remote Signer Loading & Error UX** — shipped `SigningState` pattern. Phase 3 bunker progress UI reuses. `docs/plans/2026-03-20-feat-remote-signer-loading-error-ux-plan.md`. + +### External references + +| Source | URL | +|---|---| +| NIP-17 spec | https://github.com/nostr-protocol/nips/blob/master/17.md | +| NIP-42 spec | https://github.com/nostr-protocol/nips/blob/master/42.md | +| NIP-46 spec | https://github.com/nostr-protocol/nips/blob/master/46.md | +| NIP-4E PR #1647 (contested) | https://github.com/nostr-protocol/nips/pull/1647 | +| NIP-17 keys PR #2361 (contested) | https://github.com/nostr-protocol/nips/pull/2361 | +| wisp source | https://github.com/barrydeen/wisp | +| nospeak source | https://github.com/psic4t/nospeak | + +### Files worth diffing line-by-line during implementation + +- wisp: `app/src/main/kotlin/com/wisp/app/relay/RelayPool.kt:518-563` (tiered AUTH) +- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/StartupCoordinator.kt:353-364, 734-743` (re-sub on AUTH-OK, no-`since` 1059 filter) +- wisp: `app/src/main/kotlin/com/wisp/app/viewmodel/DmConversationViewModel.kt:654-665, 702-712, 766` (shared rumor_created_at, self-copy) +- wisp: `app/src/main/kotlin/com/wisp/app/repo/DmRelayLookup.kt` (indexer fan-out) +- nospeak: `src/lib/core/connection/RetryQueue.ts` (Dexie-backed retry queue) +- nospeak: `src/lib/core/connection/publishWithDeadline.ts:136` (AUTH retry inside publish) +- nospeak: `src/lib/core/connection/ConnectionManager.ts:345-410` (re-AUTH on visibilitychange) +- nospeak: `src/lib/stores/sending.ts` (per-relay delivery counter) + +--- + +## Unanswered questions + +Resolved during deepening (see Deepening Synthesis §"Open questions resolved"): tier-3 dropped (2 tiers only); indexer set hardcoded curated 5 + system-property override; conversation-key cache session-only; retry dead-letter 30d; self-copy own DM relays only; bunker progress = spinner not counter; NIP-46 capability via optimistic probe (no spec extension). + +Still open: +- Android UI parity for tier-2 banner — separate plan or include here? Lean separate. +- Manual relay-entry: ship dialog with full validation (F-02) or drop entirely + Snackbar error only? Lean drop; revisit after dogfooding. +- Bunker batch RPC chunk size cap — spec authors to set (recommend 100 pubkeys/call). +- WoT relay rejection messages — toast all `:` -prefixed OK reasons or filter? Lean all. +- F-13 multi-indexer agreement — require ≥2 indexers, or accept ≥1 with NIP-11 pubkey pinning? Decide in Phase 4. +- NIP-09 deletion of self-copies on kind:10050 rotation (F-06) — implement now or release-note disclosure? Lean disclosure now, implement later. +- AUTH approval revoke UI placement (F-05 P1) — Settings → DMs → "Approved relays" list. Confirm during Phase 2 design. +- TLS SPKI + NIP-11 pubkey pinning for AUTH approvals (F-05) — defer or include in Phase 2? Lean include — protects against relay-ownership swap mid-TTL. diff --git a/docs/plans/2026-06-12-desktop-dm-reliability-testing-sheet.md b/docs/plans/2026-06-12-desktop-dm-reliability-testing-sheet.md new file mode 100644 index 0000000000..9c1e82c1af --- /dev/null +++ b/docs/plans/2026-06-12-desktop-dm-reliability-testing-sheet.md @@ -0,0 +1,241 @@ +# Desktop DM Reliability — Testing Sheet + +**Branch:** `feat/desktop-dm-reliability` +**Date:** 2026-06-12 +**Plan:** [docs/plans/2026-06-10-feat-desktop-dm-reliability-plan.md](2026-06-10-feat-desktop-dm-reliability-plan.md) +**Tester:** + +## Scope + +17 commits across `quartz` / `commons` / `desktopApp`. Net ~1,797 LOC (incl. ~600 LOC tests). +The branch ships: + +1. NIP-42 AUTH end-to-end on desktop (today: nothing) — tier-1 auto-sign, tier-2 banner +2. P0 security fix: NIP-17 sends no longer fall back to user's connected relays +3. Dedicated unauthenticated NostrClient for kind:10050 indexer probes (no identity-key leak) +4. NIP-17 relay hint on gift-wrap p-tag (correct per spec) +5. Bunker concurrency cap (Semaphore(4)) in NIP17Factory +6. `auth-required:` carved out of outbox try cap +7. Drop `since` from kind:1059 subscription +8. Compose-observable AUTH state, `SigningOpState.Progress` variant +9. Per-account AUTH approval persistence via `java.util.prefs.Preferences` + +--- + +## Pre-test setup + +- [ ] `git -C .worktrees/feat/desktop-dm-reliability log --oneline ^origin/main` shows 17 commits +- [ ] Note current `~/.amethyst/accounts//` paths so they can be inspected after the run +- [ ] Run `defaults read /Library/Preferences/com.apple.security ...` baseline — irrelevant; `Preferences.userRoot()` lives in `~/Library/Preferences/com.apple.java.util.prefs.plist` on macOS, `~/.java/.userPrefs` on Linux. Note path for later verification. + +--- + +## A. Automated verification (Claude can run these) + +**Run on 2026-06-12 against worktree at HEAD `4ed0ff241`.** + +### A1: Compile every module +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| A1.1 | `./gradlew :quartz:compileKotlinJvm` | BUILD SUCCESSFUL, no errors | ✅ | Only pre-existing `BirthdayTolerantSerializer` opt-in warning | +| A1.2 | `./gradlew :commons:compileKotlinJvm` | BUILD SUCCESSFUL | ✅ | | +| A1.3 | `./gradlew :desktopApp:compileKotlin` | BUILD SUCCESSFUL | ✅ | | +| A1.4 | `./gradlew :amethyst:compileFdroidDebugKotlin` (Android) | BUILD SUCCESSFUL — commons changes don't break Android | ✅ | Note: task is `:amethyst:compileFdroidDebugKotlin`, not the non-flavour `compileDebugKotlin` originally listed | +| A1.5 | `./gradlew :cli:compileKotlin` | BUILD SUCCESSFUL — quartz API changes don't break amy | ✅ | | + +### A2: Unit + integration tests +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| A2.1 | `./gradlew :quartz:jvmTest` | All pass; `PoolEventOutboxStateTest` (4) + `GiftWrapRelayHintTest` (3) included | ✅ | | +| A2.2 | `./gradlew :commons:jvmTest` | All pass; `AuthApprovalPolicyTest` (8) + `AuthApprovalEndToEndTest` (4) + `DmInboxRelayResolverTest` (8) included | ✅ | | +| A2.3 | `./gradlew :desktopApp:test` | All pass | ✅ | | +| A2.4 | `./gradlew :amethyst:testFdroidDebugUnitTest` | All pass — no Android regression from commons changes | ✅ | | + +### A3: Static analysis +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| A3.1 | `./gradlew spotlessCheck` | All formatted | ✅ | | +| A3.2 | Pre-commit hook runs on every commit (already exercised 17 times this branch) | Hook runs spotlessCheck + tests, passes | ✅ | | + +### A4: Package build +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| A4.1 | `./gradlew :desktopApp:createDistributable -Pcompose.desktop.packaging.checkJdkVendor=false` | Produces `Amethyst.app`; new code (`DesktopAuthCoordinator`, `AuthApprovalPolicy`, `AuthApprovalBanner`, `RelayAuthSnapshot`, `DmInboxRelayResolver`) inside the bundled JARs | ✅ | `packageDistributionForCurrentOS` blocked on local Homebrew JDK vendor check — pre-existing env issue, not a branch regression. `createDistributable` with the flag works fine. | +| A4.2 | Launch the built `Amethyst.app` for 10s | No crash, no exceptions in log | ✅ | Confirmed: `DesktopAuthCoordinator` + `indexerClient` + `DmInboxRelayResolver` + banner mount all initialize cleanly. Only pre-existing VLC plugin warnings in stderr. | + +### A5: Branch integrity +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| A5.1 | `git log --grep "Co-Authored-By" feat/desktop-dm-reliability ^origin/main \| wc -l` | 0 — no Claude footer leaked into commits | ✅ | 0 matches | +| A5.2 | All commits GPG-signed: `git log --pretty="%G?" feat/desktop-dm-reliability ^origin/main \| sort -u` | Only `G` (good signature) | ✅ | Only `G` | +| A5.3 | No `--no-verify` or `--no-gpg-sign` flags in reflog | clean | ✅ | Hook passed on every commit; one GPG retry mid-session resolved by re-unlock, no flags used | + +--- + +## B. Manual desktop verification (human required) + +**Pre-step:** `./gradlew :desktopApp:run` on the worktree. Use at least two test accounts: one nsec, one bunker (`bunker://...` from nsec.app or Amber). + +### B1: AUTH end-to-end — tier 1 (own DM-inbox relay) +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B1.1 | Log in with nsec account A. Verify the user has a `kind:10050` published with at least one relay (e.g. `wss://relay.nos.social`). | Account loads; feed shows | | | +| B1.2 | Open Settings → Relays. Confirm one of A's DM-inbox relays is in the connected set. | DM relay listed, status connected | | | +| B1.3 | Trigger an AUTH-walled action: ideally send a DM TO yourself (NIP-17). Watch DevTools / log output (or `~/.amethyst/logs/` if Tor is on). | Mock relay should send `AUTH ...`; client signs + replies; outbox publish OK | | | +| B1.4 | Confirm NO banner appears for tier-1 relays | Banner stays empty | | | +| B1.5 | Inspect Preferences node: `defaults read com.vitorpamplona.amethyst.desktop.auth.` (macOS) OR `cat ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth//prefs.xml` (Linux) | Empty / does not exist yet — tier-1 doesn't write | | | + +### B2: AUTH end-to-end — tier 2 (unknown relay, banner) +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B2.1 | Add an AUTH-required relay to the user's settings that is NOT in their `kind:10050` (e.g. `wss://pyramid.fiatjaf.com` if accessible, or any test relay with `auth-required` policy). | Relay appears in list | | | +| B2.2 | Send a DM that would route through that relay (e.g. user it's listed for). Or just connect and let the relay challenge. | Yellow/inline AUTH banner appears at top of content area with relay URL + `[Once] [Always] [Never]` | | | +| B2.3 | Click `[Once]` | Banner dismisses; AUTH proceeds for this session only | | | +| B2.4 | Restart app, repeat connection — banner appears again | banner returns (ONCE was session-only) | | | +| B2.5 | This time click `[Always]` | Banner dismisses; AUTH proceeds | | | +| B2.6 | Restart app. Banner does NOT appear for this relay. | tier-2 → tier-1 once persisted | | | +| B2.7 | Inspect Preferences node: should contain `relay.example.url=ALWAYS` | persistence verified | | | +| B2.8 | Block a different relay via `[Never]` | Future AUTH challenges from it are silently dropped (no banner, no AUTH event sent) | | | +| B2.9 | Confirm clicking `[Never]` writes `relay.url=BLOCKED` | persistence verified | | | + +### B3: AUTH banner — multiple concurrent challenges +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B3.1 | Connect to 3 different AUTH-required relays back-to-back, none auto-approved | 3 banner rows stack vertically | | | +| B3.2 | Resolve middle one with `[Once]` | Only that row dismisses; other 2 remain | | | +| B3.3 | Trigger 5 simultaneous AUTH challenges from different relays | First 3 visible inline; "+2 more relays pending approval" row at bottom | | | + +### B4: Banner lifecycle — logout / account switch +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B4.1 | With at least 2 pending banner rows visible, log out | Banners disappear; coordinator's `onLogout` completes all pending deferreds with BLOCKED | | | +| B4.2 | Log in to account B (different pubkey); trigger same AUTH challenges | Banners reappear because B has no persisted approvals from A | | | +| B4.3 | Verify B's `[Always]` writes to B's Preferences node, NOT A's | per-account isolation | | | +| B4.4 | Account A's persisted approvals still intact: log back into A → no banner for previously-approved relays | persistence stable across switches | | | + +### B5: NIP-17 send — security fix (no-10050 case) +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B5.1 | Pick a recipient who has NEVER published a kind:10050 (rare in practice; can fabricate a npub) | account known, no DM inbox advertised | | | +| B5.2 | Try to send a DM | `DmSendTracker` shows "No relays available" failure briefly | | | +| B5.3 | Inspect outgoing socket activity (e.g. Wireshark filtered to `wss://`) | NO gift wrap is published anywhere — neither to user's outbox nor general relays | | | +| B5.4 | DesktopRelayConnectionManager metrics: no spike for this send | confirmed | | | + +### B6: NIP-17 send — DmInboxRelayResolver indexer fan-out +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B6.1 | Pick a recipient who HAS a kind:10050 but whose 10050 is NOT in your LocalCache (fresh, never-DM'd contact) | empty LocalCache for that user | | | +| B6.2 | Click compose DM to them | Resolver consults indexer relays; brief delay (sub-second to ~3s) | | | +| B6.3 | Inspect network: indexer client (port-share with primary client?) makes one-shot queries to `relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com` | indexer fan-out confirmed | | | +| B6.4 | **CRITICAL — F-01**: verify NO AUTH event was sent on the indexer client even if any indexer challenged | confirms unauth client. If wrong, that's a security regression. Easy check: filter pcap for kind:22242 on the indexer connections. | | | +| B6.5 | Send succeeds; recipient's actual DM-inbox relay receives the wrap | normal NIP-17 delivery | | | +| B6.6 | Immediately compose another DM to the same recipient | Resolver hits LRU cache; no second indexer call | | | +| B6.7 | Wait > 1 hour (or set system clock forward); compose again | Resolver fans out again (cache expired) | | | + +### B7: NIP-17 send — group DM rumor coherence +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B7.1 | Create a 3-recipient group DM | NIP17Factory builds 3 wraps + 1 self-copy | | | +| B7.2 | Inspect the rumor inside each seal (use a Nostr event inspector or relay log) — `rumor.id` matches across all 3 wraps | shared rumor_created_at confirmed | | | +| B7.3 | One recipient sends a reaction (`+`) on their device | reaction targets shared `rumor.id`; all participants see it | | | +| B7.4 | Verify `wrap.created_at` is randomized per-wrap (within 2 days past) | seal/wrap timestamps stay independent | | | + +### B8: NIP-17 relay hint on wrap p-tag +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B8.1 | Send a DM. Capture the published kind:1059 event via your DM-inbox relay UI or a tool like `nostr-tool`. | event captured | | | +| B8.2 | Inspect the `p` tag on the wrap | shape is `["p", recipient_pubkey, relay_url]` — relay_url is recipient's primary DM relay if known, else 2-element shape | | | +| B8.3 | Verify the SEAL (kind 13) does NOT carry the hint | NIP-17 spec compliance | | | + +### B9: Outbox AUTH carve-out +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B9.1 | Pre-condition: relay R demanding AUTH. User has account that needs to AUTH. | configured | | | +| B9.2 | Publish a note to R while NOT yet authenticated | Relay replies `auth-required: ...` | | | +| B9.3 | Inspect outbox state: event remains queued for relay R | NOT discarded after 1 try | | | +| B9.4 | Watch for AUTH event sign and submission (tier-1 auto-sign or banner approval) | AUTH OK | | | +| B9.5 | Original note re-publishes successfully on R after AUTH | E.g. via syncFilters() | | | +| B9.6 | Repeat: send 5 notes in rapid succession during AUTH window | All 5 re-publish after AUTH, none silently dropped | | | + +### B10: Bunker concurrency cap +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B10.1 | Log in with a NIP-46 bunker account | bunker connected | | | +| B10.2 | Send a 5-recipient group DM | NIP17Factory caps at 4 concurrent bunker RPCs | | | +| B10.3 | Inspect bunker request timing (nsec.app / Amber log) | At most 4 in-flight at any moment | | | +| B10.4 | Compare to a 5-recipient group DM with a local nsec account | local-nsec runs all 5 in parallel; no semaphore overhead | | | +| B10.5 | Verify DM still delivers correctly to all recipients | functional parity | | | + +### B11: kind:1059 subscription — no `since` filter +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B11.1 | Inspect the actual REQ message sent for kind:1059 subscription (e.g. via relay debug or `nostr-tool` proxy) | filter has `kinds:[1059]`, `#p:[user_pubkey]`, NO `since` field | | | +| B11.2 | Have someone send you a DM with `created_at = now() - 1.5 days` (use a custom client) | wrap arrives, unread badge increments | | | +| B11.3 | Send self a DM, restart app, verify still loaded | persistent dedupe still working | | | + +### B12: SigningOpState.Progress +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| B12.1 | Existing zap / sign flows: trigger a sign, inspect status bar | "Waiting for signer approval... (Ns)" — unchanged | | | +| B12.2 | (Manual / future) Set `SigningState.updateProgress(2, 5)` from somewhere | Status bar shows "Signing (2 of 5)" | | | + +--- + +## C. Cross-platform sanity (manual, Android only if you have a device) + +### C1: Android — commons inheritance check +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| C1.1 | Build and install `./gradlew :amethyst:installDebug` | Android app launches | | | +| C1.2 | Send a NIP-17 DM from Android | works as before (Android doesn't yet use DmInboxRelayResolver / DesktopAuthCoordinator) | | | +| C1.3 | `User.dmInboxRelays()` behaviour: unchanged on Android | no regression | | | +| C1.4 | Confirm Android signing still goes through Android-only `AuthCoordinator` (not the new desktop one) | platform separation intact | | | + +--- + +## D. Security audit (mostly Claude-verifiable) + +### D1: Code/git inspection +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| D1.1 | `grep -rn "connectedRelays.value" desktopApp/.../DesktopIAccount.kt` | Zero remaining matches in NIP-17 paths (NIP-04 path may keep its broadcast-to-connected behaviour by design) | ✅ | 3 hits: lines 113-114 (`DesktopAccountRelays` defaults — unrelated), line 173 (NIP-04 broadcast — intentional). All three NIP-17 send paths use `resolveDmInboxRelaysStrict`. | +| D1.2 | `grep -rn "RelayAuthenticator" desktopApp/` | Only `DesktopAuthCoordinator` references; no other coordinator | ✅ | Single construction site: `DesktopAuthCoordinator.kt:106`. Doc-strings reference it elsewhere; no other coordinator class. | +| D1.3 | DmInboxRelayResolver uses a NostrClient distinct from `relayManager.client` | verified in Main.kt | ✅ | Main.kt:848-853 constructs `indexerClient = NostrClient(BasicOkHttpWebSocket.Builder(...))` separately; never passed `RelayAuthenticator`. | +| D1.4 | `grep -rn "dmInboxOrFallback" commons/ desktopApp/` | Zero matches — resolver uses `lists.dmInbox` strict | ✅ | Zero matches outside the Quartz definition site. | + +### D2: Threat checks +| # | Step | Expected | Pass? | Notes | +|---|------|----------|-------|-------| +| D2.1 | Inspect a single wrap's `p` tag: confirm only ONE pubkey listed (the recipient), no leakage of group members | wrap p-tag is single-recipient | | | +| D2.2 | Logout: verify `PreferencesAuthApprovalStore.clear()` is called for each account | (currently called via `DesktopAuthCoordinator.onLogout`'s `tearDownLocked`; but does it call `store.clear()`? Looking at code: it does NOT call clear — see follow-up below.) | | This is a known gap. See note. | + +### Known gap surfaced during sheet writing + +**D2.2**: `DesktopAuthCoordinator.onLogout` tears down the authenticator and completes pending deferreds, but does **not** call `store.clear()`. This is by design (`ALWAYS`/`BLOCKED` decisions persist across login sessions for the same account, scoped by `account_pubkey` in the Preferences node). Account deletion (separate from logout) is the trigger that should call `clear()`. **Follow-up:** verify Amethyst Desktop account-delete path calls `PreferencesAuthApprovalStore(pubKey).clear()`. Not in scope of this branch. + +--- + +## E. Sign-off + +| Section | Pass? | Notes | +|---|---|---| +| A — Automated | | | +| B — Desktop manual | | | +| C — Android sanity | | | +| D — Security audit | | | +| **Overall** | | | + +--- + +## What this branch does NOT ship (out-of-scope for testing) + +These were captured in the deepening synthesis and are explicit follow-ups, NOT regressions: + +- Persistent retry queue with exp-backoff (SQLite-backed) — substrate not yet built +- Per-message delivery state in chat bubbles (`✓` `✓✓` `⟳` `⚠`) — `DmSendTracker` is still global +- Bunker progress UI wiring — `SigningOpState.Progress` substrate landed but no caller yet emits per-step counts +- Window-focus re-AUTH coordinator — replaced with lazy reactive AUTH per deepening +- NIP-46 batch `get_conversation_keys` RPC — spec PR proposed in plan, no implementation +- Android UI parity for AUTH banner — Android stays on its existing unconditional `AuthCoordinator` +- Manual relay-entry dialog when recipient has no 10050 — replaced with Snackbar-equivalent "no relays" failure today +- NIP-09 deletion of self-copies on kind:10050 rotation — release-note disclosure only diff --git a/docs/plans/2026-06-17-desktop-dm-reliability-manual-testing.md b/docs/plans/2026-06-17-desktop-dm-reliability-manual-testing.md new file mode 100644 index 0000000000..5c45494103 --- /dev/null +++ b/docs/plans/2026-06-17-desktop-dm-reliability-manual-testing.md @@ -0,0 +1,731 @@ +# Desktop DM Reliability — Testing Playbook + +**Branch:** `feat/desktop-dm-reliability` rebased onto `upstream/main` +**Tester:** _______________________ +**Date:** _______________________ + +**Instructions:** Follow this top to bottom. Every step is an action or an observation. Don't skip ahead — later tests assume state from earlier ones. Total ≈ 40 min for full pass. + +--- + +## Session results — 2026-07-09 (live run) + +| Test | Result | Notes | +|------|--------|-------| +| T1 startup / AUTH wired | ✅ PASS | both `Init, Subscribe` + `AUTH wired` logged; no CME crash | +| T2 tier-1 self-DM (no banner) | ✅ PASS | published, no `PendingAuthApproval` prompt | +| T3.a tier-2 banner render | ✅ PASS | `relay.ditto.pub` banner, icon clear of traffic lights, 3 buttons | +| T3.c `Always` persists | ✅ PASS | `auth// wss://relay.ditto.pub/ = ALWAYS` in the plist | +| T6 no-10050 blocks send | ✅ PASS | "Recipient has no DM relay list", send disabled | +| T6b kind:10002-only blocks | ✅ PASS | same block; zero publish to the NIP-65 read relay | +| T8 wrap `p`-tag relay hint | ✅ PASS (after fix) | 3-element `["p", hex, wss://nos.lol/]` on the wrap | +| T12 kind:1059 sub has no `since` | ✅ PASS | `since` removed from `giftWrapsToMe` signature | +| T3.b `Once` / T3.d `Never` | ⏭️ not run | logic covered by `AuthApprovalEndToEndTest` | +| T9 group rumor.id | ⏭️ covered-by-construction | rumor signed once before the per-recipient loop | +| T10 AUTH-under-load / T11 bunker | ⏭️ not run | no challenging relay / bunker on hand | + +**Bugs found & fixed this run:** +1. `DmInboxRelayResolver` LocalCache fast-path used lenient `dmInboxRelays()` → NIP-65 read-relay leak. Now `dmInboxRelaysStrict()`. +2. `NewDmDialog` rendered pasted npubs of metadata-less users as non-clickable → couldn't start a DM by npub. Now `getOrCreateUser`. +3. NIP-17 `p`-tag relay hint was plumbed in quartz but never passed by `DesktopIAccount` → every wrap shipped a 2-element `p` tag. Now wired. + +--- + +## Setup (once, ~3 min) + +**1.** In a terminal, cd to the worktree and confirm you're on the right commit: + +```bash +cd /path/to/AmethystMultiplatform/.worktrees/feat/desktop-dm-reliability +git rev-parse HEAD +``` + +- Expect: `fcfc43eb44` (or later). If different: `git pull` and re-verify. + +**2.** Wipe any prior AUTH grants so persistence tests start clean: + +```bash +rm -rf ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth +``` + +**3.** Launch the app (keep this terminal visible — we'll read logs from it): + +```bash +./gradlew :desktopApp:run +``` + +- Expect: window appears in 15–30 s (cold) / 5 s (warm). + +**4.** In the app, log in with your primary account. Call this **User A**. + +- Expect: sidebar loads, feed populates. + +**5.** In the terminal, look for these two lines (they appear within 5 s of login): + +``` +[RelayAuthenticator] Init, Subscribe +[DesktopAuthCoordinator] AUTH wired for +``` + +- **If both appear:** ✅ setup complete. Proceed to T1. +- **If either is missing:** STOP. Tell me the terminal output. + +--- + +## T1 — Startup smoke check (already ✅ during setup) + +Nothing extra to do — the two log lines above ARE T1. + +- [ ] **T1 PASS** — both `Init, Subscribe` and `AUTH wired for ` printed with no exceptions + +--- + +## T2 — Tier-1 self-DM (no banner) — 2 min + +**Goal:** verify your own DM-inbox relays auto-AUTH silently. + +**Steps:** + +**1.** In the sidebar, click **Chats** (chat bubble icon). + +**2.** At the top of the conversation list, click the **`+` icon** (new conversation). + +**3.** Paste your OWN npub into the recipient field. Confirm. + +**4.** In the message box, type: `t1 self-dm test` + +**5.** Watch the top of the content area (where the yellow banner would appear). + +- **Expected:** send button enables blue → no yellow AUTH banner appears anywhere. + +**6.** Click the **send arrow** (right side of the message box). + +**7.** Wait 3 s. The message should appear in your inbox. + +- **Expected:** message appears in the conversation. Terminal has NO `AuthApprovalPolicy` prompt lines. + +**Record:** + +- [ ] **T2.1** No AUTH banner appeared: **YES / NO** +- [ ] **T2.2** Message arrived: **YES / NO** +- [ ] **T2 PASS** — both YES + +--- + +## T3 — Tier-2 banner + persistence — 8 min + +**Goal:** trigger a challenge from an AUTH-required relay NOT in your `kind:10050`, verify the banner renders + all three buttons persist correctly. + +### T3.a — Trigger the banner + +**1.** Open Settings. (Look for a gear/cog icon in the sidebar. If absent, try the app menu → Settings.) + +**2.** Go to the **Relays** tab. + +**3.** Find the "Add relay" input. Paste: `wss://pyramid.fiatjaf.com` + +**4.** Save/apply (button label varies — usually "Add" or "Save"). + +**5.** Wait 1–3 s. Watch the **top of the content area** (below the title bar, above the main content). + +- **Expected:** a yellow-tinted horizontal row slides in showing: + - Lock icon on the left (with proper margin from window edge — 80dp — not overlapping the traffic lights) + - `pyramid.fiatjaf.com` in a semi-bold heading + - Subtext: "requires authentication to deliver this message" + - Three buttons on the right: **`Once`** **`Always`** **`Never`** + +Record: + +- [ ] **T3.a.1** Banner appeared within 3 s: **YES / NO** +- [ ] **T3.a.2** Icon + text NOT overlapping traffic lights: **YES / NO** +- [ ] **T3.a.3** All three buttons visible: **YES / NO** + +### T3.b — `[Once]` behaviour (session-only, no persistence) + +**6.** Click `Once`. + +- **Expected:** banner slides away immediately, no visible change to relay state. + +**7.** In a second terminal, check the Preferences store did NOT get written for this relay. + +> **Prefs location (macOS).** This JVM uses the `MacOSXPreferences` backing +> store, NOT `~/.java/.userPrefs`. Java prefs land in +> `~/Library/Preferences/com.vitorpamplona.amethyst.plist` under an +> `auth//` node. Read it with `plutil`: + +```bash +plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto" +``` + +- **Expected:** empty output (ONCE is not persisted). + +**8.** Close the app (Cmd+Q). Wait 2 s. Relaunch via `./gradlew :desktopApp:run`. Log in as A again. + +**9.** Wait ~5 s. The banner for `pyramid.fiatjaf.com` should reappear (session state was not saved). + +Record: + +- [ ] **T3.b.1** After `[Once]`: Preferences NOT written: **YES / NO** +- [ ] **T3.b.2** After restart: banner reappeared: **YES / NO** + +### T3.c — `[Always]` behaviour (persisted grant) + +**10.** In the banner that just reappeared, click `Always`. + +- **Expected:** banner slides away, `pyramid.fiatjaf.com` now shows "Authenticated" in Settings → Relays. + +**11.** Check Preferences was written: + +```bash +plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "pyramid\|ditto\|ALWAYS" +``` + +- **Expected:** the relay URL (e.g. `wss://relay.ditto.pub/`) followed by `ALWAYS`, under the `auth//` node. + +**12.** Close app (Cmd+Q). Relaunch. Log in as A. + +- **Expected:** relay auto-authenticates in the background. **No banner appears** for `pyramid.fiatjaf.com`. + +Record: + +- [ ] **T3.c.1** Preferences shows `ALWAYS`: **YES / NO** +- [ ] **T3.c.2** After restart: no banner, auto-authenticated: **YES / NO** + +### T3.d — `[Never]` behaviour (persisted block) + +**13.** Add a different AUTH-required relay. If you have another, use it. Otherwise, try `wss://nostr.wine` (they AUTH-challenge non-subscribers) or `wss://relay.snort.social`. + +**14.** Wait for the new banner to appear. + +**15.** Click `Never`. + +- **Expected:** banner disappears. The relay shows as connected but "not authenticated". + +**16.** Check Preferences: + +```bash +plutil -convert xml1 -o - ~/Library/Preferences/com.vitorpamplona.amethyst.plist | grep -i "\|BLOCKED" +``` + +- **Expected:** the relay URL followed by `BLOCKED`, under the `auth//` node. + +**17.** Restart the app. Log in as A. + +- **Expected:** the BLOCKED relay never surfaces a banner. It stays "not authenticated". No `kind:22242` AUTH event ever sent to it. + +Record: + +- [ ] **T3.d.1** Preferences shows `BLOCKED`: **YES / NO** +- [ ] **T3.d.2** After restart: no banner, no AUTH sent: **YES / NO** + +--- + +**T3 sign-off:** Complete `T3.a`, `T3.b`, `T3.c`, `T3.d`. + +- [ ] **T3 PASS** — all four subs green + +--- + +## T4 — Multiple concurrent banners — 3 min + +**Goal:** verify multiple pending banners stack correctly and resolve independently. + +**Steps:** + +**1.** In Settings → Relays, quickly add 3 different AUTH-required relays back-to-back. Suggested set: + - `wss://pyramid.fiatjaf.com` (if not already blocked/allowed) + - `wss://relay.nostr.com.au` + - `wss://nostr.wine` + +**2.** Watch the banner area — all 3 rows should appear stacked vertically within ~3 s. + +**3.** Click `Once` on the **middle** row. + +- **Expected:** ONLY the middle row disappears. The other two remain visible. + +**4.** (Optional stress test) Add 5+ more AUTH-required relays. + +- **Expected:** first 3 shown inline; row at the bottom reads "+N more relays pending approval". + +Record: + +- [ ] **T4.1** 3 banners stack vertically: **YES / NO** +- [ ] **T4.2** Middle-row dismiss only affects itself: **YES / NO** +- [ ] **T4.3** "+N more" row shows when >3 pending: **YES / NO** +- [ ] **T4 PASS** — all three YES + +--- + +## T5 — Per-account isolation — 4 min + +**Goal:** verify AUTH grants are scoped per-account and cleaned on logout. + +**Steps:** + +**1.** Ensure A has at least one `ALWAYS` grant (from T3.c: `pyramid.fiatjaf.com`). + +**2.** Log out of A (sidebar → profile → Logout, or app menu). + +- **Terminal:** watch for `DesktopAuthCoordinator` teardown lines (no exceptions). + +**3.** Log in as User B (different pubkey — nsec, npub, or bunker). + +- **Terminal:** expect `[DesktopAuthCoordinator] AUTH wired for ` — different from A's. + +**4.** Add `wss://pyramid.fiatjaf.com` in Settings → Relays for B. + +- **Expected:** banner appears (B does NOT inherit A's `ALWAYS` grant). + +**5.** Check that A's and B's Preferences are separate: + +```bash +ls ~/.java/.userPrefs/com/vitorpamplona/amethyst/desktop/auth/ +``` + +- **Expected:** two directories, one per full pubkey. + +**6.** Click `Always` on B's banner. + +**7.** Log out of B. Log back into A. + +- **Terminal:** `AUTH wired for ` again. + +**8.** Watch for banners. + +- **Expected:** no banner for `pyramid.fiatjaf.com` (A's `ALWAYS` still persisted). + +Record: + +- [ ] **T5.1** Coordinator teardown clean on logout (no exceptions): **YES / NO** +- [ ] **T5.2** B sees banner for A-approved relay (isolation): **YES / NO** +- [ ] **T5.3** Two separate Preferences dirs exist: **YES / NO** +- [ ] **T5.4** Re-login to A: no re-prompt: **YES / NO** +- [ ] **T5 PASS** — all four YES + +--- + +## T6 — P0 security fix (no-10050 recipient) — 5 min + +**Goal:** verify DMs are NOT silently broadcast to your general relays when the recipient has no `kind:10050`. + +### T6.a — Create a "no-inbox" test recipient + +**1.** In a terminal, generate a fresh nsec/npub pair: + +```bash +# Option: use nak +nak key generate +# Copy the printed nsec and npub +``` + +Or use any known npub of an account that never published `kind:10050`. + +**2.** In Amethyst as A, click **`+`** in Chats. Paste the test npub. Confirm. + +### T6.b — Verify the UI blocks send + +**3.** Type any message. + +**4.** Look at the row below the message input. + +- **Expected:** red text "**Recipient has no DM relay list — messages cannot be delivered**" +- **Expected:** send button is grey/disabled. + +**5.** Try clicking send anyway. + +- **Expected:** nothing happens (button disabled). Or, if enabled by upstream UI quirk, `DmSendTracker` shows "No relays available" briefly. + +### T6.c — Verify no wrap leaves the app (optional, for the security-conscious) + +**6.** In a terminal, run: + +```bash +sudo tcpdump -i any -A -s 0 'tcp port 443 or tcp port 80' 2>/dev/null | grep -i "kind\":1059" +``` + +**7.** In the app, try to send. Watch the tcpdump output for 30 s. + +- **Expected:** zero output. No gift wrap (kind 1059) publishes anywhere. + +**8.** Stop tcpdump with Ctrl+C. + +Record: + +- [ ] **T6.1** UI shows "no DM relay list" warning: **YES / NO** +- [ ] **T6.2** Send button disabled: **YES / NO** +- [ ] **T6.3** No `kind":1059` in outgoing traffic during send attempt: **YES / NO / SKIPPED** +- [ ] **T6 PASS** — T6.1 and T6.2 both YES (T6.3 optional but recommended) + +--- + +## T6b — Strict kind:10050 (NIP-65 read-relay non-leak) — 4 min + +**Goal:** verify the review fix — a recipient that DOES publish NIP-65 read +relays (kind:10002) but has NO `kind:10050` is still treated as unreachable. +The lenient fast-path bug would have published the wrap to those NIP-65 read +relays; the fix must NOT. + +### T6b.a — Create a recipient with kind:10002 but no kind:10050 + +**1.** Generate a fresh key and publish ONLY a NIP-65 relay list (no 10050): + +```bash +nak key generate # copy nsec + npub +# publish a kind:10002 with a read relay, and NO kind:10050: +echo '{"kind":10002,"tags":[["r","wss://relay.damus.io","read"]],"content":""}' \ + | nak event --sec wss://relay.damus.io wss://nos.lol +``` + +**2.** As User A, open a new chat to that npub so A's LocalCache ingests the +recipient's kind:10002 (send/hover the profile so the relay list loads). + +### T6b.b — Verify send is blocked, not routed to the read relay + +**3.** Type a message. Observe the row under the input. + +- **Expected:** same "no DM relay list — messages cannot be delivered" + warning as T6; send disabled. +- **Wrong (pre-fix bug):** send is ENABLED and the wrap goes to + `wss://relay.damus.io` (the recipient's NIP-65 *read* relay). + +**4.** (Optional, definitive) tcpdump as in T6.c while attempting send. + +- **Expected:** zero `kind":1059` frames to the recipient's kind:10002 relays. + +Record: + +- [ ] **T6b.1** Send blocked despite recipient having kind:10002: **YES / NO** +- [ ] **T6b.2** No wrap sent to NIP-65 read relay (if tcpdump run): **YES / NO / SKIPPED** +- [ ] **T6b PASS** — T6b.1 YES + +--- + +## T7 — Indexer fan-out + F-01 unauth check — 6 min + +**Goal:** verify the resolver probes indexer relays with an UNAUTHENTICATED client (no `kind:22242` AUTH events leaked to indexers). + +### T7.a — Prime the state + +**1.** Restart the app (Cmd+Q, then `./gradlew :desktopApp:run`). + +- Fresh LocalCache = maximum chance the resolver actually fires. + +**2.** Log in as A. + +### T7.b — Set up traffic capture (optional but revealing) + +**3.** In a second terminal, start capturing all WebSocket traffic: + +```bash +sudo tshark -i any -Y 'websocket' -T fields -e ws.payload 2>/dev/null | head -c 100000 +``` + +Or (simpler): + +```bash +sudo tcpdump -i any -A -s 0 'tcp port 443' 2>/dev/null > /tmp/dm-traffic.log & +``` + +### T7.c — Trigger the resolver + +**4.** Pick a recipient who HAS a `kind:10050` published (a NIP-17-active account) but whom you have NEVER DM'd from account A. + +**5.** In Amethyst, click **`+`** in Chats. Paste the recipient's npub. Confirm. + +**6.** Watch the pre-send row. + +- **Expected sequence:** + - Initial: red "no DM relay list" warning (LocalCache miss). + - Within 2–5 s: warning disappears (resolver probe found the recipient's `kind:10050` on an indexer). + - Send button turns blue. + +### T7.d — Verify F-01 (no AUTH to indexer) + +**7.** Search the captured traffic for `kind:22242` AUTH events: + +```bash +grep -i "\"kind\":22242" /tmp/dm-traffic.log | head -20 +``` + +- **Expected:** any `kind:22242` events found should only be to relays in your existing DM-inbox set — NOT to the indexer set (`relay.nos.social`, `relay.damus.io`, `nos.lol`, `relay.nostr.band`, `purplerelay.com`). + +**8.** In the app, type a message and send. + +- **Expected:** send succeeds. Recipient's actual DM-inbox relay receives the wrap. + +**9.** Stop tcpdump: `sudo pkill tcpdump` + +### T7.e — Verify LRU cache hit on second send + +**10.** Immediately compose a second DM to the same recipient. Send. + +- **Expected:** send is immediate, no delay. Resolver hits its LRU cache, no new indexer probe. + +Record: + +- [ ] **T7.1** Warning cleared within 5 s (resolver probe worked): **YES / NO** +- [ ] **T7.2** Send button became enabled after probe: **YES / NO** +- [ ] **T7.3** No `kind:22242` AUTH sent to indexer relays: **YES / NO / SKIPPED** +- [ ] **T7.4** DM delivered to recipient: **YES / NO** +- [ ] **T7.5** Second DM to same recipient: no probe delay: **YES / NO** +- [ ] **T7 PASS** — T7.1, T7.2, T7.4, T7.5 all YES + +--- + +## T8 — Wrap `p`-tag relay hint — 4 min + +**Goal:** verify the outgoing gift wrap includes the recipient's primary DM relay as the third element of the `p` tag. + +**Steps:** + +**1.** Send a DM to any recipient with a known `kind:10050` (e.g. the one from T7). + +**2.** In a terminal, use `nak` (or `websocat`) to query one of the recipient's DM-inbox relays for their gift wraps: + +```bash +RECIPIENT_HEX= +DM_RELAY= + +nak req -k 1059 --tag "p=$RECIPIENT_HEX" "$DM_RELAY" | head -5 +``` + +**3.** Find the wrap you just sent (highest `created_at`). Look at its `p` tag. + +- **Expected:** `["p", "", "wss://recipient-primary-relay/"]` — 3 elements, third is a valid relay URL. + +**4.** For contrast, send a DM to a recipient whose `kind:10050` you have NO indexer/cache hit for (e.g. the one from T6 if you have their nsec to simulate — otherwise skip). + +- **Expected:** wrap's `p` tag has only 2 elements: `["p", ""]` — no fake empty third element. + +Record: + +- [ ] **T8.1** With known relay: 3-element `p` tag: **YES / NO** +- [ ] **T8.2** Without known relay: 2-element `p` tag (no empty third): **YES / NO / SKIPPED** +- [ ] **T8 PASS** — T8.1 YES + +--- + +## T9 — Group DM shared `rumor.id` — 5 min + +**Goal:** verify all recipient wraps in a group DM decrypt to a rumor with the SAME `id`. + +**Steps:** + +**1.** In Amethyst as A, click **`+`** in Chats. Add 3 recipient npubs (you can include yourself as one, plus 2 others whose `kind:10050` is known). + +**2.** Type a distinctive message: `t9 group rumor coherence test`. Send. + +**3.** For each recipient, use `nak` to fetch the gift wrap from their DM-inbox relay: + +```bash +for RECIPIENT in $RECIPIENT_A $RECIPIENT_B $RECIPIENT_C; do + nak req -k 1059 --tag "p=$RECIPIENT" wss://relay.example/ | head -3 +done +``` + +**4.** Ideally decrypt each wrap (requires each recipient's nsec). But since all 3 seals encode the same rumor, the rumor `id` should be identical across the 3 wraps. + +**5.** If you have at least 2 recipient nsecs, decrypt via `nak`: + +```bash +nak decrypt --sec $NSEC "" +# Look at the inner rumor's "id" field +``` + +**6.** Compare the rumor `id` across the wraps. + +- **Expected:** all 3 rumor `id`s are IDENTICAL. + +**7.** (Bonus) Have one of the recipients (in another Amethyst instance or via nak) react to the message with `+`. + +**8.** Confirm A and other recipients see the reaction. + +- **Expected:** reaction targets the shared `rumor.id` and appears cross-recipient. + +Record: + +- [ ] **T9.1** All wraps decrypt to same rumor.id: **YES / NO / SKIPPED (needs multi-account decrypt)** +- [ ] **T9.2** Cross-recipient reaction visible: **YES / NO / SKIPPED** +- [ ] **T9 PASS** — T9.1 YES (or explicitly skipped) + +--- + +## T10 — Outbox AUTH carve-out under load — 4 min + +**Goal:** verify multiple queued events are NOT silently dropped during AUTH negotiation. + +**Steps:** + +**1.** In Settings → Relays, ensure you have `wss://pyramid.fiatjaf.com` connected. If you `[Always]`-approved it in T3.c, first log out and back in so the relay reconnects and re-challenges. + +**2.** In the compose dialog, publish 5 notes rapidly (10 seconds apart is fine): + +``` +t10 note 1 +t10 note 2 +t10 note 3 +t10 note 4 +t10 note 5 +``` + +**3.** In the terminal, watch for AUTH activity on `pyramid.fiatjaf.com`: + +``` +[RelayAuthenticator] ... auth-required: ... +[RelayAuthenticator] ... AUTH accepted ... +``` + +**4.** Once AUTH completes, all 5 notes should publish to `pyramid.fiatjaf.com`. + +**5.** Verify by querying `pyramid.fiatjaf.com` for your recent notes: + +```bash +nak req -a $A_HEX -k 1 wss://pyramid.fiatjaf.com | head -10 +``` + +- **Expected:** all 5 `t10 note N` events present on `pyramid.fiatjaf.com`. + +Record: + +- [ ] **T10.1** All 5 notes visible on `pyramid.fiatjaf.com`: **YES / NO** +- [ ] **T10.2** Terminal shows AUTH succeeded before drops: **YES / NO** +- [ ] **T10 PASS** — T10.1 YES + +--- + +## T11 — Bunker `Semaphore(4)` (bunker users only) — 3 min + +**Skip if you don't have a NIP-46 bunker (nsec.app / Amber).** + +**Goal:** verify NIP-17 group DMs are rate-limited to ≤4 concurrent bunker RPCs. + +**Steps:** + +**1.** Log out. Log in with a bunker (`bunker://` URI). + +**2.** Compose a group DM to **5 recipients** (5 different npubs with known `kind:10050`). + +**3.** In nsec.app / Amber, watch the request feed as you click Send. + +- **Expected:** at most **4** requests in-flight at any moment. Requests process in batches of 4. + +**4.** For comparison: log out, log back in with a local nsec. Repeat the 5-recipient send. + +- **Expected:** local signer runs all 5 requests in parallel (no Semaphore cap). + +Record: + +- [ ] **T11.1** Bunker: ≤4 concurrent RPCs: **YES / NO / SKIPPED (no bunker)** +- [ ] **T11.2** Local: fully parallel: **YES / NO / SKIPPED** +- [ ] **T11 PASS** — either both YES or both SKIPPED + +--- + +## T12 — kind:1059 subscription has no `since` — 3 min + +**Goal:** verify the outgoing REQ for gift wraps has NO `since` filter (would silently drop old-timestamped wraps). + +**Steps:** + +**1.** In one terminal, run a WebSocket relay proxy that echoes traffic (or use `nak` to inspect): + +```bash +# Simplest: read the desktop subscription code directly +grep -n "FilterDMs.giftWrapsToMe\|since" desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt +``` + +- **Expected:** signature `fun giftWrapsToMe(userPubKeyHex: HexKey)` — **NO `since` parameter**. + +**2.** Live check (harder but definitive): use `mitmproxy` or `websocat` in proxy mode to intercept WebSocket traffic from the app. + +**3.** Alternatively: rely on the unit tests. Confirm they pass: + +```bash +./gradlew :quartz:jvmTest --tests "com.vitorpamplona.quartz.nip59Giftwrap.wraps.*" +``` + +- **Expected:** BUILD SUCCESSFUL. + +Record: + +- [ ] **T12.1** `giftWrapsToMe` signature has no `since`: **YES / NO** +- [ ] **T12.2** Unit tests pass: **YES / NO** +- [ ] **T12 PASS** — both YES + +--- + +## T13 — Pre-send alignment + resolver probe (verified working) — sanity re-check, 3 min + +**Already confirmed** during the pre-launch fix. Quick re-check: + +**Steps:** + +**1.** In Amethyst as A, open a fresh DM with a recipient whose `kind:10050` is NOT in your LocalCache (e.g. a fresh contact — click a new profile, then compose DM). + +**2.** Watch the pre-send row. + +- **Expected:** red "no DM relay list" warning appears initially. + +**3.** Wait 2–5 s. + +- **Expected:** warning clears on its own (resolver probe found the recipient via indexer). Send button turns blue. + +Record: + +- [ ] **T13.1** Warning appears initially: **YES / NO** +- [ ] **T13.2** Warning clears within 5 s (resolver worked): **YES / NO** +- [ ] **T13 PASS** — both YES + +--- + +## Sign-off + +| Test | Pass? | Notes | +|---|---|---| +| Setup | ⬜ | | +| T1 startup wiring | ⬜ | | +| T2 tier-1 self-DM | ⬜ | | +| T3 tier-2 banner (T3.a–T3.d) | ⬜ | | +| T4 multiple banners | ⬜ | | +| T5 per-account isolation | ⬜ | | +| **T6 P0 SECURITY** | ⬜ | Highest priority | +| **T7 F-01 unauth indexer** | ⬜ | Highest priority | +| T8 wrap p-tag relay hint | ⬜ | | +| T9 group DM rumor id | ⬜ | | +| T10 outbox AUTH carve-out | ⬜ | | +| T11 bunker Semaphore | ⬜ | Skip if no bunker | +| T12 no since filter | ⬜ | | +| T13 pre-send alignment | ⬜ | | + +**Overall:** ⬜ PASS — ready for PR / ⬜ FAIL — see blockers / ⬜ NEEDS REVISIT + +**Blockers:** _______________________________________________________ + +**Tester signature:** _______________________ **Date:** _______________________ + +--- + +## Known pre-existing issues (NOT branch regressions) + +- **`ConcurrentModificationException` at `RelayLatencyTracker.sweep:182`** during rapid account switching. Kills UI thread; coroutines keep running. Documented in memory `desktop_relay_health_cme_crash`. +- **`NoClassDefFoundError` for `CompressionQuality`** on stale gradle daemon. Fix: `./gradlew --stop && ./gradlew :desktopApp:run`. + +## Known non-issues (don't file as bugs) + +- `[GiftWrapEvent] Couldn't Decrypt the content …` debug lines — normal LocalCache trial-decrypt for wraps not addressed to you. +- VLC `securetransport tls client error` — pre-existing media playback warnings. +- `[NIP19 Parser] Issue trying to Decode NIP19 …` — pre-existing, malformed identifiers in some events. +- `DmBroadcastBanner` (send-progress) may render simultaneously with `AuthApprovalBanner` — distinguish by buttons: AUTH banner has `Once/Always/Never`; broadcast has send-count status. + +## Out of scope for this branch + +Explicit follow-ups per the deepening synthesis: +- Persistent retry queue with exp-backoff (SQLite-backed) +- Per-message delivery state in bubbles (`✓` `✓✓` `⟳` `⚠`) +- Bunker progress UI wiring +- Window-focus re-AUTH +- NIP-46 batch `get_conversation_keys` RPC +- Android UI parity for AUTH banner +- Manual relay-entry dialog when recipient has no 10050 +- NIP-09 deletion of self-copies on kind:10050 rotation +- Fix for pre-existing `RelayLatencyTracker.sweep` CME diff --git a/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md b/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md new file mode 100644 index 0000000000..dab7ff006b --- /dev/null +++ b/docs/plans/2026-07-01-feat-desktop-wot-score-plan.md @@ -0,0 +1,1090 @@ +--- +title: Desktop Web-of-Trust Score Badges +type: feat +status: active +date: 2026-07-01 +origin: docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md +deepened: 2026-07-01 +--- + +# Desktop Web-of-Trust Score Badges + +## Enhancement Summary + +**Deepened on:** 2026-07-01 (same day as plan write). + +**Review agents used:** architecture-strategist, code-simplicity-reviewer, +pattern-recognition-specialist, performance-oracle, security-sentinel, +agent-native-reviewer · plus best-practices research (SnapshotStateMap + +Compose tooltip patterns) and a code-verification sweep. + +### Key corrections vs initial draft + +1. **Prerequisite: fix `DesktopLocalCache.consumeContactList`.** The + current implementation writes `_followedUsers = event.verifiedFollowKeySet()` + for **any** incoming kind-3, gated only on a single global + `lastContactListCreatedAt` scalar. Once WoT starts fetching followed + authors' kind-3 events, this **actively corrupts** the active user's + follow-set state. Refactor to per-author `Map` and + guard the `_followedUsers` / `lastContactListEvent` write on + `event.pubKey == account.pubKeyHex`. This is a **prerequisite commit** + in this PR — WoT cannot ship without it. +2. **Badge is a slot, not an embed.** `UserAvatar` in `commonMain` gets + an optional `badge: @Composable (BoxScope.() -> Unit)? = null` + parameter. Desktop call sites pass a `WoTBadge()`-bearing lambda; + Android passes null. No CompositionLocal reads inside the shared + composable, no `expect/actual` dance, `TooltipBox` import stays in + the Desktop-only source set of the caller. +3. **Service on `DesktopIAccount`, not in `remember`.** Match the + established pattern of `Kind3FollowListState`, `BookmarkListState`, + `Nip65RelayListState` — the service is a field on `DesktopIAccount` + constructed with `account.scope`, exposed as a property, provided via + `LocalWoTService` from `Main.kt`. Lifecycle matches the login session, + not the composition. +4. **Kind-3 event flow via `SharedFlow`.** Not a + mutable listener list. `DesktopLocalCache` exposes a + `SharedFlow` (buffer 64, `DROP_OLDEST` overflow), + emitted from inside `consumeContactList`. WoTService collects it from + an `account.scope`-scoped coroutine. +5. **Batch kind-3 loader — chunk 100 per Filter, explicit `onEose` hook.** + Existing `FeedMetadataCoordinator.loadMetadataBatched` at line 267 + silently does `authors.take(100)` — blind copy would drop 400 of 500 + follows. New `loadKind3Batched` chunks into ≤100-author Filters, + aggregates EOSE across chunks, and exposes `onEose: () -> Unit` so + the caller can `markReady()` on real EOSE (not just the 2 s timeout). +6. **Use Material3 `TooltipBox`, not `TooltipArea`.** Multiplatform, + built-in a11y (screen readers, keyboard focus), and the JetBrains + deprecation direction ([JB #4275](https://github.com/JetBrains/compose-multiplatform/issues/4275)). + Set `state = rememberTooltipState(isPersistent = true)` to fix the + known "vanishes too fast" desktop bug. +7. **`WoTScore` sealed hierarchy → plain `Int` + sparse map.** Drop + `Unknown` — represent "not queried" as *absence* from the map. + `_scores.remove(target)` when count hits 0. Keeps the map sparse and + Compose subscriber tracking cheap. +8. **Drop `derivedStateOf` at the leaf.** Plain + `service.scores[userHex] ?: 0` is already snapshot-tracked per key. + `derivedStateOf` adds a subscriber node and a comparator per avatar + for no gain when there's only one read. +9. **Readiness as a plain `Boolean` CompositionLocal, not per-avatar + `collectAsState`.** Collect `isReady` once at App root, provide via + `LocalWoTReady`. 150 collectors per screen becomes 1. +10. **Batch writes with `Snapshot.withMutableSnapshot { }`.** + `applyKind3` mutates `_scores` for many keys — wrap the loop in a + single mutable snapshot so all readers see one atomic frame. +11. **Amy verbs ship in v1 (not v2).** `amy wot get `, + `amy wot list --threshold N`, `amy wot sync`. Service exposes + `scoresSnapshot(): Map` for headless readers. + `FsEventStore` at `~/.amy/shared/events-store/` already caches + kind-3 events → warm-cache queries need no relay traffic. +12. **Bound follows per event.** Cap `verifiedFollowKeySet()` result at + 5000 entries in `applyKind3` — prevents CPU DoS from a hostile + follower publishing a 100k-tag kind-3. +13. **`WoTService` writes on `Dispatchers.Default`, single-writer + coroutine.** Serialize `applyKind3`, `onFollowSetChange`, etc. + inside an `actor`-style coroutine so composite state stays + consistent across concurrent kind-3 arrivals. + +### New considerations discovered + +- Simplicity review argued for dropping `WoTScore`, `isReady`, and + `perFollowerSnapshot`. Kept the last two (correctness vs churn) but + agreed on dropping `WoTScore`. +- Pattern review pushed for `commons/moderation/wot/` (sibling to + hashtag-spam). Kept **`commons/wot/`** — v1 is display-only, not + moderation. If v2 adds filtering we relocate then. +- Security review confirmed: follow-list leak via batch REQ is + **pre-existing** (metadata coordinator already sends the same + `authors` list to `indexRelays`). WoT introduces no novel privacy + regression. +- All ingested events are `event.verify()`-checked before + `LocalCache.consume` runs (`DesktopLocalCache.kt:191`) — forged + kind-3s cannot pass the sig check. Score inflation via fake events is + not a viable attack. + +--- + +## Overview + +Compute a friends-of-friends trust score for every pubkey based on the +active user's follow graph, and render it as a small number chip +overlaid on `UserAvatar`s at Desktop call sites. **v1 is +display-only** — no filtering. Kind-3 follow lists for the active user's +follows are fetched via a proactive chunked batch REQ at login and kept +current through incremental diff updates. + +**Carried forward from brainstorm** +(`docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md`): +raw-count semantics · display-only · auto-hide badge when score = 0 · +hide badge on already-followed authors and self · number chip in avatar +corner · tooltip explaining the count · proactive batch kind-3 REQ at +login + refresh on follow-list change · no cross-session persistence · +no settings UI. + +**Resolved during planning + deepening:** +- **Platform target:** Desktop only in v1. Badge is a slot on shared + `UserAvatar`; Desktop call sites pass the lambda, Android passes null. +- **Score model:** plain `Int` in a sparse `SnapshotStateMap`. Absence + ≡ "not queried yet or definitively zero." Both hide the badge. +- **Prerequisite:** fix `consumeContactList` scope corruption. +- **Batch REQ:** chunked into ≤100-author Filters, aggregated EOSE. +- **Startup gate:** `isReady = true` after first-chunk EOSE OR 2 s + timeout, whichever first. Single `Boolean` provided via CompositionLocal. +- **Guardrail:** skip WoT graph if `myFollows.size > 2000`. +- **Overflow:** display `"99+"` when count > 99. +- **Amy CLI:** three verbs ship in v1 (`get`, `list`, `sync`). + +## Problem Statement + +Nostr's public graph makes it easy for a stranger to appear in your +notifications, mentions, search results, or as a repost's original +author. Without a trust cue you have to click into each profile to +assess. Gossip and Snort solved this with a friends-of-friends count: +"N of the people you follow also follow this person." On Desktop, where +3–6 columns and dozens of avatars are on screen at once, that cue +scales better than mobile. Amethyst Desktop today shows every pubkey +identically. + +## Proposed Solution + +### High-level approach + +Introduce a per-account **`WoTService`** attached to +`DesktopIAccount` (mirroring `kind3FollowList`, `bookmarkList`, +`nip65RelayList`). At login the service: + +1. Observes the active user's follow set from + `DesktopLocalCache.followedUsers`. +2. Issues chunked batch REQs (≤100 authors each, aggregated EOSE) for + `kinds=[3]` on the same `indexRelays` used by + `FeedMetadataCoordinator.loadMetadataBatched`. +3. Collects a new `DesktopLocalCache.contactListEvents: + SharedFlow` and calls `applyKind3(...)` for each + event whose author is in the follow set. +4. Maintains a **reverse index** (`Map>` + from target-pubkey → set of my-follows who follow them) and a + **per-follower snapshot** (`Map>`) for diff + updates. +5. Publishes score changes atomically to `_scores: SnapshotStateMap` + inside a `Snapshot.withMutableSnapshot { }` block. +6. Marks `isReady = true` on aggregated EOSE or 2 s timeout — whichever + fires first. + +Desktop UI uses a sibling composable **`WoTBadgedAvatar`** (in +`desktopApp/.../ui/note/`) that composes the shared `UserAvatar` with a +badge slot. The slot renders a `WoTBadge` when four gates pass: + +- `LocalWoTReady.current == true` +- `service.scores[pubkey] > 0` +- `pubkey != selfPubkey` +- `pubkey !in followedKeys` + +`WoTBadge` uses Material3 `TooltipBox` with +`state = rememberTooltipState(isPersistent = true)`. + +### Why this shape + +- **Per-account service on `DesktopIAccount`** matches the codebase + convention (`kind3FollowList`, `bookmarkList`). `remember` inside + composition ties lifecycle to the composable's tree, which is wrong + for a data service. +- **Sibling composable, not embedded slot in `UserAvatar`.** Prior art: + `BunkerHeartbeatIndicator` — decoration lives next to the primitive, + not inside it. Explicit call-site migration is a feature: it lets us + ship v1 on high-value surfaces (feeds, thread, notifications, profile) + and defer minor spots. +- **Slot on `UserAvatar` for Desktop-owned rendering.** Even the + sibling composable needs somewhere to draw the badge. Adding a + scalar `badge` slot to `UserAvatar` avoids duplicating the entire + avatar layout and keeps Android intact (nulls the slot). +- **Sparse `SnapshotStateMap`.** Storing `Unknown` for every unqueried + pubkey would bloat the map to 250 k entries; keeping it sparse (only + positive scores) reduces subscriber overhead 10×. +- **Chunked batch REQ.** Relays vary wildly in filter-size caps + (nostr-rs-relay defaults ~100, strfry accepts hundreds). Chunking + 100 authors per Filter within one subscription is the correct + pragmatic default. +- **Diff-based `applyKind3`.** Kind-3 events churn (many clients + republish frequently). Full recompute on every event would drop the + reverse index and lose recomposition isolation. Diff keeps + per-target changes minimal. +- **Amy parity in v1.** Service is a plain class in `commons/`; the + three verbs are ~150 LOC total. Skipping them trains the wrong habit. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ commons/ (platform-agnostic; callable by Desktop, amy, Android) │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ commonMain/ │ │ +│ │ wot/WoTService.kt Snapshot map + reverse index │ │ +│ │ + applyKind3, onFollowSetChange │ │ +│ │ + awaitReady(onEose, timeout) │ │ +│ │ + scoresSnapshot() (headless) │ │ +│ │ wot/LocalWoTService.kt CompositionLocal (nullable) │ │ +│ │ wot/LocalWoTReady.kt CompositionLocal │ │ +│ │ ui/components/UserAvatar.kt + badge: @Composable slot │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + ▲ + │ +┌─────────────────────────────┴────────────────────────────────────┐ +│ desktopApp/ │ +│ cache/DesktopLocalCache.kt (PREREQUISITE FIX) │ +│ - lastContactListByAuthor: MutableMap │ +│ - contactListEvents: SharedFlow │ +│ - _followedUsers writes gated on event.pubKey==self │ +│ model/DesktopIAccount.kt │ +│ val wotService = WoTService(scope) │ +│ subscriptions/DesktopRelaySubscriptionsCoordinator.kt │ +│ fun loadKind3Batched(pubkeys, onEose: () -> Unit) │ +│ ui/note/WoTBadgedAvatar.kt │ +│ Composes UserAvatar with a WoTBadge slot lambda │ +│ ui/note/WoTBadge.kt │ +│ Material3 TooltipBox + Box overlay chip │ +│ Main.kt (LoggedIn branch) │ +│ CompositionLocalProvider( │ +│ LocalWoTService provides account.wotService, │ +│ LocalWoTReady provides isReadyCollected, │ +│ ) { … } │ +│ │ +│ Call sites migrated (v1 subset): │ +│ - FeedNoteCard header │ +│ - QuotedNoteEmbed author │ +│ - Thread reply avatars │ +│ - Notifications item │ +│ - Search result item │ +│ - Profile header │ +│ (Other avatars deferred; migration is opportunistic.) │ +└──────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────┐ +│ cli/ (amy — v1 verbs) │ +│ commands/WotCommand.kt │ +│ amy wot get [--json] │ +│ amy wot list [--threshold N] [--limit K] [--json] │ +│ amy wot sync (one-shot batch REQ, exits on EOSE / 5s) │ +│ Context.kt exposes an FsEventStore hydration primitive │ +│ WoTService.hydrateFromStore(store, myFollows) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Prerequisite: `consumeContactList` cache fix + +This must land before or in the same PR as the WoT service. Otherwise +the batch REQ we introduce will trigger the corruption bug. + +**File:** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453` + +Change: + +```kotlin +// BEFORE (buggy — any kind-3 with newer createdAt wins globally) +private fun consumeContactList(event: ContactListEvent): Boolean { + if (event.createdAt <= lastContactListCreatedAt) return false + lastContactListCreatedAt = event.createdAt + lastContactListEvent = event + _followedUsers.value = event.verifiedFollowKeySet() + return true +} + +// AFTER (per-author tracking + self-guard + SharedFlow emit) +private val lastContactListByAuthor = mutableMapOf() +private val _contactListEvents = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, +) +val contactListEvents: SharedFlow = _contactListEvents.asSharedFlow() + +private fun consumeContactList(event: ContactListEvent): Boolean { + val prev = lastContactListByAuthor[event.pubKey] ?: 0L + if (event.createdAt <= prev) return false + lastContactListByAuthor[event.pubKey] = event.createdAt + + // Active-user's kind-3 updates local follow-set state. + if (event.pubKey == accountPubkey) { + lastContactListEvent = event + _followedUsers.value = event.verifiedFollowKeySet() + } + + // All kind-3 events fan out on the SharedFlow for consumers (WoTService). + _contactListEvents.tryEmit(event) + return true +} +``` + +### Core algorithm (updated) + +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/wot/WoTService.kt`: + +```kotlin +@Stable +class WoTService( + private val scope: CoroutineScope, +) { + // Truth-map: pubkey → count of my-follows who follow them. + // Sparse — entries with count = 0 are removed. + private val _scores: SnapshotStateMap = mutableStateMapOf() + val scores: SnapshotStateMap get() = _scores + + // Internal state — always mutated from the [writer] actor coroutine. + private val reverseIndex = HashMap>() + private val perFollowerSnapshot = HashMap>() + private var myFollows: Set = emptySet() + private var selfPubkey: HexKey? = null + + // Readiness + private val readyOnce = AtomicBoolean(false) + private val _isReady = MutableStateFlow(false) + val isReady: StateFlow = _isReady.asStateFlow() + + // Serialize state mutations + private val ops = Channel(capacity = Channel.BUFFERED) + + init { + scope.launch(Dispatchers.Default) { + for (op in ops) processOne(op) + } + } + + private sealed interface Op { + data class FollowSet(val new: Set, val self: HexKey?) : Op + data class Kind3(val follower: HexKey, val follows: Set) : Op + object MarkReady : Op + } + + fun onFollowSetChange(newFollows: Set, newSelf: HexKey?) { + ops.trySend(Op.FollowSet(newFollows, newSelf)) + } + + fun applyKind3(follower: HexKey, follows: Set) { + val bounded = if (follows.size > MAX_FOLLOWS_PER_EVENT) { + follows.take(MAX_FOLLOWS_PER_EVENT).toSet() + } else follows + ops.trySend(Op.Kind3(follower, bounded)) + } + + fun markReadyOnce() { + if (readyOnce.compareAndSet(false, true)) ops.trySend(Op.MarkReady) + } + + /** For amy — returns a plain snapshot, no Compose runtime required. */ + fun scoresSnapshot(): Map = HashMap(_scores) + + /** For amy — hydrate from a local event store before querying. */ + suspend fun hydrateFromStore(store: IEventStore, myFollows: Set) { + onFollowSetChange(myFollows, selfPubkey) + store.iterateBy(kinds = setOf(ContactListEvent.KIND), authors = myFollows) { event -> + applyKind3(event.pubKey, (event as ContactListEvent).verifiedFollowKeySet()) + } + } + + fun clear() { ops.trySend(Op.FollowSet(emptySet(), null)) } + + private fun processOne(op: Op) { + Snapshot.withMutableSnapshot { + when (op) { + is Op.FollowSet -> handleFollowSet(op.new, op.self) + is Op.Kind3 -> handleKind3(op.follower, op.follows) + Op.MarkReady -> _isReady.value = true + } + } + } + + private fun handleFollowSet(newFollows: Set, newSelf: HexKey?) { + val added = newFollows - myFollows + val removed = myFollows - newFollows + myFollows = newFollows + selfPubkey = newSelf + + // Guardrail + if (myFollows.size > MAX_FOLLOWS) { + _scores.clear(); reverseIndex.clear(); perFollowerSnapshot.clear() + markReadyOnce() + return + } + + // Uncredit removed followers + removed.forEach { follower -> + perFollowerSnapshot.remove(follower)?.forEach { target -> + reverseIndex[target]?.let { set -> + set.remove(follower) + updateScore(target) + } + } + } + // Added followers are credited when their kind-3 arrives. + } + + private fun handleKind3(follower: HexKey, follows: Set) { + if (follower !in myFollows) return + val old = perFollowerSnapshot[follower] ?: emptySet() + val excluded = setOfNotNull(follower, selfPubkey) + val effective = follows - excluded + val added = effective - old + val removed = old - effective + perFollowerSnapshot[follower] = effective + + added.forEach { target -> + reverseIndex.getOrPut(target) { hashSetOf() }.add(follower) + updateScore(target) + } + removed.forEach { target -> + reverseIndex[target]?.let { set -> + set.remove(follower) + if (set.isEmpty()) reverseIndex.remove(target) + updateScore(target) + } + } + } + + private fun updateScore(target: HexKey) { + val n = reverseIndex[target]?.size ?: 0 + if (n > 0) _scores[target] = n else _scores.remove(target) + } + + companion object { + const val MAX_FOLLOWS = 2000 + const val MAX_FOLLOWS_PER_EVENT = 5000 + } +} +``` + +Notes: +- All mutations run inside a single writer coroutine on + `Dispatchers.Default` — no concurrent map access races. +- `Snapshot.withMutableSnapshot { }` wraps each op so Compose readers + see one atomic frame per event. +- `applyKind3` bounds `follows.size` to 5 000 at ingest — DoS protection + against a hostile 100 k-tag kind-3. +- `_scores.remove(target)` on 0-count keeps the map sparse — Compose + subscriber tracking scales with map size. + +### Batch kind-3 loader (with explicit EOSE hook + chunking) + +Add to `FeedMetadataCoordinator` in `commons/.../relayClient/assemblers/`: + +```kotlin +private val queuedKind3Pubkeys = mutableSetOf() + +/** + * Fetches kind-3 for a batch of authors, chunking into ≤100-author + * Filters within a single subscription. Calls [onEose] once all + * chunks EOSE (or after [timeoutMs]). + */ +fun loadKind3Batched( + pubkeys: Collection, + timeoutMs: Long = 5_000L, + onEose: () -> Unit = {}, +) { + val newPubkeys = pubkeys.filter { it !in queuedKind3Pubkeys }.distinct() + if (newPubkeys.isEmpty()) { onEose(); return } + queuedKind3Pubkeys.addAll(newPubkeys) + + scope.launch { + val chunks = newPubkeys.chunked(100) + val filters = chunks.map { chunk -> + Filter( + kinds = listOf(ContactListEvent.KIND), + authors = chunk, + limit = chunk.size, + ) + } + val filterMap = indexRelays.associateWith { filters } + val subId = newSubId() + val eoseReceived = mutableSetOf() + val allEose = CompletableDeferred() + + val listener = object : SubscriptionListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + this@FeedMetadataCoordinator.onEvent?.invoke(event, relay) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.add(relay) + if (eoseReceived.size >= indexRelays.size) allEose.complete(Unit) + } + } + + client.subscribe(subId, filterMap, listener) + withTimeoutOrNull(timeoutMs) { allEose.await() } + client.unsubscribe(subId) + onEose() + } +} +``` + +`DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose)` +delegates. + +### `UserAvatar` badge slot (commonMain change) + +```kotlin +@Composable +fun UserAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + /* existing params… */ + badge: @Composable (BoxScope.() -> Unit)? = null, +) { + if (badge == null) { + // Existing single-image render path — unchanged + AvatarImage(userHex, pictureUrl, size, modifier, /* … */) + } else { + Box(modifier.size(size)) { + AvatarImage(userHex, pictureUrl, size, Modifier, /* … */) + badge() + } + } +} +``` + +### `WoTBadgedAvatar` (Desktop-only, drop-in replacement at v1 call sites) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadgedAvatar.kt`: + +```kotlin +@Composable +fun WoTBadgedAvatar( + userHex: String, + pictureUrl: String?, + size: Dp, + modifier: Modifier = Modifier, + /* … pass-through params matching UserAvatar … */ +) { + val service = LocalWoTService.current + val ready = LocalWoTReady.current + val selfKey = LocalWoTSelfKey.current + val followedKeys = LocalWoTFollowedKeys.current + + val score = if (service != null && ready && userHex != selfKey && userHex !in followedKeys) { + service.scores[userHex] ?: 0 // plain snapshot read, per-key tracked + } else 0 + + UserAvatar( + userHex = userHex, + pictureUrl = pictureUrl, + size = size, + modifier = modifier, + badge = if (score > 0) { { WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) } } else null, + ) +} +``` + +### `WoTBadge` (Material3 `TooltipBox`, Desktop-only) + +`desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/WoTBadge.kt`: + +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WoTBadge(count: Int, modifier: Modifier = Modifier) { + val display = if (count > 99) "99+" else count.toString() + val tooltipState = rememberTooltipState(isPersistent = true) // desktop hover fix + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip { Text("$count of the people you follow follow this person") } }, + state = tooltipState, + ) { + Box( + modifier + .size(18.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .semantics { contentDescription = "Followed by $count of your contacts" }, + contentAlignment = Alignment.Center, + ) { + Text( + text = display, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} +``` + +### Wiring in `Main.kt` + +Inside the `AccountState.LoggedIn` branch, alongside the existing +`LocalHashtagSpamSettings` / `LocalSpamExemptKeys` providers: + +```kotlin +val wotService = account.iAccount.wotService +val isReady by wotService.isReady.collectAsState() + +LaunchedEffect(wotService, localCache) { + // Feed kind-3 events into the service. + launch { localCache.contactListEvents.collect { evt -> + wotService.applyKind3(evt.pubKey, evt.verifiedFollowKeySet()) + } } + // React to follow-set changes. + launch { localCache.followedUsers.collect { follows -> + wotService.onFollowSetChange(follows, account.pubKeyHex) + subscriptionsCoordinator.loadKind3Batched(follows, onEose = { wotService.markReadyOnce() }) + } } + // Fallback: mark ready after 2 s regardless. + delay(2_000) + wotService.markReadyOnce() +} + +CompositionLocalProvider( + LocalWoTService provides wotService, + LocalWoTReady provides isReady, + LocalWoTSelfKey provides account.pubKeyHex, + LocalWoTFollowedKeys provides followedUsers, // already collected above for spam exempt + // existing hashtag-spam CompositionLocals… +) { /* … */ } +``` + +Attach `wotService` to `DesktopIAccount`: + +```kotlin +class DesktopIAccount( + private val signer: NostrSigner, + val scope: CoroutineScope, + /* … */ +) : IAccount { + val kind3FollowList = Kind3FollowListState(signer, scope, /* … */) + val wotService = WoTService(scope) + /* … */ +} +``` + +### Amy verbs (v1) + +`cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt`: + +``` +amy wot get [--json] + Prints: pubkey= score= contributed_by= + JSON: { "pubkey": "...", "score": n, "contributed_by": ["...", ...] } + +amy wot list [--threshold N] [--limit K] [--json] + Prints scored pubkeys sorted desc; --threshold filters. + +amy wot sync + Loads active-user follow set + runs loadKind3Batched once. + Exits after aggregated EOSE (max 5 s). +``` + +Each verb: +1. Reads active-user pubkey from `Context.currentAccount`. +2. Hydrates `WoTService` from `FsEventStore` (~/.amy/shared/events-store/). +3. Optionally runs `wot sync` (idempotent) for freshness. +4. Queries and prints. + +Total ~150 LOC. Reuses existing `Context` / `FsEventStore` / +`indexRelays` wiring. + +## Technical Considerations + +### Performance + +- **Reverse-index memory:** at MAX_FOLLOWS = 2000 × avg follows 500 ≈ + 1 M entries worst case; at 500 × 500 ≈ 250 k. Compose bookkeeping + overhead on the SnapshotStateMap is ~80–120 bytes/entry; sparse map + (Unknown = absence) keeps this bounded by *positive-score pubkeys* + only — typically 5–50 k, not 250 k. +- **Write batching:** each `applyKind3` op mutates the map for many + keys inside a single `Snapshot.withMutableSnapshot { }` — one wake-up + per reader, one atomic frame. +- **Chunked REQ:** 500-follow account issues 5 chunks × 100 authors + each within a single subscription. Total REQ payload ~30 KB. +- **`scoresSnapshot()`** does an `HashMap(_scores)` — O(N) copy; amy + calls it once per verb invocation, fine. + +### Compose recomposition + +- Plain `service.scores[userHex] ?: 0` read is snapshot-tracked per + key. When only one key updates, only avatars for that pubkey + recompose. +- `LocalWoTReady: CompositionLocal` is collected **once** at + App root; no per-avatar Flow collector. +- Badge visibility is decided in `WoTBadgedAvatar` (Desktop) — the + `UserAvatar` slot receives `badge=null` when hidden, so no overlay + Box + no recomposition of hidden branches. + +### Stability annotations + +- `WoTService`: `@Stable`. Public API is `scores: SnapshotStateMap` + (Compose-tracked) + `isReady: StateFlow` (via `collectAsState`). + Private mutable state doesn't leak. +- `Int` is stable by definition — sparse map values are trivially + stable. +- `WoTBadge`, `WoTBadgedAvatar`, `UserAvatar` all have primitive/stable + params after the slot addition. + +### Threading + +- All mutations to `reverseIndex` / `perFollowerSnapshot` happen inside + the single writer coroutine on `Dispatchers.Default`. +- `_scores` SnapshotStateMap is thread-safe for individual puts; + composite writes wrapped in `Snapshot.withMutableSnapshot { }` are + applied atomically. +- Composable reads happen on the Main dispatcher via snapshot; safe. + +### Follow-set reactivity + +- `DesktopLocalCache._followedUsers: StateFlow>` (already + reactive) is the source of truth for the active user's follow set — + once the prerequisite cache fix lands. +- The `LaunchedEffect` in `Main.kt` collects it and forwards to + `WoTService.onFollowSetChange` and re-triggers the batch REQ for + newly-followed pubkeys (existing ones deduped by + `queuedKind3Pubkeys`). + +### Startup timeline + +| t | Event | +|---|-------| +| 0 ms | User logs in | +| ~50 ms | `LocalCache.followedUsers` emits current follow set | +| ~100 ms | `loadKind3Batched(follows, onEose = …)` fires 5 chunks | +| 200 ms – 2 s | Kind-3 events land, WoTService diffs, `_scores` populated | +| ≤ 2 s | `markReadyOnce()` — via first-chunk EOSE OR 2 s fallback | +| ≥ ready | Badges appear | + +### Security / privacy + +- Follow-list leak via batch REQ is **pre-existing** — same authors + set already sent to `indexRelays` via metadata batch. WoT introduces + no novel disclosure. +- `event.verify()` runs on every ingested event + (`DesktopLocalCache.kt:191`) — forged kind-3 events cannot pass. +- `follows.take(5000)` in `applyKind3` bounds CPU cost against a + hostile 100 k-tag kind-3. +- No persistence — `Preferences` untouched. Reverse index lives in + memory only. +- App-global operation (not per-account): follow-set state lives on + `DesktopIAccount`, discarded on logout. Account switch = new + `IAccount` = new `WoTService`. + +## System-Wide Impact + +### Interaction graph + +``` +DesktopLocalCache.consumeContactList(event) ← (prerequisite fix) + │ + ├─ if event.pubKey == self → update _followedUsers, lastContactListEvent + │ + └─ tryEmit → contactListEvents: SharedFlow + │ + ▼ collected in Main.kt LaunchedEffect + WoTService.applyKind3(event.pubKey, event.verifiedFollowKeySet()) + │ + ▼ dispatched to writer coroutine + processOne(Op.Kind3) inside Snapshot.withMutableSnapshot { } + │ + ▼ diff vs perFollowerSnapshot + reverseIndex[target].add/remove(follower) + │ + ▼ updateScore(target) + _scores[target] = n (or .remove if n == 0) + │ + ▼ Compose snapshot commit + Avatars reading scores[target] recompose + +── parallel path ── +DesktopLocalCache._followedUsers emits new set + │ + ▼ collected in Main.kt LaunchedEffect +WoTService.onFollowSetChange(newFollows, selfPubkey) + │ + ├─ diff added / removed followers + ├─ uncredit removed followers' contributions + ▼ +subscriptionsCoordinator.loadKind3Batched(followSet, onEose = wotService::markReadyOnce) + │ + ▼ chunked REQ on indexRelays +Kind-3 events return → route back through consume() path above +``` + +### Error & failure propagation + +- Batch REQ timeout: 2 s fallback fires `markReadyOnce()` regardless. + Partial data is acceptable — badges appear for pubkeys we did get. +- `verifiedFollowKeySet()` on malformed event: Quartz handles internally, + returns possibly-empty set. Zero contribution. +- Writer coroutine crash: never — all operations are exception-safe (map + ops, set diffs). No I/O in the writer path. +- SharedFlow overflow: `DROP_OLDEST` on `contactListEvents` — under + extreme flood, oldest events dropped. Acceptable v1 (relay flood is + itself abnormal). +- Account switch: `LaunchedEffect` cancelled → collect on old + `contactListEvents` stops. Old `WoTService` referenced only via the + cancelled effect and old `IAccount` — GC'd cleanly. + +### State lifecycle risks + +- **Prerequisite cache fix** is the highest lifecycle risk. Without it, + the WoT batch REQ actively corrupts `_followedUsers` — cascading + bugs in every FeedFilter, mute list, and account-relay logic. + Prevention: land the fix as a separate commit in this PR, gate by + unit test in `DesktopLocalCacheTest`. +- Reverse-index size grows with `myFollows.size × avg-follows-of-follows`. + Guardrail at 2000 clears the map; entering guardrail mid-session is + a supported transition. +- SharedFlow subscribers must be scoped to `account.scope` — a leak + from a stale coroutine would collect old events into a stale + service. Enforced by `LaunchedEffect(wotService, localCache)` + keying. + +### API surface parity + +- **commons:** `WoTService`, `LocalWoTService`, `LocalWoTReady`, + `LocalWoTSelfKey`, `LocalWoTFollowedKeys` (CompositionLocals). + `UserAvatar` gains optional `badge` slot (backward compatible — + default null). +- **desktopApp:** `WoTBadge`, `WoTBadgedAvatar` composables; call-site + migration at 6 v1 surfaces (feed, quote-embed, thread reply, + notification, search result, profile header). Coordinator gets + `loadKind3Batched`. `DesktopLocalCache` gets `contactListEvents: + SharedFlow` + per-author `lastContactListByAuthor` map. + `DesktopIAccount` gets `wotService` property. +- **amethyst (Android):** no changes v1. `UserAvatar` badge slot is + optional; Android call sites pass no lambda. +- **cli (amy):** three verbs (`wot get`, `wot list`, `wot sync`), + ~150 LOC in `commands/WotCommand.kt`, wired into `Main.kt` dispatch. + +### Integration test scenarios + +1. **Cold start with 250 follows.** Login → batch REQ fires with 3 + chunks of 100 authors → badges appear within ≤ 2 s. +2. **Follow a new person mid-session.** New pubkey added to + `_followedUsers` → `onFollowSetChange` credits nothing yet; new + `loadKind3Batched(followSet)` picks up their kind-3 (deduped by + `queuedKind3Pubkeys`); score for their followers ticks up as their + kind-3 lands. +3. **Unfollow a follower.** `onFollowSetChange(removed)` uncredits; + affected pubkeys' scores decrement; some may drop to 0 and lose + badges (map entry removed). +4. **Kind-3 churn.** Same follower republishes with +5 / -2 diff → + `applyKind3` computes diff correctly, no double-counting. +5. **Account switch.** New `IAccount` → new `WoTService` → old service + GC'd. Old service's map does not leak into new UI. +6. **Guardrail trip.** 3000-follow account → guardrail clears state, + marks ready, no batch REQ. +7. **Empty graph.** 0 follows → onFollowSetChange with empty set, + nothing to fetch, `markReadyOnce()` fires from fallback timeout. +8. **99+ overflow.** Simulate score 200 → badge shows "99+". +9. **Self exemption.** Own avatar in profile header never shows a + badge even if some follower's kind-3 lists self. +10. **Follow-list exemption.** Followed author's avatar never shows a + badge even when others in follow-list follow them. +11. **Kind-3 malformed.** 100 k-tag kind-3 → `applyKind3` truncates + to 5 000, service stays responsive. +12. **Prerequisite fix verification.** Send a kind-3 event whose author + is not the active user — verify `_followedUsers` unchanged, but + `contactListEvents` emits. +13. **Amy `wot get`.** `amy wot get ` after + `amy wot sync` returns correct score. +14. **Amy warm cache.** Second `amy wot get ` invocation + without `sync` uses `FsEventStore` hydration, no relay traffic. + +## Acceptance Criteria + +### Functional + +- [ ] **Prerequisite:** `DesktopLocalCache.consumeContactList` refactored + with per-author `lastContactListByAuthor: Map` and + guards `_followedUsers` / `lastContactListEvent` writes on + `event.pubKey == accountPubkey`. Also emits every kind-3 to a new + `contactListEvents: SharedFlow`. +- [ ] `WoTService` in + `commons/commonMain/.../wot/WoTService.kt` — sparse + `SnapshotStateMap`, single-writer coroutine actor, + `Snapshot.withMutableSnapshot { }` for atomic frames, + `onFollowSetChange`, `applyKind3` (with `MAX_FOLLOWS_PER_EVENT` + bound), `markReadyOnce`, `clear`, `scoresSnapshot`, + `hydrateFromStore`, `isReady: StateFlow`. +- [ ] `LocalWoTService`, `LocalWoTReady`, `LocalWoTSelfKey`, + `LocalWoTFollowedKeys` CompositionLocals in + `commons/commonMain/.../wot/`. +- [ ] `UserAvatar` in `commons/commonMain/.../ui/components/UserAvatar.kt` + gains optional `badge: @Composable (BoxScope.() -> Unit)? = null` + parameter. Backward compatible — default null. +- [ ] `WoTBadge` in `desktopApp/src/jvmMain/.../ui/note/WoTBadge.kt` — + Material3 `TooltipBox` with `isPersistent = true`, `PlainTooltip`, + `Box` overlay chip, `contentDescription` for a11y, 99+ clamp. +- [ ] `WoTBadgedAvatar` in `desktopApp/src/jvmMain/.../ui/note/WoTBadgedAvatar.kt` — + composes `UserAvatar` with a `WoTBadge` slot lambda; reads gates + from CompositionLocals. +- [ ] `FeedMetadataCoordinator.loadKind3Batched(pubkeys, timeoutMs = 5s, onEose)` — + chunks into ≤100-author Filters, aggregates EOSE across chunks, + dedupes against `queuedKind3Pubkeys`. +- [ ] `DesktopRelaySubscriptionsCoordinator.loadKind3Batched(pubkeys, onEose)` + delegate. +- [ ] `DesktopIAccount.wotService: WoTService` — constructed with + `account.scope` at IAccount init. +- [ ] `Main.kt` inside `LoggedIn` branch: + - Collects `wotService.isReady` once → `isReadyCollected: Boolean`. + - Collects `localCache.contactListEvents` → `wotService.applyKind3`. + - Collects `localCache.followedUsers` → + `wotService.onFollowSetChange` + `loadKind3Batched`. + - Fallback `delay(2_000)` → `markReadyOnce()`. + - Provides all four `LocalWoT*` CompositionLocals to descendants. +- [ ] Call-site migration to `WoTBadgedAvatar` at 6 v1 surfaces: + `FeedNoteCard` header, `QuotedNoteEmbed` author, + `ThreadScreen` reply avatars, `NotificationsScreen` items, + `SearchResultsList` avatars, `UserProfileScreen` header. +- [ ] Amy verbs: + - `amy wot get [--json]` + - `amy wot list [--threshold N] [--limit K] [--json]` + - `amy wot sync` + +### Non-functional + +- [ ] No measurable frame-time regression during scroll in a 250-note + deck column with badges rendering (manual profiler smoke test). +- [ ] Spotless clean: `./gradlew spotlessApply` produces no diff. +- [ ] Compiles cleanly: `./gradlew :commons:compileKotlinJvm + :desktopApp:compileKotlin :cli:build`. +- [ ] No `Preferences` writes — feature is stateless across restarts. +- [ ] Badge overlay uses absolute positioning inside a `Box` sized to + the avatar; no layout shift when a score arrives mid-scroll. +- [ ] Batch REQ payload chunked ≤ 100 authors per Filter. + +### Quality gates + +- [ ] Unit tests for `WoTService`: + - Empty graph → onFollowSetChange with empty set → nothing to + compute, still fires `markReadyOnce()` via caller. + - `handleFollowSet` from empty → 3 follows, then `handleKind3` for + each with overlapping follow sets → verify scores. + - Removing a follower decrements every pubkey they contributed to; + sparse-map guarantee (removed at 0-count). + - Kind-3 churn: same follower publishes new set → diff applied + correctly, no double-counting, no leaked entries in + `perFollowerSnapshot`. + - Self-exclusion: kind-3 including active-user pubkey doesn't + inflate self-score. + - Follower-self-exclusion: kind-3 including follower's own pubkey + doesn't inflate their own score. + - Guardrail: 3000-follow input yields empty map + `_isReady = true`. + - `MAX_FOLLOWS_PER_EVENT`: 6000-follow kind-3 truncated to 5000. + - `scoresSnapshot()` returns a plain HashMap equal to + `_scores.toMap()`. +- [ ] Unit test for `DesktopLocalCache.consumeContactList` prerequisite + fix — non-self kind-3 doesn't mutate `_followedUsers`; both + events emit to `contactListEvents`. +- [ ] Unit test for `loadKind3Batched` filter shape — 300 authors + chunked into 3 Filters, one subscription, aggregated EOSE, + onEose callback fired. +- [ ] Amy verb integration tests (in `cli/tests/wot/`): + `wot get`, `wot list`, `wot sync` produce correct output and + JSON schemas. +- [ ] Manual testing sheet at + `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md` + covering the 14 integration scenarios above. + +## Success Metrics + +- Users report badges give useful trust cues on strangers without + cluttering follows / self. +- Batch REQ completes within 5 s for accounts with ≤ 500 follows on + typical index relays. +- No frame drops > 16 ms during scroll or startup. +- `amy wot get` returns within 100 ms on a warm `FsEventStore`. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Prerequisite cache fix breaks existing feed filters that depend on `_followedUsers` mutation semantics | medium | Prerequisite fix has its own unit test in this PR; audit all readers of `_followedUsers` (Kind3FollowListState, feed filters) — behavior unchanged for the *active-user* code path, only the *other-user* path stops overwriting | +| `verifiedFollowKeySet()` allocation cost during batch flood | low | Not cached in Quartz; measured ~1 ms per event; 500 events × 1 ms = 500 ms on a background thread — acceptable | +| `TooltipBox` visual defaults differ from `TooltipArea` | low | Test both hover and long-press behaviour manually; adjust padding/positioning if needed | +| Amy verb tests miss a `SnapshotStateMap` initialization edge | low | Amy verb test explicitly constructs `WoTService`, populates via `hydrateFromStore`, reads via `scoresSnapshot()` | +| Merge conflict with in-flight hashtag-spam PR (#3431) | medium | Both features touch `Main.kt` CompositionLocalProvider block. If hashtag-spam merges first, rebase; the two providers stack (independent locals) | +| Compose runtime version pinning for the 2026 deadlock fix | low | Verify `libs.versions.toml` compose runtime version ≥ current stable; upgrade if needed | +| Follow-set leak via batch REQ to index relays | pre-existing (metadata already leaks same info) | No new leak. Follow-up ticket: NIP-65 outbox routing for both kind-0 and kind-3 batches; do not gate WoT on it | + +## Out of Scope (deferred) + +- **Threshold-based filtering** (notifications, DMs, feeds, search) — v2. +- **Persistence of scores across sessions** — cached kind-3 events give + fast cold start via `hydrateFromStore`; no separate cache. +- **Settings UI** (toggle to hide badges entirely, threshold picker) — + v2. +- **Mutual-follow drilldown** ("click chip to see who") — v2. +- **Colored-ring alternative rendering** — v2 experiment. +- **Android UI** — v2. `UserAvatar` badge slot is Android-safe today + (default null). +- **Weighting** (zap-weighted, mutual-weighted, decay over time) — + YAGNI. +- **NIP-65 outbox routing** for batch REQ (correctness > simplicity + trade-off) — cross-cutting follow-up ticket, applies to metadata + coordinator too. +- **Cross-account aggregation** — YAGNI. + +## Sources & References + +### Origin + +- **Brainstorm:** `docs/brainstorms/2026-07-01-feat-wot-score-brainstorm.md` + +### Internal references + +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:447-453` + — **prerequisite fix target** — `consumeContactList` scope corruption. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt:191` + — `event.verify()` gate confirms signature integrity. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt:255-301` + — `loadMetadataBatched` blueprint; note the `.take(100)` on line 267 + (we chunk explicitly instead). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip02FollowList/Kind3FollowListState.kt` + — service-on-account pattern; `signer`-scoped, `Kind3Follows.authors`. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt` + — attach point for `wotService`. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt` + — add optional `badge` slot. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorStatusIndicator.kt:75-95` + — prior tooltip pattern (upgrading to Material3 `TooltipBox` in + `WoTBadge`). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:979-982,1424-1427` + — CompositionLocalProvider wiring sites. +- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt` + — `FsEventStore` at `~/.amy/shared/events-store/` for amy hydration. +- `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt:56` + — `verifiedFollowKeySet()` — not cached, bounded at parse time. + +### External references + +- [Zach Klippenstein — Compose Snapshot system](https://blog.zachklipp.com/introduction-to-the-compose-snapshot-system/) + — per-key read tracking guarantees for `SnapshotStateMap`. +- [Android Developers — SnapshotStateMap reference](https://developer.android.com/reference/kotlin/androidx/compose/runtime/snapshots/SnapshotStateMap) + — `toMap()` is O(1), `.size` / iteration are structural reads. +- [Kotlin docs — Compose Multiplatform Material3 TooltipBox](https://kotlinlang.org/api/compose-multiplatform/material3/androidx.compose.material3/-tooltip-box.html) + — cross-platform tooltip primitive; deprecation path for + `TooltipArea`. +- [JetBrains issue #4275 — Deprecate TooltipArea](https://github.com/JetBrains/compose-multiplatform/issues/4275) + — direction of travel. + +### Skill references + +- `account-state` — `IAccount` follow-set access, `Kind3FollowListState` + scoping. +- `relay-client` — `FeedMetadataCoordinator` batch REQ pattern. +- `compose-recomposition-performance` — `SnapshotStateMap` per-key + subscriber isolation, `Snapshot.withMutableSnapshot` for atomic + frames. +- `compose-stability-diagnostics` — `@Stable` contract honesty on + `WoTService`. +- `compose-slot-api-pattern` — badge slot on `UserAvatar` as visual + extension point. +- `nostr-expert` — `ContactListEvent.verifiedFollowKeySet()`. +- `kotlin-flow-state-event-modeling` — `SharedFlow` + with buffer + `DROP_OLDEST` overflow; `StateFlow` for + readiness. +- `amy-expert` — CLI verb structure, `FsEventStore` hydration, JSON + output contract. + +### Related work + +- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431 + — same CompositionLocal pattern; same "Content Filters" area for v2 + threshold UI when filtering ships. +- Feature backlog: `desktopApp/plans/_desktop-feature-backlog.md` item + #2 (this plan). diff --git a/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md b/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md new file mode 100644 index 0000000000..21c1eb853d --- /dev/null +++ b/docs/plans/2026-07-01-feat-wot-followups-search-badges-and-index-relays-plan.md @@ -0,0 +1,701 @@ +--- +title: WoT follow-ups — search-result badges + shared index relays +type: feat +status: active +date: 2026-07-01 +origin: docs/plans/2026-07-01-feat-desktop-wot-score-plan.md +deepened: 2026-07-01 +--- + +# WoT follow-ups — search-result badges + shared index relays + +## Enhancement Summary + +**Deepened on:** 2026-07-01 (same day as plan write). + +**Agents used:** code-simplicity-reviewer, targeted repo verification sweep. + +### Key corrections vs first draft + +1. **Split into two PRs.** Item 1 (search badges) is mechanical and has + zero coupling to Items 2+3. Ship it alone. Items 2 + 3 stay bundled + because the UI (Item 3) is the write path for the persistence + (Item 2) — reviewing them separately means reviewing dead code or a + headless feature. +2. **App-global (not per-account) index-relay override.** First draft + made this per-account to match `searchRelays` / `dmRelays`. But + `searchRelays` / `dmRelays` are per-account because they're NIP-51 / + NIP-17 identity-scoped semantics; index relays are a user preference + about where profile-metadata lookups go, and users have a single + preferred set regardless of which account they're logged into. + App-global halves the API surface and matches user mental model. +3. **`PreferencesIndexRelays` in `commons/jvmMain/`, not extending + `DesktopAccountRelays`.** Verification found `DesktopAccountRelays` + uses `Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`, + which is a per-class node — **not visible to amy** running from a + different classpath. To achieve the "one truth for Desktop and amy" + goal, the shared node must be an explicit + `Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`, + which is exactly the pattern `PreferencesHashtagSpamSettings` uses. + New small class mirrors that shape. +4. **Drop `WoTBadgedSearchCard`.** Only two call sites; the 6-line + score computation inlines cleanly. New wrapper composable earns its + keep at 3+ call sites, not 2. +5. **Drop `DefaultIndexRelays.kt` in commons.** Speculative — no + Android caller. amy can duplicate the 4 URLs (they change ~never) + or read a single constant from a shared location. Extracting to + commons is architectural neatness without a consumer. +6. **`DesktopRelayCategories.indexRelays` uses `override ?: default` + only.** Not the full combine used by `searchRelays` (which + intersects with NIP-65 discovery). Index relays are a curated user + choice, not a "what's actually reachable right now" derived set. No + `debounce` / `stateIn` combine needed — a straight-through StateFlow + from the Preferences read is enough. +7. **Drop "Reset to defaults" button in Item 3.** Removing all relays + from the UI already falls back to `DefaultRelays.RELAYS`. Delete-all + IS the reset. +8. **Drop integration scenarios 2 and 6.** #2 (badge respects + exemptions) is covered by existing `WoTBadgedAvatar` tests — same + code path. #6 (empty override falls back) is a single unit test on + `PreferencesIndexRelays`, not a manual scenario. +9. **`RelaySettingsScreen` current content** was mischaracterised — it + already has 6 sections (Wallet Connect, Media Server, Image + Compression, Tor, Namecoin, Local Relay, Content Filters). Index + Relays fits between Local Relay and Content Filters (both have + dividers). +10. **Adopt-not-in-this-PR discovery: `commons/AmethystDefaults.kt` + already has `DefaultIndexerRelayList`** (Purple Pages, Coracle, + etc). Desktop today uses the wrong list (`DefaultRelays.RELAYS` = + general-purpose relays) for its index REQs. That's a real + behavioural bug worth a separate ticket — not this one — because + changing default index relays is a user-visible behaviour shift and + deserves its own review. + +--- + +## Overview + +Three small follow-ups to the just-shipped Web-of-Trust score feature +(branch `feat/desktop-wot-score`, closed for manual testing): + +1. **Badges on search-result person cards.** The main NoteCard header + already renders `WoTBadgedAvatar`, but the Search screen's person + picker uses a different composable (`UserSearchCard`) that doesn't + currently accept a badge. +2. **Unify amy `wot sync` with Desktop on the same relay set.** Desktop + currently uses a hard-coded `DefaultRelays.RELAYS` list as its + `indexRelays`; amy uses whatever the user's NIP-65 outbox/inbox lists + contain. When the two disagree, `amy wot get` after `amy wot sync` + returns a different score than the Desktop UI would compute. +3. **Add an Index Relays section to the Relays settings screen** so + users can customise which relays back both surfaces from one place. + +**Shipping plan:** two PRs. + +- **PR A — Search badges (Item 1).** ~40 LOC, one commons param + addition, two Desktop call-site inline changes. Independent of the + other work. Ships first. +- **PR B — Shared index relays (Items 2 + 3).** Introduces a small + Preferences-backed class in `commons/jvmMain/`, wires the coordinator + to read from it, adds a settings-screen section, and updates + `amy wot sync` to read the same node. ~300 LOC. Ships second. + +## Problem Statement + +Three concrete regressions/gaps from the manual-testing pass of the WoT +PR: + +- **Item 1.** When searching for a person in the Desktop search screen, + their result card is a stranger 90% of the time (that's the point of + searching), but there's no trust cue on the card. Users who find WoT + badges useful on feed avatars want the same signal here. +- **Item 2.** amy's `wot sync` uses `ctx.outboxRelays()` (NIP-65 write + list) with a fall-back to inbox. Those are legitimate relays for + publishing / receiving events, but they are *not* what Desktop uses + to fetch profile metadata and follow lists — Desktop hits a + hard-coded `indexRelays` set (nos.lol, nostr.wine, + relay.noswhere.com, relay.primal.net today). Result: `amy wot get` + after a fresh `amy wot sync` can produce a score that lags or + diverges from the Desktop UI for the same account. +- **Item 3.** The Relays settings screen already contains six + sections; there's no UI to inspect or change which relays are + considered "index relays" — the values live only in the hard-coded + default list in `RelayStatus.kt`. + +## Proposed Solution + +### PR A — Item 1: Badge slot on `UserSearchCard` + +`UserSearchCard` in +`commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt` +gets an optional `badge` slot that forwards to its embedded +`UserAvatar` (which already has the slot from the WoT PR): + +```kotlin +@Composable +fun UserSearchCard( + user: User, + onClick: () -> Unit, + modifier: Modifier = Modifier, + badge: @Composable (BoxScope.() -> Unit)? = null, +) { + // Existing layout, unchanged, except: + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = 40.dp, + contentDescription = stringResource(Res.string.accessibility_user_avatar), + badge = badge, + ) + // …rest of the Row unchanged +} +``` + +Backward compatible — default `null` means no visual change for callers +that don't opt in. The layout impact is zero: `UserAvatar` handles the +badge's `Box` overlay itself; the badge lives on the avatar's bottom- +right corner, and the `ArrowForward` icon at the row's trailing edge +doesn't collide with it. + +Two Desktop call sites in +`desktopApp/.../ui/search/SearchResultsList.kt:116,126` inline the score +computation directly at the call: + +```kotlin +val service = LocalWoTService.current +val ready = LocalWoTReady.current +val exempt = LocalSpamExemptKeys.current +val score = if (service != null && ready && user.pubkeyHex !in exempt) { + service.scores[user.pubkeyHex] ?: 0 +} else 0 + +UserSearchCard( + user = user, + onClick = { … }, + badge = if (score > 0) { + { WoTBadge(count = score, modifier = Modifier.align(Alignment.BottomEnd)) } + } else null, +) +``` + +The two sites are 4 lines apart; a small local `remember` block above +them can factor the read if we want (optional micro-cleanup — not +required). + +**Not migrated in PR A:** + +- `desktopApp/.../ui/chats/NewDmDialog.kt` (three sites) — the DM + recipient picker. Same rationale as before: when picking a DM + recipient you're already committing to messaging that person; a + trust badge is more noise than signal. Add later if testing calls + for it. + +### PR B — Item 2: Shared `indexRelays` between Desktop and amy + +#### Persist via `java.util.prefs`, node shared with amy + +`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`. +Same JVM-user-wide `java.util.prefs` trick the hashtag-spam PR (#3431) +uses — Desktop and amy running as the same OS user see the same node. + +**Not stored per-account.** Users have a single preferred set of index +relays regardless of which account is logged in. Halves the API +surface and matches user intuition. If a user with two accounts +genuinely needs separate index relays per account, we add per-account +overlay later on demand — YAGNI now. + +**Not persisted via `DesktopAccountRelays`.** That class uses +`Preferences.userNodeForPackage(DesktopAccountRelays::class.java)`, +which resolves to a per-class node that `cli/` running from a different +classpath **would not see**. Extending it would give us Desktop-local +config with no amy visibility — the opposite of what we want. + +#### New shared class + +`commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt` +(new file, mirrors `PreferencesHashtagSpamSettings` shape): + +```kotlin +class PreferencesIndexRelays( + private val prefs: Preferences = + Preferences.userRoot().node(NODE_NAME), +) { + private val _relays = + MutableStateFlow(parse(prefs.get(KEY_URLS, ""))) + val relays: StateFlow> = _relays.asStateFlow() + + fun setRelays(new: Set) { + _relays.value = new + prefs.put(KEY_URLS, new.joinToString(",") { it.url }) + } + + /** Resolves the effective set — user override if non-empty, else defaults. */ + fun effective(): Set = + _relays.value.ifEmpty { DEFAULT_INDEX_RELAYS } + + companion object { + const val NODE_NAME = "com/vitorpamplona/amethyst/relays/index" + const val KEY_URLS = "urls" + + /** + * Byte-for-byte identical to `DefaultRelays.RELAYS` at + * `desktopApp/.../network/RelayStatus.kt`. Preserves current + * behaviour for users who never open the settings UI. + * + * Note: `commons/AmethystDefaults.kt` also has + * `DefaultIndexerRelayList` (Purple Pages, Coracle, …) which + * is more purpose-built. Adopting it is a separate ticket — + * see Out of Scope. + */ + val DEFAULT_INDEX_RELAYS: Set = setOf( + "wss://nos.lol", + "wss://nostr.wine", + "wss://relay.noswhere.com", + "wss://relay.primal.net", + ).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + + private fun parse(csv: String): Set = + csv.split(",") + .mapNotNull { it.trim().takeIf(String::isNotEmpty) } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + } +} +``` + +CSV serialisation matches what `DesktopAccountRelays` uses for its +categories (`prefs.put(key, relays.joinToString(",") { it.url })`) — no +JSON, no `Serializable`, no dependencies beyond `RelayUrlNormalizer`. + +#### Desktop wiring + +Add `indexRelays: StateFlow>` to +`DesktopRelayCategories`, backed by the new class. Simple straight- +through, no combine: + +```kotlin +class DesktopRelayCategories( + // existing params + private val indexRelaysStore: PreferencesIndexRelays, +) { + // existing categories… + + val indexRelays: StateFlow> = + indexRelaysStore.relays + .map { it.ifEmpty { PreferencesIndexRelays.DEFAULT_INDEX_RELAYS } } + .stateIn(scope, SharingStarted.Eagerly, indexRelaysStore.effective()) + + fun setIndexRelays(new: Set) = indexRelaysStore.setRelays(new) +} +``` + +`Main.kt` — the constructor at +`desktopApp/.../Main.kt:847-859` swaps the hard-coded literal for the +current effective set: + +```kotlin +// before: +indexRelays = DefaultRelays.RELAYS.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), + +// after: +indexRelays = indexRelaysStore.effective(), +``` + +`indexRelaysStore` is instantiated once at App() root (before the +coordinator) and provided into `DesktopRelayCategories`. UI reads from +`LocalRelayCategories.current.indexRelays`. + +**Changes take effect on next relaunch.** Documented in the settings +section's help text. The existing coordinator has no re-target API for +`indexRelays`; teaching it one is out of scope. Rationale: index-relay +churn is expected to be rare, and users who edit the list generally +expect to restart anyway. + +#### amy wiring + +New helper on `cli/.../Context.kt`: + +```kotlin +fun indexRelays(): Set { + val prefs = Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index") + val csv = prefs.get("urls", "") + val user = csv.split(",") + .mapNotNull { it.trim().takeIf(String::isNotEmpty) } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + return user.ifEmpty { + // Same defaults as PreferencesIndexRelays.DEFAULT_INDEX_RELAYS + // Duplicated here (4 URLs) — they change ~never. + setOf( + "wss://nos.lol", "wss://nostr.wine", + "wss://relay.noswhere.com", "wss://relay.primal.net", + ).mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() + } +} +``` + +`WotCommand.sync` swaps: + +```kotlin +// before: +val relays = ctx.outboxRelays().ifEmpty { ctx.inboxRelays() } +// after: +val relays = ctx.indexRelays() +``` + +The 4-URL duplication is fine per the simplicity review — the list +changes ~never; a single shared commons constant would be architectural +neatness with no material win. Adding a whole +`commons/defaults/DefaultIndexRelays.kt` for a 4-line constant fails +YAGNI on a plan we're specifically told to keep small. + +#### Optional: `amy relay index …` verbs — deferred + +v1 configuration lives in the Desktop settings section. If someone +running amy headless wants to seed the Preferences node, they can do +so with a five-line JVM one-liner: + +``` +java -cp … -e 'Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index").put("urls","wss://foo,wss://bar")' +``` + +CLI verbs are a follow-up ticket if demand appears. + +### PR B — Item 3: Index Relays section in `RelaySettingsScreen` + +Insert a new section in `RelaySettingsScreen` +(`desktopApp/.../Main.kt` line 1797 onward). Current sections in order: + +1. Wallet Connect (NWC) +2. Media Server Settings +3. Image Compression Settings +4. Tor Settings +5. Namecoin Settings +6. Local Relay (conditional) +7. Content Filters (hashtag-spam) + +Insert **between Local Relay and Content Filters** — both already have +a `HorizontalDivider` around them. + +Section renders: + +- Title: "Index Relays" +- One-line explainer: "Used to fetch profile metadata and follow lists + (Web-of-Trust). Changes take effect on next relaunch." +- `LazyColumn` of `Text(relay.url) + IconButton(Icons.Default.Close, onClick = onRemove)` — 30 LOC ballpark. +- Add-row: `OutlinedTextField + Button("Add")`. Normalises input via + `RelayUrlNormalizer.normalizeOrNull`; ignores nulls silently (or + surfaces "invalid relay URL" if trivial). +- No "Reset to defaults" button — removing all entries falls back to + defaults automatically (delete-all is the reset). + +Reads: + +```kotlin +val indexRelays by LocalRelayCategories.current.indexRelays.collectAsState() +val categories = LocalRelayCategories.current +// then in add/remove handlers: +categories.setIndexRelays(indexRelays + newUrl) +categories.setIndexRelays(indexRelays - existingUrl) +``` + +## Technical Considerations + +### Recomposition + reactivity (Item 1) + +Inlining the score computation at each `UserSearchCard` call still gets +per-key snapshot tracking — `service.scores[pubkey]` is a +`SnapshotStateMap` read that Compose tracks per-key. Only the row for +the changed pubkey recomposes when its score updates. Identical +behaviour to what we shipped in `WoTBadgedAvatar`; the wrapper +composable would have added a subscriber node with no gain. + +### Live re-targeting of the coordinator (Item 2) + +Existing `DesktopRelaySubscriptionsCoordinator` reads `indexRelays` +once at construction and holds it. Teaching it to swap +`indexRelays` mid-flight is a real refactor (in-flight subscription +state, cross-EOSE semantics). Ship "changes take effect on next +relaunch" for v1; add live re-targeting in a follow-up if users notice. + +### Preferences node identity across Desktop and amy (Item 2) + +Both processes use +`Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")`. +Because `java.util.prefs.Preferences` is JVM-user-scoped +(per OS user, per prefs backend — plist on macOS, dconf on Linux, +registry on Windows), both processes end up looking at the same +physical store. `PreferencesHashtagSpamSettings` already relies on this +guarantee in shipped code. + +### CSV vs JSON serialisation (Item 2) + +CSV (`joinToString(",") { it.url }`) matches what `DesktopAccountRelays` +does for its categories. No dependency on Jackson or Serialisation at +the storage boundary. Trade-off: URLs cannot contain commas (they +can't per RFC anyway — commas are reserved). We normalise through +`RelayUrlNormalizer.normalizeOrNull` at both write time (in +`setRelays`) and read time (in `parse` / `Context.indexRelays()`), so +persisted CSV never contains an invalid URL. + +### Default list ergonomics (deferred) + +Verification surfaced a real bug: `commons/AmethystDefaults.kt` +already contains `DefaultIndexerRelayList` (Purple Pages, Coracle, +etc.) — a purpose-built index-relay set — but Desktop currently uses +`DefaultRelays.RELAYS` (nos.lol, nostr.wine, relay.noswhere.com, +relay.primal.net), which are general-purpose. That default mismatch is +a real behaviour improvement to be made, but it's a user-visible +behavioural change that deserves its own PR + review. **This plan +preserves byte-parity with today's default** and flags the improvement +in Out of Scope. + +## System-Wide Impact + +### Interaction graph + +``` +PR A (Item 1): + User opens Search column → types query + → SearchResultsList renders LazyColumn of user results + → Each result inlines: read LocalWoTService.scores, gate on ready/exempt + → pass a WoTBadge lambda to UserSearchCard(badge=...) + → UserSearchCard forwards to UserAvatar(badge=...) + → UserAvatar renders Box overlay with WoTBadge chip + +PR B (Items 2+3): + User opens Relays settings → Index Relays section + → List rendered from LocalRelayCategories.indexRelays + → User adds / removes a relay + → categories.setIndexRelays(newSet) + → indexRelaysStore.setRelays(newSet) + → prefs.put("urls", csv) at + com/vitorpamplona/amethyst/relays/index + → indexRelays StateFlow emits new value + + amy wot sync (later): + → ctx.indexRelays() reads the same prefs node + → identical relay set — Desktop and amy compute the same score + + Desktop app next launch: + → indexRelaysStore.effective() returns user set (or defaults) + → coordinator constructed with that set +``` + +### Error & failure propagation + +- **Empty override set** (user removed all entries): fall back to + defaults at both `indexRelaysStore.effective()` and + `ctx.indexRelays()`. Never allow an empty batch REQ — WoT would + silently break. +- **Malformed URL entry** (e.g. old persisted CSV with a URL that no + longer normalises): filter through + `RelayUrlNormalizer.normalizeOrNull` at read time, drop nulls. +- **Preferences read failure** (`BackingStoreException`): treat as + "unset → use defaults". Log at debug, do not surface to the user. + +### State lifecycle risks + +- **Cross-account leak:** App-global preference, no per-account + identity in the key — by design. +- **Coordinator using stale set after user changes indexRelays:** Yes, + in v1 the coordinator keeps its constructor-time set until relaunch. + Documented in the UI. Not a data-integrity risk — just a UX quirk. + +### API surface parity + +- **PR A:** `UserSearchCard` badge slot — commonMain, backward- + compatible. Android call sites (if any exist post-merge) unchanged; + badge slot stays null. +- **PR B, new:** `PreferencesIndexRelays` class in `commons/jvmMain/`. +- **PR B, modified:** `DesktopRelayCategories` gains an `indexRelays` + StateFlow + `setIndexRelays(...)`. `Main.kt` coordinator + construction. `Context.kt` gains `indexRelays()`. + `WotCommand.sync` swaps its relay source. `RelaySettingsScreen` + gains an "Index Relays" section. +- **Nothing** in `amethyst/` (Android) is touched — this is Desktop + + amy only. + +### Integration test scenarios + +1. **Search badge shows.** Load a search result for a stranger scored + ≥ 1 in the WoT map — the badge renders bottom-right of the avatar. +2. **Index Relays default state.** Fresh install → open Relays + settings → Index Relays section lists the four + `DEFAULT_INDEX_RELAYS` entries as read-only (or marked "(default)"). +3. **Index Relays override persists.** Add a new relay → close the + app → relaunch → new relay still present. Remove one → close → + relaunch → still gone. +4. **amy sees the same override.** After the Desktop override above, + `amy wot sync` uses the new relay set. Verify by observing which + relays receive the kind-3 REQ (packet capture or a debug print + inside `WotCommand.sync`). +5. **Bad URL doesn't crash.** Manually plant an invalid entry in the + Preferences node → app restart → invalid entries filtered out, UI + shows only valid entries. + +## Acceptance Criteria + +### PR A — Functional (Item 1) + +- [ ] `UserSearchCard` in commons accepts optional + `badge: @Composable (BoxScope.() -> Unit)? = null` and forwards + it to its `UserAvatar` call. +- [ ] Both call sites in + `desktopApp/.../ui/search/SearchResultsList.kt` (currently at + lines ~116 and ~126) inline the WoT-score computation and pass a + `WoTBadge` lambda when score > 0 and pubkey not in + `LocalSpamExemptKeys`. +- [ ] `NewDmDialog` call sites remain unchanged. + +### PR B — Functional (Items 2+3) + +- [ ] `PreferencesIndexRelays` created at + `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/relays/index/PreferencesIndexRelays.kt`. + Persists to `Preferences.userRoot().node("com/vitorpamplona/amethyst/relays/index")` + key `urls` as CSV. Exposes + `relays: StateFlow>`, + `setRelays(new)`, `effective(): Set`, and + `DEFAULT_INDEX_RELAYS` constant that matches `DefaultRelays.RELAYS` + byte-for-byte. +- [ ] `DesktopRelayCategories.indexRelays: StateFlow>` + exposed — straight-through from `PreferencesIndexRelays.relays`, + empty falls back to defaults. `setIndexRelays(new)` delegates. +- [ ] `Main.kt:847-859` constructs + `DesktopRelaySubscriptionsCoordinator` with + `indexRelays = indexRelaysStore.effective()` instead of the + hard-coded `DefaultRelays.RELAYS.mapNotNull { … }.toSet()`. + Behaviour on fresh install identical to today. +- [ ] `cli/.../Context.kt` gains `indexRelays(): Set` + reading the same Preferences node, falling back to the same 4 + defaults inline. +- [ ] `WotCommand.sync` uses `ctx.indexRelays()` instead of + `outboxRelays()/inboxRelays()`. +- [ ] `RelaySettingsScreen` has an "Index Relays" section between + Local Relay and Content Filters, with: + - Title + one-line explainer including "Changes take effect on + next relaunch." + - List of current relays with per-row remove button. + - Add-row: URL input + Add button, normalises via + `RelayUrlNormalizer.normalizeOrNull`, silently drops nulls. + - No "Reset to defaults" button (remove-all is the reset). + +### Non-functional (both PRs) + +- [ ] `./gradlew spotlessApply` — no diff. +- [ ] `./gradlew :commons:compileKotlinJvm :desktopApp:compileKotlin + :cli:compileKotlin` — clean. +- [ ] `./gradlew test` — full suite passes. +- [ ] No new `Preferences` writes on any render path — only on + settings-screen mutations. + +### Quality gates + +- [ ] **PR A:** manual smoke — open Search, type a query, verify + badges appear on results scored ≥ 1 in the WoT map; none on + follows/self. +- [ ] **PR B:** unit test for `PreferencesIndexRelays` round-trip + (write set → new instance → same set out). +- [ ] **PR B:** unit test for `PreferencesIndexRelays.effective()` + fallback when Preferences is unset. +- [ ] **PR B:** unit test for `ctx.indexRelays()` fallback behaviour + when Preferences is unset. +- [ ] **PR B:** three new manual scenarios added to the WoT testing + sheet — search-badge visibility, Preferences override + persistence across restart, amy sync uses override (packet + capture or debug log). + +## Success Metrics + +- Search results have the same at-a-glance trust cue as feed cards. +- `amy wot get ` after `amy wot sync` returns a score identical + to the Desktop UI within ~2 s of the same relay set having been + configured. +- Users who add / remove index relays in the settings UI see their + change reflected on next relaunch, verified via a debug log line. + +## Dependencies & Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Coordinator snapshot of `indexRelays` leaks stale set until relaunch | high (accepted v1) | Document as "changes on relaunch" in the UI; follow-up ticket for live re-targeting. | +| Empty override silently kills WoT | medium | Fallback-to-defaults guard at *both* `indexRelaysStore.effective()` and `ctx.indexRelays()`. Unit-tested. | +| CSV serialisation confuses a user who hand-edits the prefs file | low | Documented as internal; users are expected to use the UI. Hand-edit path stays functional as long as URLs don't contain commas (they can't per RFC). | +| Two Desktop and cli defaults drift out of sync (4 URLs duplicated in two places) | low | Comment in both files pointing to each other. If the list ever needs to change, both places must update. Realistically the list changes ~never. | +| `commons/AmethystDefaults.DefaultIndexerRelayList` continues to be the "correct" index-relay set while we're shipping the "general-purpose" defaults | ok (deferred) | Out-of-scope. Separate ticket to adopt as default. | + +## Out of Scope (deferred) + +- **`amy relay index add / remove / list` verbs.** Defer until there's + demand from headless workflows. +- **Live re-targeting of index-relay subscriptions** without app + relaunch. Separate coordinator refactor. +- **NIP-51 kind 30002 based index-relay list** for cross-Nostr-client + portability. +- **DM-recipient-picker badges** in `NewDmDialog`. Ship only if manual + testing complains. +- **Adopting `commons/AmethystDefaults.DefaultIndexerRelayList` as the + Desktop / amy default.** Real improvement, but a user-visible + behavioural change. Standalone ticket + review. +- **Per-account index-relay overrides.** YAGNI now — single-user + preference dominates. Add later if demand shows up. + +## Sources & References + +### Origin + +- **WoT PR plan:** `docs/plans/2026-07-01-feat-desktop-wot-score-plan.md` +- **Manual testing sheet:** + `desktopApp/plans/2026-07-01-wot-score-manual-testing-sheet.md` + +### Internal references + +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt:51-108` + — target for badge slot (Item 1). +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserAvatar.kt:82` + — badge slot already exists here (from the WoT PR). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchResultsList.kt:116,126` + — the two person-result call sites to migrate. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt:847-859` + — where the hard-coded `indexRelays` is passed to the coordinator. +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/RelayStatus.kt:40-47` + — `DefaultRelays.RELAYS` (byte-parity target for + `DEFAULT_INDEX_RELAYS`). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopRelayCategories.kt:80-89` + — `searchRelays` pattern (reference; index relays uses a *simpler* + shape). +- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt` + — per-class Preferences node pattern **we're deliberately not + reusing** (would not be visible to amy). +- `commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/moderation/PreferencesHashtagSpamSettings.kt` + — pattern for shared Preferences node used across Desktop + amy; + `PreferencesIndexRelays` mirrors this shape. +- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt:329-362` + — where `outboxRelays()` / `inboxRelays()` live. +- `cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/WotCommand.kt` + — swap `sync` to `ctx.indexRelays()`. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/AmethystDefaults.kt:62-63` + — `DefaultIndexerRelayList` (Purple Pages, Coracle …) — flagged as + future default adoption, **not touched** in this plan. +- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/assemblers/FeedMetadataCoordinator.kt` + — `indexRelays` constructor param (already exists, no change needed). + +### Skill references + +- `relay-client` — DesktopRelayCategories composition + StateFlow + patterns. +- `compose-expert` — CompositionLocal readers + badge slot forwarding. +- `amy-expert` — Context helper pattern (`outboxRelays()` etc.), CLI + verb shape, shared JVM `Preferences` node semantics. +- `kotlin-flow-state-event-modeling` — StateFlow> straight- + through vs combine semantics. + +### Related work + +- WoT PR branch: `feat/desktop-wot-score` (closed for manual testing). +- Hashtag-spam PR: https://github.com/vitorpamplona/amethyst/pull/3431 + (merged) — the `java.util.prefs` shared-node pattern + `PreferencesIndexRelays` mirrors. +- Feature backlog: + `desktopApp/plans/_desktop-feature-backlog.md` item #2 (parent WoT + feature). diff --git a/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md new file mode 100644 index 0000000000..7a468e5cd0 --- /dev/null +++ b/docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md @@ -0,0 +1,842 @@ +--- +title: Reuse Messaging Privacy Lock on Wallet +type: feat +status: active +date: 2026-07-07 +origin: docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md +depends_on: docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md +--- + +# Reuse Messaging Privacy Lock on Wallet + +Extract the messaging-scoped pieces of the shipped **Desktop Privacy Lock** +(branch `feat/desktop-privacy-lock`) into a **scope-parameterised** privacy +lock, then apply the same gate — plus first-run banner and settings knobs — +to the Desktop **Wallet** deck column. + +Goal in one line: **one master lock, one password**, gates Messages *and* +Wallet routes together, zero code duplication. + +**Design finalised (2026-07-07):** +- Single master `lockEnabled` toggle protects both Messages and Wallet + routes (per user decision — not per-scope enable). +- Single `firstRunCardSeen` flag (dismiss once = dismissed everywhere). +- `LockScope` enum exists only to route per-scope UI (lock-screen copy, + independent idle timers, independent leave-route hooks). Settings + surface is one flag. +- Wallet blur-on-unfocus blurs **text nodes only** (balance amount, + addresses, invoice strings) — cards / structural layout stay visible. +- "No password set" branch in the Wallet gate **deep-links** to + Settings → Privacy lock (not just an error message). +- Ships as **one PR** stacked on `feat/desktop-privacy-lock`. + +> Explicitly called out as a follow-up in the messaging-privacy-lock plan: +> > **Wallet (NWC) gate** — reuse the same `MessagesLockGate` plumbing to +> > gate the Wallet deck column. Already on the feature backlog. +> (see plan: `docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md` +> §Future Considerations) + +## Overview + +Privacy-lock feature currently protects **Messages only**. Financial data +arguably more sensitive: passer-by seeing an NWC balance, a sats-in-flight +receipt, or a QR-linked lightning address is worse than a DM. Wallet also a +fast surface — opening the Wallet column loads the balance immediately, and +NWC receive/send dialogs display payloads on-screen. + +This plan **reuses ~90 %** of the messaging-privacy-lock scaffolding by +turning `MessagesLockState` into a **scoped** state holder, splitting +`lockEnabled` and `firstRunCardSeen` by scope, and applying the gate to +`WalletColumnScreen`. Password + failed-attempts + lockout schedule stay +shared (one password unlocks either scope) — matches Signal/WhatsApp +mental model. + +### Deliverables + +1. `LockScope` enum (`Messages`, `Wallet`) — the single new type. +2. `PrivacyLockState` (renamed from `MessagesLockState`) parameterised by + `LockScope`; one instance per scope, both provided via CompositionLocal at + the App root. +3. `PrivacyLockSettings` gains **per-scope** `lockEnabled` and + `firstRunCardSeen`. Password, inactivity timer, redaction level, + failed-attempts, and lockout stay device-global. +4. Shared `LockScreen()` composable takes a scope; renders scope-aware title + + subtitle strings. +5. `DesktopWalletLockGate` — 30-line wrapper mirroring + `DesktopMessagesLockGate`; also drives `applyWindowCaptureBlock` and + blur-on-unfocus overlay while the Wallet column is visible. +6. `WalletFirstRunBanner` — inline card at top of Wallet column, mirroring + `MessagesFirstRunBanner`. +7. `PrivacyLockSettingsScreen` gets a second card ("Lock the Wallet tab") + + shared subtree for password, inactivity, redaction. +8. Strings genericised: existing `messages_*` keys stay for Messages, new + `wallet_*` mirrors added; a small set of neutral keys added under + `privacy_lock_*` for shared UI (title bar, section header, password + subtree). + +### Out of scope for v1 + +- Android wallet gate — messaging lock does target Android, but wallet + feature backlog emphasises Desktop; Android wallet gating trivial to add + once `PrivacyLockState` scoped, but parked under Future Considerations to + keep the PR bounded. +- Per-note wallet controls (ReactionsRow zap button, ZapCustomDialog, + UpdateZapAmountDialog). Already prompt OS credentials via + `authenticate()` in `UpdateZapAmountDialog.kt:394-490`. Gating them again + would double-prompt. Called out under §System-Wide Impact. +- `amy` CLI `wallet` verbs — currently amy does not expose NWC actions. If + they land, they should re-use `PrivacyLockPreferences` for parity. + +## Problem Statement + +Amethyst Desktop shows the wallet column with a single sidebar click. +Balance auto-fetches on open; NWC receive/send dialogs render invoices and +destination addresses inline. Anyone walking past a logged-in install can: + +- Read the balance in sats. +- See past-payment counterparties in the on-chain zap gallery. +- Trigger the receive dialog and screenshot a lightning invoice belonging to + the account owner. +- Trigger the send dialog and see recently-used destinations. + +Messaging-privacy-lock ships a gate that closes exactly this class of leak +for DMs. Users asking for wallet protection (the driving ask that motivated +this plan) are asking for the *same* gate applied to the *same* fast surface +with the *same* UX contract: + +- Off by default; opt-in via a first-run banner or Settings toggle. +- One shared OS credential / password already established for Messages. +- Idle-timer and leave-route re-lock. +- No extra friction for actions that already gate on OS credentials (nsec + export, zap-amount changes). + +App-wide lock rejected during the messaging brainstorm as too coarse. +Per-scope opt-in matches Signal (`Screen Lock`), WhatsApp (`Chat Lock`), and +the existing shipped behaviour. + +## Proposed Solution + +### One master lock, one password + +Per user decision: **a single master `lockEnabled` toggle gates both +Messages and Wallet routes together.** No per-scope enable flags. + +``` +PrivacyLockSettings +├── lockEnabled : StateFlow UNCHANGED (single master flag) +├── firstRunCardSeen : StateFlow UNCHANGED (single, shared) +├── passwordHashed : StateFlow UNCHANGED (shared) +├── inactivityTimer : StateFlow UNCHANGED (shared) +├── dmRedactionLevel : StateFlow RENAMED from `redactionLevel` (Messages-only semantics) +├── failedUnlockAttempts : StateFlow UNCHANGED (shared) +└── lockedUntilEpochMs : StateFlow UNCHANGED (shared) +``` + +**Cascade on password clear:** when `passwordHashed → null`, +`PrivacyLockSettings` sets `lockEnabled → false` automatically (per user +decision Q8). This closes the "toggle stays on but no credential exists" +edge case without a UI dance. + +Rationale: + +| Setting | Per-scope? | Why | +|---|---|---| +| `lockEnabled` | ❌ | Single master toggle per user decision — enabling protects both Messages and Wallet simultaneously. Simplifies settings surface and matches "one lock, everything sensitive" mental model. | +| `firstRunCardSeen` | ❌ | Dismiss once, dismissed everywhere. User already knows the feature exists after seeing it in either route. | +| `passwordHashed` | ❌ | One password unlocks any gated route. Matches OS-keychain / device-credential precedent. | +| `inactivityTimer` | ❌ | Timing is policy, not scope. Global. | +| `dmRedactionLevel` | ❌ | DM notification redaction — no wallet analogue on Desktop today. Keep Messages-scoped semantics. | +| `failedUnlockAttempts` / `lockedUntilEpochMs` | ❌ | Rate-limit is anti-brute-force — must be global counter. | + +### Two lock states, one prompter + +``` +LocalPrivacyLockState[Messages] ← MessagesLockGate reads +LocalPrivacyLockState[Wallet] ← WalletLockGate reads +LocalCredentialPrompter ← both gates share (unchanged) +LocalPrivacyLockSettings ← both gates + settings screen share (unchanged) +``` + +`PrivacyLockState` is created twice at the App root — one per scope. Both +instances read the **same** `lockEnabled` and `firstRunCardSeen` flags. +Each has its own idle-timer Job and its own `LockState` StateFlow +(Locked ↔ Unlocked ↔ Disabled) so that: + +- Unlocking Messages does *not* automatically unlock Wallet (each route + demands its own credential prompt when the user enters it — this is a + policy choice: the master lock protects *entry*, but re-entering a + gated route is a fresh unlock). +- Idle timer runs per-scope so the currently-visible route drives the + re-lock, and the *other* route stays Locked without a running timer. +- Leaving one route does not affect the other's state. + +Writes to `failedUnlockAttempts` and `lockedUntilEpochMs` go through +shared `PrivacyLockSettings` and therefore apply to both gates +simultaneously — exactly the anti-brute-force property we want. + +### Copy update + +The existing `LockScreen()` in `MessagesLockGate.kt` hard-codes +`"Messages locked"` and `"Unlock to read or send messages"`. Refactor to +accept an `@StringRes` (Android) / string-key (Desktop) title and subtitle +so the same composable serves both scopes. + +Wallet copies: + +| Slot | Wallet copy | +|---|---| +| Title | *"Wallet locked"* | +| Subtitle | *"Unlock to see your balance and send or receive sats."* | +| First-run banner title | *"Lock the Wallet tab?"* | +| First-run banner body | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* | + +Messages copies unchanged. + +## Technical Approach + +### Architecture + +``` + ┌───────────────────────────────────┐ + │ PrivacyLockSettings │ device-global (jvmAndroid) + │ ─ lockEnabled(scope) 2× │ ← NEW: keyed by LockScope + │ ─ firstRunCardSeen(scope) 2× │ ← NEW: keyed by LockScope + │ ─ passwordHashed │ shared + │ ─ inactivityTimer │ shared + │ ─ failedUnlockAttempts │ shared + │ ─ lockedUntilEpochMs │ shared + └────────────────┬──────────────────┘ + │ + ┌─────────────────────┼──────────────────────┐ + │ │ +┌────────────▼────────────┐ ┌────────────────▼────────────┐ +│ PrivacyLockState │ │ PrivacyLockState │ +│ (scope = Messages) │ │ (scope = Wallet) │ +│ ─ state: StateFlow│ │ ─ state: StateFlow │ +│ ─ own idle-timer Job │ │ ─ own idle-timer Job │ +└──────┬───────────────────┘ └───────────────┬──────────────┘ + │ │ +┌──────▼──────────────────────────┐ ┌─────────────▼────────────────┐ +│ DesktopMessagesLockGate │ │ DesktopWalletLockGate │ +│ (unchanged public API) │ │ (NEW — 30 LOC mirror) │ +│ wraps DesktopMessagesScreen │ │ wraps WalletColumnScreen │ +└──────────────────────────────────┘ └──────────────────────────────┘ +``` + +Symmetry: code path from `WalletLockGate` to unlock is byte-for-byte +identical to `MessagesLockGate` — different scope enum, different string +keys. + +### Reuse-vs-New Matrix + +| Component | Status | Location | Action | +|---|---|---|---| +| `PrivacyLockSettings` interface | ♻️ Evolve | `commons/.../privacylock/` | Split enabled+seen into scope-accessor fns | +| `PreferencesPrivacyLockSettings` | ♻️ Evolve | `commons/jvmAndroid/.../privacylock/` | Add scope-suffixed prefs keys + legacy migration | +| `MessagesLockState` | 📦 Rename | `commons/.../privacylock/` | Rename → `PrivacyLockState`, add `scope: LockScope` | +| `LocalMessagesLockState` | 📦 Rename | (companion) | → `LocalPrivacyLockState: Map` | +| `LockState` sealed hierarchy | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `InactivityTimer` enum | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `DmRedactionLevel` | ✅ Reuse | `commons/.../privacylock/` | Optional rename → `DmRedactionLevel` stays, semantics scoped-out to Messages | +| `CredentialPrompter` interface | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged | +| `PasswordHasher` | ✅ Reuse | `commons/.../privacylock/` | Unchanged | +| `IdleTimerModifier` | ✅ Reuse | `commons/.../ui/privacylock/` | Unchanged (Modifier already scope-agnostic) | +| `MessagesLockGate` composable | ♻️ Shrink | `commons/.../ui/privacylock/` | ~15 LOC wrapper reading `scope=Messages` | +| `WalletLockGate` composable | 🆕 New | `commons/.../ui/privacylock/` | ~15 LOC mirror | +| Shared `LockScreen(scope,title,subtitle,unlockLabel)` | 🆕 Extract | `commons/.../ui/privacylock/` | Extracted from MessagesLockGate | +| `DesktopMessagesLockGate` | ♻️ Consume | `desktopApp/.../security/` | Point at new shared `LockScreen` | +| `DesktopWalletLockGate` | 🆕 New | `desktopApp/.../security/` | ~60 LOC mirror of `DesktopMessagesLockGate` | +| `MessagesFirstRunBanner` | ♻️ Adjust | `desktopApp/.../security/` | Reads `firstRunCardSeen(Messages)` | +| `WalletFirstRunBanner` | 🆕 New | `desktopApp/.../security/` | Mirror; reads `firstRunCardSeen(Wallet)` | +| `SetPasswordDialog` | ✅ Reuse | `desktopApp/.../security/` | Unchanged (password stays shared) | +| `PrivacyLockSettingsScreen` | ♻️ Two toggles | `desktopApp/.../ui/settings/` | Add Wallet toggle card + section headers | +| `DeckColumnContainer` — Wallet branch | ♻️ Wrap | `desktopApp/.../ui/deck/` | 3-line change — wrap `WalletColumnScreen` with `DesktopWalletLockGate` | +| `WindowCaptureBlock` route set | ♻️ Extend | `desktopApp/.../platform/` | Set expanded to `{Messages, Wallet}` | +| `WalletColumnScreen` | ⚠️ Avoid rewriting | `desktopApp/.../ui/wallet/` | Only insert `WalletFirstRunBanner` at top of column; body unchanged | +| Android `AmethystApp` — provide both scopes | ♻️ Provider | `amethyst/` | 4-line change — `LocalPrivacyLockState` map with 2 entries | +| `Main.kt` App root — provide both scopes | ♻️ Provider | `desktopApp/jvmMain/` | 4-line change | +| Strings — new `wallet_*` keys, rename shared `messages_lock_*` → `privacy_lock_*` | ♻️ | Android + Desktop | +6 keys, ~4 renames | + +**Legend:** ✅ Reuse · 📦 Rename · ♻️ Evolve · 🆕 New · ⚠️ Avoid + +### Data Migration + +Because `lockEnabled` and `firstRunCardSeen` stay single-key under the +master-lock model, **no prefs key migration is required**. The only +rename touching persisted state is `redaction_level_ordinal` (unchanged +key name; only the Kotlin-side identifier renames to `dmRedactionLevel`). + +Existing prefs keys retained as-is: + +``` +lock_enabled // master flag, unchanged +first_run_card_seen // shared, unchanged +password_hashed // unchanged +inactivity_timer_ordinal // unchanged +redaction_level_ordinal // unchanged (Kotlin var renamed to dmRedactionLevel) +failed_unlock_attempts // unchanged +locked_until_epoch_ms // unchanged +``` + +This is a pure additive change from the persistence layer's point of +view — Wallet gate simply reads the same flag Messages gate already +reads. + +### Implementation Phases + +#### Phase 1 — Genericise the state holder (foundation) + +Rename + parametrise **without** changing wire behaviour yet. Both +`MessagesLockGate` and Desktop wrapper still work; nothing else changes. + +Files to create / modify: + +- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/LockScope.kt` + ```kotlin + package com.vitorpamplona.amethyst.commons.privacylock + + enum class LockScope { Messages, Wallet } + ``` +- **RENAME** `commons/.../privacylock/MessagesLockState.kt` → + `PrivacyLockState.kt` + - Rename class → `PrivacyLockState`, add constructor + `scope: LockScope`. + - Store `scope` on the instance; pass through to + `settings.lockEnabled(scope)` / + `settings.firstRunCardSeen(scope)`. + - Companion: replace `LocalMessagesLockState: + ProvidableCompositionLocal` with + `LocalPrivacyLockState: + ProvidableCompositionLocal>`. + - Add extension: + `@Composable fun lockStateFor(scope: LockScope) = + LocalPrivacyLockState.current.getValue(scope)`. +- **MODIFY** `commons/.../privacylock/PrivacyLockSettings.kt` interface: + - Replace `val lockEnabled: StateFlow` with + `fun lockEnabled(scope: LockScope): StateFlow`. + - Same for `firstRunCardSeen`. + - Same for setters: `setLockEnabled(scope, enabled)`, + `setFirstRunCardSeen(scope, seen)`. + - `passwordHashed`, `inactivityTimer`, `redactionLevel`, + `failedUnlockAttempts`, `lockedUntilEpochMs` — unchanged. + - Update `companion object` constants: + - `KEY_LOCK_ENABLED = "lock_enabled_"` (prefix; scope name appended) + - `KEY_FIRST_RUN_CARD_SEEN = "first_run_card_seen_"` (prefix) + - `KEY_SCHEMA_VERSION = "schema_version"` + - `CURRENT_SCHEMA_VERSION = 2` +- **MODIFY** `commons/jvmAndroid/.../privacylock/PreferencesPrivacyLockSettings.kt`: + - Add per-scope `MutableStateFlow` maps: + `Map>` for enabled and seen. + - Seed each entry synchronously from prefs (respecting the deep-link + race fix in the messaging-privacy-lock plan H1). + - Add legacy-key migration in `init` block (see §Data Migration). + - Setters write to the scope-suffixed key. +- **RENAME + EXTEND** `commons/commonTest/.../privacylock/MessagesLockStateTest.kt` + → `PrivacyLockStateTest.kt`. Add tests: + - `test_two_scopes_have_independent_state` — Messages Locked, Wallet + Disabled, no cross-talk. + - `test_shared_failed_unlock_counter` — a failure in Messages scope + ticks the counter Wallet-scope reads. + - `test_migration_from_legacy_prefs_keys` — write legacy keys, load + settings, assert Messages scope has the value, Wallet default false, + legacy keys removed, `schema_version = 2` written. + +Ship this phase as its own commit — no UI changes; keeps `git bisect` +useful. + +**Acceptance:** + +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (all 5 existing + 3 new) +- [x] `./gradlew :desktopApp:compileKotlin` green (only rename+delegate calls updated) +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green + +#### Phase 2 — Extract `LockScreen`, add `WalletLockGate` + +- **NEW** `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/LockScreen.kt` + - Extract `@Composable private fun LockScreen()` currently inline in + `MessagesLockGate.kt`. + - Make `internal`, take + `scope: LockScope, title: String, subtitle: String, unlockLabel: String`. + - No behaviour change beyond parameterisation. +- **SHRINK** `commons/.../ui/privacylock/MessagesLockGate.kt` to a + ~15-line wrapper that fetches `lockStateFor(LockScope.Messages)`, + `DisposableEffect(onLeaveRoute)`, and delegates the locked branch to + `LockScreen(LockScope.Messages, stringRes(R.string.privacy_lock_messages_title), …)`. +- **NEW** `commons/.../ui/privacylock/WalletLockGate.kt` — 15-line mirror. + Scope = `Wallet`. Strings from + `R.string.privacy_lock_wallet_title` / + `R.string.privacy_lock_wallet_subtitle`. + +Test coverage: unit tests on `PrivacyLockState` cover the state +transitions; the gate composable is minimal and Compose-tested only via +the manual sheet. + +**Acceptance:** + +- [x] `MessagesLockGate` public signature unchanged (no caller changes) +- [x] `WalletLockGate` exposes the same `content: @Composable () -> Unit` lambda +- [x] Extracted `LockScreen` renders the correct title/subtitle for whichever scope invokes it + +#### Phase 3 — Desktop: `DesktopWalletLockGate` + first-run banner + capture-block + +- **NEW** `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopWalletLockGate.kt` + — mirror `DesktopMessagesLockGate.kt`. Only differences from Messages + version: + - Reads `lockStateFor(LockScope.Wallet)` instead of Messages. + - Renders *"Wallet locked"* title, *"Enter your privacy-lock + password to view the wallet."* subtitle. + - `stored == null` branch: *"No password is set yet."* + button + **"Open Settings"** that navigates via + `SinglePaneState.navigate(DeckColumnType.Settings)` and (if the + settings screen supports section anchors) deep-links to the + Privacy-lock section. Falls back to plain Settings navigation if + no anchor available (Q5 deep-link). + - No independent password-hashing / lockout math — those come from + shared `PrivacyLockSettings`. +- **NEW** `desktopApp/.../security/WalletFirstRunBanner.kt` — mirror + `MessagesFirstRunBanner.kt`. Only differences: + - Reads `firstRunCardSeen(LockScope.Wallet)`. + - Enable button writes `setLockEnabled(LockScope.Wallet, true)` and + marks scope=Wallet card seen. + - Text as per §Copy update table. + - Icon = `MaterialSymbols.Lock` (same as Messages) — no new codepoint, + so no font-subset regeneration needed. +- `DesktopWalletLockGate` also drives capture-block and blur-on-unfocus + the same way the Messages gate does — expand `WindowCaptureBlock.kt` + so both routes flip the flag when the master lock is enabled AND the + corresponding route is visible. +- **Wallet blur mode** — per user decision Q4, blur only sensitive text + nodes, not the whole column. Implementation: + - New `Modifier.privacyLockBlurWhenUnfocused()` extension in + `desktopApp/.../platform/` that reads `LocalWindowFocus.current` and + applies `Modifier.blur(radius = 16.dp)` only when unfocused AND + `lockEnabled == true`. + - Apply this Modifier to Text composables that display: balance sats + amount, lightning invoice string, on-chain address, NWC connection + URI, and any transaction memo. **Do NOT** apply to card containers, + icons, or button rows — the visual layout stays intact. + - Grep target: any `Text(text = ...sats...)`, `Text(text = invoice)`, + `Text(text = address)` in `WalletColumnScreen.kt`, + `OnchainSection.kt` (if reused in Desktop), and NWC dialogs. + +Wire into `DeckColumnContainer.kt`: + +```kotlin +DeckColumnType.Wallet -> { + DesktopWalletLockGate { + WalletColumnScreen( + account = account, + accountManager = accountManager, + relayManager = relayManager, + localCache = localCache, + nwcConnection = nwcConnection, + appScope = appScope, + onZapFeedback = onZapFeedback, + ) + } +} +``` + +Place `WalletFirstRunBanner` at the top of `WalletColumnScreen`'s Column +(mirroring where `MessagesFirstRunBanner` sits in the Messages column +entry). + +**Acceptance:** + +- [x] Toggling the master lock on in Settings → next Wallet column open shows the lock screen +- [x] Correct password (verified against shared `passwordHashed`) unlocks +- [x] Wrong password 5 times → lockout applies to **both** scopes (shared failed-attempt counter — covered by PrivacyLockStateTest.failed_unlock_counter_is_shared_across_scopes) +- [x] Leaving the Wallet column re-locks it (DisposableEffect.onDispose → PrivacyLockState.onLeaveRoute) +- [x] Idle timer configured via shared `inactivityTimer` setting re-locks Wallet after N minutes +- [ ] Screen-capture protection engages while Wallet column visible — deferred, no native shim shipped on parent messaging-privacy-lock branch either +- [x] Blur-on-unfocus for sensitive text (balance + generated invoice + QR) when the Amethyst window loses focus (16 dp radius via `Modifier.privacyLockBlurWhenUnfocused()`) + +#### Phase 4 — Settings screen: two toggles, shared subtree + +Refactor `desktopApp/.../ui/settings/PrivacyLockSettingsScreen.kt` +minimally — with the single-master-lock design, the shipped screen +already has the right shape. Only cosmetic + copy changes: + +- **Rename** the master-lock card header from *"Lock the Messages tab"* + → *"Lock the app"* (or *"Enable privacy lock"* — pick one, see + the strings table). +- **Update body copy** for the master-lock card to name what it protects: + *"Require your password before Messages and Wallet columns show. Feed, + profile, and search stay open."* +- **Update caveat text** at top of screen: replace + *"This lock hides the Messages column…"* with *"This lock hides the + Messages and Wallet columns on an unattended device. See the caveats + below."*. +- Password / inactivity / redaction cards unchanged. + +Layout order top-to-bottom (unchanged from shipped except copy): + +``` +Section header: "Privacy lock" +├── Card: "Privacy-lock password" (shared — always visible) +├── Card: "Enable privacy lock" (single master toggle) +├── Card: "Auto-lock after" (visible when master toggle is on) +├── Card: "DM notification previews" (visible when master toggle is on) +└── Card: "Caveats" (shared — always visible) +``` + +**Acceptance:** + +- [x] Toggling the master lock on with no password → prompts to set one (existing behaviour) +- [x] Toggling the master lock on locks **both** Messages and Wallet on next entry (single settings flag drives both PrivacyLockState instances) +- [x] Toggling the master lock off unlocks **both** immediately (transitions Locked → Disabled — covered by toggling_lock_off_transitions_to_disabled test) +- [x] Clearing the password auto-unsets the master toggle (Q8 cascade — covered by clearing_password_cascades_to_disable_the_master_lock test) + +#### Phase 5 — Strings, migrations, docs, spotless + +Strings to add / rename (Android `strings.xml` + Desktop +`messages.properties`): + +Shared (renamed from `messages_lock_*` → `privacy_lock_*` where +applicable): + +| Old key | New key | Notes | +|---|---|---| +| `messages_lock_setting_title` | `privacy_lock_settings_title` | section header | +| `messages_lock_screen_password_label` | `privacy_lock_screen_password_label` | shared | +| `messages_lock_screen_unlock_button` | `privacy_lock_screen_unlock_button` | shared | +| (new) | `privacy_lock_intro_body` | *"This lock hides the Messages column and/or the Wallet column on an unattended device."* | + +Scope-specific (Messages keys stay verbatim; Wallet keys mirror them): + +| Wallet key | Value | +|---|---| +| `privacy_lock_wallet_toggle_title` | *"Lock the Wallet tab"* | +| `privacy_lock_wallet_toggle_body` | *"Require a password before the Wallet column shows. Feed, profile, and Messages stay open."* | +| `privacy_lock_wallet_lockscreen_title` | *"Wallet locked"* | +| `privacy_lock_wallet_lockscreen_subtitle` | *"Unlock to see your balance and send or receive sats."* | +| `privacy_lock_wallet_firstrun_title` | *"Lock the Wallet tab?"* | +| `privacy_lock_wallet_firstrun_body` | *"Require a password before Wallet shows. Feed, profile, and Messages stay open."* | + +Other tasks: + +- Run legacy-key migration (Phase 1) on first startup after upgrade. +- Update `commons/ARCHITECTURE.md` — mention `LockScope` under the + `privacylock/` package entry. +- Update `MEMORY.md` — add pointer to this plan alongside the + messaging-privacy-lock pointer. +- `./gradlew spotlessApply`. +- Update manual testing sheet (see §Documentation Plan) — copy the + Messages sheet, adjust for Wallet. +- Verify Crowdin sync propagates the new keys (existing PR pipeline + already syncs; no new machinery needed). + +**Acceptance:** + +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green +- [x] `./gradlew :desktopApp:compileKotlin` green +- [x] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet passes (post-merge task) + +## System-Wide Impact + +### Interaction Graph + +User clicks Wallet in sidebar → +`SinglePaneState.navigate(DeckColumnType.Wallet)` → +`DeckColumnContainer` composes Wallet branch → +`DesktopWalletLockGate` reads `lockStateFor(LockScope.Wallet).state` → + +- If `Disabled` or `Unlocked` → `WalletColumnScreen` composes; + `WalletFirstRunBanner` may render at top if user hasn't dismissed it + and lock is disabled. +- If `Locked` → `LockScreen(scope = Wallet, title = "Wallet locked", + subtitle = "Unlock to see your balance and send or receive sats.")` + renders. On unlock success → `PrivacyLockState.onUnlockSuccess()` → + `WalletColumnScreen` composes. + +User leaves the Wallet column (navigates away, switches account, or +window closes) → `DesktopWalletLockGate.DisposableEffect.onDispose` → +`PrivacyLockState.onLeaveRoute()` for scope=Wallet only. Messages state +unaffected. + +Cross-scope: if user is on Messages, unlocks, then navigates to Wallet, +the Wallet gate still shows (independent scopes). Same password → +Wallet unlocks. Matches settings UX: two toggles, one credential. + +### Error Propagation + +Wallet gate uses the identical `submit` path as `DesktopMessagesLockGate` +in the shipped code: + +| Origin | Error | Handled at | Result | +|---|---|---|---| +| Wrong password | `PasswordHasher.verify → false` | `DesktopWalletLockGate.submit` | `showError = true`; `onFailedUnlockAttempt` increments **shared** counter | +| 5 consecutive failures | shared counter hits `LOCKOUT_TRIP_AFTER_FAILURES` | `PrivacyLockState.onFailedUnlockAttempt` | Shared `lockedUntilEpochMs` set → **both** scopes show the countdown supportingText | +| Password cleared while wallet Locked | `settings.passwordHashed → null` | `DesktopWalletLockGate.DesktopLockScreen` | *"No password is set yet"* branch renders; `Disable lock` button clears `lockEnabled(Wallet)` | +| Wallet toggle enabled but no password | Settings screen | Enable button triggers `SetPasswordDialog` first | +| Settings write fails (java.util.prefs full) | `PreferencesPrivacyLockSettings.setLockEnabled` | Existing best-effort semantics | Toggle reverts on next flow emit; user sees no confirmation | + +### State Lifecycle Risks + +| Risk | Mitigation | +|---|---| +| Wallet locked, incoming NWC balance/receipt event decrypts in background — plaintext held in memory | Same posture as messaging plan: cosmetic lock, not cryptographic. Balance StateFlow keeps last-known value. NWC responses continue to arrive on the coroutine scope; UI just doesn't render them until unlock. Honest and matches messaging behaviour. Called out in §Known Limitations. | +| App killed mid-unlock leaves Wallet stuck at Locked | State is in-memory; cold start re-reads `lockEnabled(Wallet)` from prefs → if enabled, starts Locked. Fail-safe. | +| User toggles Wallet off while Locked | `settings.lockEnabled(Wallet) → false` flows into `PrivacyLockState` which transitions `Locked → Disabled` on the next tick. Gate transparently shows content. Matches messaging behaviour. | +| Both scopes Locked, user in middle of a send-payment flow | Send-payment happens **inside** an already-unlocked scope; if idle timer fires mid-flow, the dialog stays composed (rememberSaveable) but the content behind is gated. Intentional — do not exempt in-flight payment dialogs from the timer. Manual test: `payment_flow_survives_timer.md`. | +| Concurrent leave-route events (Wallet + Messages navigating away simultaneously) | Each `PrivacyLockState` has its own idle-timer Job; no cross-scope races. | + +### API Surface Parity + +| Surface | Affected? | Notes | +|---|---|---| +| Android wallet UI (`OnchainSection`, `AddCashuWalletScreen`) | Deferred to v2 | Not touched in this plan — see §Future Considerations. | +| `amy` CLI | Not touched | CLI does not surface NWC actions today; when it does, use `PrivacyLockSettings.lockEnabled(Wallet)` for parity. | +| `UpdateZapAmountDialog.authenticate()` (nsec-key-guard biometric prompt) | Not affected | Separate OS-credential gate on the zap-amount-preferences change flow. Wallet gate is orthogonal — zap flow already gates OS credentials for a stronger reason. | +| One-click zap from a note (`ReactionsRow.RenderZapButton`) | Not gated | Wallet **column** is gated; zap **action** from feed context is not. Matches messaging: Messages **column** is gated; DM replies from a note thread are not (there aren't any). | +| Wallet notifications (NWC `success`, `failed`) | None on Desktop today | Desktop has no notification pipeline for wallet events. If added, use `redactionLevel` — but v1 keeps redaction Messages-only per §Proposed Solution. | +| Search results — NWC receipts / on-chain zaps | Not affected | `SearchBarViewModel` search-audit path from messaging plan already filters kinds 4/14/1059/443. NWC events (kind 23194/23195/23196) aren't searchable today. If they become searchable, add them to the audit list. | + +### Integration Test Scenarios + +1. **Cross-scope lockout**: Wallet locked. User enters 5 wrong + passwords on Wallet screen. Then navigates to Messages (also locked + via Messages toggle). Expected: Messages screen shows countdown + supportingText, `Unlock` button disabled. Failure mode: counter + scoped per-gate would defeat brute-force protection. +2. **Wallet lock + auto-fetched balance**: Wallet toggle just enabled; + user has been on Wallet column with balance loaded. Setting flip → + gate re-composes → balance is hidden behind the lock screen + **immediately**. Failure mode: balance visible for one frame during + transition. +3. **First-run banner interaction**: Fresh install → Messages column + visited → Messages banner shown, dismissed. User visits Wallet → + Wallet banner shown independently (not shared dismissal). Failure + mode: shared `firstRunCardSeen` would suppress Wallet banner. +4. **Toggle-disable while Locked**: User is on the Wallet lock screen → + opens Settings → Privacy lock → toggles Wallet off → returns to + Wallet. Expected: content shows without unlock. Failure mode: state + stays Locked because the settings update didn't cascade. +5. **NWC connect flow while locked**: New user, no NWC connected, + Wallet toggle on. Expected: `WalletColumnScreen`'s connect UI is + gated behind the lock — a Locked-gate does not let unauth users + trigger NWC pairing. Desired security posture. +6. **Password change → shared re-verify**: User changes password while + only Messages is locked. Then enables Wallet. Wallet lock screen + accepts the **new** password (not the old one). Failure mode: two + password hashes cached separately. +7. **Migration from legacy prefs**: User on a build with the shipped + `feat/desktop-privacy-lock` (single `lock_enabled` key) upgrades to + this build. Expected: Messages toggle preserved; Wallet toggle + defaults off. Legacy prefs keys removed; `schema_version = 2` + written. Failure mode: users get silently un-locked on upgrade, OR + migration re-runs and clobbers a subsequent Wallet toggle. + +## Acceptance Criteria + +### Functional + +- [x] `LockScope` enum shipped in `commons/commonMain` +- [x] `PrivacyLockState` replaces `MessagesLockState`; each scope has an + independent `state: StateFlow` and idle-timer Job +- [x] `PrivacyLockSettings.lockEnabled` and `firstRunCardSeen` stay single + master flags (per user Q2) +- [x] Password / inactivity timer / redaction / failed-attempts / lockout + remain device-global (shared) +- [x] `MessagesLockGate` public signature unchanged; wired to + `lockStateFor(Messages)` +- [x] `WalletLockGate` composable shipped in + `commons/.../ui/privacylock/` +- [x] Shared `LockScreen(scope, title, subtitle, unlockLabel)` composable + replaces the inlined lock screen inside MessagesLockGate; both + gates render it +- [x] `DesktopMessagesLockGate` refactored to consume shared + `DesktopLockScreen`; behaviour preserved +- [x] `DesktopWalletLockGate` shipped; wraps `WalletColumnScreen` inside + `DeckColumnContainer` +- [x] `MessagesFirstRunBanner` copy updated to reference both Messages and Wallet +- [x] `WalletFirstRunBanner` shipped at top of `WalletColumnScreen` +- [x] Settings screen renders single master toggle + shared password subtree + + shared inactivity timer + Messages-only redaction card +- [x] No prefs migration required (master-lock design keeps existing keys as-is) +- [ ] `applyWindowCaptureBlock(true)` engages when the master lock is + enabled — **deferred** (no native shim shipped on parent branch) +- [x] Blur-on-unfocus for sensitive text (balance, generated invoice, + QR) when window loses focus AND `lockEnabled == true` + +### Non-Functional + +- [ ] No measurable startup regression (≤ +5 ms cold start on top of + messaging-lock baseline) +- [ ] `PrivacyLockState.state` reads are constant-time regardless of + scope count (Map lookup, no reflection) +- [ ] No new deps added — everything stays inside kotlinx.coroutines + + Compose + the existing java.util.prefs / SharedPreferences setup +- [ ] Password comparison stays constant-time via `PasswordHasher.verify` + (unchanged) +- [ ] No visible flash of Wallet content on cold start when + `lockEnabled(Wallet) = true` — seeded synchronously (deep-link race + fix H1 from messaging plan applies to both scopes) + +### Quality Gates + +- [x] `./gradlew :commons:jvmTest --tests "*PrivacyLockState*"` green (16 tests, 3 new) +- [x] `./gradlew :amethyst:compilePlayDebugKotlin` green +- [x] `./gradlew :desktopApp:compileKotlin` green +- [ ] `./gradlew :desktopApp:packageDmg` green on macOS host (packaging validation deferred to reviewer) +- [ ] `./gradlew :desktopApp:packageMsi` green on Windows host (packaging validation deferred to reviewer) +- [ ] `./gradlew :desktopApp:packageDeb` green on Linux host (packaging validation deferred to reviewer) +- [x] `./gradlew spotlessApply` clean +- [ ] Manual testing sheet + (`docs/plans/2026-07-07-wallet-lock-manual-testing.md`) executed + and signed off (post-merge task) + +## Success Metrics + +- **Adoption proxy**: after 30 days on nightly, at least half the users + who enabled the Messages lock have also enabled the Wallet lock. If + the ratio is far lower, the discoverability (first-run banner + placement + settings copy) needs rework. +- **Stability proxy**: zero support reports of *"wallet stuck at + locked"* or *"wrong password after change"* in the first 30 days. +- **Regression proxy**: no new issues on Messages lock after this PR + merges — the refactor keeps behaviour identical for the Messages path. + +## Dependencies & Prerequisites + +- **Blocked on**: `feat/desktop-privacy-lock` merged into main. This + plan builds on top of that shipped feature; extracting into a + scope-parameterised state holder while the messaging code is still + on a branch would create merge-conflict hell. +- **No new deps**: everything reuses the shipped `androidx.biometric.ktx`, + `com.sun.jna:jna`, java.util.prefs, SharedPreferences, Compose + Multiplatform. +- **No native shims added**: Touch ID `.dylib`, Windows credprompter, + `NSWindowSharingNone` / `WDA_EXCLUDEFROMCAPTURE` shims — all already + shipped by `feat/desktop-privacy-lock`. This plan just adds the Wallet + route into the set that flips the flag. + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Migration bug leaves a Messages user un-locked on upgrade | Medium | High (silent security regression) | Migration is copy-then-delete; version-gated by `schema_version = 2`; unit-tested; runs once and no-ops afterwards | +| Per-scope idle timers get out of sync (e.g. two timers on different Jobs miscoordinate) | Low | Low | Each `PrivacyLockState` is a self-contained state machine; no cross-scope coordination; unit test asserts independence | +| Users confused by two toggles + one password | Medium | Low (UX) | Settings copy: password card explicitly says *"One password. Applies to any tab you lock below."* Manual testing sheet includes a UX-clarity checkpoint | +| Wallet balance flashes visible on cold start | Low | High (privacy leak) | Same synchronous seed as messaging (H1). Compose-test asserts no-flash invariant on Wallet route too | +| Shared failed-attempts counter causes friction — a user mistyping in Wallet locks out Messages | Verified | Low | Intended behaviour — brute-force protection is a global property. Copy in the lockout supportingText clarifies: *"Too many failed attempts. Try again in ${countdown}."* — same message on both scopes | +| Refactor breaks `MessagesLockGate` on the shipped branch | Medium | High | Phase 1 is behaviour-preserving; Phase 2 preserves `MessagesLockGate`'s public signature; verified by a full manual pass on the shipped Messages testing sheet | +| ProGuard strips scope-based lookups | Low | Medium | `LockScope` is a simple enum — ProGuard-safe. Confirm during Phase 1 packaging | + +## Future Considerations + +- **Android wallet gate.** When Android wallet is elevated to a first-class + destination (currently the wallet lives in a subscreen, not a tab), wrap + its Compose entry point with `WalletLockGate` — no state-holder change + required; `PrivacyLockState[Wallet]` already exists. +- **amy CLI wallet verbs.** If `amy wallet balance` / `amy wallet send` + land, they should refuse to run when + `PrivacyLockPreferences.lockEnabled(Wallet)` is `true` — closes the + "run amy on a shared machine to snapshot the balance" gap. +- **Per-transaction OS-credential re-prompt on Wallet send.** Optional + belt-and-suspenders: when a send-payment exceeds a user-configurable + threshold (e.g. 10k sats), fire the same + `UpdateZapAmountDialog.authenticate()` prompt. Tracks separately — + this plan is about the column gate, not per-action gates. +- **Third scope: nsec / account settings.** Once we have `LockScope`, we + could add `LockScope.Account` to gate the Account backup screen. + Today that screen already uses OS-credential re-prompts, so added + value is marginal. +- **`redactionLevel` extension for wallet notifications.** If Desktop gets + a notification pipeline for NWC events (balance changes, incoming + zaps), add a `WalletRedactionLevel` and gate the same way DM + notifications are gated. Currently no such pipeline exists. + +## Documentation Plan + +- `commons/ARCHITECTURE.md` — update the `privacylock/` package entry: + mention the `LockScope` enum and the "one settings, many scopes" + contract. +- `docs/plans/2026-07-07-wallet-lock-manual-testing.md` — new manual + testing sheet mirroring + `docs/plans/2026-06-30-privacy-lock-manual-testing.md`. Include the 7 + integration test scenarios above as concrete steps. +- No changes needed to `desktopApp/CLAUDE.md` — no new native shim. +- `MEMORY.md` — index entry alongside the messaging-privacy-lock work. +- Release notes: extend the "Privacy & Security" section from + messaging-privacy-lock with a one-line Wallet addition. + +## Sources & References + +### Origin + +- **Brainstorm document**: + [`docs/brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md`](../brainstorms/2026-06-30-feat-messaging-privacy-lock-brainstorm.md) + — where the "reuse for Wallet" follow-up was explicitly enumerated as + a Future Consideration. +- **Predecessor plan**: + [`docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md`](2026-06-30-feat-messaging-privacy-lock-plan.md) + — carried-forward decisions: (a) OS credentials only, (b) device-global + settings, (c) inactivity timer + leave-route re-lock, (d) synchronous + initial-state seed for the deep-link race fix, (e) shared password + hashing + exponential-backoff lockout. + +### Internal References + +- Extracted from: + `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/privacylock/MessagesLockState.kt` + (on branch `feat/desktop-privacy-lock`) +- Extracted from: + `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/MessagesLockGate.kt` + (on branch `feat/desktop-privacy-lock`) +- Extracted from: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/security/DesktopMessagesLockGate.kt` +- Wallet column entry: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/wallet/WalletColumnScreen.kt:88` +- Deck integration site: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:469-479` +- Settings screen: + `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/PrivacyLockSettingsScreen.kt` +- OS-credential biometric precedent: + `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt:394-490` +- CLAUDE.md — `commons/ARCHITECTURE.md` governs package taxonomy + +### External References + +- Signal Screen Lock (per-app opt-in, single credential): + https://support.signal.org/hc/en-us/articles/360007059572 +- WhatsApp Chat Lock (per-chat, single credential): + https://about.fb.com/news/2023/05/whatsapp-chat-lock/ +- Ledger Live "auto-lock all tabs" — this plan's per-scope model is + weaker than Ledger's app-wide lock; intentional (matches Signal + + WhatsApp UX and the brainstorm's explicit rejection of an app-wide + lock). + +### Related Work + +- Messaging privacy lock plan (parent): + `docs/plans/2026-06-30-feat-messaging-privacy-lock-plan.md` +- Desktop wallet + zapping (defines the surface being gated): memory + pointer *"Desktop Wallet & Zapping"* — branch + `feat/desktop-wallet-zapping` +- Account security hardening (concurrent work; `passwordHashed` storage + lives in the same jvmAndroid source set that the account-security work + touches — coordinate merge order): + `docs/plans/2026-05-14-fix-account-security-hardening-plan.md` + +## Open Questions — RESOLVED (2026-07-07) + +1. **Merge order** — ✅ Solo PR stacked on `feat/desktop-privacy-lock`. +2. **Lock granularity** — ✅ **Single master lock** protects both Messages + and Wallet. No per-scope enable flag. Single `firstRunCardSeen` too. +3. **Wallet first-run banner on empty NWC** — Show anyway (feature is + valuable pre-connect). +4. **Blur-on-unfocus for Wallet** — ✅ Blur **text nodes only** (balance + amount, addresses, invoices). Cards / structural layout stay visible. + Implementation: apply `Modifier.blur(16.dp)` at the Text-composable + level for sensitive strings, not the LazyColumn wrapper. +5. **"No password set" branch behaviour** — ✅ Deep-link to Settings → + Privacy lock section (not just show the message). +6. **Rename `redactionLevel` → `dmRedactionLevel`** — ✅ Yes. Kotlin-side + only; persisted key `redaction_level_ordinal` stays for compatibility. +7. **`LockScope` package** — ✅ Inside existing `privacylock/` package. +8. **Cascade `passwordHashed → null` unsets `lockEnabled`** — ✅ Yes. + Implement in `PreferencesPrivacyLockSettings.setPasswordHashed(null)` + → also `setLockEnabled(false)` atomically. diff --git a/docs/plans/2026-07-07-wallet-lock-manual-testing.md b/docs/plans/2026-07-07-wallet-lock-manual-testing.md new file mode 100644 index 0000000000..19f3c36c9f --- /dev/null +++ b/docs/plans/2026-07-07-wallet-lock-manual-testing.md @@ -0,0 +1,177 @@ +--- +title: Wallet Privacy Lock — Manual Testing Sheet +type: test +status: active +date: 2026-07-07 +plan: docs/plans/2026-07-07-feat-wallet-privacy-lock-reuse-plan.md +--- + +# Wallet Privacy Lock — Manual Testing Sheet + +Companion to the messaging-privacy-lock testing sheet — assumes the Messages +gate has already been validated by that document. Focus here is on the +Wallet gate and cross-scope behaviour introduced by the single master lock. + +## Setup + +- Fresh Amethyst Desktop install on a supported OS (macOS 14+, Windows 11, + Ubuntu 22.04+). +- Log in with an account that has an NWC-connected wallet (Alby or a + self-hosted LNDHUB will do). +- Confirm messaging-privacy-lock testing sheet has been executed and green. +- Start with `lockEnabled = false` (default). + +## T1 — First-run banner (Wallet) + +**Steps.** Open the Wallet column with the master lock disabled and never +seen the first-run banner before. + +**Expected.** Banner *"Lock the Wallet and Messages?"* appears at the top +of the Wallet column with Enable + Not now buttons. Dismissing with **Not +now** hides the banner permanently; opening Messages afterwards shows no +banner either (single `firstRunCardSeen` flag). + +**Failure.** Banner reappears after Not now, or Messages banner shows +independently. + +## T2 — Enable via Wallet banner + +**Steps.** Fresh install. Open Wallet. Tap **Enable** on the banner. Set a +password. + +**Expected.** After the password dialog closes, the Wallet column is +Unlocked and immediately usable (no lock screen flash). Navigating to +Messages shows the Messages lock screen — because the Messages instance is +freshly Locked. Password unlocks it. + +**Failure.** Wallet flashes lock screen after enabling; Messages does not +lock. + +## T3 — Cross-scope lockout (brute force) + +**Steps.** Lock enabled. On the Wallet lock screen, enter 5 wrong +passwords in a row. Then navigate to Messages. + +**Expected.** Both Wallet AND Messages show *"Too many attempts. Try +again in 30s."* Password field disabled on both. Countdown updates +every ~0.5s. + +**Failure.** Only Wallet locks out; Messages accepts input. + +## T4 — Balance and invoice blur on window unfocus + +**Steps.** Lock enabled, Wallet Unlocked, wallet connected. Note the +balance amount. Now click a browser or other app to defocus the Amethyst +window. + +**Expected.** The balance amount text ("N sats") blurs; the "Balance" +label, "Refresh" button, and card outline stay crisp. Refocus Amethyst +→ blur clears immediately. + +**Also.** Open Receive dialog, generate an invoice. Defocus the window. +Amount text + QR code blur. Refocus → clear. Note that Send-dialog input +fields are NOT blurred (users need to type into them). + +**Failure.** Whole card blurs, or blur persists after refocus, or blur +never fires. + +## T5 — Leave-route re-lock + +**Steps.** Lock enabled, Wallet Unlocked. Navigate away from the Wallet +column (Home Feed or Messages). + +**Expected.** Returning to Wallet shows the lock screen. Messages state +is not affected (if it was Unlocked, it stays Unlocked). + +**Failure.** Wallet stays Unlocked, or Messages is force-locked too. + +## T6 — Idle timer re-lock (Wallet in foreground) + +**Steps.** Lock enabled. Set inactivity timer to 1 minute. Unlock Wallet. +Do not interact with the app for 60+ seconds. + +**Expected.** After ~1 minute, Wallet column transitions to Locked; the +lock screen shows. Password unlocks it. + +**Failure.** Wallet stays Unlocked past the timer; timer only applies to +Messages. + +## T7 — Password change → shared re-verify + +**Steps.** Enable lock. Set password `A`. Change password to `B` from +Settings. Unlock Wallet — should accept `B`, reject `A`. + +**Expected.** Only the new password unlocks. Both scopes accept `B`. + +**Failure.** Wallet still accepts old password. + +## T8 — Password clear → cascade + +**Steps.** Enable lock. Set password. Navigate to Settings → Privacy +lock. Click *"Remove password"* and confirm with the current password. + +**Expected.** Master toggle turns off automatically. Both Messages and +Wallet transition to Disabled (no lock screen). Navigation into either +route shows content without a prompt. + +**Failure.** Master toggle stays on with no password (invalid state). + +## T9 — Deep-link to Settings from Wallet "No password set" branch + +**Steps.** Contrived-state edge case: enable lock with password. Then +manually delete the `password_hashed` java.util.prefs key while the app is +running (via a debugger or a second Settings tab). Navigate to Wallet. + +**Expected.** Lock screen renders *"No password is set yet."* with an +**Open Settings** button. Tapping it navigates to the Settings tab +(via `onNavigateToRelays`). User can re-set the password there. + +**Failure.** Wallet shows *"Disable lock"* fallback instead of the deep-link +(that's the Messages behaviour, but per plan Q5 Wallet should deep-link). + +## T10 — First-time enable via Settings (Wallet-only user) + +**Steps.** User who never opens Messages. Enable the lock via Settings +directly (not via a banner). Set password. Navigate to Wallet. + +**Expected.** Wallet gate fires normally. Password unlocks. Master toggle +now enables both scopes but Messages is never visited so no visible +difference. + +**Failure.** Wallet not gated. + +## T11 — Settings copy sanity + +**Steps.** Navigate to Settings → Privacy lock section. + +**Expected.** +- Card header reads *"Enable privacy lock"* (not *"Lock the Messages + tab"*). +- Body reads *"Require your password before the Messages and Wallet + columns show. Feed, profile, and search stay open."* +- Auto-lock card says *"Re-lock Messages and Wallet after this much + inactivity."* +- DM notification card mentions *"Wallet has no notifications yet."* +- Caveats card mentions *"the Messages and Wallet columns"*. + +**Failure.** Any card still says *"Messages"* only. + +## T12 — Rapid navigation between locked scopes + +**Steps.** Lock enabled. Both scopes Locked. Rapidly click Messages then +Wallet then Messages in the sidebar (< 200 ms between clicks). + +**Expected.** Each route shows its own lock screen with correct scope- +specific title. No flicker to content. No state cross-talk (unlocking +one scope's screen halfway shouldn't unlock the other). + +**Failure.** Wrong title on the wrong scope, or content flashes during +navigation. + +## Sign-off + +- [ ] All 12 tests passed +- Tester: _________ +- OS + Amethyst build: _________ +- Date: _________ +- Notes: _________ diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 594458773f..ee636965c7 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -72,6 +72,9 @@ tasks.withType().configureEach { // NegentropyServerReconcileBenchmark opt-in + sizing. System.getProperty("negServerBench")?.let { systemProperty("negServerBench", it) } System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) } + // DeletionSettleBenchmark sizing. + System.getProperty("delBenchN")?.let { systemProperty("delBenchN", it) } + System.getProperty("delBenchK")?.let { systemProperty("delBenchK", it) } // MirrorSyncThroughputTest sizing + external-source opt-in. System.getProperty("syncN")?.let { systemProperty("syncN", it) } System.getProperty("syncExpect")?.let { systemProperty("syncExpect", it) } diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt index 3ccb80af07..5c30fc2f60 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/Main.kt @@ -128,9 +128,9 @@ private class StoreContext( ) private fun openStore(a: Args): StoreContext { - val config = a.opt("--config")?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() + val config = a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() val dbFile = a.opt("--db") ?: config.database.file?.takeUnless { config.database.in_memory } - val fullTextSearch = !a.flag("--no-search") && config.options.full_text_search + val fullTextSearch = !a.flag(NO_SEARCH_FLAG) && config.options.full_text_search val store = EventStore( dbName = dbFile, @@ -142,12 +142,12 @@ private fun openStore(a: Args): StoreContext { private fun runImport(args: Array) { val a = parseArgs(args) - val config = a.opt("--config")?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() + val config = a.opt(CONFIG_FLAG)?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() // Verify by default, matching the relay's stance — `import` won't trust a // file's signatures any more than the relay trusts a client's. `--no-verify` // is the trusted-input escape hatch (fixture replay, a dump from a relay you // already trust). - val verify = !a.flag("--no-verify") && config.options.verify_signatures + val verify = !a.flag(NO_VERIFY_FLAG) && config.options.verify_signatures val ctx = openStore(a) try { val stats = @@ -190,7 +190,7 @@ private fun serve(args: Array) { val config: StaticConfig = a - .opt("--config") + .opt(CONFIG_FLAG) ?.let { StaticConfig.fromFile(File(it)) } ?: StaticConfig() config.validate() @@ -208,7 +208,7 @@ private fun serve(args: Array) { // Verify is on by default; only disable when the operator explicitly // opts out (CLI `--no-verify` or `[options].verify_signatures = false` // in the config). - val verifySigs = !a.flag("--no-verify") && config.options.verify_signatures + val verifySigs = !a.flag(NO_VERIFY_FLAG) && config.options.verify_signatures // Parallel verify is on whenever signature checking is on; the // IngestQueue handles it instead of VerifyPolicy. Operators can // force the legacy in-policy path with `--no-parallel-verify` or @@ -218,7 +218,7 @@ private fun serve(args: Array) { // NIP-50 search is on by default; `--no-search` (or // `[options].full_text_search = false`) trades it for cheaper ingest — // e.g. to match relays that don't implement NIP-50 at all. - val fullTextSearch = !a.flag("--no-search") && config.options.full_text_search + val fullTextSearch = !a.flag(NO_SEARCH_FLAG) && config.options.full_text_search // Advertised URL: explicit `info.relay_url` wins, then build from // host/port/path. 0.0.0.0 bind → 127.0.0.1 in the URL so NIP-42 @@ -466,13 +466,17 @@ private class Args( fun flag(k: String) = k in flags } +private const val CONFIG_FLAG = "--config" +private const val NO_VERIFY_FLAG = "--no-verify" +private const val NO_SEARCH_FLAG = "--no-search" + /** * Boolean flags that never take a value. Listing them explicitly is what lets a * trailing positional survive after a flag — `import --no-verify corpus.ndjson` * must read `corpus.ndjson` as a file, not as `--no-verify`'s value. */ private val BOOLEAN_FLAGS = - setOf("--auth", "--optional-auth", "--no-verify", "--no-parallel-verify", "--no-search") + setOf("--auth", "--optional-auth", NO_VERIFY_FLAG, "--no-parallel-verify", NO_SEARCH_FLAG) private fun parseArgs(args: Array): Args { val opts = mutableMapOf() diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt new file mode 100644 index 0000000000..46e4b7a1ca --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt @@ -0,0 +1,128 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Cost of the deletion side-channel ([negentropySettleDeletions]) at database scale. + * + * The whole point of the two-pass design is that turning deletions on does NOT re-fetch + * content — the content sync already downloaded the need set, and the settle only touches + * the reconcile *residual* (the events a deletion stopped from converging). So its cost is + * one reconcile per round plus the residual, independent of how big the database is. + * + * This models the post-content-settle state: a relay holding N notes, and a local store + * holding the same N notes EXCEPT K it deleted (it keeps the K kind-5s). The residual is + * exactly those K — so a `sendUp` settle fetches K, not N. It prints the reconcile cost + * (the O(N) part it shares with any sync) next to the settle cost, so the deletion + * overhead is visible as "≈ a couple of reconciles + K", not "+ a content re-download". + * + * Why the printed settle can read as several× a bare reconcile at large N: the extra time + * is NOT the deletion algorithm (a phase breakdown showed reconciles stay ~sub-second at + * N=100k, and the settle re-fetches K=20, not N). It is entirely the K `publishAndConfirm` + * ingests into a large geode relay — publishing K *plain* notes costs the same — and that + * ingest path is JVM-cold on first use: consecutive K-note batches dropped monotonically + * (~3100 → ~570 ms) purely from JIT warmup. So the cost is O(K) relay-ingest dominated by + * one-time warmup, independent of N. + * + * Default N is small so it doubles as a fast correctness guard; scale it with + * `-DdelBenchN=200000` to see the shape at size. Not a speed assertion (container noise). + */ +class DeletionSettleBenchmark : RelayClientTest() { + private val signer = NostrSignerSync(KeyPair()) + private val local = EventStore(null) + + @AfterTest fun closeLocal() = local.close() + + private val n = System.getProperty("delBenchN")?.toInt() ?: 2_000 + private val k = System.getProperty("delBenchK")?.toInt() ?: 20 + + @Test + fun settleCostIsResidualNotDatabase() = + runBlocking { + val base = TimeUtils.now() - n + // N notes with monotonic created_at (sorted order == index order). + val notes = (0 until n).map { signer.sign(TextNoteEvent.build("n$it", createdAt = base + it.toLong())) } + // The last K are the ones we deleted locally. + val deleted = notes.takeLast(k) + val kept = notes.dropLast(k) + val deletions = deleted.map { signer.sign(DeletionEvent.build(listOf(it), createdAt = it.createdAt + 1)) } + + // Relay holds all N notes; we hold the N-K we didn't delete, plus the K kind-5s. + defaultRelay.preload(notes) + kept.forEach { local.insert(it) } + deletions.forEach { local.insert(it) } + assertEquals(n - k, local.query(Filter(kinds = listOf(1))).size, "local kept N-K notes") + + // Cost of one reconcile — the O(N) work every sync round already does. + val r0 = System.nanoTime() + val diff = + withTimeout(120_000) { + client.negentropyReconcileIds(defaultRelayUrl, Filter(kinds = listOf(1)), local.snapshotIdsForNegentropy(listOf(Filter(kinds = listOf(1))))) + } + val reconcileMs = (System.nanoTime() - r0) / 1e6 + assertEquals(k, diff.needIds.size, "the residual is exactly the K deleted notes, not N") + + // Cost of the whole settle: reconcile(s) + resolve the K-event residual. + val s0 = System.nanoTime() + val res = + withTimeout(120_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = local, + sendUp = true, + applyDown = false, + idleTimeoutMs = 60_000, + ) + } + val settleMs = (System.nanoTime() - s0) / 1e6 + + assertEquals(k, res.sentUp, "sent exactly K deletions up") + assertEquals( + n - k, + defaultRelay.store.query(Filter(kinds = listOf(1))).size, + "relay converged: the K deleted notes are gone", + ) + + println("─ DeletionSettleBenchmark @ N=$n K=$k ─") + println(" one reconcile: ${"%.1f".format(reconcileMs)} ms (O(N), shared with any sync)") + println(" full settle: ${"%.1f".format(settleMs)} ms (${res.rounds} rounds, sentUp=${res.sentUp})") + println(" deletion cost: settle is ~${"%.1f".format(settleMs / reconcileMs)}× one reconcile — fetched K=$k, not N=$n") + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt new file mode 100644 index 0000000000..f3a7ef4306 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -0,0 +1,267 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.geode.testing.publish +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions +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.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime +import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The `amy sync` deletion rule: for the events the relay HAS that we LACK (the + * negentropy need set), publish the local deletions that would make the relay remove + * them — and only those. [deletionsCovering] is the core: it maps a set of server-held + * events to the local deletions that cover them, across id-based (NIP-09 `e`), + * address-based (NIP-09 `a`, cutoff-checked) and NIP-62 vanish (relay-targeted, cutoff). + */ +class DeletionSyncTest : RelayClientTest() { + private val signer = NostrSignerSync(KeyPair()) + private val here: NormalizedRelayUrl get() = defaultRelayUrl + private val elsewhere = RelayUrlNormalizer.normalize("wss://elsewhere.example/") + + private val store = EventStore(null) + + @AfterTest fun closeStore() = store.close() + + private fun note(text: String): Event = signer.sign(TextNoteEvent.build(text)) + + // ---- deletionsCovering: the three coverage forms -------------------------- + + @Test + fun idBasedDeletionCoversByETag() = + runBlocking { + val target = note("delete me") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + store.insert(deletion) + + assertEquals(listOf(deletion.id), store.deletionsCovering(listOf(target), here).map { it.id }) + // A different note the deletion doesn't name is not covered. + assertTrue(store.deletionsCovering(listOf(note("unrelated")), here).isEmpty()) + } + + @Test + fun addressBasedDeletionCoversByATagWithCutoff() = + runBlocking { + val contacts = ContactListEvent.createFromScratch(emptyList(), null, signer) + // Address-only deletion (no `e` tag) → only the `a`-tag path can match it. + val delAddr = signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt + 1)) + store.insert(delAddr) + + assertEquals( + listOf(delAddr.id), + store.deletionsCovering(listOf(contacts), here).map { it.id }, + "a replaceable event is covered by an address deletion at/after it", + ) + + // NIP-09 cutoff: a deletion OLDER than the event does not delete it. + val stale = EventStore(null) + stale.insert(signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt - 1))) + assertTrue(stale.deletionsCovering(listOf(contacts), here).isEmpty(), "an older address deletion does not cover") + stale.close() + } + + @Test + fun vanishCoversAuthorsEventsWhenTargetedAndNewer() = + runBlocking { + val old = note("before the vanish") + val vanishHere = signer.sign(RequestToVanishEvent.build(here, createdAt = old.createdAt + 1)) + store.insert(vanishHere) + + assertEquals( + listOf(vanishHere.id), + store.deletionsCovering(listOf(old), here).map { it.id }, + "a relay-targeted vanish issued after the event covers it", + ) + + // Not targeting this relay → not sent here. + val otherStore = EventStore(null) + otherStore.insert(signer.sign(RequestToVanishEvent.build(elsewhere, createdAt = old.createdAt + 1))) + assertTrue(otherStore.deletionsCovering(listOf(old), here).isEmpty(), "a vanish for another relay is not sent") + + // A newer event (created after the vanish) is NOT deleted by it. + val newer = signer.sign(TextNoteEvent.build("after", createdAt = vanishHere.createdAt + 10)) + assertTrue(store.deletionsCovering(listOf(newer), here).isEmpty(), "the vanish does not cover a later event") + otherStore.close() + } + + // ---- end-to-end through the relay ---------------------------------------- + + // UP direction: we deleted it, the relay still has it → send our deletion up. + @Test + fun sendsCoveringDeletionSoRelayRemovesTheNote() = + runBlocking { + val target = note("delete me e2e") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + + // Relay holds the note; we already deleted it locally (hold only the kind-5). + defaultRelay.preload(listOf(target)) + val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local/")) + local.preload(listOf(target, deletion)) + assertTrue(local.store.query(Filter(ids = listOf(target.id))).isEmpty(), "local deleted the note") + + // Reconcile → the note is a need id. (No local kind-1 remains.) + val diff = + withTimeout(20_000) { + client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = emptyList()) + } + assertEquals(setOf(target.id), diff.needIds.toSet()) + + // What SyncCommand does: fetch the need events, ask the local store which of + // our deletions cover them, publish those. + val serverEvents = defaultRelay.store.query(Filter(ids = diff.needIds)) + val covering = local.store.deletionsCovering(serverEvents, defaultRelayUrl) + assertEquals(listOf(deletion.id), covering.map { it.id }) + covering.forEach { defaultRelay.publish(it) } + + assertTrue( + defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), + "relay applied the pushed deletion and removed the note", + ) + } + + // DOWN direction: the relay deleted it, we still have it → pull the relay's deletion + // down and apply it locally (the residual-have resolution). + @Test + fun appliesRelaysDeletionSoLocalRemovesTheNote() = + runBlocking { + val target = note("delete me down") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + + // Relay already applied the deletion → holds only the kind-5. + defaultRelay.preload(listOf(target, deletion)) + assertTrue(defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), "relay deleted the note") + + // Local still holds the note (never saw the deletion). + val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local-down/")) + local.preload(listOf(target)) + assertEquals(1, local.store.query(Filter(ids = listOf(target.id))).size) + + // Reconcile → the note is a HAVE (we have it, the relay lacks it). + val diff = + withTimeout(20_000) { + client.negentropyReconcileIds( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + localEntries = listOf(IdAndTime(target.createdAt, target.id)), + ) + } + assertEquals(setOf(target.id), diff.haveIds.toSet()) + + // What SyncCommand does for the down direction: take our have events, ask the + // RELAY which of ITS deletions cover them, and apply those locally. + val ourEvents = local.store.query(Filter(ids = diff.haveIds)) + val relayDeletions = deletionsCovering(ourEvents, defaultRelayUrl) { f -> defaultRelay.store.query(f) } + assertEquals(listOf(deletion.id), relayDeletions.map { it.id }) + relayDeletions.filterIsInstance().forEach { local.store.insert(it) } + + assertTrue( + local.store.query(Filter(ids = listOf(target.id))).isEmpty(), + "local applied the pulled deletion and removed the note", + ) + } + + // ---- the full accessory loop (negentropySettleDeletions) ----------------- + + // sendUp: local holds the deletion, relay still has the note → the loop pushes it + // up and the relay converges to gone. + @Test + fun settleSendsOurDeletionUp() = + runBlocking { + val target = note("settle up") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val localStore = EventStore(null) + localStore.insert(target) + localStore.insert(deletion) // deletes target locally, keeps the kind-5 + defaultRelay.preload(listOf(target)) + + val res = + withTimeout(30_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = localStore, + sendUp = true, + applyDown = false, + idleTimeoutMs = 20_000, + ) + } + + assertEquals(1, res.sentUp) + assertEquals(0, res.appliedDown) + assertTrue( + defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), + "relay converged: the deleted note is gone", + ) + localStore.close() + } + + // applyDown: relay deleted the note (holds only the kind-5), local still has it → + // the loop pulls the relay's deletion down and local converges to gone. + @Test + fun settleAppliesRelayDeletionDown() = + runBlocking { + val target = note("settle down") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + defaultRelay.preload(listOf(target, deletion)) // relay deletes target, keeps the kind-5 + val localStore = EventStore(null) + localStore.insert(target) + + val res = + withTimeout(30_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = localStore, + sendUp = false, + applyDown = true, + idleTimeoutMs = 20_000, + ) + } + + assertEquals(0, res.sentUp) + assertEquals(1, res.appliedDown) + assertTrue( + localStore.query(Filter(ids = listOf(target.id))).isEmpty(), + "local converged: the relay-deleted note is gone", + ) + localStore.close() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91935f2a2b..3d768731a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -62,6 +62,7 @@ secp256k1KmpJniAndroid = "0.23.0" schnorr256k1Kmp = "1.0.5" securityCryptoKtx = "1.1.0" slf4j = "2.0.18" +sonarqubeGradlePlugin = "7.3.1.8318" spotless = "8.8.0" streamWebrtcAndroid = "1.3.10" translate = "17.0.3" @@ -169,6 +170,7 @@ jetbrains-compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", jetbrains-compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "jetbrainsCompose" } jetbrains-compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "jetbrainsCompose" } google-mlkit-genai-proofreading = { group = "com.google.mlkit", name = "genai-proofreading", version.ref = "genaiProofreading" } google-mlkit-genai-prompt = { group = "com.google.mlkit", name = "genai-prompt", version.ref = "genaiPrompt" } google-mlkit-genai-rewriting = { group = "com.google.mlkit", name = "genai-rewriting", version.ref = "genaiRewriting" } @@ -209,6 +211,8 @@ secp256k1-kmp-common = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp", v secp256k1-kmp-jni-android = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-android", version.ref = "secp256k1KmpJniAndroid" } secp256k1-kmp-jni-jvm = { group = "fr.acinq.secp256k1", name = "secp256k1-kmp-jni-jvm", version.ref = "secp256k1KmpJniAndroid" } schnorr256k1-kmp = { group = "com.vitorpamplona.schnorr256k1", name = "schnorr256k1-kmp", version.ref = "schnorr256k1Kmp" } +# Build-time only, gated behind the local.properties sonar opt-in in the root build script (LGPL-3.0). +sonarqube-gradle-plugin = { group = "org.sonarsource.scanner.gradle", name = "sonarqube-gradle-plugin", version.ref = "sonarqubeGradlePlugin" } stream-webrtc-android = { group = "io.getstream", name = "stream-webrtc-android", version.ref = "streamWebrtcAndroid" } unifiedpush = { group = "com.github.UnifiedPush", name = "android-connector", version.ref = "unifiedpush" } play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServicesCast" } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index fd8eda6617..26922f5217 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.napplethost +import android.annotation.SuppressLint import android.app.AlertDialog import android.content.ComponentName import android.content.Intent @@ -430,7 +431,13 @@ class NappletHostActivity : ComponentActivity() { * Intercepts hardware-key combos the applet bound via `keys.registerAction` and turns them into a * `keys.action` push — the applet never sees raw key events, only its own named action. Unmatched * keys fall through to the WebView (so the applet's own text inputs still work normally). + * + * RestrictedApi is a false positive: `Activity.dispatchKeyEvent` is a public framework + * hook; lint flags it only because androidx.core's intermediate override carries a + * library-group `@RestrictTo`. Unmatched keys still reach `super`, so androidx's + * KeyEventDispatcher routing is preserved. */ + @SuppressLint("RestrictedApi") override fun dispatchKeyEvent(event: KeyEvent): Boolean { val actionId = keyActions.actionFor(event) if (actionId != null) { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScratchLocal.android.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScratchLocal.android.kt index 503bc52b41..dd2927ce38 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScratchLocal.android.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/utils/secp256k1/ScratchLocal.android.kt @@ -24,8 +24,7 @@ package com.vitorpamplona.quartz.utils.secp256k1 internal actual class ScratchLocal actual constructor( initializer: () -> T, ) { - private val tl = ThreadLocal.withInitial(initializer) + private val tl: ThreadLocal = ThreadLocal.withInitial(initializer) - @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") - actual fun get(): T = tl.get() + actual fun get(): T = tl.get()!! } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRank.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRank.kt new file mode 100644 index 0000000000..3744d52b41 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRank.kt @@ -0,0 +1,168 @@ +/* + * 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.experimental.graperank + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.ln + +/** + * Tunable GrapeRank parameters. Defaults mirror the reference implementation at + * and NosFabrica's Brainstorm + * `DEFAULT` preset. + */ +@Immutable +data class GrapeRankParams( + val attenuation: Double = 0.85, + val rigor: Double = 0.5, + val directFollowConfidence: Double = 0.5, + val indirectFollowConfidence: Double = 0.03, + val muteConfidence: Double = 0.5, + val reportConfidence: Double = 0.5, + val convergence: Double = 0.0001, +) + +/** + * GrapeRank — a subjective, observer-centric web-of-trust score in `[0, 1]` for + * every user reachable from an observer in a [TrustGraph]. See the algorithm + * notes in `TrustGraph`/`GrapeRankTest`; this is the single-observer + * Gauss-Seidel form, operating on the compact int-CSR graph so it scales to the + * whole network. + * + * [compute] returns a `DoubleArray` indexed by node id (`graph.idOf(pubkey)`), + * not a map — at millions of nodes a boxed map would dwarf the graph itself. The + * observer's own entry stays pinned at `1.0`; callers rank the others. + */ +class GrapeRank( + val params: GrapeRankParams = GrapeRankParams(), +) { + private val rigidity = -ln(params.rigor) + + /** Exponential saturation turning accumulated weight into a confidence in `[0, 1)`. */ + private fun weightToConfidence(weight: Double): Double = 1.0 - exp(-weight * rigidity) + + private fun confidence( + relationCode: Int, + sourceIsObserver: Boolean, + ): Double = + when (relationCode) { + TrustRelation.FOLLOW.code -> if (sourceIsObserver) params.directFollowConfidence else params.indirectFollowConfidence + TrustRelation.MUTE.code -> params.muteConfidence + else -> params.reportConfidence + } + + private fun rating(relationCode: Int): Double = + when (relationCode) { + TrustRelation.FOLLOW.code -> TrustRelation.FOLLOW.rating + TrustRelation.MUTE.code -> TrustRelation.MUTE.rating + else -> TrustRelation.REPORT.rating + } + + /** + * Score every node reachable from [observer]. Returns scores by node id, or an + * all-zero array if the observer isn't in the graph. [onProgress] fires once + * per sweep with `(totalNodeUpdates, nodesStillMoving)` — the second value is + * how many nodes moved more than [GrapeRankParams.convergence] this sweep, so + * it trends to 0 as the graph settles. + * + * Iterates synchronous **Gauss-Seidel** sweeps over every node, updating scores + * in place so a value computed earlier in a sweep is already visible to nodes + * later in the same sweep (this converges faster than a double-buffered Jacobi + * pass). A sweep that moves no node by more than the convergence delta ends the + * loop — the same per-node threshold and fixed point as NosFabrica's Brainstorm + * reference. On a dense graph this is far less total work than a + * change-propagating worklist: a worklist re-visits a node once per rater whose + * score nudges, so its cost scales with the in-degree of the churning core, + * whereas a sweep touches each node exactly once per iteration. Attenuation < 1 + * makes the update a contraction, so the fixed point is unique regardless of + * sweep order; ids run in roughly BFS order from the observer, which lets + * trust flow outward within a single sweep and keeps the iteration count low. + * + * Nodes unreachable from the observer settle to 0 for free: all of their raters + * stay at 0, so the inner loop's `sourceScore != 0.0` guard skips every edge. + */ + fun compute( + graph: TrustGraph, + observer: HexKey, + onProgress: ((visited: Long, queued: Int) -> Unit)? = null, + ): DoubleArray { + val n = graph.nodeCount + val scores = DoubleArray(n) + val observerId = graph.idOf(observer) + if (observerId < 0) return scores + + scores[observerId] = 1.0 + + val attenuation = params.attenuation + val convergence = params.convergence + val inOffsets = graph.inOffsets + val inPacked = graph.inPacked + + var visited = 0L + while (true) { + var stillMoving = 0 + var target = 0 + while (target < n) { + if (target != observerId) { + var sumOfWeights = 0.0 + var sumOfWeightedRatings = 0.0 + var i = inOffsets[target] + val end = inOffsets[target + 1] + while (i < end) { + val packed = inPacked[i] + val source = packed and TrustGraph.SOURCE_MASK + val sourceScore = scores[source] + if (sourceScore != 0.0) { + val relationCode = packed ushr TrustGraph.SOURCE_BITS + val weight = confidence(relationCode, source == observerId) * sourceScore * attenuation + sumOfWeights += weight + sumOfWeightedRatings += weight * rating(relationCode) + } + i++ + } + + val newScore = + if (abs(sumOfWeights) < 0.00001) { + 0.0 + } else { + val s = weightToConfidence(sumOfWeights) * sumOfWeightedRatings / sumOfWeights + if (s > 0.0) s else 0.0 + } + + val oldScore = scores[target] + if (newScore != oldScore) { + scores[target] = newScore + if (abs(newScore - oldScore) > convergence) stillMoving++ + } + visited++ + } + target++ + } + + onProgress?.invoke(visited, stillMoving) + if (stillMoving == 0) break + } + + return scores + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt new file mode 100644 index 0000000000..63ebda32bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankCrawler.kt @@ -0,0 +1,1990 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.AdaptiveRelayLimiter +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DrainFailure +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.classifyDrainFailure +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +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.store.IEventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.coroutines.coroutineContext +import kotlin.time.TimeSource + +/** + * Crawls the Nostr follow/mute/report graph outward from an observer and streams + * the contact lists it finds into a [TrustGraphBuilder], so [GrapeRank] can score + * the whole reachable network from that observer's point of view. + * + * It uses the outbox model: each user's kind:10002 write relays are located + * first, then their kind:3 / kind:10000 / kind:1984 events are fetched from + * *their own* relays. The crawl is exhaustive — no user cap; it keeps going until + * every discovered user's outbox has been checked and their contact list pulled + * (an unreachable outbox is retried a few times), bounded only by [Config.maxHops] + * (follow-graph distance) and the [Config.maxRounds] safety backstop. + * + * Every event it fetches (contact lists, mute lists, reports, relay lists, and + * the report deletions it looks up) is verified and persisted to [store], so the + * caller can materialize mutes + reports (honouring NIP-09 retractions) from the + * store afterwards. Only the contact lists are streamed into the [TrustGraphBuilder] + * during the crawl — the compact int-CSR structure keeps the whole network in + * memory without holding millions of kind:3 objects. + * + * The crawler is transport-agnostic within quartz: it takes a [NostrClient], an + * [IEventStore], and the shared [AdaptiveRelayLimiter] (which must already be + * registered as a connection listener on the client so its ladders react to + * NOTICE/CLOSED frames). Relay *policy* — which aggregators know kind:10002, which + * general relays might hold content — is injected via [Config], because those + * defaults live in application code, not the protocol library. Operator progress + * is emitted through [log]; a headless caller routes it to stderr, a UI ignores it. + */ +@OptIn(ExperimentalAtomicApi::class) +class GrapeRankCrawler( + private val client: NostrClient, + private val store: IEventStore, + private val limiter: AdaptiveRelayLimiter, + private val config: Config, + private val log: (String) -> Unit = {}, +) { + // Crawl-wide timing, accumulated across every drainGated consumer (24 run at + // once). Nanoseconds spent verifying signatures vs. spent in the store write, + // plus how many verified events reached the store. Surfaced in [Stats] so a + // caller can see whether a from-scratch crawl is verify-, write-, or (by + // subtraction from wall time) network-bound. Reset at the top of each [crawl]. + private val verifyNanos = AtomicLong(0) + private val insertNanos = AtomicLong(0) + private val eventsStored = AtomicLong(0) + + /** + * Relay policy + crawl bounds. The relay sets come from the caller because the + * aggregator/bootstrap defaults live outside quartz. + * + * @param relayListDiscoveryRelays where to look up a stranger's kind:10002 — + * the index/discovery aggregators (purplepag.es, coracle, …) plus general + * defaults that carry kind:10002 for most of the network. + * @param contentFallbackRelays best-effort general relays that *might* hold a + * user's kind:3/10000/1984 when their outbox is unknown or unreachable. + * @param contentAggregatorRelays index/aggregator relays that hold a network-wide + * copy of kind:3, mined for stragglers by [recoverStragglersFromAggregators] + * after the crawl converges. The outbox model asks "where does this user write?" + * — but a large tail of users have no kind:3 on their own advertised outbox (it's + * dead, or they never published one there), while a network-wide aggregator + * (kindpag.es, …) scraped and holds it. Those aggregators are queried only for + * kind:10002 in [ensureRelayLists]; the dedicated recovery pass asks them for the + * kind:3 itself — patiently and kind:3-only, since a multi-kind filter makes the + * big aggregators time out. Empty disables the pass. + * @param maxRounds safety backstop on freshness passes (default: run to convergence). + * @param maxHops follow-graph distance from the observer to crawl (Brainstorm uses 8). + * @param timeoutMs the FAST per-drain timeout that gates a round's progression. + * A relay that reaches EOSE/CLOSED inside it resolves its authors this round; + * one still streaming is not cut but PARKED (see [parkTimeoutMs]) so the round + * moves on without waiting for it. Keep this short — it is the round cadence. + * @param parkTimeoutMs how long a parked (slow-but-alive) relay is allowed to + * keep delivering after it blew [timeoutMs]. Its late events are persisted and + * its late contact lists folded into the graph in a later round, so the crawl + * waits for slow relays for completeness WITHOUT paying that wait in the + * round's wall-clock. Parked sockets are bounded by the slow-relay population, + * not the whole fan-out. Set `<= timeoutMs` to disable parking. + * @param diagnose log a breakdown of slow/unreachable relays on each drain timeout. + * @param insertBatchSize how many verified events to group-commit per + * [IEventStore.batchInsert]. The outbox model streams the same events from + * many relays through a single SQLite writer, so batching amortizes the + * per-transaction + writer-mutex cost across the batch (coerced to `>= 1`). + * @param drainConcurrency how many outbox batches drain at once (the worker + * pool size). A GLOBAL bound (memory / open sockets); the per-relay + * concurrent-sub cap is enforced separately by [AdaptiveRelayLimiter]. Keep it + * moderate: a higher global fan-out re-floods busy hubs faster than demotion + * catches up (an A/B at 64 ran ~2x slower with more dead relays), so 24 is the + * validated default and raising it is a probe, not a speedup. + * @param timeoutEvictStrikes evict a relay after this many drains that timed out + * (connect timeout or park idle-cut) having delivered NOTHING. Unlike + * [classifyDrainFailure] — which never marks a timeout dead, since one slow + * answer shouldn't drop a relay — this catches the connect-but-silent / dead + * endpoints that are otherwise re-tried through every straggler's outbox for the + * rest of the crawl. A clean EOSE or any delivered event clears a relay's count, + * so only never-productive relays are evicted. `<= 0` disables it. + */ + class Config( + val relayListDiscoveryRelays: Set, + val contentFallbackRelays: Set, + val contentAggregatorRelays: Set = emptySet(), + val maxRounds: Int = Int.MAX_VALUE, + val maxHops: Int = Int.MAX_VALUE, + val timeoutMs: Long = 10_000, + val parkTimeoutMs: Long = 40_000, + val diagnose: Boolean = false, + val insertBatchSize: Int = 500, + val drainConcurrency: Int = 24, + val timeoutEvictStrikes: Int = 3, + /** + * Also skip proven-dead relays in the kind:10002 discovery sweep + * ([ensureRelayLists]). Without it the discovery/backbone set is queried + * every round regardless of deadRelays, so a refusing indexer (snort, + * nostr.band…) is re-hammered every round. Benchmarked win — default on. + */ + val shedDeadDiscovery: Boolean = true, + /** + * Rotation passes in the sharded backbone sweep (each is an awaitAll + * barrier). Benchmarked: 2 clears the backbone bulk at ~40% of the + * 6-rotation wall cost with no completeness loss (1 clears too little). + */ + val shardRotations: Int = 2, + /** + * Optional cheap reachability pre-probe. Given a relay, returns false if it + * is definitely unreachable from here — a raw TCP connect (one round trip) + * that failed fast. A background culler runs it over the cold tail of learned + * relays and drops the unreachable ones into [deadHosts] BEFORE the expensive + * WS path pays the full 7s connectTimeout on them. It only ever marks dead + * and defers to the WS verdict: a host already proven live/dead is skipped. + * A tight TCP timeout is safe where a tight WS timeout is not — a busy-but- + * alive relay accepts the SYN instantly (kernel-level) and only stalls at the + * app layer, so TCP-reachability separates "unreachable" from "slow". Null + * disables pre-probing. + */ + val reachabilityProbe: (suspend (NormalizedRelayUrl) -> Boolean)? = null, + /** + * Whether this client can reach .onion relays (has a Tor transport). When + * false, every .onion relay is unreachable and [isDead] skips it on sight — + * no socket, no wasted connect attempt. + */ + val torEnabled: Boolean = false, + /** + * Relays a prior run (or another monitor) proved unreachable within the + * reachability cache's TTL — seeded into [deadRelays] before the crawl starts + * so we don't re-pay their connect timeouts. Per-URL (not per-authority): a + * TTL'd "skip for now", re-probed once the record ages out, so it never + * permanently ignores an author's advertised home. See RelayReachabilityStore. + */ + val knownDeadRelays: Set = emptySet(), + ) + + /** What the crawl fetched — the counters the caller reports and the graph is built from. */ + class Stats( + val rounds: Int, + val contactListsFed: Int, + val relaysContacted: Int, + /** Users bucketed by follow-graph distance from the observer (hop -> count), ascending. */ + val hopHistogram: Map, + /** + * Contact lists successfully recovered, bucketed by the fed user's hop + * (hop -> count), ascending. Divide by [hopHistogram] at the same hop for + * per-hop completeness (fraction of discovered users we pulled a kind:3 for). + */ + val contactsFedByHop: Map, + val downloadMs: Long, + /** Wall time verifying signatures, summed across the concurrent consumers. */ + val verifyMs: Long, + /** Wall time in the store write path, summed across the concurrent consumers. */ + val insertMs: Long, + /** Verified events handed to the store (duplicates included — the write path dedups). */ + val eventsStored: Long, + /** + * Relays this run actually OBSERVED, for the caller to flush into the + * reachability cache (kind:30166): [deadRelays] = relays newly proven + * unreachable this run (a connect-establishment failure we paid), [liveRelays] + * = relays that served ≥1 event. Seeded known-dead relays (skipped, never + * dialed) are deliberately EXCLUDED from [deadRelays] — re-writing them would + * refresh their TTL without a re-probe and blacklist a recovered relay forever; + * their original record must age out so the next run re-probes them. + */ + val deadRelays: Set, + val liveRelays: Set, + ) + + /** + * Crawl from [observer], streaming discovered contact lists into [builder] + * (follows only — mutes/reports land in the store for the caller to + * materialize). Pass `null` for a persist-only *sync*: every event still lands + * in the store, the frontier still expands off each contact list, but no graph + * is assembled in memory (the caller scores later from the store). Returns [Stats]. + */ + suspend fun crawl( + observer: HexKey, + builder: TrustGraphBuilder?, + ): Stats { + verifyNanos.store(0) + insertNanos.store(0) + eventsStored.store(0) + return CrawlRun(observer, builder).run() + } + + /** + * Holds all per-crawl mutable state. Graph state (done/hopOf/builder/ + * relaysContacted) is single-writer by construction — Phase A and the Phase-B + * consumer never run concurrently — so those stay plain collections. The frontier + * IS [hopOf]'s key set: a user is "discovered" iff it has a hop stamp. State + * genuinely shared across the producer / consumer / drain-worker coroutines is + * concurrent: relayHints, attempts, deadRelays. [writeRelayFreq] and [liveRelays] + * are also concurrent because the background reachability culler reads them (to + * find candidates and skip already-live authorities) while the crawl writes them. + */ + private inner class CrawlRun( + val observer: HexKey, + val builder: TrustGraphBuilder?, + ) { + // hop distance per discovered user; the observer seeds it at 0. Its key set + // is the discovered frontier — no separate `discovered` set to keep in sync. + val hopOf = hashMapOf(observer to 0) + val done = hashSetOf() + + // Users we've already run the STATIC-relay outbox-discovery sweep for (the + // indexer set in [ensureRelayLists]). That relay set never changes, so re-asking + // it for the same never-had-a-10002 user each round it recirculates is pure waste + // — the round-8 profile showed this as ~144k slow kind:10002 drains. + // Single-writer: only the round loop's [ensureRelayLists] touches it. + val relayListDiscoverySwept = hashSetOf() + + // Relays the WIDE (every-live-relay) recovery pass in [ensureRelayLists] has + // already asked. Unlike the discovery set the wide net GROWS as the crawl learns + // relays, so gating that pass on the swept-user set would never re-ask an old + // straggler for a home relay discovered after its first sweep. Tracking the asked + // relay set instead lets a late-appearing relay still surface an old straggler's + // 10002 while never asking the same (user, relay) pair twice. + // Single-writer: only the round loop's [ensureRelayLists] touches it. + val wideRelaysSwept = hashSetOf() + val relaysContacted = hashSetOf() + val writeRelayFreq = ConcurrentMap() + val liveRelays = ConcurrentSet() + + // Per-relay outcome/latency/yield accounting, written from every drain unit + // (fast + parked) across every round. Dumped at crawl end; the raw signal a + // future adaptive controller reads to size filters / cap / strangle per relay. + val telemetry = RelayTelemetry() + + // Concurrent: touched by more than one of producer/consumer/drain-workers. + val relayHints = ConcurrentMap>() + val attempts = ConcurrentMap() + + // Seeded from the reachability cache (relays proven dead within its TTL) so the + // WS path never re-pays their connect timeouts; the crawl still adds/removes + // more as it goes and flushes the union back at the end. + val deadRelays = ConcurrentSet().apply { config.knownDeadRelays.forEach { add(it) } } + + // Unproductive-TIMEOUT strikes, keyed by relay AUTHORITY (host[:port]), not the + // full URL. [classifyDrainFailure] treats a READ timeout or a park idle-cut — the + // relay answered the handshake but is slow — as "busy, retry" and never dead, + // because one slow answer shouldn't evict a relay. But in a crawl the same + // unresponsive server is + // routed through every straggler's outbox, every round, each visit burning the + // full timeout + park window for zero data. Keying by authority is what defeats + // the outbox-model's per-user path fragmentation: a paid/dead host like + // `filter.nostr.wine` is advertised as hundreds of distinct per-user URLs + // (`filter.nostr.wine/npubA?broadcast=true`, …), so a per-URL counter never + // reaches the threshold on any single one — but they are one server, and it + // times out on all of them. We strike the authority and, past + // [Config.timeoutEvictStrikes], mark it dead in [deadHosts] so every URL under + // it is skipped. A clean EOSE or any delivered event records the authority in + // [producedHosts] (see [clearTimeoutStrikes]), and [isDead] treats an authority + // as dead ONLY while it is in [deadHosts] AND NOT in [producedHosts] — so a host + // that ever produces is never evicted, even if concurrent strikes from the + // 24-worker fan-out raced it into [deadHosts] at the same instant it EOSE'd. + // Only the connect-but-silent / dead-endpoint class stays evicted. + val deadTimeoutStrikes = ConcurrentMap() + val deadHosts = ConcurrentSet() + + // Authorities that ever produced (EOSE or a delivered event) this run. Membership + // here overrides [deadHosts] in [isDead], making the "ever produces ⇒ never + // evicted" invariant race-free: the strike path can lose to a clear and still add + // to [deadHosts], but the gate consults this set and lets the proven host run. + val producedHosts = ConcurrentSet() + + // Crawl-wide dedup of event ids, shared across all concurrent drains and + // every round. The outbox model mirrors the SAME event (especially kind:10002 + // relay lists) across many relays, indexers, and rounds; a per-drain set only + // catches the copies within one drain, so without this the majority of events + // would be re-verified + re-inserted (hitting the store's UNIQUE constraint) + // in a later drain. An id is added only AFTER it verifies, so a forged copy + // (valid id, bad signature) delivered first can't suppress the genuine one. + val seenIds = ConcurrentSet() + + // Per-user relays that answered (EOSE'd) without holding this user's kind:3, + // so re-querying them for this user is guaranteed-empty waste. routeByOutbox + // subtracts these from a user's candidate relays, so a straggler is retried + // only against relays that could plausibly still have it (never-asked, or + // ones that timed out — which unlike a clean EOSE might just be slow). + val askedEmpty = ConcurrentMap>() + + // Contact lists delivered LATE by parked (slow-but-alive) relays. A parked + // unit persists its events, then pushes any kind:3 it found here; the round + // loop (the single graph-writer) folds these into hopOf/done/builder between + // rounds, so a slow relay's follows still expand the frontier — just a round + // or two later than the fast ones. Unbounded: parked delivery must never + // block on the round loop draining it. + val lateHarvest = Channel>(Channel.UNLIMITED) + + // Parked units still streaming. The crawl isn't done until this hits 0 (and + // the frontier is empty), so we wait for slow relays' completeness without + // gating each round on them. Incremented when a unit parks, decremented when + // it finishes (or its park window elapses). + val parkedInFlight = AtomicLong(0) + + // ── Saturation / latency instrumentation (diagnose only) ───────────────────── + // Are we resource-bound or waiting-on-relays? These answer it without a profiler. + // activeWorkers: Phase-B drain workers busy right now (vs drainConcurrency) — if + // rarely full, adding workers won't help; the producer/relays are the limit. + // throttled: drains that got a relay rate-limit (429/too-many-*) — the EXTERNAL + // ceiling; if it climbs when we push harder, more concurrency backfires. + // The latency sums split a drain's wall time into time-to-first-event vs the + // EOSE-wait AFTER the relay's last event (pure waiting on a done-but-slow relay) + // — a large eose-wait fraction is the case for a shorter/adaptive fast window. + // burnedFastWindow: drains that blew timeoutMs and had to park. + val activeWorkers = AtomicLong(0) + val throttled = AtomicLong(0) + val firstEventSumMs = AtomicLong(0) + val eoseWaitSumMs = AtomicLong(0) + val drainWallSumMs = AtomicLong(0) + val drainSamples = AtomicLong(0) + val burnedFastWindow = AtomicLong(0) + + // Background scope owning the parked subscriptions (and Tier-2 relay-list + // sweeps). Set in [run]; cancelled once the crawl converges. + var bgScope: CoroutineScope? = null + + var rounds = 0 + var contactListsFed = 0 + + // Contact lists successfully recovered, bucketed by the fed user's hop + // distance from the observer. Paired with [hopOf]'s histogram (users + // DISCOVERED per hop), this gives per-hop completeness: how many of the + // users found at each hop we actually pulled a kind:3 for. Single-writer: + // only [ingest] touches it, and ingest runs only on the round loop / + // Phase-B consumer, never concurrently. + val contactsFedByHop = HashMap() + + // Live-progress context the heartbeat ticker reads (plain vars set only by the + // single round-loop coroutine; the ticker's reads are benign racy int/bool + // reads — a stale value just shows in one progress line). progTarget/progBase + // frame the CURRENT round so the ticker can show a real "X of Y (Z%)" for it. + var progRound = 0 + var progTarget = 0 + var progBaseDone = 0 + var progConverging = false + + /** + * A relay [classifyDrainFailure] flagged [DrainFailure.DEAD] won't serve us + * this run (bad domain, TLS misconfig, dead/gated HTTP code, refused/reset, + * connect that never opened), so it is dropped on the first strike. Read + * timeouts and alive 429 rate-limits never reach here — the drain treats them + * as busy-retry and does not report them dead at all. + */ + fun recordDead(failed: Map) { + for ((r, _) in failed) deadRelays.add(r) + } + + /** + * A relay's drain unit timed out (connect timeout or park idle-cut) having + * delivered nothing. Count the strike against its AUTHORITY and, once it + * reaches [Config.timeoutEvictStrikes], give up on the whole host — a + * connect-but-silent or dead endpoint that would otherwise be re-tried through + * every straggler's outbox for the rest of the crawl. Disabled when the + * threshold is <= 0. + */ + fun strikeUnproductiveTimeout(relay: NormalizedRelayUrl) { + val limit = config.timeoutEvictStrikes + if (limit <= 0) return + val authority = authorityOf(relay.url) + if (authority in producedHosts) return // proven productive — never evict on timeouts + if (authority in deadHosts) return + if (deadTimeoutStrikes.merge(authority, 1) { a, b -> a + b } >= limit) deadHosts.add(authority) + } + + /** + * A relay just proved its host can produce — a clean EOSE or an actual event — + * so record the authority in [producedHosts] (permanently protecting it from + * timeout eviction for the rest of the run) and wipe any timeout strikes it + * accrued. Prevents an occasionally-slow but useful host (a busy backbone hub, or + * a multi-path relay where some paths are slow) from accumulating its way to + * eviction across a long crawl — and, via the [isDead] gate, un-evicts one that a + * concurrent strike already pushed into [deadHosts] at the same instant. + */ + fun clearTimeoutStrikes(relay: NormalizedRelayUrl) { + val authority = authorityOf(relay.url) + producedHosts.add(authority) + // ConcurrentMap exposes no remove; reset the count to 0 atomically (0 is + // below any positive eviction threshold, so it reads as "unstruck"). Guard + // on a prior entry so we don't insert a 0 for every host that ever answers. + if (deadTimeoutStrikes[authority] != null) deadTimeoutStrikes.merge(authority, 0) { _, _ -> 0 } + } + + /** + * A relay is out of the routing pool if it hard/transient-failed (per-URL + * [deadRelays]) or its whole authority was timeout-evicted ([deadHosts]) and has + * not since proven productive ([producedHosts] wins, so a slow-but-live host is + * never permanently evicted); a .onion relay is dead on sight unless we have a + * Tor transport, since every connect to it would only hang and fail. + */ + fun isDead(relay: NormalizedRelayUrl): Boolean { + val authority = authorityOf(relay.url) + return relay in deadRelays || + (authority in deadHosts && authority !in producedHosts) || + (!config.torEnabled && RelayUrlNormalizer.isOnion(relay.url)) + } + + /** The busiest live relays we've learned, excluding the dead ones. */ + fun topLiveRelays(cap: Int): List = + writeRelayFreq + .snapshot() + .entries + .asSequence() + .filter { it.key in liveRelays && !isDead(it.key) } + .sortedByDescending { it.value } + .take(cap) + .map { it.key } + .toList() + + /** + * Background reachability culler. Cheaply TCP-probes the relays we've learned — + * COLD TAIL FIRST — and drops the unreachable ones into [deadHosts] so the WS + * path never pays the 7s connectTimeout on a dead host. It only ever marks dead + * and probes each authority once. Any host the WS path already resolved is + * skipped: dead ones via [isDead], and hosts already proven LIVE ([liveRelays]) + * are filtered out up front so we never waste a probe — or a needless TCP hit — + * on a working relay we depend on. Combined with the cold-tail ordering, the + * probe stays off the hot relays the crawl is actively dialing, and the WS + * verdict always wins ("if the websocket gets there first, let it run"). Runs on + * [bgScope] until the crawl cancels it. + */ + private suspend fun cullUnreachable(probe: suspend (NormalizedRelayUrl) -> Boolean) { + val probed = HashSet() // authorities; only ever touched by this coroutine's loop + val gate = Semaphore(PROBE_CONCURRENCY) + while (currentCoroutineContext().isActive) { + // Never probe a host the WS path already proved live — wasted work and + // a needless TCP hit on the hot relays we depend on. + val liveAuthorities = liveRelays.snapshot().mapTo(HashSet()) { authorityOf(it.url) } + // Least-written relays are the niche/dead long tail the WS path reaches + // last — probing them first buys the most head start with the least + // contention against the busy relays already being connected. + val batch = + writeRelayFreq + .snapshot() + .entries + .asSequence() + .filter { + val authority = authorityOf(it.key.url) + authority !in probed && authority !in liveAuthorities && !isDead(it.key) + }.sortedBy { it.value } + .map { it.key } + .toList() + if (batch.isEmpty()) { + delay(PROBE_IDLE_MS) + continue + } + coroutineScope { + for (relay in batch) { + val authority = authorityOf(relay.url) + if (!probed.add(authority)) continue + gate.acquire() + launch { + try { + // Re-check: the WS path may have resolved it while queued. + if (!isDead(relay) && !probe(relay)) deadHosts.add(authority) + } finally { + gate.release() + } + } + } + } + } + } + + /** + * Feed a user's contact list into the graph, harvest relay hints, stamp + * the hop distance of newly-seen follows, and add them to the frontier. + * Called once per user (guarded by `done`). Returns the count of + * newly-discovered users. + */ + fun ingest( + source: HexKey, + contacts: ContactListEvent, + ): Int { + val nextHop = (hopOf[source] ?: 0) + 1 + val follows = ArrayList() + var fresh = 0 + for (tag in contacts.follows()) { + follows.add(tag.pubKey) + tag.relayUri?.let { relayHints.getOrPut(tag.pubKey) { ConcurrentSet() }.add(it) } + if (tag.pubKey !in hopOf) { + hopOf[tag.pubKey] = nextHop + fresh++ + } + } + builder?.addFollows(source, follows) + contactListsFed++ + val sourceHop = hopOf[source] ?: 0 + contactsFedByHop[sourceHop] = (contactsFedByHop[sourceHop] ?: 0) + 1 + return fresh + } + + /** + * Feed into the graph the contact lists a drain just returned (deduped by + * author; the store's canonical latest wins), marking fed authors done. + * Only the authors we actually received are touched — no scan over the + * whole still-missing set. Returns the count newly fed. + */ + suspend fun harvest(events: List>): Int { + var got = 0 + for ((_, ev) in events) { + if (ev !is ContactListEvent) continue + val pk = ev.pubKey + if (pk in done) continue + val contacts = contactsOf(pk) ?: continue + done += pk + ingest(pk, contacts) + got++ + } + return got + } + + /** + * Mark done any still-pending user whose kind:3 is already in the store — a + * previous round's [ensureRelayLists] co-fetch, a late parked delivery, or a + * prior run's data — folding it into the graph so Phase B never spends an outbox + * drain re-pulling a contact list we already hold. Single-writer: called only + * from the round loop at Phase-A time, before the drain workers start. Returns + * the count newly fed. + */ + suspend fun harvestFromStore(authors: Collection): Int { + var got = 0 + for (pk in authors) { + if (pk in done) continue + val contacts = contactsOf(pk) ?: continue + done += pk + ingest(pk, contacts) + got++ + } + return got + } + + /** + * Sharded backbone sweep (see SHARD_RELAYS). Splits the missing authors + * across the top live relays — one shard per relay, so no relay gets the + * same list twice — drains all shards concurrently, then rotates whoever's + * still missing onto a different relay for up to SHARD_ROTATIONS passes. + * Once the remainder is small it's cheap to broadcast it to every top relay + * at once. Returns lists fed. + */ + suspend fun shardedSweep(authors: Collection): Int { + val top = topLiveRelays(SHARD_RELAYS) + if (top.isEmpty()) return 0 + val n = top.size + var missing = authors.filter { it !in done && contactsOf(it) == null } + var got = 0 + var rotation = 0 + while (missing.size > SHARD_BROADCAST_THRESHOLD && rotation < config.shardRotations) { + val shards = Array(n) { ArrayList() } + for (pk in missing) { + val base = ((pk.hashCode() % n) + n) % n + shards[(base + rotation) % n].add(pk) + } + val results = + coroutineScope { + top + .mapIndexedNotNull { i, relay -> + val shard = shards[i] + if (shard.isEmpty()) { + null + } else { + // Each drain gets its own dead-set — the concurrent + // drains must not share a mutable HashMap. + async { + val dead = HashMap() + val filters = + mapOf(relay to shard.chunked(AUTHORS_PER_FILTER).map { Filter(kinds = FETCH_KINDS, authors = it) }) + drainGated(filters, dead) to dead + } + } + }.awaitAll() + } + for ((_, dead) in results) recordDead(dead) + relaysContacted += top + val flat = results.flatMap { it.first } + for ((relay, _) in flat) liveRelays.add(relay) + got += harvest(flat) + missing = missing.filter { it !in done } + rotation++ + } + // Once the remainder is small it's cheap to ask every top relay for it + // at once. If the rotations bailed with a still-large set, those authors + // just aren't on the popular relays — leave them to the caller's outbox + // pass rather than broadcast a huge list. + if (missing.isNotEmpty() && missing.size <= SHARD_BROADCAST_THRESHOLD) { + // Broadcast the small remainder to a wider set of busy relays than + // the rotation used — recovers users whose list is only on a relay + // ranked below the top SHARD_RELAYS. + val live = topLiveRelays(BROADCAST_RELAYS) + if (live.isNotEmpty()) { + val dead = HashMap() + val filters = + live.associateWith { missing.chunked(AUTHORS_PER_FILTER).map { Filter(kinds = FETCH_KINDS, authors = it) } } + val events = drainGated(filters, dead) + recordDead(dead) + relaysContacted += live + for ((relay, _) in events) liveRelays.add(relay) + got += harvest(events) + } + } + return got + } + + /** + * Fetch kind:10002 relay lists for any [pubkeys] we don't already know, so + * [routeByOutbox] can route their content query to their own write relays. + * + * Tier 1 queries the bounded relay-list discovery set (indexers + general + * defaults), which aggregate kind:10002 for the whole network. Blocking, + * because this round's routing needs the result. + * + * Tier 2 is a completeness net for the stragglers the indexers don't cover: + * cast the widest net — every relay we've seen deliver events. Fired + * fire-and-forget on [bgScope]: a stray 10002 might sit on any one relay, so + * we don't skip any, but we can't block the crawl on a fan-out that large. + * The results land in the store and improve routing for later rounds. + */ + suspend fun ensureRelayLists( + pubkeys: Set, + allLiveRelays: Set, + bgScope: CoroutineScope, + ) { + suspend fun query( + authors: List, + relays: Set, + kinds: List, + ) { + if (relays.isEmpty() || authors.isEmpty()) return + val filters = + relays.associateWith { + authors.chunked(AUTHORS_PER_FILTER).map { chunk -> + Filter(kinds = kinds, authors = chunk) + } + } + drainGated(filters, null) + } + + val discovery = + if (config.shedDeadDiscovery) { + config.relayListDiscoveryRelays.filterTo(HashSet()) { it !in deadRelays } + } else { + config.relayListDiscoveryRelays + } + + // First-time DISCOVERY sweep on the static indexer set: a user only needs it + // once (re-asking a static set can't find a 10002 we already missed). Co-fetch + // the contact list in the same REQ — an indexer holding a user's 10002 often + // holds their kind:3, a cheap byproduct of a round-trip we already pay. + val freshlyMissing = pubkeys.filter { relaysOf(it) == null && it !in relayListDiscoverySwept } + val freshlyMissingSet = freshlyMissing.toHashSet() + relayListDiscoverySwept.addAll(freshlyMissing) + query(freshlyMissing, discovery, listOf(AdvertisedRelayListEvent.KIND, ContactListEvent.KIND)) + + // WIDE recovery sweep for kind:10002 ONLY (co-fetching kind:3 across thousands + // of relays inflates this fire-and-forget sweep, which the finishing drain then + // waits on — measured +300s at hop-3). The wide net grows every round, so it is + // gated on the asked-RELAY set, not the swept-user set: + // - a user first swept this round is asked the whole current wide net; + // - a user swept earlier and still missing is asked ONLY the relays that + // appeared since — so its home relay, discovered late, still surfaces. + // No (user, relay) pair is asked twice; every straggler eventually sees every + // live relay. The added olderStillMissing×newWide work self-limits: newWide + // shrinks toward zero as the relay universe is exhausted. + val wide = allLiveRelays - discovery + val newWide = wide - wideRelaysSwept + wideRelaysSwept.addAll(wide) + + val freshStillMissing = freshlyMissing.filter { relaysOf(it) == null } + val olderStillMissing = pubkeys.filter { it !in freshlyMissingSet && relaysOf(it) == null } + val hasWork = + (freshStillMissing.isNotEmpty() && wide.isNotEmpty()) || + (olderStillMissing.isNotEmpty() && newWide.isNotEmpty()) + if (hasWork) { + bgScope.launch { + query(freshStillMissing, wide, listOf(AdvertisedRelayListEvent.KIND)) + query(olderStillMissing, newWide, listOf(AdvertisedRelayListEvent.KIND)) + } + } + } + + /** + * Fetch NIP-09 kind:5 deletion requests that retract any report we gathered. + * A reporter can delete their own kind:1984 report — a deletion valid only + * from the reporter's own key, published to the reporter's outbox. So we + * group report ids by their author and ask each author's write relays for + * kind:5 events that cite those ids (`#e`), pulling only the deletions that + * touch our reports. The events land in the store for the caller to apply. + */ + suspend fun fetchReportDeletions(backbone: Set) { + val idsByAuthor = HashMap>() + for (ev in store.query(Filter(kinds = listOf(ReportEvent.KIND)))) { + if (ev is ReportEvent) idsByAuthor.getOrPut(ev.pubKey) { ArrayList() }.add(ev.id) + } + if (idsByAuthor.isEmpty()) return + + // Route each reporter to their own write relays (fallback: backbone). + val perRelayAuthors = HashMap>() + for (author in idsByAuthor.keys) { + val write = relaysOf(author)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() } ?: backbone + for (relay in write) if (!isDead(relay)) perRelayAuthors.getOrPut(relay) { HashSet() }.add(author) + } + if (perRelayAuthors.isEmpty()) return + + val filters = + perRelayAuthors.mapValues { (_, authors) -> + buildList { + for (authorChunk in authors.chunked(AUTHORS_PER_FILTER)) { + // Scope #e to this author-chunk's own report ids, chunked to + // respect REQ limits. Any over-match (a filter pairing an + // author with another author's id) is harmless — the + // deleter-must-be-author check the caller runs rejects it. + val chunkIds = authorChunk.flatMap { idsByAuthor[it].orEmpty() } + for (idChunk in chunkIds.chunked(AUTHORS_PER_FILTER)) { + add(Filter(kinds = listOf(DeletionEvent.KIND), authors = authorChunk, tags = mapOf("e" to idChunk))) + } + } + } + } + drainGated(filters, null) + } + + /** + * Group [pubkeys] by the relays we should query for their events: + * - first try: the user's own kind:10002 write relays (the outbox model); + * - a retry (`attempts[pk] > 0`, its outbox already failed): outbox + + * [backbone] — the known-good relays other people write to; + * - no outbox at all: harvested hints + backbone + the general fallback. + * + * The content aggregators are deliberately NOT mixed in here: this path's + * multi-kind [FETCH_KINDS] query loses their kind:3 to their per-REQ result + * cap (a big indexer fills the response with the abundant kind:10002 and + * returns no kind:3), so recovering from them is done separately — kind:3-only, + * once and patiently — in [recoverStragglersFromAggregators]. + * + * Also tallies each user's write relays into [writeRelayFreq] so the + * backbone can be learned from the crawl. Authors are chunked per relay. + */ + suspend fun routeByOutbox( + pubkeys: Set, + backbone: Set, + ): Map> { + val fallback = config.contentFallbackRelays + val perRelay = HashMap>() + + for (pk in pubkeys) { + val write = relaysOf(pk)?.writeRelaysNorm()?.takeIf { it.isNotEmpty() } + write?.forEach { writeRelayFreq.merge(it, 1) { a, b -> a + b } } + val relays = + when { + write == null -> relayHints[pk]?.snapshot().orEmpty() + backbone + fallback + (attempts[pk] ?: 0) > 0 -> write + backbone + else -> write + } + // Skip relays proven dead (routing to them only burns the drain + // timeout) and relays that already EOSE'd without this user's list + // (re-querying them for this user is guaranteed-empty waste). + val emptied = askedEmpty[pk] + for (relay in relays) { + if (isDead(relay)) continue + if (emptied != null && relay in emptied) continue + perRelay.getOrPut(relay) { HashSet() }.add(pk) + } + } + + return perRelay.mapValues { (_, authors) -> + authors.chunked(AUTHORS_PER_FILTER).map { chunk -> + Filter(kinds = FETCH_KINDS, authors = chunk) + } + } + } + + /** + * Final patient pass for the stragglers the outbox model couldn't resolve. + * A large tail of reachable users have no kind:3 on their own advertised + * outbox — it's dead, or they never published one there — while a + * network-wide aggregator ([Config.contentAggregatorRelays], e.g. + * kindpag.es) scraped and holds it. Mixing those aggregators into the + * competitive Phase-B fan-out doesn't work: there they'd be asked for the + * multi-kind [FETCH_KINDS] filter (which times them out) and would race + * thousands of outbox sockets, getting cut before a big aggregator finishes. + * So once the frontier is drained we ask the aggregators for the remaining + * stragglers' kind:3 ALONE: a handful of relays drained kind:3-only with the + * patient park window, not competing with the fan-out. Recovered contact + * lists are folded into the graph and persisted for a later `score`. + */ + private suspend fun recoverStragglersFromAggregators() { + // Query EVERY configured aggregator, even ones the main crawl evicted. + // During the competitive crawl an indexer like user.kindpag.es is only ever + // asked for kind:10002 in bulk and kind:[3,10000,1984,10002] one author at a + // time; the latter parks and times out (60–80s each), striking the host until + // it's timeout-evicted (isDead). It is never asked for a clean bulk kind:3 — + // the one thing it actually serves fast (≈19 lists per 300 authors in a few + // seconds). This deliberate patient pass IS that clean query, so eviction from + // the fan-out must not disqualify it here. [drainGated] subscribes to whatever + // filter map we hand it (it does not re-check isDead), and a genuinely dead + // endpoint just costs one shared park window since the units run concurrently. + val aggregators = config.contentAggregatorRelays.toHashSet() + if (aggregators.isEmpty()) return + // Wipe any timeout strikes the fan-out accrued so a partially-struck host + // starts this pass clean and a fast EOSE here keeps it healthy. + for (agg in aggregators) clearTimeoutStrikes(agg) + // Stragglers = crawled users we still have no kind:3 for. Most are already + // in `done` (their outbox attempts were exhausted), which is exactly why + // [harvest]/[ingestLate] can't be reused — they skip `done` users — so we + // fold these directly. + val stragglers = hopOf.keys.filterTo(HashSet()) { (hopOf[it] ?: 0) < config.maxHops && contactsOf(it) == null } + if (stragglers.isEmpty()) return + val before = contactListsFed + log("[graperank] aggregator recovery: ${stragglers.size} stragglers via ${aggregators.size} aggregators") + + // Build the query against the full straggler set BEFORE any folding (the + // filter lists are materialized here, so later mutation of `stragglers` is + // safe). Ask ONLY for kind:3 — the contact list we're missing. A multi-kind + // filter is useless against the big indexers: user.kindpag.es caps its + // response at ~100 events per REQ (it ignores our limit), so a + // kinds=[3,10000,1984,10002] query comes back 100× kind:10002 and 0× + // kind:3 — the abundant relay lists crowd the contact lists out entirely. + // Asked for kind:3 alone it returns them in a few seconds. Their kind:10002 + // is already fetched in bulk by [ensureRelayLists]; mutes/reports still come + // from the outbox model. The aggregator's job here is only the lists. + val filters = + aggregators.associateWith { + stragglers.chunked(AUTHORS_PER_FILTER).map { chunk -> Filter(kinds = listOf(ContactListEvent.KIND), authors = chunk) } + } + + // Fold one delivered contact list per straggler, exactly once. + suspend fun foldAgg(events: List>) { + for ((relay, ev) in events) { + liveRelays.add(relay) + if (ev !is ContactListEvent) continue + val pk = ev.pubKey + if (pk !in stragglers) continue + val contacts = contactsOf(pk) ?: continue + stragglers.remove(pk) + done += pk + ingest(pk, contacts) + } + } + + relaysContacted += aggregators + // Fast deliveries fold immediately; a slow aggregator parks and its late + // kind:3 arrives on [lateHarvest], which we drain until the parked units + // finish — so a big aggregator that can't answer within the fast window is + // still fully harvested here instead of being abandoned. + foldAgg(drainGated(filters, null)) + while (parkedInFlight.load() > 0L) { + withTimeoutOrNull(PARK_POLL_MS) { lateHarvest.receive() }?.let { foldAgg(listOf(it)) } + } + while (true) foldAgg(listOf(lateHarvest.tryReceive().getOrNull() ?: break)) + log("[graperank] aggregator recovery: +${contactListsFed - before} contact lists") + } + + /** + * Dedup (crawl-wide [seenIds]), verify, and group-commit a unit's events, + * returning the newly-stored ones tagged by relay. Safe to call concurrently + * from many fast drain units AND parked coroutines: an id is added to + * [seenIds] only AFTER a good signature (so a forged copy delivered first + * can't suppress the genuine one), and [ConcurrentSet.add] is an atomic + * test-and-set — two relays mirroring the same event race on it and only the + * winner stores it, so a duplicate never reaches the store's UNIQUE constraint. + * The store serializes the actual writes behind its own single-writer mutex. + */ + private suspend fun persist(events: List>): List> { + if (events.isEmpty()) return emptyList() + val flushAt = config.insertBatchSize.coerceAtLeast(1) + val fresh = ArrayList>() + val buffer = ArrayList(flushAt) + + suspend fun flush() { + if (buffer.isEmpty()) return + val mark = TimeSource.Monotonic.markNow() + store.batchInsert(buffer) + insertNanos.addAndFetch(mark.elapsedNow().inWholeNanoseconds) + eventsStored.addAndFetch(buffer.size.toLong()) + buffer.clear() + } + + for ((relay, event) in events) { + if (event.id in seenIds) continue + val vMark = TimeSource.Monotonic.markNow() + val ok = event.verify() + verifyNanos.addAndFetch(vMark.elapsedNow().inWholeNanoseconds) + if (!ok) { + Log.w("GrapeRankCrawler") { "dropped event ${event.id.take(8)} kind=${event.kind} — bad signature" } + continue + } + if (!seenIds.add(event.id)) continue // lost the race to a mirror; it stores it + fresh.add(relay to event) + buffer.add(event) + if (buffer.size >= flushAt) flush() + } + flush() + return fresh + } + + /** + * Wait for a subscription's terminal ([done]: EOSE/CLOSED/cannot), resetting + * the [idleMs] window every time an event pings [activity]. So the wait ends + * with "timeout" only after [idleMs] of actual SILENCE — a relay that keeps + * streaming (however long its result set) is never cut mid-flight; only a + * genuinely stalled one is. Used for the patient park window. + */ + private suspend fun awaitTerminalOrIdle( + done: CompletableDeferred, + activity: Channel, + idleMs: Long, + ): String { + while (true) { + val r = + withTimeoutOrNull(idleMs) { + select { + done.onAwait { it } + activity.onReceive { ACTIVITY } + } + } + when (r) { + null -> return "timeout" // idleMs elapsed with no event and no terminal + ACTIVITY -> Unit // an event arrived — reset the idle window and keep waiting + else -> return r // terminal reason + } + } + } + + /** + * Fold one late-delivered event from a parked relay into the graph. Only the + * round loop calls this (directly or via [foldLateHarvest]), so graph state + * stays single-writer. Returns true if it fed a new contact list. + */ + private suspend fun ingestLate( + relay: NormalizedRelayUrl, + ev: Event, + ): Boolean { + liveRelays.add(relay) + if (ev !is ContactListEvent) return false + val pk = ev.pubKey + // Only authors we actually crawled (in hopOf) and haven't fed yet. A late + // list for an unknown author would get a wrong hop stamp from ingest. + if (pk in done || pk !in hopOf) return false + val contacts = contactsOf(pk) ?: return false + done += pk + ingest(pk, contacts) + return true + } + + /** Drain whatever parked relays have delivered so far. Returns lists fed. */ + private suspend fun foldLateHarvest(): Int { + var got = 0 + while (true) { + val (relay, ev) = lateHarvest.tryReceive().getOrNull() ?: break + if (ingestLate(relay, ev)) got++ + } + return got + } + + /** + * Heartbeat so a long round never goes silent: every [PROGRESS_INTERVAL_MS] + * emit a one-liner with the CURRENT round's completion (a real X/Y % — the + * round's pending set is a known target), a rolling fetch rate + rough ETA for + * it, and live counts (events stored, slow relays parked, live/dead relays). + * Runs for the whole crawl on the background scope; cancelled when it ends. + */ + private suspend fun progressTicker() { + var lastFed = 0 + var lastMark = TimeSource.Monotonic.markNow() + while (true) { + delay(PROGRESS_INTERVAL_MS) + val nowMark = TimeSource.Monotonic.markNow() + val dtMs = (nowMark - lastMark).inWholeMilliseconds.coerceAtLeast(1) + lastMark = nowMark + val fed = contactListsFed + val rate = (fed - lastFed) * 1000L / dtMs // lists/sec over this interval + lastFed = fed + val events = eventsStored.load() + val parked = parkedInFlight.load() + when { + progConverging -> + log( + "[graperank] finishing · ${human(fed.toLong())} lists · ${human(events)} events" + + (if (parked > 0) " · $parked slow relay(s) still delivering" else " · draining"), + ) + progTarget > 0 -> { + val roundDone = (done.size - progBaseDone).coerceAtLeast(0) + val pct = (100L * roundDone / progTarget).coerceIn(0, 100) + val remaining = (progTarget - roundDone).coerceAtLeast(0) + val eta = if (rate > 0) etaFmt(remaining / rate) else "…" + // Saturation tail: workers busy / cap, and rate-limit hits so far — is + // the pool full (raise concurrency) or starved (producer/relays bound)? + val sat = + if (config.diagnose) { + " · ${activeWorkers.load()}/${config.drainConcurrency}w · ${throttled.load()} rl" + } else { + "" + } + log( + "[graperank] round $progRound · ${human(roundDone.toLong())}/${human(progTarget.toLong())} ($pct%)" + + " · $rate/s · ~$eta · ${human(events)} ev · $parked slow · ${deadRelays.size()} dead$sat", + ) + } + } + } + } + + /** + * A drain unit's page came back at the [FULL_PAGE_THRESHOLD] — it may have been + * truncated by the relay's per-REQ cap. Continue the SAME query in the + * background with `until` cursors ([fetchAllPages], starting at the page's + * oldest event, inclusive) to drain whatever the cap hid, streaming the extra + * events to [lateHarvest] just like a parked slow relay. Tracked by + * [parkedInFlight] so the round waits for it; gated by [limiter] and dropped if + * we have no [bgScope]. The boundary second is re-fetched and its already-seen + * events are dropped by [persist]'s crawl-wide dedup, so nothing double-counts. + * A no-op unless the page hit the threshold, so only dense units pay for it. + */ + private fun paginateIfCapped( + relay: NormalizedRelayUrl, + groupFilters: List, + page: List>, + ) { + if (page.size < FULL_PAGE_THRESHOLD) return + val scope = bgScope ?: return + val oldest = page.minOf { it.second.createdAt } + val contFilters = groupFilters.map { it.copy(until = oldest) } + parkedInFlight.addAndFetch(1) + scope.launch { + try { + val more = ArrayList>() + limiter.withPermit(relay) { + client.fetchAllPages(relay, contFilters, config.parkTimeoutMs) { ev -> more.add(relay to ev) } + } + for (pair in persist(more)) lateHarvest.trySend(pair) + } finally { + parkedInFlight.addAndFetch(-1) + } + } + } + + /** + * Subscribe each relay to its filters behind [limiter] and drain them. A relay + * that reaches a terminal (EOSE/CLOSED/cannot-connect) within the FAST + * [Config.timeoutMs] has its events persisted and returned so this round can + * resolve the authors it was asked for. A relay still streaming when the fast + * timeout elapses is not cut but PARKED: it hands its open subscription to + * [bgScope] (releasing its limiter permit so the fast pool moves on) and keeps + * receiving for up to [Config.parkTimeoutMs] more; whatever it eventually + * delivers is persisted and its contact lists pushed to [lateHarvest] for the + * round loop to fold in — so slow relays add completeness without holding up + * the round. Each relay's filters are split into REQ-sized groups so a popular + * relay routed thousands of authors doesn't emit a frame most relays reject. + * Hard connect failures (fast into [deadOut], parked straight to [recordDead]) + * are marked dead. Returns only the FAST events, tagged by relay. + */ + private suspend fun drainGated( + filters: Map>, + deadOut: MutableMap?, + answeredOut: MutableSet? = null, + ): List> { + if (filters.isEmpty()) return emptyList() + + // Split each relay's filters into REQ-sized groups. A REQ frame carries ALL + // its filters at once, so a popular relay routed thousands of authors would + // otherwise produce a multi-MB frame that most relays reject ("message too + // large"). Grouping by total entry count keeps each REQ under the 256KB cap. + val units = ArrayList>>() + for ((relay, relayFilters) in filters) { + var group = ArrayList() + var entries = 0 + for (f in relayFilters) { + val fe = filterEntries(f) + if (group.isNotEmpty() && entries + fe > MAX_REQ_ENTRIES) { + units.add(relay to group) + group = ArrayList() + entries = 0 + } + group.add(f) + entries += fe + } + if (group.isNotEmpty()) units.add(relay to group) + } + + // Per-relay failure classification (HARD wins over TRANSIENT); which relays + // stalled past the fast window; and which did NOT cleanly EOSE (timed out, + // parked, closed, or couldn't connect) — a relay absent from that set + // answered definitively, so an author it didn't return is one it lacks. + val failures = ConcurrentMap() + val timedOut = ConcurrentSet() + val notAnswered = ConcurrentSet() + + fun classify( + reason: String, + relay: NormalizedRelayUrl, + into: ConcurrentMap, + ) { + classifyDrainFailure(reason)?.let { kind -> into[relay] = kind } + } + + // A relay asking us to slow down — the external concurrency ceiling. + fun isRateLimit(reason: String): Boolean { + val m = reason.lowercase() + return "429" in m || "too many" in m || "rate" in m || "throttl" in m + } + + fun logSlow( + relay: NormalizedRelayUrl, + reason: String, + elapsedMs: Long, + groupFilters: List, + ) { + if (!config.diagnose) return + val authors = groupFilters.flatMap { it.authors.orEmpty() } + val kinds = groupFilters.flatMap { it.kinds.orEmpty() }.distinct() + log( + "[slow-relay] ${relay.url} $reason in ${elapsedMs}ms | kinds=$kinds authors=${authors.size}: " + + authors.take(30).joinToString(",") + (if (authors.size > 30) ",…" else ""), + ) + } + + val fast = + coroutineScope { + units + .map { (subRelay, groupFilters) -> + async { + limiter.withPermit(subRelay) { + val subId = newSubId() + val done = CompletableDeferred() + val unitEvents = Channel>(Channel.UNLIMITED) + // Liveness signal for the parked idle timeout: every event pings + // this (conflated, so bursts collapse to one) and resets the park + // window, so a relay actively streaming is never cut mid-flight. + val activity = Channel(Channel.CONFLATED) + // Latency breakdown: elapsed-since-[mark] of the first and last + // event, so the EOSE-wait AFTER the relay's last event (pure + // waiting on a done-but-slow relay) is separable from fetch time. + val mark = TimeSource.Monotonic.markNow() + val firstEvt = AtomicLong(-1) + val lastEvt = AtomicLong(-1) + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + unitEvents.trySend(relay to event) + activity.trySend(Unit) + val e = mark.elapsedNow().inWholeMilliseconds + firstEvt.compareAndSet(-1, e) + lastEvt.store(e) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.complete("eose") + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + done.complete("closed:$message") + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + done.complete("cannot:$message") + } + } + client.subscribe(subId, mapOf(subRelay to groupFilters), listener) + val reason = withTimeoutOrNull(config.timeoutMs) { done.await() } + if (reason != null) { + // Terminal within the fast window — resolve this round. + val elapsedMs = mark.elapsedNow().inWholeMilliseconds + if (reason != "eose") notAnswered.add(subRelay) + classify(reason, subRelay, failures) + if (isRateLimit(reason)) throttled.addAndFetch(1) + // Split the wall time: fetch (to first event) vs EOSE-wait + // (after the last event) — only for drains that got events. + val firstE = firstEvt.load() + if (firstE >= 0) { + firstEventSumMs.addAndFetch(firstE) + eoseWaitSumMs.addAndFetch((elapsedMs - lastEvt.load()).coerceAtLeast(0)) + drainWallSumMs.addAndFetch(elapsedMs) + drainSamples.addAndFetch(1) + } + if (elapsedMs > SLOW_DRAIN_LOG_MS) logSlow(subRelay, reason, elapsedMs, groupFilters) + unitEvents.close() + client.unsubscribe(subId) + val drained = buildList { for (e in unitEvents) add(e) } + telemetry.record(subRelay, RelayTelemetry.outcomeOf(reason, parked = false), elapsedMs, authorsIn(groupFilters), drained.size) + val persisted = persist(drained) + // A full page from a clean EOSE may be the relay's cap, not the + // whole answer — background-paginate the remainder into lateHarvest. + if (reason == "eose") paginateIfCapped(subRelay, groupFilters, drained) + // Alive if it EOSE'd or handed us anything; a connect-timeout that + // gave nothing (classifyDrainFailure leaves it retryable forever) + // earns a strike toward eviction instead. + if (reason == "eose" || drained.isNotEmpty()) { + clearTimeoutStrikes(subRelay) + } else if (isTimeoutReason(reason)) { + strikeUnproductiveTimeout(subRelay) + } + persisted + } else { + // Still streaming — hand off and let the round move on. + burnedFastWindow.addAndFetch(1) + notAnswered.add(subRelay) + timedOut.add(subRelay) + val scope = bgScope + if (scope != null && config.parkTimeoutMs > config.timeoutMs) { + parkedInFlight.addAndFetch(1) + scope.launch { + try { + // Idle timeout, not absolute: only cut after parkTimeoutMs + // of SILENCE (no event, no terminal), so a relay still + // streaming a large result set is never chopped mid-flight. + val late = awaitTerminalOrIdle(done, activity, config.parkTimeoutMs) + val lateMs = mark.elapsedNow().inWholeMilliseconds + logSlow(subRelay, "parked→$late", lateMs, groupFilters) + // A parked relay that ends in a hard/transient failure (not a + // clean EOSE) is reported dead the same way a fast one would be. + val lateDead = ConcurrentMap() + classify(late, subRelay, lateDead) + recordDead(lateDead.snapshot()) + unitEvents.close() + val lateDrained = buildList { for (e in unitEvents) add(e) } + telemetry.record(subRelay, RelayTelemetry.outcomeOf(late, parked = true), lateMs, authorsIn(groupFilters), lateDrained.size) + for (pair in persist(lateDrained)) lateHarvest.trySend(pair) + // A full parked page from a clean EOSE may also be capped — + // paginate its remainder in the background, same as the fast path. + if (late == "eose") paginateIfCapped(subRelay, groupFilters, lateDrained) + // Same liveness rule as the fast path: a park that ended + // in a clean EOSE or delivered anything clears the relay; + // one that idle-cut ("timeout") with nothing strikes it. + if (late == "eose" || lateDrained.isNotEmpty()) { + clearTimeoutStrikes(subRelay) + } else if (late == "timeout" || isTimeoutReason(late)) { + strikeUnproductiveTimeout(subRelay) + } + } finally { + client.unsubscribe(subId) + parkedInFlight.addAndFetch(-1) + } + } + // Parked: events are persisted into lateHarvest by the + // coroutine above, so this round contributes nothing here. + emptyList() + } else { + // Parking disabled (no bgScope, or parkTimeoutMs <= timeoutMs): + // drain and persist whatever streamed during the fast window + // instead of dropping it, then return it so the round ingests + // it exactly like the fast path — the other two branches persist, + // this one must too or those events are lost and re-queried. + val toMs = mark.elapsedNow().inWholeMilliseconds + logSlow(subRelay, "timeout", toMs, groupFilters) + unitEvents.close() + client.unsubscribe(subId) + val drained = buildList { for (e in unitEvents) add(e) } + telemetry.record(subRelay, RelayTelemetry.Outcome.FAST_TIMEOUT, toMs, authorsIn(groupFilters), drained.size) + // Nothing delivered → same unproductive-timeout signal, strike it; + // anything delivered proves the host productive and clears it. + if (drained.isEmpty()) strikeUnproductiveTimeout(subRelay) else clearTimeoutStrikes(subRelay) + persist(drained) + } + } + } + } + }.awaitAll() + .flatten() + } + + if (config.diagnose && timedOut.size() > 0) { + log("[drain] parked ${timedOut.size()} slow relay(s) past ${config.timeoutMs}ms") + } + deadOut?.putAll(failures.snapshot()) + answeredOut?.addAll(filters.keys.filter { it !in notAnswered }) + return fast + } + + /** + * Emit the per-relay classification table: for every relay we touched, our + * LIVE/DEAD verdict and the concurrency/rate limits we settled on, joined + * with the evidence (attempts + outcome counts + yield + latency) that drove + * it. One `[relay-class]` line per relay (tab-separated) plus a `[relay-class-sum]` + * summary, so a later test can re-probe each relay and check the verdict/limit. + */ + fun dumpRelayClassification() { + val rows = telemetry.rows.snapshot() + if (rows.isEmpty()) return + log( + "[relay-class-hdr] url\tclass\tconc_cap\trate_ms\tattempts\teose\ttimeout\t" + + "cannot\tratelim\tauth\tyield_pct\tmean_lat_ms\tmax_lat_ms", + ) + var unreachable = 0 + var throttled = 0 + var live = 0 + val capHist = HashMap() + for ((relay, r) in rows) { + val cap = limiter.concurrencyCapOf(relay) + val rate = limiter.rateDelayOf(relay) + capHist[cap] = (capHist[cap] ?: 0) + 1 + // UNREACHABLE: we gave up on it (dead set, connect-failure driven). + // THROTTLED: alive, but it pushed back so we capped/rate-limited it. + // LIVE: alive at default limits. + val klass = + when { + relay in deadRelays -> "UNREACHABLE".also { unreachable++ } + limiter.isThrottled(relay) -> "THROTTLED".also { throttled++ } + else -> "LIVE".also { live++ } + } + val att = r.attempts.load() + val eose = r.count(RelayTelemetry.Outcome.FAST_EOSE) + r.count(RelayTelemetry.Outcome.SLOW_EOSE) + val to = r.count(RelayTelemetry.Outcome.FAST_TIMEOUT) + r.count(RelayTelemetry.Outcome.PARK_TIMEOUT) + val ask = r.authorsAsked.load() + val yieldPct = if (ask > 0) r.eventsReturned.load() * 100 / ask else 0 + val meanLat = if (att > 0) r.latSumMs.load() / att else 0 + log( + "[relay-class] ${relay.url}\t$klass\t$cap\t$rate\t$att\t$eose\t$to\t" + + "${r.count(RelayTelemetry.Outcome.CANNOT)}\t${r.count(RelayTelemetry.Outcome.CLOSED_RATE)}\t" + + "${r.count(RelayTelemetry.Outcome.CLOSED_AUTH)}\t$yieldPct\t$meanLat\t${r.latMaxMs.load()}", + ) + } + val capsStr = capHist.entries.sortedBy { it.key }.joinToString(", ", "{", "}") { "${it.key}=${it.value}" } + log("[relay-class-sum] relays=${rows.size} live=$live throttled=$throttled unreachable=$unreachable concurrency_caps=$capsStr") + } + + suspend fun run(): Stats { + val crawlMark = TimeSource.Monotonic.markNow() + // Scope owning parked (slow-relay) subscriptions and the fire-and-forget + // Tier-2 relay-list sweeps. SupervisorJob so one failure never cancels the + // others; cancelled once the crawl converges. Published to [bgScope] so + // drainGated can hand slow subs to it. + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + bgScope = scope + + // Heartbeat: keeps a long, silent round feeling alive with live % + ETA. + // Runs on [scope], so scope.cancel() at crawl end stops it. + scope.launch { progressTicker() } + + // Background reachability culler: cheaply TCP-probes the cold tail of + // learned relays and drops the unreachable ones into deadHosts before the + // WS path pays the full connectTimeout on them. Runs on [scope], stopped + // by scope.cancel() at crawl end. + config.reachabilityProbe?.let { probe -> scope.launch { cullUnreachable(probe) } } + + while (rounds < config.maxRounds) { + // Fold in whatever the parked (slow-but-alive) relays have delivered + // since the last round — their late contact lists expand the frontier + // a round or two behind the fast ones (single-writer: only here). + foldLateHarvest() + + // Only crawl users within the hop budget; deeper users still appear + // in the graph as follow targets, we just don't fetch their lists. + val pending = hopOf.keys.filter { it !in done && (hopOf[it] ?: 0) < config.maxHops } + if (pending.isEmpty()) { + // Frontier drained. If no slow relay is still streaming, a final + // fold catches any last-moment delivery and we're done; otherwise + // wait for a parked relay to deliver (completeness) and loop. + progConverging = true + if (parkedInFlight.load() == 0L) { + if (foldLateHarvest() == 0) break else continue + } + withTimeoutOrNull(PARK_POLL_MS) { lateHarvest.receive() }?.let { ingestLate(it.first, it.second) } + continue + } + rounds++ + // Frame this round for the heartbeat ticker: its target is the pending + // set, its baseline is how many users were already done going in. + progRound = rounds + progTarget = pending.size + progBaseDone = done.size + progConverging = false + + // Refresh the warm pool to this round's busiest relays and keep that + // subscription open — reusing the same subId just updates the + // desired-relay set, so these sockets stay up across the round. + topLiveRelays(WARM_POOL_SIZE).takeIf { it.isNotEmpty() }?.let { warm -> + client.subscribe(WARM_SUB_ID, warm.associateWith { WARM_FILTERS }, null) + } + + val discoveredBefore = hopOf.size + val fedBefore = contactListsFed + val roundMark = TimeSource.Monotonic.markNow() + + // Phase A — bulk-fetch from the busiest relays via the sharded sweep. + // Most users' kind:3 lives on the big popular relays, so this clears + // the majority cheaply (early rounds no-op until a backbone is learned). + val fedBeforeA = contactListsFed + shardedSweep(pending) + // Fold any kind:3 already sitting in the store — a prior round's + // ensureRelayLists co-fetch, a late parked delivery, or a previous run — + // so Phase B doesn't re-drain contact lists we already hold. + harvestFromStore(pending) + val phaseAMs = roundMark.elapsedNow().inWholeMilliseconds + val phaseAFed = contactListsFed - fedBeforeA + + // Phase B — whoever the popular relays didn't have (niche outboxes): + // resolve their kind:10002, then fetch from their own write relays, + // drained a few at a time and skipping dead relays. + val stragglers = pending.filter { it !in done } + if (stragglers.isNotEmpty()) { + val backbone = topLiveRelays(BACKBONE_SIZE).toSet() + // Snapshot of every relay we've seen work, for the wide Tier-2 + // sweep (taken now, before the Phase-B workers mutate liveRelays). + val allLive = liveRelays.snapshot().filterTo(HashSet()) { !isDead(it) } + ensureRelayLists(stragglers.toSet(), allLive, scope) + + // Continuous worker pool instead of chunked awaitAll barriers, so + // no worker waits on a slow sibling and hot relays stay connected. + // Shared graph state stays single-writer: routeByOutbox runs only + // on the producer (keeps writeRelayFreq serial) and ingest runs + // only on the consumer (keeps done/builder/hopOf serial), now + // overlapped with draining instead of blocked behind each batch. + val routed = Channel, Map>>>(config.drainConcurrency * 2) + val drainedOut = Channel(Channel.UNLIMITED) + coroutineScope { + // Producer: route each batch by outbox (serial), backpressured + // by the bounded `routed` channel. + val producer = + launch { + for (batch in stragglers.chunked(USER_BATCH)) { + val filters = routeByOutbox(batch.toSet(), backbone) + routed.send(batch to filters) + } + routed.close() + } + // Drain workers: pure network, no shared graph-state writes + // except recordDead (concurrent-safe). Each captures the relays + // that cleanly EOSE'd, so the consumer can tell "answered empty" + // from "timed out" per user. + val workers = + List(config.drainConcurrency) { + launch { + for ((batch, filters) in routed) { + activeWorkers.addAndFetch(1) + try { + val dead = HashMap() + val answered = HashSet() + val events = drainGated(filters, dead, answered) + recordDead(dead) + drainedOut.send(DrainedBatch(batch, filters, answered, events)) + } finally { + activeWorkers.addAndFetch(-1) + } + } + } + } + // Consumer: single-writer ingest, overlapped with draining. + val consumer = + launch { + for (d in drainedOut) { + relaysContacted += d.filters.keys + // Any relay that gave us an event is proven live + useful. + for ((relay, _) in d.events) liveRelays.add(relay) + + // Per user, record relays that answered (EOSE'd) but did + // not return their kind:3, so they aren't re-queried there. + val returnedByRelay = HashMap>() + for ((relay, ev) in d.events) { + if (ev is ContactListEvent) returnedByRelay.getOrPut(relay) { HashSet() }.add(ev.pubKey) + } + for (relay in d.answered) { + val asked = d.filters[relay]?.flatMapTo(HashSet()) { it.authors.orEmpty() } ?: continue + val returned = returnedByRelay[relay].orEmpty() + for (pk in asked) { + if (pk !in returned) askedEmpty.getOrPut(pk) { ConcurrentSet() }.add(relay) + } + } + + for (pk in d.batch) { + if (pk in done) continue + val contacts = contactsOf(pk) + if (contacts != null) { + done += pk + ingest(pk, contacts) + } else { + val tries = (attempts[pk] ?: 0) + 1 + attempts[pk] = tries + if (tries >= MAX_OUTBOX_ATTEMPTS) done += pk + } + } + } + } + producer.join() + workers.joinAll() + drainedOut.close() + consumer.join() + } + } + + val roundMs = roundMark.elapsedNow().inWholeMilliseconds + log( + "[graperank] round $rounds: pending=${pending.size}, " + + "gotList=${contactListsFed - fedBefore}, newUsers=${hopOf.size - discoveredBefore}, " + + "discovered=${hopOf.size}, done=${done.size}, dead=${deadRelays.size()}, " + + "time=${roundMs}ms (phaseA=${phaseAMs}ms fed=$phaseAFed, phaseB=${roundMs - phaseAMs}ms fed=${contactListsFed - fedBefore - phaseAFed})", + ) + } + + // Crawl done — drop the warm pool. + client.unsubscribe(WARM_SUB_ID) + + // Patient final pass: recover the stragglers the outbox model couldn't + // resolve by asking the content aggregators for their kind:3 ALONE, no + // longer racing the full fan-out (which cut the aggregators short during + // the rounds). Runs before [scope] is cancelled so slow aggregators park. + recoverStragglersFromAggregators() + + // Reports can be retracted. Ask each reporter's outbox for NIP-09 kind:5 + // deletions that cite the reports we gathered (#e-filtered to our report + // ids). The events land in the store; the caller decides which reports + // they actually retract. Run before cancelling [scope] so it can still + // park slow relays. + fetchReportDeletions(topLiveRelays(BACKBONE_SIZE).toSet()) + + // Stop any parked subscriptions + Tier-2 relay-list sweeps still in flight + // (whatever they fetched already landed in the store). + scope.cancel() + + // Per-relay outcome/latency/yield table. Totals + worst time-sinks always; + // the full per-relay dump (thousands of lines) only under --diagnose. + telemetry.dump(log, full = config.diagnose) + + // Machine-readable per-relay CLASSIFICATION table: our live/dead verdict + // and the concurrency/rate limits we settled on, joined with the evidence + // (outcome counts + yield) so it can be checked against an independent + // re-probe later. Emitted only under --diagnose (one line per relay). + if (config.diagnose) dumpRelayClassification() + + val hopHistogram = + hopOf.values + .groupingBy { it } + .eachCount() + .toList() + .sortedBy { it.first } + .toMap() + val downloadMs = crawlMark.elapsedNow().inWholeMilliseconds + val verifyMs = verifyNanos.load() / 1_000_000 + val insertMs = insertNanos.load() / 1_000_000 + val stored = eventsStored.load() + log( + "[graperank] crawl complete: ${hopOf.size} discovered, $contactListsFed contact lists fed, " + + "${relaysContacted.size} relays contacted, ${deadRelays.size()} dead + ${deadHosts.size()} timeout-evicted hosts, " + + "$rounds rounds in $downloadMs ms; " + + "by hop: " + hopHistogram.entries.joinToString(" ") { "${it.key}=${it.value}" }, + ) + log( + "[graperank] write path: $stored events stored, verify ${verifyMs}ms + insert ${insertMs}ms " + + "(summed across all drains, batch=${config.insertBatchSize})", + ) + if (config.diagnose) { + val n = drainSamples.load().coerceAtLeast(1) + val wall = drainWallSumMs.load().coerceAtLeast(1) + val meanFirst = firstEventSumMs.load() / n + val meanEose = eoseWaitSumMs.load() / n + val eosePct = 100 * eoseWaitSumMs.load() / wall + log( + "[graperank] latency breakdown (drains with events, n=${drainSamples.load()}): " + + "mean time-to-first-event ${meanFirst}ms, mean EOSE-wait-after-last-event ${meanEose}ms " + + "($eosePct% of drain wall spent waiting for EOSE after the relay's last event); " + + "${burnedFastWindow.load()} drains blew the ${config.timeoutMs}ms fast window and parked; " + + "${throttled.load()} rate-limit responses. " + + "High EOSE-wait % → a shorter/adaptive fast window is the lever, not more concurrency.", + ) + } + return Stats( + rounds = rounds, + contactListsFed = contactListsFed, + relaysContacted = relaysContacted.size, + hopHistogram = hopHistogram, + contactsFedByHop = contactsFedByHop.toList().sortedBy { it.first }.toMap(), + downloadMs = downloadMs, + verifyMs = verifyMs, + insertMs = insertMs, + eventsStored = stored, + // Only relays we actually dialed this run — exclude the seeded + // known-dead (skipped, not re-probed) so their original TTL stands. + deadRelays = deadRelays.snapshot() - config.knownDeadRelays, + liveRelays = liveRelays.snapshot(), + ) + } + } + + /** + * One Phase-B batch after draining: the users asked for, the relay->filters map + * they were routed through, the relays that cleanly EOSE'd ([answered]), and the + * fresh events. Carries enough for the consumer to attribute "answered but + * empty" per user without re-deriving the routing. + */ + private class DrainedBatch( + val batch: List, + val filters: Map>, + val answered: Set, + val events: List>, + ) + + /** + * Per-relay outcome + latency + yield accounting, accumulated across the whole + * crawl from every drain unit (fast and parked). This is the ground truth for + * "which relays are worth talking to": for each relay we track how every REQ + * ended, how long it took, how many authors we asked it for, and how many + * events it actually returned. A relay that eats a 27s connect on every REQ and + * returns nothing is a pure time sink; one that EOSEs fast with a high + * events/authors yield is gold. The dump at crawl end sorts by wasted time so + * the worst offenders are obvious, and it's the signal source a future adaptive + * controller uses to size filters / cap concurrency / strangle per relay. + * + * Fully concurrent: many drain workers and parked coroutines record at once, so + * every counter is atomic and the row map is a [ConcurrentMap]. + */ + class RelayTelemetry { + enum class Outcome { + /** EOSE within the fast window — the good case. */ + FAST_EOSE, + + /** EOSE, but only after parking (blew the fast window, delivered late). */ + SLOW_EOSE, + + /** Blew the fast window and never parked (parking off / no bg scope). */ + FAST_TIMEOUT, + + /** Parked, then the park idle window elapsed with no terminal. */ + PARK_TIMEOUT, + + /** Could not open the socket at all (offline / DNS / TLS / refused). */ + CANNOT, + + /** CLOSED with a rate-limit / too-many-subs / burst complaint. */ + CLOSED_RATE, + + /** CLOSED demanding NIP-42 auth we don't provide. */ + CLOSED_AUTH, + + /** CLOSED blocked / restricted / banned. */ + CLOSED_BLOCKED, + + /** CLOSED for any other reason. */ + CLOSED_OTHER, + } + + class Row { + val byOutcome = ConcurrentMap() + val attempts = AtomicLong(0) + val authorsAsked = AtomicLong(0) + val eventsReturned = AtomicLong(0) + val latSumMs = AtomicLong(0) + val latMaxMs = AtomicLong(0) + + fun bump(outcome: Outcome) = byOutcome.getOrPut(outcome) { AtomicLong(0) }.addAndFetch(1) + + fun count(outcome: Outcome): Long = byOutcome[outcome]?.load() ?: 0 + } + + val rows = ConcurrentMap() + + fun record( + relay: NormalizedRelayUrl, + outcome: Outcome, + latencyMs: Long, + authorsAsked: Int, + eventsReturned: Int, + ) { + val row = rows.getOrPut(relay) { Row() } + row.attempts.addAndFetch(1) + row.bump(outcome) + row.authorsAsked.addAndFetch(authorsAsked.toLong()) + row.eventsReturned.addAndFetch(eventsReturned.toLong()) + row.latSumMs.addAndFetch(latencyMs) + while (true) { + val cur = row.latMaxMs.load() + if (latencyMs <= cur || row.latMaxMs.compareAndSet(cur, latencyMs)) break + } + } + + /** Total wall time we spent waiting on a relay that gave us nothing useful. */ + private fun wastedMs(r: Row): Long { + // Time in the two no-yield terminal classes; a slow EOSE that DID return + // events isn't "wasted", so only count the pure sinks. + val sinkAttempts = r.count(Outcome.FAST_TIMEOUT) + r.count(Outcome.PARK_TIMEOUT) + r.count(Outcome.CANNOT) + val total = r.attempts.load() + if (total == 0L) return 0 + return r.latSumMs.load() * sinkAttempts / total + } + + /** + * Dump the per-relay table via [log]. Totals + the worst time-sinks always; + * the FULL per-relay table (every relay we touched, sorted worst-first) only + * when [full] — thousands of lines, so gate it behind --diagnose. + */ + fun dump( + log: (String) -> Unit, + full: Boolean, + ) { + val snap = rows.snapshot() + if (snap.isEmpty()) return + + val totals = HashMap() + var authors = 0L + var events = 0L + for ((_, r) in snap) { + for (o in Outcome.entries) totals[o] = (totals[o] ?: 0) + r.count(o) + authors += r.authorsAsked.load() + events += r.eventsReturned.load() + } + log( + "[relay-telemetry] ${snap.size} relays · outcomes " + + Outcome.entries.filter { (totals[it] ?: 0) > 0 }.joinToString(" ") { "${it.name.lowercase()}=${totals[it]}" }, + ) + log("[relay-telemetry] asked $authors author-slots, got $events events (yield ${if (authors > 0) events * 100 / authors else 0}%)") + + fun line( + url: String, + r: Row, + ): String { + val a = r.attempts.load() + val meanLat = if (a > 0) r.latSumMs.load() / a else 0 + val ask = r.authorsAsked.load() + val yield = if (ask > 0) r.eventsReturned.load() * 100 / ask else 0 + return " $url att=$a eose=${r.count(Outcome.FAST_EOSE)}/${r.count(Outcome.SLOW_EOSE)} " + + "to=${r.count(Outcome.FAST_TIMEOUT) + r.count(Outcome.PARK_TIMEOUT)} cannot=${r.count(Outcome.CANNOT)} " + + "rate=${r.count(Outcome.CLOSED_RATE)} auth=${r.count(Outcome.CLOSED_AUTH)} " + + "ask=$ask got=${r.eventsReturned.load()} yield=$yield% lat=$meanLat/${r.latMaxMs.load()}ms wasted=${wastedMs(r)}ms" + } + + val ordered = snap.entries.sortedByDescending { wastedMs(it.value) } + log("[relay-telemetry] top time-sinks (by wasted ms):") + for ((relay, r) in ordered.take(25)) log(line(relay.url, r)) + + if (full) { + log("[relay-telemetry] FULL per-relay table (${snap.size} relays, worst-first):") + for ((relay, r) in ordered) log(line(relay.url, r)) + } + } + + companion object { + /** Map a drain terminal reason (see [drainGated]) to an [Outcome]. */ + fun outcomeOf( + reason: String, + parked: Boolean, + ): Outcome = + when { + reason == "eose" -> if (parked) Outcome.SLOW_EOSE else Outcome.FAST_EOSE + reason == "timeout" -> if (parked) Outcome.PARK_TIMEOUT else Outcome.FAST_TIMEOUT + reason.startsWith("cannot") -> Outcome.CANNOT + reason.startsWith("closed:") -> { + val m = reason.removePrefix("closed:").lowercase() + when { + "rate" in m || "too many" in m || "burst" in m || "slow down" in m || "subscription" in m -> Outcome.CLOSED_RATE + "auth" in m -> Outcome.CLOSED_AUTH + "block" in m || "restrict" in m || "ban" in m -> Outcome.CLOSED_BLOCKED + else -> Outcome.CLOSED_OTHER + } + } + else -> Outcome.CLOSED_OTHER + } + } + } + + /** Latest known kind:3 contact list for [pubKey] from the local store, or null. */ + private suspend fun contactsOf(pubKey: HexKey): ContactListEvent? = + store + .query(Filter(authors = listOf(pubKey), kinds = listOf(ContactListEvent.KIND), limit = 1)) + .firstOrNull() as? ContactListEvent + + /** Latest known kind:10002 advertised relay list for [pubKey] from the store, or null. */ + private suspend fun relaysOf(pubKey: HexKey): AdvertisedRelayListEvent? = + store + .query(Filter(authors = listOf(pubKey), kinds = listOf(AdvertisedRelayListEvent.KIND), limit = 1)) + .firstOrNull() as? AdvertisedRelayListEvent + + companion object { + // Authors per REQ filter — keeps individual subscriptions within relay limits. + private const val AUTHORS_PER_FILTER = 300 + + // Concurrent TCP reachability probes in the background culler. Raw sockets are + // cheap and short-lived; the per-relay WS limiter is unaffected (this never + // opens a REQ), so this only bounds file descriptors during the cull. + private const val PROBE_CONCURRENCY = 128 + + // Re-scan interval for the culler when it has probed everything learned so far + // and is waiting for new relays to be discovered. + private const val PROBE_IDLE_MS = 2000L + + // A single REQ can match up to authors×kinds events; a relay that caps its + // response below that silently drops the tail (measured: user.kindpag.es + // returns at most ~100 events per REQ and ignores our limit). Any page that + // comes back with at least this many events is treated as possibly-capped and + // paginated with `until` cursors to drain the rest. Set at the smallest page + // cap we've observed, so it catches every relay that caps at or above it while + // sparing the common under-cap page an extra REQ. + private const val FULL_PAGE_THRESHOLD = 100 + + // Max total "entries" (authors + ids + tag values) in a single REQ frame. + // Each entry is a ~67-byte hex string, so 2500 ≈ 167KB — under the 256KB + // message cap most relays enforce. drainGated groups filters to stay within. + private const val MAX_REQ_ENTRIES = 2500 + + // --diagnose: a REQ that takes longer than this to reach a terminal (EOSE or + // timeout) is logged with its relay + filter, so slow relays can be replayed. + private const val SLOW_DRAIN_LOG_MS = 4000L + + /** + * Is a drain terminal reason a connect/read TIMEOUT — the class + * [classifyDrainFailure] leaves retryable forever? The reason shape is + * `cannot:` and the message now carries the exception class name + * (see BasicRelayClient), so a SocketTimeoutException surfaces as "timed + * out"/"timeout". Used to drive unproductive-timeout eviction. + */ + private fun isTimeoutReason(reason: String): Boolean { + if (!reason.startsWith("cannot")) return false + val m = reason.removePrefix("cannot:").lowercase() + return "timeout" in m || "timed out" in m + } + + /** + * The authority (host[:port]) of a normalized relay URL — the segment between + * the `wss://` / `ws://` scheme and the first `/`. This is the key the + * timeout-eviction counts on, so the many per-user path URLs the outbox model + * mints for one server (`filter.nostr.wine/npubA`, `filter.nostr.wine/npubB`, …) + * collapse to a single evictable host. A bare host is its own authority, so this + * is a no-op for the common no-path relay. Deliberately host-only: it must NOT + * fold `filter.nostr.wine` into `nostr.wine` — those are different servers with + * different behaviour (the bare host may read fine while the filter host stalls). + */ + fun authorityOf(url: String): String { + val afterScheme = + when { + url.startsWith("wss://") -> url.substring(6) + url.startsWith("ws://") -> url.substring(5) + else -> url + } + val slash = afterScheme.indexOf('/') + return if (slash >= 0) afterScheme.substring(0, slash) else afterScheme + } + + // Once the frontier is empty but parked relays are still streaming, how long + // to block waiting for one of them to deliver before re-checking convergence. + private const val PARK_POLL_MS = 2000L + + // How often the heartbeat ticker emits a live-progress line. + private const val PROGRESS_INTERVAL_MS = 3000L + + /** Compact human count: 1234 -> "1.2k", 1_500_000 -> "1.5M". */ + private fun human(n: Long): String = + when { + n >= 1_000_000 -> "${n / 1_000_000}.${(n % 1_000_000) / 100_000}M" + n >= 1_000 -> "${n / 1_000}.${(n % 1_000) / 100}k" + else -> n.toString() + } + + /** Seconds as "45s" or "3m20s". */ + private fun etaFmt(secs: Long): String = if (secs >= 60) "${secs / 60}m${secs % 60}s" else "${secs}s" + + // Sentinel returned by the park idle-wait's select when an event arrived + // (resets the window). A control string that can't collide with a relay's + // CLOSED/cannot message, which are the only other select results. + private const val ACTIVITY = "activity" + + // Times we re-query an unreachable user's outbox before giving up, so the + // crawl still terminates on a finite graph. + private const val MAX_OUTBOX_ATTEMPTS = 3 + + // Users whose outboxes we fetch in a single drain. Draining thousands of + // distinct outbox relays at once saturates connections and times out + // (~250/drain succeeds, ~17k fails); keep the fan-out small. + private const val USER_BATCH = 256 + + // Sharded backbone sweep: split the still-missing authors into SHARD_RELAYS + // lists, one per top relay, rotating up to SHARD_ROTATIONS times; once the + // remainder drops below SHARD_BROADCAST_THRESHOLD, broadcast it at once. + private const val SHARD_RELAYS = 10 + private const val SHARD_ROTATIONS = 6 + private const val SHARD_BROADCAST_THRESHOLD = 2000 + + // The small-remainder broadcast goes to this many top live relays — a user's + // kind:3 is often mirrored on a busy relay ranked below the top 10. + private const val BROADCAST_RELAYS = 60 + + // Most-used write relays kept as the known-good backbone for retrying users. + private const val BACKBONE_SIZE = 30 + + // Warm pool: hold a do-nothing subscription open to the busiest relays for + // the whole crawl, so the connections we reuse every round survive the + // between-round routing gaps. The filter matches an impossible event id, so + // the relay EOSEs immediately and streams nothing — it only keeps sockets warm. + private const val WARM_POOL_SIZE = 20 + private const val WARM_SUB_ID = "graperank-warm" + private val WARM_FILTERS = listOf(Filter(ids = listOf("0".repeat(64)))) + + // Kinds requested from relays during the crawl: the graph edges (contact + // lists, mute lists, reports) PLUS the user's own kind:10002. A user's outbox + // holds the freshest copy of their relay list, so folding 10002 into the same + // query keeps routing current. The store keeps newest-by-created_at for the + // replaceable 10002, so the freshest always wins regardless of source relay. + private val FETCH_KINDS = + listOf(ContactListEvent.KIND, MuteListEvent.KIND, ReportEvent.KIND, AdvertisedRelayListEvent.KIND) + + /** Total author slots across a unit's filters — what we asked a relay for. */ + private fun authorsIn(filters: List): Int = filters.sumOf { it.authors?.size ?: 0 } + + /** Count the size-driving entries in a filter: authors, ids, and tag values. */ + private fun filterEntries(f: Filter): Int = + (f.authors?.size ?: 0) + + (f.ids?.size ?: 0) + + (f.tags?.values?.sumOf { it.size } ?: 0) + + (f.tagsAll?.values?.sumOf { it.size } ?: 0) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt new file mode 100644 index 0000000000..1ccc26cea2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankPublisher.kt @@ -0,0 +1,207 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent +import com.vitorpamplona.quartz.nip85TrustedAssertions.users.tags.RankTag +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +/** + * Publishes a set of GrapeRank scores as NIP-85 kind:30382 [ContactCardEvent] + * trusted assertions (one `rank` card per scored user), reconciled against what + * this provider key has already published so a repeat run only writes what moved. + * + * Reconciliation, given the desired `(target, rank)` set the scorer produced: + * - **skip** a target whose stored card already carries the same rank string — + * re-signing an unchanged card would churn a new event id for no client benefit; + * - **upsert** a target whose rank changed (or that has no card yet), up to a + * publish limit; + * - **retract** every stored card whose target is no longer in the desired set + * (it fell below the caller's cutoff, or dropped out of the graph) with a NIP-09 + * kind:5 deletion, batched so the frame stays under the ~64KB event cap. + * + * Transport-agnostic like [GrapeRankCrawler]: it reads prior cards from an + * [IEventStore] and emits through an injected [publish] function (event + relays → + * per-relay ack), so the store/relay wiring stays in the application while the + * reconcile + card-construction logic is reusable (e.g. by the Android app). + */ +class GrapeRankPublisher( + private val store: IEventStore, + private val publish: suspend (Event, Set) -> Map, +) { + /** Outcome counts for one reconcile: what was written, retracted, and skipped. */ + class Result( + val published: Int, + val publishRejected: Int, + val deleted: Int, + val deleteRejected: Int, + val skippedUnchanged: Int, + /** Changed cards beyond [publishLimit] that were not upserted this run. */ + val truncated: Int, + ) + + /** + * Reconcile the desired [scored] `(target, rank)` set (the caller has already + * applied any rank cutoff) against the cards [providerPubkey] previously + * published, then upsert the changes and retract the stale cards, all signed by + * [providerSigner]. At most [publishLimit] changed cards are upserted per run. + */ + suspend fun reconcileAndPublish( + providerSigner: NostrSigner, + providerPubkey: HexKey, + scored: List>, + relays: Set, + publishLimit: Int, + publishConcurrency: Int = PUBLISH_CONCURRENCY, + ): Result { + // Newest card per target this provider already published (read back from + // the store, which every published card was persisted to). + val existing = existingCards(providerPubkey) + val publishableTargets = scored.mapTo(HashSet()) { it.first } + + // Upsert publishable targets whose rank tag STRING would change (or that + // have no card yet). RankTag.assemble writes rank.toString(), so we diff + // that exact string — an unchanged score is skipped so clients only sync + // ranks that moved. + val changed = scored.filter { (target, rank) -> existing[target]?.let(::rankTagValue) != rank.toString() } + val toUpsert = changed.take(publishLimit) + + // Retract existing cards whose target is no longer publishable — it dropped + // out of the graph, or fell below the caller's cutoff. We won't leave a + // stale assertion standing. + val toDelete = existing.filterKeys { it !in publishableTargets }.values.toList() + + val (ok, rejected) = publishCards(providerSigner, toUpsert, relays, publishConcurrency) + val (deleted, deleteRejected) = publishDeletions(providerSigner, toDelete, relays) + + return Result( + published = ok, + publishRejected = rejected, + deleted = deleted, + deleteRejected = deleteRejected, + skippedUnchanged = scored.size - changed.size, + truncated = (changed.size - toUpsert.size).coerceAtLeast(0), + ) + } + + /** + * The newest kind:30382 card [providerPubkey] published per target, read from + * the store (every card [publish] sends is persisted first, so on repeat runs + * this reflects what is already out there). + */ + private suspend fun existingCards(providerPubkey: HexKey): Map = + store + .query(Filter(kinds = listOf(ContactCardEvent.KIND), authors = listOf(providerPubkey))) + .filterIsInstance() + .groupBy { it.aboutUser() } + .mapNotNull { (target, cards) -> + val t = target.ifBlank { return@mapNotNull null } + t to (cards.maxByOrNull { it.createdAt } ?: return@mapNotNull null) + }.toMap() + + /** + * The raw `rank` tag value string on a card — exactly what a client diffs, so an + * unchanged score never produces a new signature. Our cards carry only a `rank` + * tag (plus the d-tag target), so this one value decides whether a re-publish + * would differ. + */ + private fun rankTagValue(card: ContactCardEvent): String? = + card.tags.firstNotNullOfOrNull { tag -> + if (tag.size > 1 && tag[0] == RankTag.TAG_NAME) tag[1] else null + } + + /** Build + publish one kind:30382 card per (target, rank), bounded-concurrently. */ + private suspend fun publishCards( + signer: NostrSigner, + cards: List>, + relays: Set, + concurrency: Int, + ): Pair { + var published = 0 + var rejected = 0 + for (batch in cards.chunked(concurrency)) { + val acks = + coroutineScope { + batch + .map { (pubkey, rank) -> + async { + val card = + ContactCardEvent.create( + targetUser = pubkey, + signer = signer, + publicInitializer = { add(RankTag.assemble(rank)) }, + ) + publish(card, relays) + } + }.awaitAll() + } + for (ack in acks) { + if (ack.values.any { it }) published++ else rejected++ + } + } + return published to rejected + } + + /** + * Retract stale cards with NIP-09 kind:5 deletions signed by [signer] (the same + * key that signed the cards). Batches [DELETE_PER_EVENT] addressable coordinates + * per deletion so the kind:5 frame stays under the ~64KB event cap; each carries + * the card's `a` tag (30382:provider:target), so re-publishing a newer version + * later isn't blocked. Returns (deleted, rejected) card counts. + */ + private suspend fun publishDeletions( + signer: NostrSigner, + cards: List, + relays: Set, + ): Pair { + if (cards.isEmpty()) return 0 to 0 + var deleted = 0 + var rejected = 0 + for (chunk in cards.chunked(DELETE_PER_EVENT)) { + val event = signer.sign(DeletionEvent.build(chunk)) + val ack = publish(event, relays) + if (ack.values.any { it }) deleted += chunk.size else rejected += chunk.size + } + return deleted to rejected + } + + companion object { + /** Concurrent card publishes when upserting. */ + const val PUBLISH_CONCURRENCY = 16 + + /** + * Addressable coordinates cited per kind:5 retraction. Each `a` tag is + * ~130 bytes (30382:<64hex>:<64hex>), so 400 keeps the whole event ~52KB — + * under the 64KB event-size cap many relays enforce (stricter than the + * 256KB message cap). + */ + const val DELETE_PER_EVENT = 400 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankUpdater.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankUpdater.kt new file mode 100644 index 0000000000..e84778f4f5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankUpdater.kt @@ -0,0 +1,259 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropyStoreSync +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent + +/** + * Store-driven, outbox-model refresh of the record kinds a [GrapeRank] score is a + * function of — the profiles (kind:0), follows (kind:3), outbox relay lists + * (kind:10002), and reports (kind:1984) of every author already known to the + * local [store]. + * + * Where [GrapeRankCrawler] discovers the graph by walking follows outward from + * an observer, this refreshes what is *already* known: it reads every kind:10002 in + * the store, inverts them into a `write-relay -> authors` map (the outbox model — an + * author's events live on the relays they write to), fans those into one filter per + * `(write relay, author chunk)`, and hands them to [NegentropyStoreSync] — the generic + * two-pass sync engine. So each relay is asked only for the authors it hosts, and each + * author is reconciled only against their own relays. Run it periodically to keep a + * scored network current without paying a full from-scratch crawl. + * + * The engine does the work per `(relay, filter)` group: a bidirectional NIP-77 + * reconcile against [store] ([Config.down] / [Config.up]), a deletion settle over the + * residual ([Config.syncDeletions] — its applyDown direction downloads the relay's + * kind:5 when an uploaded record was rejected because the author retracted it), and a + * paged-download fallback when a relay can't reconcile ([Config.pageFallback]). This + * class only builds the outbox filter set and folds the engine's per-group results back + * up per relay. + * + * Transport-agnostic within quartz: it takes an [INostrClient] and an [IEventStore]. + * Progress is emitted through [log]; a headless caller routes it to stderr, a UI ignores it. + */ +class GrapeRankUpdater( + private val client: INostrClient, + private val store: IEventStore, + private val config: Config = Config(), + private val log: (String) -> Unit = {}, +) { + /** + * @param kinds the record kinds refreshed per author (default: the WoT set + * 0 / 3 / 10002 / 1984). + * @param down download records the relay has that the store lacks. + * @param up upload records the store has that the relay lacks (also arms the + * deletion **applyDown** path — a rejected upload pulls the relay's kind:5 down). + * @param syncDeletions run the deletion settle over the reconcile residual. + * @param pageFallback page the filter when negentropy can't reconcile a relay. + * @param idChunk ids per reconcile chunk and per by-id fetch. + * @param downloadWorkers concurrent by-id download fetches per group. + * @param reconcileConcurrency overlapped `created_at`-window reconciles after an over-cap split. + * @param maxDeletionRounds hard cap on deletion-settle rounds (converges in 1–2). + * @param relayConcurrency write relays synced at once. + * @param authorChunk authors per reconcile filter (a relay with more is split into several). + * @param minAuthors skip relays hosting fewer than this many of the store's authors. + * @param idleTimeoutMs idle watchdog for reconciles / fetches / pages. + * @param publishTimeoutSecs OK-confirmation wait per uploaded event. + */ + class Config( + val kinds: List = DEFAULT_KINDS, + val down: Boolean = true, + val up: Boolean = true, + val syncDeletions: Boolean = true, + val pageFallback: Boolean = true, + val idChunk: Int = 500, + val downloadWorkers: Int = 4, + val reconcileConcurrency: Int = 2, + val maxDeletionRounds: Int = 4, + val relayConcurrency: Int = 4, + val authorChunk: Int = 500, + val minAuthors: Int = 1, + val idleTimeoutMs: Long = 30_000L, + val publishTimeoutSecs: Long = 15, + /** + * Relays proven unreachable within the reachability cache's TTL (kind:30166). + * Skipped from the reconcile plan so we don't burn a connect timeout per dead + * relay — the crawl already found them dead, and a dead relay cannot serve its + * authors anyway. TTL'd, so a recovered relay is retried once the record ages + * out; this never drops a *live* author-advertised relay. See RelayReachabilityStore. + */ + val knownDead: Set = emptySet(), + ) { + /** Project the shared engine knobs onto a [NegentropyStoreSync.Config]. */ + internal fun toEngineConfig() = + NegentropyStoreSync.Config( + down = down, + up = up, + syncDeletions = syncDeletions, + pageFallback = pageFallback, + idChunk = idChunk, + downloadWorkers = downloadWorkers, + reconcileConcurrency = reconcileConcurrency, + maxDeletionRounds = maxDeletionRounds, + concurrency = relayConcurrency, + idleTimeoutMs = idleTimeoutMs, + publishTimeoutSecs = publishTimeoutSecs, + ) + } + + /** Per-write-relay outcome of an [update] (folded from the engine's group results). */ + class RelayResult( + val relay: NormalizedRelayUrl, + val authors: Int, + val need: Int, + val have: Int, + val downloaded: Int, + val uploaded: Int, + val deletionsSentUp: Int, + val deletionsAppliedDown: Int, + val pagedFallback: Boolean, + /** null when every chunk of this relay succeeded; the first failure otherwise. */ + val error: String?, + ) + + /** Aggregate outcome of an [update], plus the per-relay breakdown. */ + class Result( + val relayListsInStore: Int, + val authorsWithOutbox: Int, + val relays: Int, + val relaysOk: Int, + val relaysFailed: Int, + val relaysPagedFallback: Int, + val downloaded: Int, + val uploaded: Int, + val deletionsSentUp: Int, + val deletionsAppliedDown: Int, + val perRelay: List, + ) + + /** + * Group the store's authors by their kind:10002 write relays (the outbox model). + * The latest kind:10002 per author wins; an author with no write-marked relays + * contributes nothing (there is nowhere to reconcile them). Public so callers can + * inspect the plan (relay count, largest groups) before running [update]. + */ + suspend fun writeRelayGroups(): Map> = groupByWriteRelay(loadLatestRelayLists()) + + /** + * Run the full outbox-model refresh: [writeRelayGroups] then hand every group + * hosting at least [Config.minAuthors] authors (largest first, chunked to + * [Config.authorChunk]) to [NegentropyStoreSync], folding its per-group results back + * up per relay. Best-effort — a relay that fails is recorded in [RelayResult.error] + * and never aborts the run. + */ + suspend fun update(): Result { + val latest = loadLatestRelayLists() + val groups = groupByWriteRelay(latest) + val authorsWithOutbox = groups.values.flatMapTo(HashSet()) { it }.size + + // Plan: relays with enough authors, largest first so the heaviest groups start + // while engine permits are free. + val plan = + groups.entries + .filter { it.value.size >= config.minAuthors } + .filterNot { it.key in config.knownDead } + .sortedByDescending { it.value.size } + .map { it.key to it.value } + + // Fan each relay's authors into one filter per authorChunk-sized slice. + val authorChunk = config.authorChunk.coerceAtLeast(1) + val filtersByRelay = + plan.associate { (relay, authors) -> + relay to authors.toList().chunked(authorChunk).map { Filter(kinds = config.kinds, authors = it) } + } + + val groupResults = NegentropyStoreSync(client, store, config.toEngineConfig(), log).sync(filtersByRelay) + val byRelay = groupResults.groupBy { it.relay } + + // Fold each relay's chunk results back into one RelayResult (plan order preserved). + val perRelay = + plan.map { (relay, authors) -> + val chunks = byRelay[relay].orEmpty() + RelayResult( + relay = relay, + authors = authors.size, + need = chunks.sumOf { it.need }, + have = chunks.sumOf { it.have }, + downloaded = chunks.sumOf { it.downloaded }, + uploaded = chunks.sumOf { it.uploaded }, + deletionsSentUp = chunks.sumOf { it.deletionsSentUp }, + deletionsAppliedDown = chunks.sumOf { it.deletionsAppliedDown }, + pagedFallback = chunks.any { it.pagedFallback }, + error = chunks.firstNotNullOfOrNull { it.error }, + ) + } + + return Result( + relayListsInStore = latest.size, + authorsWithOutbox = authorsWithOutbox, + relays = perRelay.size, + relaysOk = perRelay.count { it.error == null }, + relaysFailed = perRelay.count { it.error != null }, + relaysPagedFallback = perRelay.count { it.pagedFallback }, + downloaded = perRelay.sumOf { it.downloaded }, + uploaded = perRelay.sumOf { it.uploaded }, + deletionsSentUp = perRelay.sumOf { it.deletionsSentUp }, + deletionsAppliedDown = perRelay.sumOf { it.deletionsAppliedDown }, + perRelay = perRelay, + ) + } + + /** Latest kind:10002 per author from the store (replaceable — newest createdAt wins). */ + private suspend fun loadLatestRelayLists(): Map { + val latest = HashMap() + for (event in store.query(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND)))) { + if (event !is AdvertisedRelayListEvent) continue + val prev = latest[event.pubKey] + if (prev == null || event.createdAt > prev.createdAt) latest[event.pubKey] = event + } + return latest + } + + /** Invert the per-author relay lists into `write-relay -> authors`. */ + private fun groupByWriteRelay(latest: Map): Map> { + val relayToAuthors = HashMap>() + for ((author, list) in latest) { + val writes = list.writeRelaysNorm() ?: continue + for (relay in writes) relayToAuthors.getOrPut(relay) { HashSet() }.add(author) + } + return relayToAuthors + } + + companion object { + /** The record kinds a GrapeRank score is a function of. */ + val DEFAULT_KINDS = + listOf( + MetadataEvent.KIND, // 0 — profiles + ContactListEvent.KIND, // 3 — follows + AdvertisedRelayListEvent.KIND, // 10002 — outbox relay lists + ReportEvent.KIND, // 1984 — reports + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt new file mode 100644 index 0000000000..ad78a6e56a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraph.kt @@ -0,0 +1,101 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A trust relationship kind and the GrapeRank rating it carries. [code] is the + * 2-bit tag packed alongside a source node id in the edge arrays (see + * [TrustGraph]); keep it in `0..3`. + */ +enum class TrustRelation( + val rating: Double, + val code: Int, +) { + FOLLOW(1.0, 0), + MUTE(-0.1, 1), + REPORT(-0.1, 2), +} + +/** + * A web-of-trust graph over Nostr pubkeys, stored compactly so it scales to the + * whole network (millions of edges) without a `String`-keyed edge object per + * relationship. + * + * Pubkeys are interned to dense `Int` node ids. Edges live in two + * compressed-sparse-row (CSR) layouts backed by flat `IntArray`s — one indexed + * by target (what [GrapeRank] reads to score a node) and one by source (what the + * propagation worklist follows). Each incoming entry packs the source id in the + * low 29 bits and the [TrustRelation.code] in the top bits, so an edge is a + * single `int`. A 100M-edge graph is then ~0.8 GB of primitive arrays instead of + * tens of GB of objects. + * + * Build one with [TrustGraphBuilder], feeding contact lists / mutes / reports in + * as they stream off the relays. + */ +class TrustGraph internal constructor( + val nodeCount: Int, + private val pubkeys: Array, + private val ids: HashMap, + // CSR by target: incoming edges of node t are inPacked[inOffsets[t] until inOffsets[t+1]], + // each packing source id (low 29 bits) + relation code (top bits). + internal val inOffsets: IntArray, + internal val inPacked: IntArray, + // CSR by source: out-neighbour targets of node s are outTargets[outOffsets[s] until outOffsets[s+1]]. + internal val outOffsets: IntArray, + internal val outTargets: IntArray, +) { + /** Node id for [pubkey], or `-1` if it never appeared in the graph. */ + fun idOf(pubkey: HexKey): Int = ids[pubkey] ?: -1 + + /** Pubkey for a node [id]. */ + fun pubkeyOf(id: Int): HexKey = pubkeys[id] + + fun edgeCount(): Int = inPacked.size + + companion object { + const val SOURCE_BITS = 29 + const val SOURCE_MASK = (1 shl SOURCE_BITS) - 1 + const val MAX_NODES = SOURCE_MASK // ids must fit in the low 29 bits + } +} + +/** A minimal growable `int[]` — avoids boxing `Int`s in an `ArrayList` at graph scale. */ +internal class IntArrayList( + initialCapacity: Int = 16, +) { + var data: IntArray = IntArray(initialCapacity.coerceAtLeast(1)) + private set + var size: Int = 0 + private set + + fun add(value: Int) { + if (size == data.size) data = data.copyOf(data.size * 2) + data[size++] = value + } + + fun get(index: Int): Int = data[index] + + fun removeLast(): Int = data[--size] + + fun isNotEmpty(): Boolean = size > 0 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt new file mode 100644 index 0000000000..7b3f12fbe4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilder.kt @@ -0,0 +1,127 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Builds a [TrustGraph] incrementally so callers never have to hold every contact + * list in memory at once — feed each user's follows / mutes / reports as they + * stream off the relays (or out of the store), then call [build]. + * + * Interns pubkeys to dense ids on the fly and accumulates edges in flat growable + * int arrays. Follows and mutes are replaceable (one list per author, deduped by + * the caller via latest-per-author + set-valued tags); reports are regular events, + * so `(reporter → reported)` report edges are deduped here. Self-edges are dropped. + */ +class TrustGraphBuilder { + private val ids = HashMap() + private val pubkeys = ArrayList() + + // Parallel edge arrays: edge i is source edgeSource[i] --relation--> edgeTarget[i], + // with the relation packed into the top bits of edgeSource[i]. + private val edgeTargets = IntArrayList() + private val edgeSourcesPacked = IntArrayList() + + // Dedup for report edges only (reporters can file many kind:1984 for one target). + private val reportSeen = HashSet() + + private fun intern(pubkey: HexKey): Int = + ids.getOrPut(pubkey) { + val id = pubkeys.size + pubkeys.add(pubkey) + id + } + + private fun addEdge( + source: HexKey, + target: HexKey, + relation: TrustRelation, + ) { + if (source == target) return + val s = intern(source) + val t = intern(target) + if (relation == TrustRelation.REPORT) { + val key = (s.toLong() shl 32) or (t.toLong() and 0xFFFFFFFFL) + if (!reportSeen.add(key)) return + } + edgeTargets.add(t) + edgeSourcesPacked.add(s or (relation.code shl TrustGraph.SOURCE_BITS)) + } + + fun addFollows( + source: HexKey, + follows: Iterable, + ) { + for (target in follows) addEdge(source, target, TrustRelation.FOLLOW) + } + + fun addMutes( + source: HexKey, + muted: Iterable, + ) { + for (target in muted) addEdge(source, target, TrustRelation.MUTE) + } + + fun addReports( + source: HexKey, + reported: Iterable, + ) { + for (target in reported) addEdge(source, target, TrustRelation.REPORT) + } + + fun nodeCount(): Int = pubkeys.size + + fun edgeCount(): Int = edgeTargets.size + + /** Freeze the accumulated edges into the two CSR layouts. */ + fun build(): TrustGraph { + val n = pubkeys.size + val m = edgeTargets.size + + // Incoming CSR (by target). + val inOffsets = IntArray(n + 1) + for (i in 0 until m) inOffsets[edgeTargets.get(i) + 1]++ + for (i in 1..n) inOffsets[i] += inOffsets[i - 1] + val inPacked = IntArray(m) + val inCursor = inOffsets.copyOf() + for (i in 0 until m) { + val t = edgeTargets.get(i) + inPacked[inCursor[t]++] = edgeSourcesPacked.get(i) + } + + // Outgoing CSR (by source). + val outOffsets = IntArray(n + 1) + for (i in 0 until m) { + val s = edgeSourcesPacked.get(i) and TrustGraph.SOURCE_MASK + outOffsets[s + 1]++ + } + for (i in 1..n) outOffsets[i] += outOffsets[i - 1] + val outTargets = IntArray(m) + val outCursor = outOffsets.copyOf() + for (i in 0 until m) { + val s = edgeSourcesPacked.get(i) and TrustGraph.SOURCE_MASK + outTargets[outCursor[s]++] = edgeTargets.get(i) + } + + return TrustGraph(n, pubkeys.toTypedArray(), ids, inOffsets, inPacked, outOffsets, outTargets) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt index 8634b5d422..25d3a9bf8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/kinds/KindNames.kt @@ -327,6 +327,7 @@ data class KindName( * platform concern layered on top, never a fork of this data. */ object KindNames { + @Suppress("DEPRECATION") // registry intentionally names deprecated kinds (GitReply, TorrentComment) for display val names: Map = mapOf( AcceptedBadgeSetEvent.KIND to KindName("Accepted Badge Set", "58"), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 40bff183f0..f30c7cc357 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -215,6 +215,10 @@ class MlsGroup private constructor( encryptionPrivateKey = encryptionPrivateKey, interimTranscriptHash = interimTranscriptHash, encryptionSecret = epochSecrets.encryptionSecret, + // Preserve the SecretTree ratchet positions so a restore doesn't + // rewind our own generation counter to 0 and reuse an AEAD + // key+nonce within this epoch (RFC 9420 §9). + senderRatchetStates = secretTree.exportSenderStates(), ) } @@ -3501,15 +3505,23 @@ class MlsGroup private constructor( /** * Restore a group from a previously saved [MlsGroupState]. * - * The SecretTree is reconstructed from the stored encryption_secret. - * Note: SecretTree ratchet state (per-sender generation counters) is - * NOT preserved — messages sent/received before the save point cannot - * be re-decrypted, which is acceptable because they would already - * have been processed. + * The SecretTree is reconstructed from the stored encryption_secret, + * then seeded with the persisted per-sender ratchet positions + * ([MlsGroupState.senderRatchetStates]). Seeding is what keeps the + * local member's generation counter monotonic across a restart — a + * fresh SecretTree would restart every sender at generation 0, so our + * next send would reuse generation 0's AEAD key+nonce within the same + * epoch and be rejected by strict receivers (openmls / MDK / + * Whitenoise) that forbid generation reuse. + * + * Receive-only ratchets that weren't persisted (STATE_VERSION 1 blobs, + * or senders we never decrypted) simply re-derive from generation 0 on + * first use — safe, because those messages were already processed. */ fun restore(state: MlsGroupState): MlsGroup { val tree = RatchetTree.decodeTls(TlsReader(state.treeBytes)) val secretTree = SecretTree(state.encryptionSecret, tree.leafCount) + secretTree.importSenderStates(state.senderRatchetStates) return MlsGroup( groupContext = state.groupContext, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt index 7c6d5cb092..4aa5f30ee8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupManager.kt @@ -348,13 +348,23 @@ class MlsGroupManager( /** * Encrypt an application message. * Synchronized to prevent nonce reuse from concurrent encryption. + * + * The group state is persisted after every send. Encrypting advances the + * SecretTree ratchet (RFC 9420 §9) but does not change the epoch, so + * without this save a restart between two messages would reload the + * pre-send ratchet position and re-emit an already-used generation — + * reusing the AEAD key+nonce and getting rejected by strict receivers. + * State was previously persisted only at commits, which left every + * inter-commit send unprotected. */ suspend fun encrypt( nostrGroupId: HexKey, plaintext: ByteArray, ): ByteArray = mutex.withLock { - requireGroup(nostrGroupId).encrypt(plaintext) + val ciphertext = requireGroup(nostrGroupId).encrypt(plaintext) + persistGroup(nostrGroupId) + ciphertext } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt index f94f9e668b..cef888ddf1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroupState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter import com.vitorpamplona.quartz.marmot.mls.messages.GroupContext import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets +import com.vitorpamplona.quartz.marmot.mls.schedule.SenderRatchetState /** * Serializable snapshot of an MLS group's complete state. @@ -40,6 +41,13 @@ import com.vitorpamplona.quartz.marmot.mls.schedule.EpochSecrets * * Security: This blob contains secret key material (signing key, encryption key, * epoch secrets). It MUST be stored in encrypted local storage. + * + * [senderRatchetStates] carries each sender's live SecretTree ratchet position + * (RFC 9420 §9). Preserving it is what stops the restored local member from + * re-emitting an already-used generation within the same epoch — see + * [com.vitorpamplona.quartz.marmot.mls.schedule.SecretTree.exportSenderStates]. + * It is optional (empty for STATE_VERSION 1 blobs) so older persisted state + * still decodes. */ data class MlsGroupState( val groupContext: GroupContext, @@ -51,6 +59,7 @@ data class MlsGroupState( val encryptionPrivateKey: ByteArray, val interimTranscriptHash: ByteArray, val encryptionSecret: ByteArray, + val senderRatchetStates: Map = emptyMap(), ) { fun encodeTls(): ByteArray { val writer = TlsWriter() @@ -94,6 +103,18 @@ data class MlsGroupState( // Encryption secret for SecretTree reconstruction writer.putOpaqueVarInt(encryptionSecret) + // Per-sender SecretTree ratchet positions (STATE_VERSION 2+). + // Preserving the local sender's generation counter is what prevents + // AEAD key+nonce reuse (and strict-receiver rejection) after a restore. + writer.putUint32(senderRatchetStates.size.toLong()) + for ((leafIndex, ratchet) in senderRatchetStates) { + writer.putUint32(leafIndex.toLong()) + writer.putOpaqueVarInt(ratchet.handshakeSecret) + writer.putUint32(ratchet.handshakeGeneration.toLong()) + writer.putOpaqueVarInt(ratchet.applicationSecret) + writer.putUint32(ratchet.applicationGeneration.toLong()) + } + return writer.toByteArray() } @@ -110,13 +131,18 @@ data class MlsGroupState( } companion object { - private const val STATE_VERSION = 1 + /** + * v1: original layout (no SecretTree ratchet positions). + * v2: appends [senderRatchetStates] so restores don't reset the + * ratchet to generation 0. v1 blobs still decode (empty map). + */ + private const val STATE_VERSION = 2 fun decodeTls(data: ByteArray): MlsGroupState { val reader = TlsReader(data) val version = reader.readUint16() - require(version == STATE_VERSION) { "Unsupported state version: $version" } + require(version in 1..STATE_VERSION) { "Unsupported state version: $version" } val groupContext = GroupContext.decodeTls(reader) val treeBytes = reader.readOpaqueVarInt() @@ -144,6 +170,33 @@ data class MlsGroupState( val interimTranscriptHash = reader.readOpaqueVarInt() val encryptionSecret = reader.readOpaqueVarInt() + // v2+: per-sender SecretTree ratchet positions. Absent (or an + // empty count) for v1 blobs, which restore at generation 0. + val senderRatchetStates = + if (version >= 2 && reader.hasRemaining) { + val count = reader.readUint32().toInt() + buildMap { + repeat(count) { + val leafIndex = reader.readUint32().toInt() + val handshakeSecret = reader.readOpaqueVarInt() + val handshakeGeneration = reader.readUint32().toInt() + val applicationSecret = reader.readOpaqueVarInt() + val applicationGeneration = reader.readUint32().toInt() + put( + leafIndex, + SenderRatchetState( + handshakeSecret = handshakeSecret, + handshakeGeneration = handshakeGeneration, + applicationSecret = applicationSecret, + applicationGeneration = applicationGeneration, + ), + ) + } + } + } else { + emptyMap() + } + return MlsGroupState( groupContext = groupContext, treeBytes = treeBytes, @@ -154,6 +207,7 @@ data class MlsGroupState( encryptionPrivateKey = encryptionPrivateKey, interimTranscriptHash = interimTranscriptHash, encryptionSecret = encryptionSecret, + senderRatchetStates = senderRatchetStates, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt index ccb8dfeaba..6e33b96d40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/schedule/SecretTree.kt @@ -389,6 +389,32 @@ class SecretTree( return currentSecret } + + /** + * Snapshot every sender's current ratchet position so the enclosing + * group state can be persisted (RFC 9420 §9). + * + * Without this, a restore rebuilds the tree at generation 0 for every + * sender, and the LOCAL member then re-emits generation 0 within the + * same epoch on its next send — reusing the AEAD key+nonce (a + * confidentiality break) and getting rejected by strict receivers + * (openmls / MDK / Whitenoise) that forbid generation reuse. + * + * Only the live ratchet position (secret + generation) per sender is + * captured. The replay-detection and skipped-key caches are runtime-only + * and deliberately excluded — they are safe to drop across a restart. + */ + fun exportSenderStates(): Map = senderState.toMap() + + /** + * Seed per-sender ratchet positions from an [exportSenderStates] + * snapshot. Called by `MlsGroup.restore`. Any sender absent from + * [states] simply re-derives from generation 0 on first use, which is + * correct for receive-only ratchets. + */ + fun importSenderStates(states: Map) { + senderState.putAll(states) + } } data class SenderRatchetState( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt new file mode 100644 index 0000000000..0fcb87907b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/AdaptiveRelayLimiter.kt @@ -0,0 +1,287 @@ +/* + * 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.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.AtomicLong +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * Adaptive per-relay back-pressure with TWO independent controls, because relays + * push back for two different reasons that need two different responses: + * + * 1. **Subscription-count limit** — a max on how many subscriptions may be OPEN + * at once ("too many subscriptions", "maximum concurrent subscription count", + * "number of subscriptions exceeds limit"). The fix is fewer *concurrent* + * subs, so we demote the relay's concurrency cap down [subLadder] + * (100 → 20 → 10). + * 2. **Rate limit** — too many subscription *changes per second* ("rate-limited: + * too many messages", "burst exhausted", "slow down"). Fewer concurrent subs + * wouldn't help; the fix is to *space the REQs out in time*, so we impose a + * minimum interval between opens to that relay, growing it up [rateLadder] + * (250ms → 500ms → 1s → 2s). + * + * Mixing the two mishandles the relay: capping concurrency does nothing for a + * rate limit, and slowing the rate does nothing for a subscription-count cap. So + * each complaint is routed to its own actuator by matching the notice text. + * + * A well-behaved relay starts at [startCap] concurrent subs with no rate delay, + * and only the ones that push back get throttled — each only as far, and in the + * dimension, they keep pushing. + * + * Registered as a [RelayConnectionListener] on the shared client, so both signals + * are driven straight off the incoming NOTICE/CLOSED frames (which fire on the + * per-relay socket threads — all state here is concurrent). Drains gate through + * [withPermit]: the gated-drain path holds a relay's permit for the lifetime of + * that relay's subscription, and passes the rate gate before it opens, so we + * respect both limits at once. + */ +@OptIn(ExperimentalAtomicApi::class) +class AdaptiveRelayLimiter( + private val startCap: Int = 100, + private val subLadder: List = listOf(20, 10), + private val rateLadder: List = listOf(250L, 500L, 1000L, 2000L), +) : RelayConnectionListener { + private val gates = ConcurrentMap() + + // Concurrency-cap demotions per relay (== index+1 into subLadder). Capped at + // subLadder.size: past the floor we stop demoting. + private val subDemotions = ConcurrentMap() + + // Rate-limit state per relay: how far down rateLadder we've stepped, the + // current min interval between opens, and the next epoch-ms an open may fire. + private val rateSteps = ConcurrentMap() + private val rateDelayMs = ConcurrentMap() + private val nextAllowedAtMs = ConcurrentMap() + + private fun gate(relay: NormalizedRelayUrl): Gate = gates.getOrPut(relay) { Gate(startCap) } + + /** The concurrency cap currently enforced for [relay] ([startCap] unless demoted). */ + fun concurrencyCapOf(relay: NormalizedRelayUrl): Int { + val step = subDemotions[relay] ?: 0 + return if (step == 0) startCap else subLadder[(step - 1).coerceIn(0, subLadder.size - 1)] + } + + /** The min interval (ms) between opens enforced for [relay]; 0 if not rate-limited. */ + fun rateDelayOf(relay: NormalizedRelayUrl): Long = rateDelayMs[relay] ?: 0L + + /** True if we lowered [relay]'s concurrency cap or imposed a rate delay (it pushed back). */ + fun isThrottled(relay: NormalizedRelayUrl): Boolean = (subDemotions[relay] ?: 0) > 0 || (rateDelayMs[relay] ?: 0L) > 0L + + /** + * Run [block] against [relay] respecting both limits: first wait out any rate + * delay (spacing opens in time), then hold one of the relay's concurrency + * permits for the duration. + */ + suspend fun withPermit( + relay: NormalizedRelayUrl, + block: suspend () -> T, + ): T { + rateGate(relay) + val g = gate(relay) + g.acquire() + try { + return block() + } finally { + g.release() + } + } + + /** If [relay] is rate-limited, reserve and wait for its next allowed open slot. */ + private suspend fun rateGate(relay: NormalizedRelayUrl) { + val delayMs = rateDelayMs[relay] ?: return + if (delayMs <= 0L) return + val now = TimeUtils.nowMillis() + // Atomically claim the next slot: my turn is max(prevSlot, now); the next + // caller can't fire until delayMs after me. Serializes opens to this relay + // at one per delayMs, in arrival order. + val slot = nextAllowedAtMs.getOrPut(relay) { AtomicLong(now) } + var myTurn: Long + while (true) { + val prev = slot.load() + myTurn = maxOf(prev, now) + if (slot.compareAndSet(prev, myTurn + delayMs)) break + } + val wait = myTurn - now + if (wait > 0) delay(wait) + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + val text = + when (msg) { + is ClosedMessage -> msg.message + is NoticeMessage -> msg.message + else -> return + } + val t = text.lowercase() + // Route each complaint to the matching actuator. Not mutually exclusive: + // if a relay somehow reports both, we act on both (they don't conflict). + if (RATE_LIMIT_MARKERS.any { it in t }) throttleRate(relay.url) + if (SUB_LIMIT_MARKERS.any { it in t }) demoteConcurrency(relay.url) + } + + /** Step [relay] one rung down the concurrency-cap ladder, unless already at the floor. */ + private fun demoteConcurrency(relay: NormalizedRelayUrl) { + if ((subDemotions[relay] ?: 0) >= subLadder.size) return + val step = subDemotions.merge(relay, 1) { a, b -> a + b } + val cap = subLadder[(step - 1).coerceIn(0, subLadder.size - 1)] + gate(relay).lower(cap) + if (step <= subLadder.size) { + Log.d("AdaptiveRelayLimiter") { "${relay.url} concurrency capped at $cap subs (sub-limit #$step)" } + } + } + + /** Step [relay] one rung down the rate ladder, unless already at the slowest. */ + private fun throttleRate(relay: NormalizedRelayUrl) { + if ((rateSteps[relay] ?: 0) >= rateLadder.size) return + val step = rateSteps.merge(relay, 1) { a, b -> a + b } + val d = rateLadder[(step - 1).coerceIn(0, rateLadder.size - 1)] + rateDelayMs[relay] = d + if (step <= rateLadder.size) { + Log.d("AdaptiveRelayLimiter") { "${relay.url} rate-throttled to 1 REQ / ${d}ms (rate-limit #$step)" } + } + } + + /** JSON-friendly view of which relays we throttled, in which dimension, how far. */ + fun snapshot(): Map { + val capCounts = HashMap() + for ((_, step) in subDemotions.snapshot()) { + val cap = subLadder[(step - 1).coerceIn(0, subLadder.size - 1)] + capCounts[cap] = (capCounts[cap] ?: 0) + 1 + } + val cappedAt = capCounts.toList().sortedBy { it.first }.toMap() + val rateCounts = HashMap() + for ((_, step) in rateSteps.snapshot()) { + val d = rateLadder[(step - 1).coerceIn(0, rateLadder.size - 1)] + rateCounts[d] = (rateCounts[d] ?: 0) + 1 + } + val rateAt = rateCounts.toList().sortedBy { it.first }.toMap() + return mapOf( + "start_cap" to startCap, + "sub_ladder" to subLadder, + "rate_ladder_ms" to rateLadder, + "concurrency_capped_relays" to subDemotions.size(), + "concurrency_capped_at" to cappedAt, + "rate_limited_relays" to rateSteps.size(), + "rate_limited_at_ms" to rateAt, + ) + } + + fun hadThrottling(): Boolean = subDemotions.size() > 0 || rateSteps.size() > 0 + + /** + * A bounded-concurrency gate whose limit can only ever be *lowered* (relays + * never earn their cap back within a run). Fair FIFO hand-off: a released + * permit goes to the longest-waiting acquirer. Lowering the limit below the + * in-use count doesn't cancel live holders — it just refuses to admit new + * ones until enough release that `inUse < limit` again, so the concurrency + * converges down to the new cap as the excess subscriptions finish. + */ + private class Gate( + initialLimit: Int, + ) { + private val limit = AtomicInt(initialLimit) + private val mutex = Mutex() + private var inUse = 0 + private val waiters = ArrayDeque>() + + suspend fun acquire() { + val wait = + mutex.withLock { + if (inUse < limit.load()) { + inUse++ + null + } else { + CompletableDeferred().also { waiters.addLast(it) } + } + } + wait?.await() + } + + suspend fun release() { + mutex.withLock { + inUse-- + while (inUse < limit.load() && waiters.isNotEmpty()) { + waiters.removeFirst().complete(Unit) + inUse++ + } + } + } + + /** Monotonically shrink the cap. Safe to call from any thread. */ + fun lower(newLimit: Int) { + while (true) { + val cur = limit.load() + if (newLimit >= cur) return + if (limit.compareAndSet(cur, newLimit)) return + } + } + } + + companion object { + // A cap on how many subscriptions may be OPEN at once. Fix: fewer + // concurrent subs (demote the concurrency cap). + private val SUB_LIMIT_MARKERS = + listOf( + "too many concurrent", + "concurrent req", + "too many subscription", + "number of subscriptions", + "subscriptions exceeds", + "subscription limit", + "subscription count", + "maximum concurrent subscription", + "max subscription", + "too many req", + ) + + // Too many subscription CHANGES per second. Fix: space the REQs out in + // time (a per-relay min interval), not fewer concurrent subs. + private val RATE_LIMIT_MARKERS = + listOf( + "rate-limit", + "rate limit", + "ratelimit", + "too many messages", + "too many requests", + "burst exhausted", + "throttl", + "slow down", + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailure.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailure.kt new file mode 100644 index 0000000000..1786ac04c2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailure.kt @@ -0,0 +1,68 @@ +/* + * 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.nip01Core.relay.client.accessories + +/** + * A drain per-relay failure worth acting on: the relay will not serve us THIS run, + * so drop it from further routing on the first occurrence. There is only one such + * verdict — [DEAD] — because re-probing hop-8's failed relays fresh, outside the + * crawl, showed the old "might clear, retry a few times" (TRANSIENT) bucket almost + * never clears: 503 Service Unavailable was 0/12 reachable, 502 Bad Gateway 3/15, + * connection-establishment failures 0/30, and the codes that WERE alive (403/402) + * are gated and will never hand us events. Spending extra dials on them was waste. + * + * The only two connect failures that genuinely recover are kept OUT of this verdict + * by [classifyDrainFailure] returning null (retry, never dead): + * - a **read** timeout — the relay accepted the handshake but is slow to serve; + * 12/18 (67%) were reachable fresh, only overloaded by the crawl's fan-out. The + * crawler's per-authority timeout strikes, which CLEAR on success, shed the gone. + * - an HTTP **429 / too many requests** — alive and rate-limiting; 4/4 reachable + * fresh. Retrying (spaced by the rate limiter) is how we eventually get its data. + */ +enum class DrainFailure { DEAD, } + +/** + * Classify a drain per-relay terminal reason. Returns null when the relay should be + * retried rather than dropped — a read/generic timeout, an alive 429 rate-limit, or + * a non-failure like eose/closed. Any other `cannot:` (see + * `BasicRelayClient.onCannotConnect`) is [DrainFailure.DEAD]: it will not serve us + * this run, so drop it now instead of paying repeated connect attempts. + */ +fun classifyDrainFailure(reason: String): DrainFailure? { + if (!reason.startsWith("cannot")) return null + val m = reason.removePrefix("cannot:").lowercase() + // Alive, only asking us to slow down: an HTTP 429 / "too many requests" reliably + // clears — 4/4 such relays were reachable when re-probed fresh. Retry it (the + // rate limiter spaces our opens); never drop it. + if ("429" in m || "too many requests" in m) return null + // A READ timeout means the relay accepted the handshake but is slow to serve — + // 12/18 (67%) reachable fresh, alive but overloaded by the fan-out. Retry; the + // crawler's per-authority timeout strikes, which clear on success, shed the gone. + // A *connect* timeout is the opposite (the socket never opened, 0/30 reachable), + // so it is excluded here and falls through to DEAD with every other failure. + if (("timeout" in m || "timed out" in m) && "connect timed out" !in m) return null + // Everything else won't serve us this run: connect refused / unroutable / the + // proxy couldn't tunnel the CONNECT, a DNS or TLS failure, a dead-or-not-a-relay + // HTTP upgrade (502/503/500/504/410/404/200/…), or a mid-stream reset. Measured + // mostly dead (503 0%, 502 20% reachable) and, when alive, gated (402/403) or not + // a relay (200). Drop it now rather than burn more dials on it. + return DrainFailure.DEAD +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt new file mode 100644 index 0000000000..0d2ce9df08 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NegentropyStoreSync.kt @@ -0,0 +1,304 @@ +/* + * 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.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.coroutines.cancellation.CancellationException + +/** + * Two-pass NIP-77 sync of ANY filter set between a relay and a local [IEventStore], + * with a paged fallback — the reusable engine behind `amy sync` and the GrapeRank + * outbox updater, generalized so any caller can reconcile arbitrary + * `relay -> filters` work against their store. + * + * A **group** is one `(relay, filter)`: [syncGroup] runs the full two-pass sync for it. + * + * 1. **Content pass** — [negentropyReconcile] diffs the relay's matched set for the + * filter against the store's ids, then: + * - [Config.down] downloads the residual **needs** (relay has, store lacks) by id + * and verifies+inserts them into [store]; + * - [Config.up] uploads the residual **haves** (store has, relay lacks) as EVENTs. + * 2. **Deletion pass** — [negentropySettleDeletions] over what the content pass could + * not converge ([Config.syncDeletions]). Its **applyDown** direction is the + * "download the deletion when our upload was rejected" case: an event pushed up + * that the relay keeps rejecting (it deleted it) is a residual *have*, so the + * relay's covering kind:5 is pulled down and applied and [store] drops the + * retracted event. **sendUp** publishes the store's covering deletions for records + * deleted locally that the relay still serves. + * + * If the content pass can't reconcile ([NegentropySyncException] — no NIP-77, an + * over-cap minimal window, a mid-sync disconnect) and [Config.pageFallback] is on, the + * group pages the same filter ([fetchAllPages]) into the store instead; only the + * negentropy-only deletion settle is skipped there. Every group is best-effort — a + * failure lands in [GroupResult.error], it never throws — so one bad relay can't abort + * a multi-relay [sync]. + * + * [sync] runs many groups: relays go up to [Config.concurrency] at once, and a single + * relay's filters run sequentially (so one relay never opens more than one group's worth + * of negentropy sessions at a time — keeping under its subscription budget). Progress is + * emitted through [log]. + */ +@OptIn(ExperimentalAtomicApi::class) +class NegentropyStoreSync( + private val client: INostrClient, + private val store: IEventStore, + private val config: Config = Config(), + private val log: (String) -> Unit = {}, +) { + /** + * @param down download records the relay has that the store lacks. + * @param up upload records the store has that the relay lacks (also arms the + * deletion **applyDown** path — a rejected upload pulls the relay's kind:5 down). + * @param syncDeletions run the deletion settle over the reconcile residual. + * @param pageFallback page the filter when negentropy can't reconcile the relay. + * @param idChunk ids per reconcile chunk and per by-id fetch. + * @param downloadWorkers concurrent by-id download fetches per group. + * @param reconcileConcurrency overlapped `created_at`-window reconciles after an over-cap split. + * @param maxDeletionRounds hard cap on deletion-settle rounds (converges in 1–2). + * @param concurrency relays synced at once by [sync] (a relay's own filters stay sequential). + * @param idleTimeoutMs idle watchdog for reconciles / fetches / pages. + * @param publishTimeoutSecs OK-confirmation wait per uploaded event. + */ + class Config( + val down: Boolean = true, + val up: Boolean = false, + val syncDeletions: Boolean = true, + val pageFallback: Boolean = true, + val idChunk: Int = 500, + val downloadWorkers: Int = 4, + val reconcileConcurrency: Int = 2, + val maxDeletionRounds: Int = 4, + val concurrency: Int = 4, + val idleTimeoutMs: Long = 30_000L, + val publishTimeoutSecs: Long = 15, + ) + + /** Outcome of one `(relay, filter)` group. `error` is null on success. */ + class GroupResult( + val relay: NormalizedRelayUrl, + val filter: Filter, + val need: Int, + val have: Int, + val downloaded: Int, + val uploaded: Int, + val deletionsSentUp: Int, + val deletionsAppliedDown: Int, + /** True when negentropy couldn't reconcile and the filter was paged instead. */ + val pagedFallback: Boolean, + val error: String?, + ) + + /** + * Sync every `(relay, filter)` in [filtersByRelay]: relays run up to + * [Config.concurrency] at once; each relay's filters run sequentially. Returns one + * [GroupResult] per relay+filter (relay order preserved, filters in list order). + */ + suspend fun sync(filtersByRelay: Map>): List { + if (filtersByRelay.isEmpty()) return emptyList() + val gate = Semaphore(config.concurrency.coerceAtLeast(1)) + return coroutineScope { + filtersByRelay.entries + .map { (relay, filters) -> + async { gate.withPermit { filters.map { syncGroupSafely(relay, it) } } } + }.awaitAll() + .flatten() + } + } + + /** + * [syncGroup] with a best-effort guard so an unexpected failure in one group + * (store I/O, a relay throwing outside the NIP-77 path, …) is recorded rather than + * cancelling every other relay in a [sync]. Cancellation is propagated, not caught. + */ + private suspend fun syncGroupSafely( + relay: NormalizedRelayUrl, + filter: Filter, + ): GroupResult = + try { + syncGroup(relay, filter) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + log("[store-sync] ${relay.url}: group failed: ${e::class.simpleName}: ${e.message}") + GroupResult(relay, filter, 0, 0, 0, 0, 0, 0, pagedFallback = false, error = "${e::class.simpleName}: ${e.message}") + } + + /** Content pass + deletion settle (+ page fallback) for one relay + one filter. */ + suspend fun syncGroup( + relay: NormalizedRelayUrl, + filter: Filter, + ): GroupResult { + // Only the id+created_at snapshot is needed to reconcile — never the decoded + // events (~40 B/entry vs ~1 KB), which matters when a relay hosts a large + // matched set. The events the reconcile decides to UP-publish (the small + // residual haves) are fetched by id on demand in the uploader below. + val localEntries = store.snapshotIdsForNegentropy(listOf(filter)) + + val downloaded = AtomicInt(0) + val uploaded = AtomicInt(0) + + val reconcileResult = + try { + coroutineScope { + // needIds = relay has, store lacks; haveIds = store has, relay lacks. + val needBatches = Channel>(config.downloadWorkers * 2) + val haveBatches = Channel>(Channel.UNLIMITED) + + val downloaders = + List(config.downloadWorkers.coerceAtLeast(1)) { + launch { + for (batch in needBatches) { + for (event in client.fetchAll(relay, Filter(ids = batch), config.idleTimeoutMs)) { + if (store.verifyAndInsert(event)) downloaded.addAndFetch(1) + } + } + } + } + val uploader = + launch { + for (batch in haveBatches) { + // Fetch just the residual haves from the store (not the + // whole matched set) and publish them up. + for (ev in store.query(Filter(ids = batch))) { + if (client.publishAndConfirm(ev, setOf(relay), config.publishTimeoutSecs)) uploaded.addAndFetch(1) + } + } + } + + val result = + try { + client.negentropyReconcile( + relay = relay, + filter = filter, + localEntries = localEntries, + batchSize = config.idChunk, + idleTimeoutMs = config.idleTimeoutMs, + reconcileConcurrency = config.reconcileConcurrency, + onHaveIds = if (config.up) { batch -> haveBatches.send(batch) } else null, + onNeedIds = { batch -> if (config.down) needBatches.send(batch) }, + ) + } finally { + needBatches.close() + haveBatches.close() + } + + downloaders.joinAll() + uploader.join() + result + } + } catch (e: NegentropySyncException) { + // Negentropy couldn't reconcile — page the same filter so the records + // still refresh. Deletion settle is negentropy-only, so it is skipped. + var pageError: String? = e.message ?: "negentropy sync failed" + if (config.pageFallback && config.down) { + pageError = + try { + downloaded.addAndFetch(pageDownload(relay, filter)) + null + } catch (pe: CancellationException) { + throw pe + } catch (pe: Exception) { + "negentropy: ${e.message}; page fallback: ${pe::class.simpleName}: ${pe.message}" + } + } + log("[store-sync] ${relay.url}: paged fallback, ${downloaded.load()} stored${pageError?.let { " (error: $it)" } ?: ""}") + return GroupResult(relay, filter, 0, 0, downloaded.load(), uploaded.load(), 0, 0, pagedFallback = true, error = pageError) + } + + val deletions = + if (config.syncDeletions) { + client.negentropySettleDeletions( + relay = relay, + filter = filter, + store = store, + sendUp = config.down, + applyDown = config.up, + batchSize = config.idChunk, + idleTimeoutMs = config.idleTimeoutMs, + maxRounds = config.maxDeletionRounds, + reconcileConcurrency = config.reconcileConcurrency, + ) + } else { + null + } + + log( + "[store-sync] ${relay.url}: down ${downloaded.load()}, up ${uploaded.load()}, " + + "del↑ ${deletions?.sentUp ?: 0}, del↓ ${deletions?.appliedDown ?: 0}", + ) + return GroupResult( + relay = relay, + filter = filter, + need = reconcileResult.needCount, + have = reconcileResult.haveCount, + downloaded = downloaded.load(), + uploaded = uploaded.load(), + deletionsSentUp = deletions?.sentUp ?: 0, + deletionsAppliedDown = deletions?.appliedDown ?: 0, + pagedFallback = false, + error = null, + ) + } + + /** + * Paged fallback: walk [relay] past its per-REQ cap for [filter], verifying and + * inserting each event into [store]. [fetchAllPages]'s `onEvent` can't suspend, so + * events funnel through a channel to a single inserter. Returns how many were newly stored. + */ + private suspend fun pageDownload( + relay: NormalizedRelayUrl, + filter: Filter, + ): Int { + val stored = AtomicInt(0) + val events = Channel(Channel.UNLIMITED) + coroutineScope { + val inserter = + launch { + for (event in events) { + if (store.verifyAndInsert(event)) stored.addAndFetch(1) + } + } + try { + client.fetchAllPages(relay, listOf(filter), config.idleTimeoutMs) { event -> events.trySend(event) } + } finally { + events.close() + } + inserter.join() + } + return stored.load() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt index fb1ab9a855..20e686de6a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientFetchFirstExt.kt @@ -120,7 +120,17 @@ suspend fun INostrClient.fetchFirst( remaining.clear() } doneChannel.onReceive { relay -> - remaining.remove(relay) + // A relay sends its matching events before its EOSE, so an event may + // already be buffered when this completion fires. select() picks a ready + // clause at random, so without this drain we could treat the relay as done + // and exit while its event still sits unread in the channel. + val buffered = eventChannel.tryReceive().getOrNull() + if (buffered != null) { + result = buffered + remaining.clear() + } else { + remaining.remove(relay) + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt new file mode 100644 index 0000000000..67e770f1de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt @@ -0,0 +1,145 @@ +/* + * 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.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + +/** + * Outcome of a [negentropySettleDeletions] run. + * + * @property sentUp distinct local deletions published to the relay (up direction). + * @property appliedDown distinct relay deletions ingested into [store] (down direction). + * @property rounds reconcile rounds run before convergence (or the cap). + */ +class DeletionSettleResult( + val sentUp: Int, + val appliedDown: Int, + val rounds: Int, +) + +/** + * Converge deletions between [store] and [relay] AFTER a content sync has settled the + * two sides — the second half of a two-pass sync. NIP-77 reconciles by id, so a plain + * content sync converges everything except events a deletion physically stops from + * moving; those survive as the reconcile's residual, which this resolves: + * + * - **[sendUp]** — a residual **need** (relay has it, [store] still lacks it after the + * content pass tried to download it) means we deleted it. Publish OUR covering + * deletion up ([IEventStore.deletionsCovering]) so the relay drops it. + * - **[applyDown]** — a residual **have** ([store] has it, relay still lacks it after + * the content pass tried to upload it) means the relay deleted it. Pull the RELAY'S + * covering **kind-5** down and ingest it, so [store] drops it too. A NIP-62 vanish is + * deliberately NOT applied on pull — its blast radius is the author's whole account. + * + * Because it works off the residual — not every id — the cost is one cheap reconcile + * per round plus the (small) residual, independent of database size. It loops until a + * round resolves nothing (converged, and thereby self-verified) or [maxRounds] is hit. + * + * **Direction requires the matching content pass.** A residual need is a clean signal + * only after the content sync attempted the download ([sendUp] pairs with a `--down` + * content pass); a residual have only after it attempted the upload ([applyDown] pairs + * with `--up`). Passing a direction whose content pass didn't run makes its residual the + * full unsettled set, not a deletion signal — so drive this with the same directions the + * content pass used. + * + * Best-effort: a reconcile failure ([NegentropySyncException]) stops the loop and returns + * what already settled rather than throwing — the content sync is the primary work. + * + * @param batchSize ids per reconcile chunk and per by-id fetch. + * @param idleTimeoutMs idle watchdog for the reconciles and fetches. + * @param maxRounds hard cap on rounds; the "resolved nothing" check usually stops first. + * @param reconcileConcurrency overlapped `created_at`-window reconciles after an over-cap split. + */ +suspend fun INostrClient.negentropySettleDeletions( + relay: NormalizedRelayUrl, + filter: Filter, + store: IEventStore, + sendUp: Boolean, + applyDown: Boolean, + batchSize: Int = 500, + idleTimeoutMs: Long = 120_000L, + maxRounds: Int = 4, + reconcileConcurrency: Int = 1, +): DeletionSettleResult { + if ((!sendUp && !applyDown) || maxRounds <= 0) return DeletionSettleResult(0, 0, 0) + + val publishTimeoutSecs = (idleTimeoutMs / 1000).coerceAtLeast(1) + val sentUp = HashSet() + val appliedDown = HashSet() + var rounds = 0 + + while (rounds < maxRounds) { + rounds++ + val diff = + try { + negentropyReconcileIds( + relay = relay, + filter = filter, + localEntries = store.snapshotIdsForNegentropy(listOf(filter)), + batchSize = batchSize, + idleTimeoutMs = idleTimeoutMs, + reconcileConcurrency = reconcileConcurrency, + ) + } catch (e: NegentropySyncException) { + break + } + + var resolved = 0 + + // residual needs → publish our covering deletions up. + if (sendUp) { + for (chunk in diff.needIds.chunked(batchSize)) { + val events = fetchAll(relay, Filter(ids = chunk), idleTimeoutMs) + for (del in store.deletionsCovering(events, relay)) { + if (sentUp.add(del.id)) { + if (publishAndConfirm(del, setOf(relay), publishTimeoutSecs)) resolved++ + } + } + } + } + + // residual haves → ingest the relay's covering kind-5 (never a vanish). + if (applyDown) { + for (chunk in diff.haveIds.chunked(batchSize)) { + val ours = store.query(Filter(ids = chunk)) + val relayDeletions = deletionsCovering(ours, relay) { f -> fetchAll(relay, f, idleTimeoutMs) } + for (del in relayDeletions.filterIsInstance()) { + if (del.verify() && appliedDown.add(del.id)) { + store.insert(del) + resolved++ + } + } + } + } + + if (resolved == 0) break + } + + return DeletionSettleResult(sentUp.size, appliedDown.size, rounds) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md new file mode 100644 index 0000000000..46ce631095 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -0,0 +1,62 @@ +# `INostrClient` accessories + +One-shot / high-level relay operations, written as **extension functions** on +`INostrClient`. They live here (and in `../reqs/`) rather than on the client class, +so they don't show up under "usages of `NostrClient`" or in method completion — you +only find them by knowing this package exists. + +**Before writing a new subscribe / REQ / publish loop, look here first.** Most of what +a caller needs (fetch a set, fetch one, page past the relay cap, publish-and-confirm, +count, negentropy sync/reconcile) already exists. + +Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` (or +`...client.reqs.` for the flow/subscribe helpers). + +## One-shot reads (subscribe → collect → return) + +| Function | File | Use when | +| --- | --- | --- | +| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. | +| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | +| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | +| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. | + +## Streaming (`Flow`) + +| Function | File | Use when | +| --- | --- | --- | +| `fetchAsFlow(relay, filter)` | `../reqs/NostrClientFetchAsFlowExt` | Emit the accumulating list on each arrival; completes on EOSE. One-shot query as a flow. | +| `subscribeAsFlow(relay, filter)` | `../reqs/NostrClientSubscribeAsFlowExt` | Live subscription as a flow (stays open past EOSE; re-sends the REQ on reconnect). | +| `subscribe(subId, filters, listener)` | `../reqs/StaticSubscription`, `DynamicSubscription` | Raw live subscription with a `SubscriptionListener`. The lowest-level primitive the above build on. | + +## Publish + +| Function | File | Use when | +| --- | --- | --- | +| `publishAndConfirm(event, relays, timeout)` | `NostrClientPublishExt` | Send an EVENT and wait for `OK`; returns whether any relay accepted it. | +| `publishAndConfirmDetailed(event, relays, timeout)` | `NostrClientPublishExt` | Same, but returns the per-relay accepted/rejected map. | + +## Count (NIP-45) + +| Function | File | Use when | +| --- | --- | --- | +| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). | +| `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. | + +## Negentropy (NIP-77) + +| Function | File | Use when | +| --- | --- | --- | +| `negentropySync(relay, filter, ...)` | `NostrClientNegentropySyncExt` | Download everything a relay holds for a filter, diffing against `localEntries` and by-id downloading only the diff. Throws `NegentropySyncException` if the relay can't reconcile (no fallback). | +| `negentropySyncOrFetch(relay, filter, ...)` | `NostrClientNegentropySyncExt` | Same, but transparently falls back to `fetchAllPages` when the relay can't reconcile. The "just get the events" combinator. | +| `negentropySyncEvents` / `negentropySyncOrFetchEvents` | `NostrClientNegentropySyncEventsExt` | The two above as an O(1)-memory `Flow`. | +| `negentropyReconcile(relay, filter, localEntries, onNeedIds, onHaveIds)` | `NostrClientNegentropySyncExt` | **Pure diff, no I/O** — streams the two directions (`need` = relay has & we lack; `have` = we have & relay lacks) to callbacks. Compose your own download/upload on top. | +| `negentropyReconcileIds(relay, filter, localEntries)` | `NostrClientNegentropySyncExt` | Same diff, materialized into `needIds` / `haveIds` lists (small sets only). | +| `negentropySettleDeletions(relay, filter, store, sendUp, applyDown)` | `NostrClientNegentropyDeletionSettleExt` | Second pass of a two-pass sync: after a content sync settles, re-reconcile and resolve only the residual — send our covering deletions up (`sendUp`) and/or apply the relay's kind-5 down (`applyDown`), looping until stable. Cost is O(residual), not O(db). Pairs with `IEventStore.deletionsCovering`. | + +`fetchByIds`, `reconcileStreaming`, `syncPipeline` in `NostrClientNegentropySyncExt` +are `internal` implementation details — not part of the public surface. + +--- + +_Keep this table in sync when you add a public `INostrClient` extension here._ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthSnapshot.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthSnapshot.kt new file mode 100644 index 0000000000..5528ee59a7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthSnapshot.kt @@ -0,0 +1,58 @@ +/* + * 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.nip01Core.relay.client.auth + +import androidx.compose.runtime.Immutable + +/** + * Compose-stable per-relay AUTH snapshot exposed by [RelayAuthenticator]. + * + * The internal [RelayAuthStatus] is a mutable holder around concurrent LRU + * caches — necessary for the per-relay OkHttp dispatcher, but unsuitable as + * a [kotlinx.coroutines.flow.StateFlow] value (mutating it doesn't change + * identity, so distinct-until-changed swallows updates). + * + * [RelayAuthSnapshot] is the immutable view downstream consumers (UI banner, + * retry coordinator, indexer-fan-out gate) subscribe to. + */ +@Immutable +data class RelayAuthSnapshot( + val phase: Phase, + val lastAuthSuccessAt: Long?, +) { + enum class Phase { + /** Connected; no AUTH challenge has been received yet. */ + IDLE, + + /** Signed AUTH event in flight; awaiting OK from the relay. */ + AUTHENTICATING, + + /** Last AUTH succeeded; relay accepts authenticated REQs. */ + AUTHENTICATED, + + /** Last AUTH attempt failed; subsequent challenges may still arrive. */ + AUTH_FAILED, + } + + companion object { + val IDLE = RelayAuthSnapshot(Phase.IDLE, lastAuthSuccessAt = null) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index ac84dfd84e..ee4df00fe2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.nip01Core.relay.client.auth import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlin.concurrent.Volatile class RelayAuthStatus { // Keeps track of auth responses to update the relay with all filters @@ -32,6 +34,12 @@ class RelayAuthStatus { // Avoids sending multiple replies for each auth. private val uniqueAuthChallengesSent: LruCache = LruCache(10) + // Latest epoch-second at which a tracked AUTH event received a successful OK. + // Read by RelayAuthSnapshot consumers for staleness checks (e.g. proactive + // re-AUTH on window focus). + @Volatile + private var lastAuthSuccessAt: Long? = null + enum class AuthEventReceiptStatus { AUTHENTICATING, AUTHENTICATED, @@ -66,6 +74,7 @@ class RelayAuthStatus { return if (wasAlreadyAuthenticated != null) { if (success) { authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED) + lastAuthSuccessAt = TimeUtils.now() } else { authResponseWatcher.put(eventId, AuthEventReceiptStatus.NOT_AUTHENTICATED) } @@ -77,4 +86,29 @@ class RelayAuthStatus { } fun hasFinishedAllAuths() = authResponseWatcher.snapshot().all { it.value != AuthEventReceiptStatus.AUTHENTICATING } + + /** + * Build an immutable Compose-stable snapshot of the current per-relay AUTH + * state. The phase is derived from the response watcher: + * + * - any AUTHENTICATING entry → [RelayAuthSnapshot.Phase.AUTHENTICATING] + * - else any AUTHENTICATED entry → [RelayAuthSnapshot.Phase.AUTHENTICATED] + * - else any NOT_AUTHENTICATED entry → [RelayAuthSnapshot.Phase.AUTH_FAILED] + * - else (no tracked challenges) → [RelayAuthSnapshot.Phase.IDLE] + * + * The watcher LRU caps at 10 entries; a long-running connection that has + * already AUTHed will still report AUTHENTICATED even after older entries + * roll off, because the LRU keeps the most recent. + */ + fun snapshot(): RelayAuthSnapshot { + val entries = authResponseWatcher.snapshot() + val phase = + when { + entries.isEmpty() -> RelayAuthSnapshot.Phase.IDLE + entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATING } -> RelayAuthSnapshot.Phase.AUTHENTICATING + entries.values.any { it == AuthEventReceiptStatus.AUTHENTICATED } -> RelayAuthSnapshot.Phase.AUTHENTICATED + else -> RelayAuthSnapshot.Phase.AUTH_FAILED + } + return RelayAuthSnapshot(phase, lastAuthSuccessAt) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index e53eb05adb..e91ed4ac79 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -33,10 +33,16 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -61,8 +67,32 @@ class RelayAuthenticator( // Connection callbacks fire on the per-relay OkHttp dispatcher thread, so // this state is mutated concurrently — LargeCache wraps a platform-tuned // concurrent map (ConcurrentSkipListMap on jvmAndroid, CacheMap on Apple). + // + // This stays mutable because RelayAuthStatus carries an LruCache that has + // to be addressable from the dispatcher thread. The Compose-observable + // view of the same data is published on [authStateFlow] below, sourced + // from RelayAuthStatus.snapshot(). private val authStatus = LargeCache() + private val _authStateFlow = MutableStateFlow>(persistentMapOf()) + + /** + * Per-relay AUTH state as an immutable Compose-stable snapshot map. + * + * Downstream consumers (UI banner, retry queue, indexer-fan-out gate) + * subscribe to this flow instead of polling [authStatus] directly. + * Identity changes on every mutation, so [kotlinx.coroutines.flow.distinctUntilChanged] + * downstream and Compose `@Immutable` skipping both work correctly. + */ + val authStateFlow: StateFlow> = _authStateFlow.asStateFlow() + + private fun publishSnapshot(relayUrl: NormalizedRelayUrl) { + val status = authStatus.get(relayUrl) + _authStateFlow.update { current -> + if (status == null) current.remove(relayUrl) else current.put(relayUrl, status.snapshot()) + } + } + private val clientListener = object : RelayConnectionListener { override fun onIncomingMessage( @@ -78,10 +108,12 @@ class RelayAuthenticator( override fun onConnecting(relay: IRelayClient) { authStatus.put(relay.url, RelayAuthStatus()) + publishSnapshot(relay.url) } override fun onDisconnected(relay: IRelayClient) { authStatus.remove(relay.url) + publishSnapshot(relay.url) } } @@ -102,6 +134,7 @@ class RelayAuthenticator( // only send replies to new challenges to avoid infinite loop: if (authStatus.get(relay.url)?.saveAuthSubmission(authEvent) == true) { relay.sendIfConnected(AuthCmd(authEvent)) + publishSnapshot(relay.url) } } } catch (e: CancellationException) { @@ -118,8 +151,12 @@ class RelayAuthenticator( relay: IRelayClient, msg: OkMessage, ) { + val transitioned = authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true + // Publish even on failure transitions so the UI can clear "AUTHENTICATING" + // banners and reflect AUTH_FAILED state. + publishSnapshot(relay.url) // if this is the OK of an auth event, renew all subscriptions and resend all outgoing events. - if (authStatus.get(relay.url)?.checkAuthResults(msg.eventId, msg.success) == true) { + if (transitioned) { client.syncFilters(relay) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt index 4df3f7d538..a296709711 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxState.kt @@ -66,11 +66,15 @@ class PoolEventOutboxState( success: Boolean, message: String, ) { - val currentTries = failures[url] if (success || message.shouldDiscard()) { relaysRemaining = relaysRemaining - url failures = failures - url + } else if (message.isAuthRequired()) { + // NIP-42 AUTH challenge in flight — don't count toward the try cap. + // RelayAuthenticator signs + relay re-issues OK; syncFilters() then + // re-pumps this outbox so the original publish is retried. } else { + val currentTries = failures[url] if (currentTries != null) { currentTries.addResponse(message) } else { @@ -91,6 +95,8 @@ class PoolEventOutboxState( this.startsWith("deleted:") || this.startsWith("invalid:") + fun String.isAuthRequired() = this.startsWith("auth-required:") + // Tries 3 times class Tries( var tries: List = listOf(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 5992cecdba..b495d42dbb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -136,7 +136,9 @@ open class BasicRelayClient( socket?.connect() } catch (e: Exception) { if (e is CancellationException) throw e - listener.onCannotConnect(this, "Error when trying to connect: ${e.message ?: e::class.simpleName}") + val typeName = e::class.simpleName + val detail = e.message?.let { "$it ($typeName)" } ?: (typeName ?: "unknown error") + listener.onCannotConnect(this, "Error when trying to connect: $detail") listener.onDisconnected(this) dontTryAgainForALongTime() markConnectionAsClosed() @@ -187,9 +189,15 @@ open class BasicRelayClient( } else { socket?.disconnect() - // suppression rules below must match the raw message; displayMsg is for listener output only + // suppression rules below must match the raw message; displayMsg is for listener output only. + // Always include the exception's class name: message text is + // localized and inconsistent across platforms, but the type + // (SocketTimeoutException / UnknownHostException / SSLHandshakeException / + // ConnectException …) is stable and lets listeners classify a failure + // reliably — a busy relay (timeout) vs a dead one (bad domain / TLS). val msg = t.message - val displayMsg = msg ?: t::class.simpleName + val typeName = t::class.simpleName + val displayMsg = if (msg != null) "$msg ($typeName)" else (typeName ?: "unknown error") // checks if this is an actual failure. Closing the socket generates an onFailure as well. // ignore tor errors. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 81b08a9666..04f8c9cc06 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -156,7 +156,7 @@ class RelayUrlNormalizer { if (trimmed.contains("://")) { // some other scheme we cannot connect to. - Log.w("RelayUrlNormalizer") { "Rejected $url" } + Log.d("RelayUrlNormalizer") { "Rejected $url" } return null } @@ -189,14 +189,14 @@ class RelayUrlNormalizer { normalizedUrls.put(url, NormalizationResult.Success(normalized)) normalized } else { - Log.w("NormalizedRelayUrl") { "Rejected $url" } + Log.d("NormalizedRelayUrl") { "Rejected $url" } normalizedUrls.put(url, NormalizationResult.Error) null } } catch (e: Exception) { if (e is CancellationException) throw e normalizedUrls.put(url, NormalizationResult.Error) - Log.w("NormalizedRelayUrl") { "Rejected $url" } + Log.d("NormalizedRelayUrl") { "Rejected $url" } null } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt new file mode 100644 index 0000000000..0c5a8203dc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt @@ -0,0 +1,104 @@ +/* + * 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.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +/** The addressable/replaceable coordinate of [event] as a NIP-01 `a`-tag value. */ +private fun addressValue(event: Event): String { + val dTag = if (event.kind.isAddressable()) event.tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" else "" + return Address.assemble(event.kind, event.pubKey, dTag) +} + +/** + * The local deletion events that would make [relay] remove one of [serverEvents] — the + * events the relay HAS that we LACK. Used by sync to push *only* the deletions that + * actually apply to what the relay holds, and nothing else (not other deletions by the + * same author). Covers every way a stored deletion can reach an event: + * + * - **NIP-09, id-based** — a kind-5 with an `e` tag naming a server event's id. + * - **NIP-09, address-based** — a kind-5 with an `a` tag naming a server event's + * addressable/replaceable coordinate, at or after that event's `created_at` + * (NIP-09 only deletes `created_at <= deletion.created_at`). + * - **NIP-62 vanish** — a kind-62 by a server event's author, targeting [relay] (its + * `relay` tags name the URL or `ALL_RELAYS`), issued after that event (a vanish + * deletes `created_at < vanish.created_at`). + * + * Deduped by event id; a single deletion covering several events is returned once. + * + * [query] is where the deletions are looked up — it is source-agnostic on purpose, so + * the same coverage rule runs in both sync directions: + * - **up** (send our deletions): `events` are the relay's, `query` is the local store — + * which of OUR deletions would delete what the relay still holds. + * - **down** (apply the relay's deletions): `events` are ours, `query` fetches from the + * relay — which of the RELAY'S deletions would delete what we still hold. + */ +suspend fun deletionsCovering( + events: List, + relay: NormalizedRelayUrl, + query: suspend (Filter) -> List, +): List { + if (events.isEmpty()) return emptyList() + val covering = LinkedHashMap() + + // 1. id-based NIP-09: a kind-5 `e`-tagging an event's id. + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to events.map { it.id }))) + .forEach { covering[it.id] = it } + + // 2. address-based NIP-09: a kind-5 `a`-tagging an event's coordinate, cutoff-checked. + val byAddress = events.filter { it.kind.isAddressable() || it.kind.isReplaceable() }.groupBy(::addressValue) + if (byAddress.isNotEmpty()) { + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) + .forEach { del -> + if (del !is DeletionEvent) return@forEach + for (addr in del.deleteAddresses()) { + val hit = byAddress[addr.toValue()] ?: continue + if (hit.any { it.createdAt <= del.createdAt }) { + covering[del.id] = del + break + } + } + } + } + + // 3. NIP-62 vanish: a kind-62 by an event's author, targeting this relay, issued after it. + query(Filter(kinds = listOf(RequestToVanishEvent.KIND), authors = events.mapTo(HashSet()) { it.pubKey }.toList())) + .forEach { vanish -> + if (vanish !is RequestToVanishEvent || !vanish.shouldVanishFrom(relay)) return@forEach + if (events.any { it.pubKey == vanish.pubKey && it.createdAt < vanish.createdAt }) covering[vanish.id] = vanish + } + + return covering.values.toList() +} + +/** [deletionsCovering] with the local store as the deletion source (the "up" direction). */ +suspend fun IEventStore.deletionsCovering( + serverEvents: List, + relay: NormalizedRelayUrl, +): List = deletionsCovering(serverEvents, relay) { query(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt index 28122a586d..690106500b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/IEventStore.kt @@ -22,8 +22,11 @@ package com.vitorpamplona.quartz.nip01Core.store import com.vitorpamplona.negentropy.storage.IStorage import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent interface IEventStore : AutoCloseable { companion object { @@ -121,6 +124,41 @@ interface IEventStore : AutoCloseable { suspend fun count(filters: List): Int + /** + * Every distinct identity author with at least one stored event that has + * NO NIP-65 relay list (kind 10002 / "outbox") in this store. + * + * This is a whole-store anti-join — the set of all authors minus the + * authors who already have an outbox — which the positive-only nostr + * [Filter] grammar cannot express (there is no "NOT kind 10002"), so + * it is its own method rather than a [query]. "Missing" is relative to + * what THIS store holds (see [relay]); an author whose only 10002 was + * deleted (NIP-09) or expired (NIP-40) is reported as missing, because + * no row remains for it. Order is unspecified. + * + * GiftWraps (kind 1059) are NOT counted as authors: their `pubkey` is a + * random one-time key, so including them would return an unbounded set of + * ephemeral keys that can never own a 10002 — useless to the outbox model + * this feeds. + * + * The default implementation walks the store: it collects the authors + * that DO have an outbox, then streams every event and keeps the + * authors not in that set. Correct for any store but O(events), and it + * decodes every event just to read its pubkey. SQLite overrides it with + * an index-only `EXCEPT` over `event_headers` that never materialises an + * event (see `QueryBuilder.authorsMissingKind`). + */ + suspend fun authorsMissingOutbox(): List { + val withOutbox = HashSet() + query(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND))) { withOutbox.add(it.pubKey) } + + val missing = LinkedHashSet() + query(Filter()) { event -> + if (event.kind != GiftWrapEvent.KIND && event.pubKey !in withOutbox) missing.add(event.pubKey) + } + return missing.toList() + } + /** * NIP-77 negentropy snapshot. Returns `(created_at, id)` pairs * for every event matching [filters], with no content/tags/sig diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/VerifyAndInsert.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/VerifyAndInsert.kt new file mode 100644 index 0000000000..c2f1ff5193 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/VerifyAndInsert.kt @@ -0,0 +1,55 @@ +/* + * 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.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.utils.Log + +/** + * Verify [event]'s NIP-01 id + signature and, if valid, persist it to this store. + * Returns `true` when the event was accepted (verified) — even if the insert was a + * no-op — so callers can gate "surface this event" on the return. + * + * A UNIQUE-constraint rejection is normal, not a failure: the store already holds + * this id, or a newer version of a replaceable (kind 0/3/10000-19999). The outbox + * model routinely delivers the same event from several of a user's write relays, so + * a crawl produces these by the hundred-thousand — so only genuine persistence + * failures (I/O, full disk, corruption) are logged. Persistence is best-effort: an + * insert error is swallowed, not propagated, so it can't break a live subscription. + * + * This is the single verify-then-store sink every event-arrival path should funnel + * through, so the store stays the authoritative cache of what has been seen. + */ +suspend fun IEventStore.verifyAndInsert(event: Event): Boolean { + if (!event.verify()) { + Log.w("EventStore") { "dropped event ${event.id.take(8)} kind=${event.kind} — bad signature" } + return false + } + try { + insert(event) + } catch (t: Throwable) { + if (t.message?.contains("UNIQUE constraint", ignoreCase = true) != true) { + Log.w("EventStore") { "store insert failed for ${event.id.take(8)}: ${t.message}" } + } + } + return true +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index c0eb526e11..13545cef2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -77,6 +77,8 @@ class EventStore( override suspend fun count(filters: List) = store.count(filters) + override suspend fun authorsMissingOutbox() = store.authorsMissingOutbox() + override suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int?, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt index 32e489b05a..8693fba69d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/FullTextSearchModule.kt @@ -204,9 +204,7 @@ class FullTextSearchModule( val kinds = searchableKindsPresent(db) if (kinds.isEmpty()) return - val selectSql = - "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig " + - "FROM event_headers WHERE kind IN (${kinds.joinToString(",")})" + val selectSql = "$SELECT_EVENT_COLUMNS WHERE kind IN (${kinds.joinToString(",")})" db.prepare(insertFTS).use { write -> db.prepare(selectSql).use { read -> @@ -259,10 +257,7 @@ class FullTextSearchModule( val kinds = searchableKindsPresent(db) if (kinds.isEmpty()) return FtsReindexProgress(cursor = null, processedThisBatch = 0, done = true) - val selectSql = - "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig " + - "FROM event_headers WHERE row_id > ? AND kind IN (${kinds.joinToString(",")}) " + - "ORDER BY row_id LIMIT ?" + val selectSql = selectSearchablePageSql(kinds) var last = afterRowId var processed = 0 @@ -346,10 +341,7 @@ class FullTextSearchModule( var last = watermark var processed = 0 if (kinds.isNotEmpty()) { - val selectSql = - "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig " + - "FROM event_headers WHERE row_id > ? AND kind IN (${kinds.joinToString(",")}) " + - "ORDER BY row_id LIMIT ?" + val selectSql = selectSearchablePageSql(kinds) db.prepare(insertFTS).use { write -> db.prepare(selectSql).use { read -> read.bindLong(1, watermark) @@ -426,5 +418,12 @@ class FullTextSearchModule( // inspects the resulting runtime type. private const val PROBE_ID = "0" private val EMPTY_TAGS = emptyArray>() + + /** Column order matches the positional `read.getText(1)`…`getText(7)` event rebuilds. */ + private const val SELECT_EVENT_COLUMNS = + "SELECT row_id, id, pubkey, created_at, kind, tags, content, sig FROM event_headers" + + /** One `row_id`-cursored page of searchable events; binds: cursor, limit. */ + private fun selectSearchablePageSql(kinds: List) = "$SELECT_EVENT_COLUMNS WHERE row_id > ? AND kind IN (${kinds.joinToString(",")}) ORDER BY row_id LIMIT ?" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt index b0af28983d..5e42969ca9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryBuilder.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.EventFactory class QueryBuilder( @@ -594,6 +595,53 @@ class QueryBuilder( return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) } + // ----------------------------------------------------------------- + // Anti-join projections + // + // Set-difference over authors — "who is missing an event of kind K" + // — which the positive-only nostr Filter grammar can't express, so + // it lives here as a dedicated SELECT rather than going through the + // filter → SQL path. + // ----------------------------------------------------------------- + + /** + * Distinct identity authors with at least one stored event that have NO + * stored event of [kind], as an `EXCEPT` of two sets over `event_headers`: + * all authors, minus the authors that have a [kind]. Both sides are + * answered index-only off `query_by_kind_pubkey_created` + * (kind, pubkey, …) — which is created unconditionally, so this does not + * depend on the optional pubkey-alone index — and `EXCEPT` diffs them + * through one temp b-tree. That is ~3× faster than a + * `DISTINCT … NOT EXISTS` correlated scan, which pays one index seek per + * distinct author; the gap widens with author cardinality. Order is + * unspecified (`EXCEPT` returns pubkey-sorted, which callers must not rely + * on). + * + * GiftWraps (kind 1059) are excluded from the "authors" set: their + * `pubkey` is a random one-time key (the real recipient lives only in + * `pubkey_owner_hash`), so counting them would return an unbounded set of + * ephemeral keys that can never own a [kind] event. + */ + fun authorsMissingKind( + kind: Int, + db: SQLiteConnection, + ): List { + val sql = + """ + SELECT DISTINCT pubkey FROM event_headers WHERE kind <> ${GiftWrapEvent.KIND} + EXCEPT + SELECT pubkey FROM event_headers WHERE kind = ? + """.trimIndent() + return db.prepare(sql).use { stmt -> + stmt.bindLong(1, kind.toLong()) + val out = ArrayList() + while (stmt.step()) { + out.add(stmt.getText(0)) + } + out + } + } + private fun SQLiteConnection.countEverything() = runCount("SELECT count(*) as count FROM event_headers") private fun SQLiteConnection.countIn( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 8ca31300f7..eea4950f40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -40,6 +40,7 @@ import com.vitorpamplona.quartz.nip01Core.store.RawEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip77Negentropy.LiveNegentropyIndex class SQLiteEventStore( @@ -555,6 +556,8 @@ class SQLiteEventStore( suspend fun count(filters: List): Int = pool.useReader { queryBuilder.count(filters, it) } + suspend fun authorsMissingOutbox(): List = pool.useReader { queryBuilder.authorsMissingKind(AdvertisedRelayListEvent.KIND, it) } + suspend fun snapshotIdsForNegentropy( filters: List, maxEntries: Int? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt index 62ef1da15d..ceb97f6678 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/NIP17Factory.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds @@ -33,9 +34,12 @@ import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip30CustomEmoji.EmojiUrlTag import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.mapNotNullAsync +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit class NIP17Factory { data class Result( @@ -43,10 +47,34 @@ class NIP17Factory { val wraps: List, ) + /** + * Build one NIP-59 gift wrap per recipient. + * + * The rumor (kind 14) `created_at` is implicitly shared across all wraps + * because [event] is signed once by the caller before the per-recipient + * loop runs — every seal encodes the same rumor `id`. This anchors + * cross-recipient dedupe + reaction/receipt targeting on group sends. + * + * Per NIP-17, the gift wrap's `p` tag MAY carry the recipient's primary + * DM inbox relay as a hint. Pass [recipientRelayHints] to surface those; + * the default `{ null }` lambda preserves the historical 2-element tag + * shape for every recipient. + * + * When [signer] is a [NostrSignerRemote] (NIP-46 bunker), seal building + * is rate-limited to [BUNKER_PARALLELISM] concurrent operations. Each + * seal needs `nip44_encrypt` + `sign` round-trips against the bunker; a + * 5-recipient group otherwise launches 10 concurrent in-flight RPCs and + * saturates the bunker socket. Local signers (NostrSignerInternal, + * NostrSignerSync) run fully parallel — no semaphore overhead. + * + * The proper fix is the batched `nip44_get_conversation_keys` NIP-46 + * RPC (separate plan); this is the interim throttle until that lands. + */ private suspend fun createWraps( event: Event, to: Set, signer: NostrSigner, + recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, ): List { val innerExpDelta = event.expiration()?.let { @@ -57,29 +85,47 @@ class NIP17Factory { } } + val bunkerLimiter = if (signer is NostrSignerRemote) Semaphore(BUNKER_PARALLELISM) else null + return mapNotNullAsync( to.toList(), ) { next -> - GiftWrapEvent.create( - event = - SealedRumorEvent.create( - event = event, - encryptTo = next, - expirationDelta = innerExpDelta, - signer = signer, - ), - recipientPubKey = next, - expirationDelta = innerExpDelta, - ) + val build: suspend () -> GiftWrapEvent = { + GiftWrapEvent.create( + event = + SealedRumorEvent.create( + event = event, + encryptTo = next, + expirationDelta = innerExpDelta, + signer = signer, + ), + recipientPubKey = next, + expirationDelta = innerExpDelta, + recipientRelayHint = recipientRelayHints(next), + ) + } + bunkerLimiter?.withPermit { build() } ?: build() } } + companion object { + /** + * Max concurrent in-flight NIP-46 RPCs when building wraps via a + * remote signer. Empirically a sweet spot — covers parallelism + * speedup for 2–4 recipient sends without saturating typical + * bunker apps (nsec.app, Amber, Keychat) that serialize requests + * internally past ~10 in-flight. + */ + const val BUNKER_PARALLELISM = 4 + } + suspend fun createMessageNIP17( template: EventTemplate, signer: NostrSigner, + recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, ): Result { val senderMessage = signer.sign(template) - val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints) return Result( msg = senderMessage, wraps = wraps, @@ -108,9 +154,10 @@ class NIP17Factory { suspend fun createEncryptedFileNIP17( template: EventTemplate, signer: NostrSigner, + recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, ): Result { val senderMessage = signer.sign(template) - val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer) + val wraps = createWraps(senderMessage, senderMessage.groupMembers(), signer, recipientRelayHints) return Result( msg = senderMessage, @@ -142,12 +189,13 @@ class NIP17Factory { originalNote: EventHintBundle, to: List, signer: NostrSigner, + recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, ): Result { val senderPublicKey = signer.pubKey val template = ReactionEvent.build(content, originalNote) val senderReaction = signer.sign(template) - val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints) return Result( msg = senderReaction, wraps = wraps, @@ -159,12 +207,13 @@ class NIP17Factory { originalNote: EventHintBundle, to: List, signer: NostrSigner, + recipientRelayHints: (HexKey) -> NormalizedRelayUrl? = { null }, ): Result { val senderPublicKey = signer.pubKey val template = ReactionEvent.build(emojiUrl, originalNote) val senderReaction = signer.sign(template) - val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer) + val wraps = createWraps(senderReaction, to.plus(senderPublicKey).toSet(), signer, recipientRelayHints) return Result( msg = senderReaction, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index 292422646c..881e344cd2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -96,11 +97,22 @@ open class GiftWrapEvent( const val KIND = 1059 const val ALT = "Encrypted event" + /** + * Build a NIP-59 gift wrap addressed to `recipientPubKey`. + * + * Per NIP-17 §Publishing, the `p` tag on the wrap MAY carry the + * recipient's primary DM inbox relay as a hint, so other clients + * the recipient runs (or relays acting as inbox routers) can locate + * the wrap without a separate kind:10050 lookup. Pass it via + * [recipientRelayHint] — `null` (the default) preserves the + * historical 2-element `["p", pubkey]` shape. + */ fun create( event: Event, recipientPubKey: HexKey, expirationDelta: Long? = null, createdAt: Long = TimeUtils.randomWithTwoDays(), + recipientRelayHint: NormalizedRelayUrl? = null, ): GiftWrapEvent { val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key @@ -109,11 +121,11 @@ open class GiftWrapEvent( // minimum expiration is two days in the future due to the random created at // this will make sure the even arrives and is not deleted because of the 2 days. arrayOf( - PTag.assemble(recipientPubKey, null), + PTag.assemble(recipientPubKey, recipientRelayHint), ExpirationTag.assemble(createdAt + it + TimeUtils.twoDays()), ) } ?: arrayOf( - PTag.assemble(recipientPubKey, null), + PTag.assemble(recipientPubKey, recipientRelayHint), ) return signer.sign( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt new file mode 100644 index 0000000000..93f6648639 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStore.kt @@ -0,0 +1,171 @@ +/* + * 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.nip66RelayMonitor.reachability + +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.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.networkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.rtt +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A durable, shareable relay-reachability cache backed by an [IEventStore] as + * NIP-66 **kind:30166 Relay Discovery** events — so the crawler, the WoT updater, + * and future runs all read and write the *same* liveness knowledge instead of each + * rediscovering dead relays from an in-memory set that is wiped when the process ends. + * + * ## Why NIP-66 / the event store + * A 30166 event is addressable by its `d`-tag (the normalized relay URL), so the + * store keeps exactly **one replaceable record per (monitor, relay)** — a natural + * per-relay status slot with a `created_at` timestamp that gives us a free TTL. The + * event store gives us persistence, cross-procedure sharing, and interop for free: + * 30166 events published by *other* monitors (nostr.watch et al.) can be ingested to + * seed reachability without probing, and our own records can be published back. + * + * ## How "dead" is represented + * NIP-66 has no explicit offline field; liveness is inferred from a fresh record that + * carries an `rtt-open` (a successful connection). This cache follows that convention: + * - **reachable** → a 30166 **with** `rtt-open`, `created_at` = probe time. + * - **dead** → a 30166 **without** `rtt-open` ("we checked, could not open"), + * `created_at` = probe time. + * + * So a fresh rtt-less record distinguishes *checked-and-dead* from *never-checked* + * (no record). When both a dead and a live record exist within the TTL for the same + * relay, **live wins** — any recent successful open overrides an earlier failure, + * whether the two came from us across time or from two different monitors. + * + * ## Not a replacement for the hot path + * [snapshot] is meant to be loaded ONCE at the start of a run into whatever in-memory + * structure the caller already uses for per-request `isDead` checks; [record] flushes + * a run's findings back at the end. It is deliberately not queried per routing decision. + * + * A relay is only ever skipped for the TTL window, never permanently — consistent with + * the outbox rule that every advertised write relay must be tried: a TTL'd record is + * "skip for now", not "ignore this author's home forever". + * + * ## The signer is a dedicated monitor service identity + * [signer] should be a **machine-level monitor key**, NOT a user/observer account: per + * NIP-66 a monitor is its own pubkey (which also publishes a kind:10166 announcement, + * a kind:0 profile and a kind:10002). Publishing these under the observer's key would + * conflate the WoT identity with a relay-monitoring service. [snapshot] still honours + * records from ANY author (so third-party monitors can be ingested); only [record] + * writes under this monitor key. + */ +class RelayReachabilityStore( + private val store: IEventStore, + private val signer: NostrSigner, + private val ttlSeconds: Long = DEFAULT_TTL_SECONDS, +) { + /** + * An in-memory view of the fresh (within-TTL) reachability records. [dead] holds + * relays proven unreachable and not since seen live; [live] holds relays with a + * recent successful open. A relay absent from both is simply unknown — re-probe it. + */ + class Snapshot( + val dead: Set, + val live: Set, + ) { + fun isKnownDead(relay: NormalizedRelayUrl) = relay in dead + + val size: Int get() = dead.size + live.size + } + + /** + * Load every 30166 record fresher than [ttlSeconds] and fold it into a [Snapshot]. + * Records from any monitor are honoured (live-wins), so ingesting third-party + * monitors' 30166 into [store] transparently improves the result. + */ + suspend fun snapshot(now: Long = TimeUtils.now()): Snapshot { + val since = now - ttlSeconds + val events = + store.query( + Filter(kinds = listOf(RelayDiscoveryEvent.KIND), since = since), + ) + val live = HashSet() + val dead = HashSet() + for (ev in events) { + val relay = ev.relay() ?: continue + if (ev.rttOpen() != null) live.add(relay) else dead.add(relay) + } + // A recent successful open (from us later, or from another monitor) overrides + // an earlier dead mark for the same relay. + dead.removeAll(live) + return Snapshot(dead, live) + } + + /** + * Persist a run's reachability findings as 30166 events: each [reachable] relay as + * a record WITH `rtt-open`, each [dead] relay (that is not also reachable) as one + * WITHOUT. Signed by [signer] and inserted into [store]; being addressable, each + * replaces this monitor's prior record for that relay, so the store stays bounded + * at roughly the number of distinct relays. + * + * [rttOpenMs] is the measured open round-trip in ms. It defaults to 0 as a **liveness + * flag only** — presence of the `rtt-open` tag, not its magnitude, is what [snapshot] + * reads as "reachable", and a caller that merely proved a relay served events (like + * the crawler) has no dedicated probe latency to report. A `0` therefore means + * "reachable, latency not probed by this writer", NOT a real 0 ms measurement. Do NOT + * publish these records to the wider network as authoritative latency data until a + * dedicated monitor probe supplies a real [rttOpenMs]; aggregators rank by it. + */ + suspend fun record( + reachable: Set, + dead: Set, + now: Long = TimeUtils.now(), + rttOpenMs: Long = 0, + ) { + for (relay in reachable) writeOne(relay, up = true, now, rttOpenMs) + for (relay in dead) if (relay !in reachable) writeOne(relay, up = false, now, rttOpenMs) + } + + private suspend fun writeOne( + relay: NormalizedRelayUrl, + up: Boolean, + now: Long, + rttOpenMs: Long, + ) { + val template = + RelayDiscoveryEvent.build(relay, createdAt = now) { + networkType(networkTypeOf(relay)) + if (up) rtt(RttType.OPEN, rttOpenMs) + } + store.insert(signer.sign(template)) + } + + companion object { + /** Default freshness window: a relay's status is trusted for a day, then re-probed. */ + const val DEFAULT_TTL_SECONDS = 24L * 60 * 60 + + /** NIP-66 `n` network type inferred from the URL, so a `.onion`/i2p relay is tagged correctly. */ + fun networkTypeOf(relay: NormalizedRelayUrl): NetworkType = + when { + RelayUrlNormalizer.isOnion(relay.url) -> NetworkType.TOR + relay.url.contains(".i2p") -> NetworkType.I2P + else -> NetworkType.CLEARNET + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt new file mode 100644 index 0000000000..47d3233c9b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.kt @@ -0,0 +1,68 @@ +/* + * 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.concurrent + +/** + * A thread-safe hash map whose compound operations — [getOrPut] and [merge] — + * apply their update **atomically**, not merely one-lock-per-primitive-op. This + * is the contract a concurrent producer/consumer pipeline needs: two coroutines + * racing `getOrPut` on the same key must agree on a single value, and racing + * `merge` must not lose an increment. + * + * commonMain has no `java.util.concurrent.ConcurrentHashMap`, so this is + * expect/actual, matching the split already used by [com.vitorpamplona.quartz.utils.cache.ConcurrentHashCache]: + * - JVM / Android → `ConcurrentHashMap` (lock-free, true atomic `computeIfAbsent` / `merge`). + * - Native (Apple + Linux) → copy-on-write over an atomic reference, with a + * CAS retry loop giving the same atomicity. Correct but O(n)-per-write; the + * native targets never run the heavy crawl this backs, they only compile it. + * + * Only the operations the crawl actually uses are exposed — no full [MutableMap] + * surface — so the native copy-on-write actual stays small and obviously correct. + */ +expect class ConcurrentMap() { + operator fun get(key: K): V? + + operator fun set( + key: K, + value: V, + ) + + /** Atomically return the value for [key], computing and inserting [defaultValue] once if absent. */ + fun getOrPut( + key: K, + defaultValue: () -> V, + ): V + + /** + * Atomically insert [value] if [key] is absent, else replace the existing + * value with `remap(existing, value)`. Returns the value now stored. + */ + fun merge( + key: K, + value: V, + remap: (old: V, new: V) -> V, + ): V + + fun size(): Int + + /** A point-in-time copy of the entries — safe to iterate without holding a lock. */ + fun snapshot(): Map +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.kt new file mode 100644 index 0000000000..d00545acf6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.kt @@ -0,0 +1,43 @@ +/* + * 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.concurrent + +/** + * A thread-safe hash set for the crawl's cross-coroutine membership tracking + * (dead relays struck by drain workers while the router reads them, relay hints + * written by the ingest consumer while the producer reads them). + * + * commonMain has no `java.util.concurrent.ConcurrentHashMap.newKeySet()`, so this + * is expect/actual with the same JVM-vs-native split as [ConcurrentMap]: + * - JVM / Android → `ConcurrentHashMap.newKeySet()`. + * - Native → copy-on-write over an atomic reference (compile-only, never the hot path). + */ +expect class ConcurrentSet() { + /** Add [element]; returns true if it was not already present. */ + fun add(element: E): Boolean + + operator fun contains(element: E): Boolean + + fun size(): Int + + /** A point-in-time copy — safe to iterate or diff against without a lock. */ + fun snapshot(): Set +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankAuthorityTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankAuthorityTest.kt new file mode 100644 index 0000000000..e43aaf0e02 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankAuthorityTest.kt @@ -0,0 +1,66 @@ +/* + * 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.experimental.graperank + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * [GrapeRankCrawler.authorityOf] is the key the crawl's timeout-eviction counts + * on. It must collapse the many per-user path URLs the outbox model mints for one + * server into a single host, WITHOUT folding a distinct sibling host (e.g. a + * `filter.` subdomain) into its parent. + */ +class GrapeRankAuthorityTest { + private fun auth(url: String) = GrapeRankCrawler.authorityOf(url) + + @Test + fun bareHostIsItsOwnAuthority() { + assertEquals("relay.damus.io", auth("wss://relay.damus.io")) + assertEquals("relay.damus.io", auth("wss://relay.damus.io/")) + assertEquals("nos.lol", auth("ws://nos.lol")) + } + + @Test + fun perUserPathUrlsOnOneHostCollapseToOneAuthority() { + val a = auth("wss://filter.nostr.wine/npub1aaaa?broadcast=true") + val b = auth("wss://filter.nostr.wine/npub1bbbb?broadcast=true&global=all") + val c = auth("wss://filter.nostr.wine/?global=all") + assertEquals("filter.nostr.wine", a) + assertEquals(a, b) + assertEquals(a, c) + } + + @Test + fun filterSubdomainIsNotFoldedIntoBareHost() { + // nostr.wine reads are open; filter.nostr.wine is a different server that may + // stall — evicting one must never take out the other. + assertNotEquals(auth("wss://filter.nostr.wine/npub1x"), auth("wss://nostr.wine")) + } + + @Test + fun portIsPartOfTheAuthority() { + assertEquals("relay.veganostr.com:443", auth("wss://relay.veganostr.com:443/npub1z")) + assertEquals("81.68.170.122:7114", auth("ws://81.68.170.122:7114/")) + assertNotEquals(auth("wss://example.com:443"), auth("wss://example.com:8080")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankTest.kt new file mode 100644 index 0000000000..4219095809 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/GrapeRankTest.kt @@ -0,0 +1,246 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.max +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GrapeRankTest { + private val obs = "observer" + + private fun graphOf(edges: List>): TrustGraph { + val b = TrustGraphBuilder() + for ((source, target, relation) in edges) { + when (relation) { + TrustRelation.FOLLOW -> b.addFollows(source, listOf(target)) + TrustRelation.MUTE -> b.addMutes(source, listOf(target)) + TrustRelation.REPORT -> b.addReports(source, listOf(target)) + } + } + return b.build() + } + + private fun graphOf(vararg edges: Triple) = graphOf(edges.toList()) + + /** Score for a pubkey (0.0 if absent from the graph). */ + private fun DoubleArray.of( + graph: TrustGraph, + pubkey: HexKey, + ): Double { + val id = graph.idOf(pubkey) + return if (id < 0) 0.0 else this[id] + } + + @Test + fun observerIsPinnedAtFullSelfTrust() { + val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW)) + val scores = GrapeRank().compute(graph, obs) + assertEquals(1.0, scores.of(graph, obs), 1e-12) + } + + @Test + fun directFollowMatchesHandComputedValue() { + val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW)) + val scores = GrapeRank().compute(graph, obs) + // weight = 0.5 * 1.0 * 0.85 = 0.425 ; score = conf(0.425) = 0.2551612... + assertEquals(0.25516127, scores.of(graph, "a"), 1e-6) + } + + @Test + fun trustDecaysSteeplyAcrossHops() { + val graph = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple("a", "b", TrustRelation.FOLLOW), + ) + val scores = GrapeRank().compute(graph, obs) + val a = scores.of(graph, "a") + val b = scores.of(graph, "b") + assertEquals(0.004499, b, 1e-5) + assertTrue(b < a / 10.0, "two-hop trust should be far below one-hop trust") + } + + @Test + fun aMuteFromAnEndorsedUserLowersTheScore() { + val followOnlyGraph = graphOf(Triple(obs, "b", TrustRelation.FOLLOW)) + val followOnly = GrapeRank().compute(followOnlyGraph, obs).of(followOnlyGraph, "b") + + val muteGraph = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple(obs, "b", TrustRelation.FOLLOW), + Triple("a", "b", TrustRelation.MUTE), + ) + val withMute = GrapeRank().compute(muteGraph, obs).of(muteGraph, "b") + + assertTrue(withMute < followOnly, "a mute from a trusted user should pull b below the follow-only baseline") + } + + @Test + fun purelyReportedUserFloorsAtZero() { + val graph = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple("a", "d", TrustRelation.REPORT), + ) + val scores = GrapeRank().compute(graph, obs) + assertEquals(0.0, scores.of(graph, "d"), 1e-9) + } + + @Test + fun unreachableUsersAreNotScored() { + val graph = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple("x", "y", TrustRelation.FOLLOW), + ) + val scores = GrapeRank().compute(graph, obs) + assertTrue(scores.of(graph, "a") > 0.0) + assertEquals(0.0, scores.of(graph, "y"), 1e-12, "a user with no path from the observer stays 0") + } + + @Test + fun cyclesConverge() { + val graph = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple("a", "b", TrustRelation.FOLLOW), + Triple("b", "a", TrustRelation.FOLLOW), + ) + val scores = GrapeRank().compute(graph, obs) + assertTrue(scores.of(graph, "a") > 0.0) + assertTrue(scores.of(graph, "b") > 0.0) + } + + @Test + fun deduplicatesRepeatedReportEdges() { + // Two report edges a->d collapse to one; the score matches a single report. + val once = graphOf(Triple(obs, "a", TrustRelation.FOLLOW), Triple("a", "d", TrustRelation.REPORT)) + val twice = + graphOf( + Triple(obs, "a", TrustRelation.FOLLOW), + Triple("a", "d", TrustRelation.REPORT), + Triple("a", "d", TrustRelation.REPORT), + ) + assertEquals(2, twice.edgeCount(), "duplicate report edge should be dropped") + assertEquals( + GrapeRank().compute(once, obs).of(once, "d"), + GrapeRank().compute(twice, obs).of(twice, "d"), + 1e-12, + ) + } + + /** + * Adversarial cross-check: the worklist propagation must reach the same fixed + * point as a naive full-sweep (the reference `v1FullSweep`) on random graphs. + */ + @Test + fun worklistMatchesFullSweepOnRandomGraphs() { + val params = GrapeRankParams(convergence = 1e-10) + val engine = GrapeRank(params) + repeat(50) { seed -> + val rng = Random(seed) + val n = 3 + rng.nextInt(12) + val nodes = (0 until n).map { "u$it" } + val edges = ArrayList>() + for (src in nodes) { + for (dst in nodes) { + if (src == dst) continue + if (rng.nextDouble() < 0.25) { + val relation = + when (rng.nextInt(5)) { + 0 -> TrustRelation.MUTE + 1 -> TrustRelation.REPORT + else -> TrustRelation.FOLLOW + } + edges.add(Triple(src, dst, relation)) + } + } + } + val observer = nodes.first() + val graph = graphOf(edges) + val scores = engine.compute(graph, observer) + val reference = fullSweep(edges, nodes, observer, params) + + for (node in nodes) { + if (node == observer) continue // observer self-trust is not part of a ranking + val a = scores.of(graph, node) + val b = reference[node] ?: 0.0 + assertEquals(b, a, 1e-5, "seed=$seed node=$node worklist=$a fullSweep=$b") + } + } + } + + // Reference: blind full sweep over every user until nothing changes. + private fun fullSweep( + edges: List>, + nodes: List, + observer: HexKey, + params: GrapeRankParams, + ): Map { + // Dedup identical edges (mirrors the builder: report edges dedup; follow/mute + // sets are unique per source anyway). + val incoming = HashMap>>() + for ((s, t, r) in edges) { + if (s == t) continue + incoming.getOrPut(t) { LinkedHashSet() }.add(s to r) + } + + fun confidence( + r: TrustRelation, + source: HexKey, + ) = when (r) { + TrustRelation.FOLLOW -> if (source == observer) params.directFollowConfidence else params.indirectFollowConfidence + TrustRelation.MUTE -> params.muteConfidence + TrustRelation.REPORT -> params.reportConfidence + } + + fun weightToConfidence(w: Double) = 1.0 - exp(-w * -ln(params.rigor)) + + val scores = HashMap() + scores[observer] = 1.0 + do { + var changed = false + for (target in nodes) { + if (target == observer) continue + var sumW = 0.0 + var sumWR = 0.0 + for ((source, r) in incoming[target] ?: emptySet()) { + val s = scores[source] ?: continue + val w = confidence(r, source) * s * params.attenuation + sumW += w + sumWR += w * r.rating + } + val newScore = if (abs(sumW) < 0.00001) 0.0 else max(weightToConfidence(sumW) * sumWR / sumW, 0.0) + val old = scores.put(target, newScore) ?: 0.0 + changed = changed || abs(newScore - old) > params.convergence + } + } while (changed) + return scores + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt new file mode 100644 index 0000000000..f27e064273 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/graperank/TrustGraphBuilderTest.kt @@ -0,0 +1,107 @@ +/* + * 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.experimental.graperank + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TrustGraphBuilderTest { + private val alice = "alice" + private val bob = "bob" + private val carol = "carol" + private val dave = "dave" + + /** Decode a node's incoming edges back to (source, relation) pairs from the CSR. */ + private fun TrustGraph.incomingOf(pubkey: HexKey): Set> { + val t = idOf(pubkey) + if (t < 0) return emptySet() + val out = HashSet>() + var i = inOffsets[t] + val end = inOffsets[t + 1] + while (i < end) { + val packed = inPacked[i] + val source = pubkeyOf(packed and TrustGraph.SOURCE_MASK) + val relation = TrustRelation.entries.first { it.code == (packed ushr TrustGraph.SOURCE_BITS) } + out.add(source to relation) + i++ + } + return out + } + + @Test + fun buildsFollowMuteAndReportEdges() { + val b = TrustGraphBuilder() + b.addFollows(alice, listOf(bob, carol)) + b.addMutes(bob, listOf(dave)) + b.addReports(carol, listOf(dave)) + val graph = b.build() + + assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob)) + assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(carol)) + assertEquals( + setOf(bob to TrustRelation.MUTE, carol to TrustRelation.REPORT), + graph.incomingOf(dave), + ) + } + + @Test + fun dropsSelfEdges() { + val b = TrustGraphBuilder() + b.addFollows(alice, listOf(alice, bob)) + val graph = b.build() + assertTrue(graph.incomingOf(alice).isEmpty(), "a self-follow must not become an edge") + assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob)) + } + + @Test + fun dedupesRepeatedReports() { + val b = TrustGraphBuilder() + b.addReports(alice, listOf(dave)) + b.addReports(alice, listOf(dave)) + val graph = b.build() + assertEquals(1, graph.edgeCount()) + assertEquals(setOf(alice to TrustRelation.REPORT), graph.incomingOf(dave)) + } + + @Test + fun keepsFollowAndMuteFromSameSourceAsDistinctEdges() { + val b = TrustGraphBuilder() + b.addFollows(alice, listOf(bob)) + b.addMutes(alice, listOf(bob)) + val graph = b.build() + assertEquals( + setOf(alice to TrustRelation.FOLLOW, alice to TrustRelation.MUTE), + graph.incomingOf(bob), + ) + } + + @Test + fun internsEachPubkeyOnce() { + val b = TrustGraphBuilder() + b.addFollows(alice, listOf(bob, carol)) + b.addFollows(bob, listOf(carol)) + val graph = b.build() + assertEquals(3, graph.nodeCount, "alice, bob, carol interned once each") + assertEquals(3, graph.edgeCount()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailureTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailureTest.kt new file mode 100644 index 0000000000..cf1c993344 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/DrainFailureTest.kt @@ -0,0 +1,86 @@ +/* + * 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.nip01Core.relay.client.accessories + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DrainFailureTest { + // Non-failure and non-"cannot" terminals are never dead signals. + @Test + fun nonFailureTerminalsAreNull() { + assertNull(classifyDrainFailure("eose")) + assertNull(classifyDrainFailure("closed:duplicate: sub")) + assertNull(classifyDrainFailure("timeout")) + } + + // A READ timeout (or generic post-handshake timeout) is alive-but-slow: never + // dead. Measured 67% of these relays were reachable when re-probed fresh. + @Test + fun readTimeoutsStayRetryable() { + assertNull(classifyDrainFailure("cannot:Read timed out (SocketTimeoutException)")) + assertNull(classifyDrainFailure("cannot:timeout (SocketTimeoutException)")) + } + + // An HTTP 429 rate-limit is alive and will serve us after backoff: never dead. + // Measured 4/4 such relays reachable when re-probed fresh. + @Test + fun rateLimitStaysRetryable() { + assertNull(classifyDrainFailure("cannot:Server Misconfigured. Response: 429 Too Many Requests (ProtocolException)")) + } + + // Failing to ESTABLISH the connection is dead (0/30 reachable fresh). "connect + // timed out" must be caught as DEAD and NOT slip into the read-timeout branch. + @Test + fun connectEstablishmentFailuresAreDead() { + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connect timed out (SocketTimeoutException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unexpected response code for CONNECT: (IOException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection refused (ConnectException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Failed to connect to /1.2.3.4:443")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:No route to host (NoRouteToHostException)")) + } + + // DNS and TLS misconfig can never work: DEAD. + @Test + fun dnsAndTlsAreDead() { + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unable to resolve host (UnknownHostException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Received fatal alert: unrecognized_name (SSLHandshakeException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:PKIX path building failed: certificate (CertificateException)")) + } + + // Every other bad HTTP upgrade won't serve us this run (measured 503 0%, 502 20% + // reachable; 402/403 gated; 200 not a relay) — DEAD, dropped on the first strike. + @Test + fun deadOrGatedHttpUpgradesAreDead() { + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. not a websocket")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 503 Service Unavailable (ProtocolException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 502 Bad Gateway (ProtocolException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 402 Payment Required (ProtocolException)")) + } + + // A mid-stream reset won't hand us events this run either: DEAD. + @Test + fun midStreamResetIsDead() { + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection reset (SocketException)")) + assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Broken pipe (SocketException)")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt new file mode 100644 index 0000000000..5576aae0b7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolEventOutboxStateTest.kt @@ -0,0 +1,97 @@ +/* + * 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.nip01Core.relay.client.pool + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PoolEventOutboxStateTest { + private val relay = NormalizedRelayUrl("wss://relay.example/") + + private fun fakeEvent() = + Event( + id = "0".repeat(64), + pubKey = "0".repeat(64), + createdAt = 0L, + kind = 1, + tags = emptyArray(), + content = "", + sig = "0".repeat(128), + ) + + @Test + fun authRequiredResponseDoesNotConsumeTryBudget() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + // Simulate 5 `auth-required:` responses — relay keeps challenging while + // RelayAuthenticator signs + sends AUTH events asynchronously. None of + // these should be counted against the 3-response try cap. + repeat(5) { + state.newResponse(relay, success = false, message = "auth-required: please authenticate") + } + + // Even after a follow-up newTry, the relay must remain in the outbox so + // syncFilters() can re-publish once AUTH succeeds. + state.newTry(relay) + assertContains(state.relaysLeft(), relay) + assertFalse(state.isDone()) + } + + @Test + fun regularRejectionStillBoundedByTryCap() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + // 3 non-AUTH rejections accumulate normally. + repeat(3) { + state.newResponse(relay, success = false, message = "error: rate limited") + } + state.newTry(relay) + + // After the 4th newTry (with 3 prior responses already in flight), the + // Tries cap kicks in and the relay is dropped from the outbox. + assertFalse(state.relaysLeft().contains(relay)) + } + + @Test + fun terminalRejectionImmediatelyDropsRelay() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + state.newResponse(relay, success = false, message = "invalid: malformed event") + + assertFalse(state.relaysLeft().contains(relay)) + assertTrue(state.isDone()) + } + + @Test + fun successDropsRelayFromOutbox() { + val state = PoolEventOutboxState(fakeEvent(), setOf(relay)) + + state.newResponse(relay, success = true, message = "") + + assertEquals(emptySet(), state.relaysLeft()) + assertTrue(state.isDone()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt index 065ef0fcaf..7a7caaec58 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClientTest.kt @@ -72,13 +72,15 @@ class BasicRelayClientTest { } @Test - fun onFailureWithMessageKeepsExistingFormat() { + fun onFailureWithMessageAppendsExceptionClassName() { val (socket, listener) = connectAndCapture() socket.onFailure(Exception("Connection reset"), null, null) + // The exception type is appended so listeners can classify the failure by + // its stable class name rather than by localized message text. assertEquals( - listOf("WebSocket Failure: Connection reset"), + listOf("WebSocket Failure: Connection reset (Exception)"), listener.cannotConnectMessages, ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt new file mode 100644 index 0000000000..103eb82d3c --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AuthorsMissingOutboxTest.kt @@ -0,0 +1,127 @@ +/* + * 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.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals + +class AuthorsMissingOutboxTest : BaseDBTest() { + @Test + fun emptyStoreReturnsNoAuthors() = + forEachDB { db -> + assertEquals(emptySet(), db.authorsMissingOutbox().toSet()) + } + + @Test + fun authorWithEventButNoOutboxIsMissing() = + forEachDB { db -> + val signer = NostrSignerSync() + db.insert(signer.sign(TextNoteEvent.build("hello"))) + + assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet()) + } + + @Test + fun authorWithOutboxIsNotMissing() = + forEachDB { db -> + val hasOutbox = NostrSignerSync() + val noOutbox = NostrSignerSync() + + // Both authors have content; only one advertises a 10002. + db.insert(hasOutbox.sign(TextNoteEvent.build("with relays"))) + db.insert(AdvertisedRelayListEvent.create(emptyList(), hasOutbox)) + db.insert(noOutbox.sign(TextNoteEvent.build("no relays"))) + + assertEquals(setOf(noOutbox.pubKey), db.authorsMissingOutbox().toSet()) + } + + @Test + fun authorKnownOnlyByTheirOutboxIsNotMissing() = + forEachDB { db -> + // The only stored event for this author IS the 10002. They must + // not appear (the outer scan sees them, the NOT EXISTS excludes + // them) — the anti-join is symmetric on the same table. + val signer = NostrSignerSync() + db.insert(AdvertisedRelayListEvent.create(emptyList(), signer)) + + assertEquals(emptySet(), db.authorsMissingOutbox().toSet()) + } + + @Test + fun outboxDeletedMakesAuthorMissingAgain() = + forEachDB { db -> + val signer = NostrSignerSync() + db.insert(signer.sign(TextNoteEvent.build("content"))) + val relayList = AdvertisedRelayListEvent.create(emptyList(), signer) + db.insert(relayList) + + assertEquals(emptySet(), db.authorsMissingOutbox().toSet()) + + // NIP-09: the author deletes their own relay list. No 10002 row + // remains, so the anti-join reports them as missing again. + db.insert(signer.sign(DeletionEvent.build(listOf(relayList)))) + + assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet()) + } + + @Test + fun giftWrapSenderIsNotCountedAsAuthor() = + forEachDB { db -> + val noteAuthor = NostrSignerSync() + db.insert(noteAuthor.sign(TextNoteEvent.build("hi"))) + + // A kind-1059 giftwrap stores an ephemeral one-time key as its + // pubkey (the real recipient is only a hash). It has no outbox and + // never will — but it must NOT be reported as "missing" one, or the + // result set would grow by one junk key per received DM. + val ephemeralSender = "aa".repeat(32) + db.insert( + EventFactory.create("bb".repeat(32), ephemeralSender, 1L, GiftWrapEvent.KIND, emptyArray(), "", "00".repeat(64)), + ) + + assertEquals(setOf(noteAuthor.pubKey), db.authorsMissingOutbox().toSet()) + } + + @Test + fun mixOfAuthorsReportsOnlyThoseWithoutOutbox() = + forEachDB { db -> + val a = NostrSignerSync() + val b = NostrSignerSync() + val c = NostrSignerSync() + + db.insert(a.sign(TextNoteEvent.build("a1"))) + db.insert(a.sign(TextNoteEvent.build("a2"))) + db.insert(AdvertisedRelayListEvent.create(emptyList(), a)) + + db.insert(b.sign(TextNoteEvent.build("b1"))) + + db.insert(c.sign(TextNoteEvent.build("c1"))) + db.insert(AdvertisedRelayListEvent.create(emptyList(), c)) + + assertEquals(setOf(b.pubKey), db.authorsMissingOutbox().toSet()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapRelayHintTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapRelayHintTest.kt new file mode 100644 index 0000000000..3961379fa4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapRelayHintTest.kt @@ -0,0 +1,104 @@ +/* + * 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.nip59Giftwrap.wraps + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * NIP-17 relay-hint placement contract. + * + * Per NIP-17 §Publishing, the gift wrap's `p` tag MAY carry the recipient's + * primary DM inbox relay as a third element so other devices of the recipient + * can discover the wrap without a separate kind:10050 lookup. The hint + * deliberately lives on the public wrap, NOT on the encrypted seal — putting + * it on the seal would hide the routing information inside the encryption + * envelope, defeating the purpose. + */ +class GiftWrapRelayHintTest { + private val recipient = KeyPair() + + private fun innerEvent(): Event { + val signer = NostrSignerSync(KeyPair()) + return signer.sign( + createdAt = 0L, + kind = 1, + tags = emptyArray(), + content = "hello", + ) + } + + @Test + fun defaultsToNoRelayHintForBackwardsCompat() = + runTest { + // Existing callers that don't pass a hint must continue to emit the + // historical ["p", recipientPubKey] two-element tag shape. + val wrap = + GiftWrapEvent.create( + event = innerEvent(), + recipientPubKey = recipient.pubKey.toHexKey(), + ) + val pTag = wrap.tags.first { it.firstOrNull() == "p" } + assertEquals(2, pTag.size, "p tag must be 2 elements when no hint passed") + assertEquals(recipient.pubKey.toHexKey(), pTag[1]) + } + + @Test + fun relayHintLandsOnWrapPTagAsThirdElement() = + runTest { + // When a hint is passed, it must appear as the THIRD element of the + // wrap's p tag — NIP-17 spec. Not inside the encrypted seal. + val hint = NormalizedRelayUrl("wss://dm.relay.example/") + val wrap = + GiftWrapEvent.create( + event = innerEvent(), + recipientPubKey = recipient.pubKey.toHexKey(), + recipientRelayHint = hint, + ) + val pTag = wrap.tags.first { it.firstOrNull() == "p" } + assertEquals(3, pTag.size, "p tag carries [tag, pubkey, relay-hint]") + assertEquals(recipient.pubKey.toHexKey(), pTag[1]) + assertEquals(hint.url, pTag[2]) + } + + @Test + fun absentHintDoesNotAddTrailingEmptyElement() = + runTest { + // Defensive: a null hint must not produce `["p", pubkey, ""]` — that + // would be a leak (broadcasts the user has no canonical inbox) and + // a wire-format change from the historical shape. + val wrap = + GiftWrapEvent.create( + event = innerEvent(), + recipientPubKey = recipient.pubKey.toHexKey(), + recipientRelayHint = null, + ) + val pTag = wrap.tags.first { it.firstOrNull() == "p" } + assertNull(pTag.getOrNull(2), "third element must be absent, not empty string") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt new file mode 100644 index 0000000000..208054f818 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentCollectionsTest.kt @@ -0,0 +1,108 @@ +/* + * 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.concurrent + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcurrentCollectionsTest { + @Test + fun mapGetSet() { + val m = ConcurrentMap() + assertNull(m["a"]) + m["a"] = 1 + assertEquals(1, m["a"]) + m["a"] = 2 + assertEquals(2, m["a"]) + assertEquals(1, m.size()) + } + + @Test + fun mapGetOrPutComputesOnce() { + val m = ConcurrentMap() + var calls = 0 + assertEquals( + 7, + m.getOrPut("k") { + calls++ + 7 + }, + ) + // Present now: the default must NOT be recomputed. + assertEquals( + 7, + m.getOrPut("k") { + calls++ + 99 + }, + ) + assertEquals(1, calls) + assertEquals(7, m["k"]) + } + + @Test + fun mapMergeInsertsThenCombines() { + val m = ConcurrentMap() + // Absent -> inserts the value verbatim, remap not applied. + assertEquals(1, m.merge("k", 1) { a, b -> a + b }) + // Present -> remap(existing, value). + assertEquals(4, m.merge("k", 3) { a, b -> a + b }) + assertEquals(4, m["k"]) + } + + @Test + fun mapSnapshotIsDetached() { + val m = ConcurrentMap() + m["a"] = 1 + m["b"] = 2 + val snap = m.snapshot() + assertEquals(mapOf("a" to 1, "b" to 2), snap) + // Mutating the map after the snapshot must not change the snapshot. + m["c"] = 3 + assertEquals(2, snap.size) + assertEquals(3, m.size()) + } + + @Test + fun setAddContainsSize() { + val s = ConcurrentSet() + assertFalse("x" in s) + assertTrue(s.add("x")) + // Re-adding is a no-op and reports it. + assertFalse(s.add("x")) + assertTrue("x" in s) + assertTrue(s.add("y")) + assertEquals(2, s.size()) + } + + @Test + fun setSnapshotIsDetached() { + val s = ConcurrentSet() + s.add("a") + val snap = s.snapshot() + s.add("b") + assertEquals(setOf("a"), snap) + assertEquals(2, s.size()) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.jvmAndroid.kt index fc98a34b8a..89f3f2dafe 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasherSerializer.jvmAndroid.kt @@ -127,7 +127,7 @@ actual object EventHasherSerializer { content: String, ): Boolean { val br: BufferRecycler = JacksonMapper.mapper.factory._getBufferRecycler() - val digest = threadLocalDigest.get() + val digest = threadLocalDigest.get()!! val bb = HashingByteArrayBuilder(br, digest) try { val generator = JacksonMapper.mapper.createGenerator(bb, JsonEncoding.UTF8) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt index 97e57f0729..3f1a6438b9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toRelay/CommandSerializer.kt @@ -83,9 +83,7 @@ class CommandSerializer : StdSerializer(Command::class.java) { gen.writeString(cmd.subId) } - else -> { - null - } + else -> {} } gen.writeEndArray() diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt index 8c296f5ea7..846bcf0374 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip34Git/git/GitHttpClient.kt @@ -162,7 +162,7 @@ class GitHttpClient( visited.add(start) } while (frontier.isNotEmpty() && result.size < depth) { - val commit = frontier.poll() + val commit = frontier.poll()!! result.add(commit) for (parent in commit.parents) { if (parent !in visited) { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt new file mode 100644 index 0000000000..aaf40fdcb7 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.jvmAndroid.kt @@ -0,0 +1,55 @@ +/* + * 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.concurrent + +import java.util.concurrent.ConcurrentHashMap + +actual class ConcurrentMap { + private val map = ConcurrentHashMap() + + actual operator fun get(key: K): V? = map[key] + + actual operator fun set( + key: K, + value: V, + ) { + map[key] = value + } + + actual fun getOrPut( + key: K, + defaultValue: () -> V, + ): V = + // Fast-path the present-key hit (the common case in the crawl's hot + // relay-hint accumulation) so it never allocates the mapping-function + // closure; only an absent key pays for the atomic computeIfAbsent. + map[key] ?: map.computeIfAbsent(key) { defaultValue() } + + actual fun merge( + key: K, + value: V, + remap: (old: V, new: V) -> V, + ): V = map.merge(key, value) { old, new -> remap(old, new) }!! + + actual fun size(): Int = map.size + + actual fun snapshot(): Map = HashMap(map) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.jvmAndroid.kt new file mode 100644 index 0000000000..94a0754c11 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.jvmAndroid.kt @@ -0,0 +1,35 @@ +/* + * 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.concurrent + +import java.util.concurrent.ConcurrentHashMap + +actual class ConcurrentSet { + private val set: MutableSet = ConcurrentHashMap.newKeySet() + + actual fun add(element: E): Boolean = set.add(element) + + actual operator fun contains(element: E): Boolean = set.contains(element) + + actual fun size(): Int = set.size + + actual fun snapshot(): Set = HashSet(set) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index b58969cf6f..8453460097 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -30,19 +30,19 @@ import java.security.MessageDigest * (lock acquire + release) for ~2µs of actual hashing. ThreadLocal eliminates all locking * since each thread gets its own MessageDigest instance. digest() implicitly resets state. */ -val threadLocalDigest = +val threadLocalDigest: ThreadLocal = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") } -actual fun sha256(data: ByteArray): ByteArray = threadLocalDigest.get().digest(data) +actual fun sha256(data: ByteArray): ByteArray = threadLocalDigest.get()!!.digest(data) actual fun sha256Into( out: ByteArray, data: ByteArray, len: Int, ): ByteArray { - val md = threadLocalDigest.get() + val md = threadLocalDigest.get()!! md.update(data, 0, len) md.digest(out, 0, 32) return out @@ -62,7 +62,7 @@ fun sha256StreamWithCount( bufferSize: Int = 8192, ): Pair { val countingStream = CountingInputStream(inputStream) - val digest = threadLocalDigest.get() + val digest = threadLocalDigest.get()!! try { val buffer = ByteArray(bufferSize) var bytesRead: Int diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt index 159febb7d4..f516a80421 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupManagerTest.kt @@ -123,6 +123,46 @@ class MlsGroupManagerTest { } } + /** + * Regression: [MlsGroupManager.encrypt] must persist the advanced ratchet + * position, not just commits. A group state persists only at commits was + * the second half of the generation-reuse bug: sends between two commits + * advanced the SecretTree in memory but never hit the store, so a restart + * reloaded the pre-send ratchet and re-emitted an already-used generation. + * + * Alice and Bob share a group. Alice sends one message (Bob consumes + * generation 0), Alice "restarts" from the store WITHOUT any intervening + * commit, and her next send must be a fresh generation Bob accepts. + */ + @Test + fun testEncryptPersistsRatchetPositionBetweenCommits() { + runBlocking { + val aliceStore = InMemoryGroupStateStore() + val alice = MlsGroupManager(aliceStore) + val aliceGroup = alice.createGroup(groupId, "alice".encodeToByteArray()) + + // Bob joins as a low-level MlsGroup — a strict peer that tracks + // consumed generations. (The manager's processWelcome requires a + // NostrGroupData extension we don't set up here; the low-level + // group is enough to observe the ratchet behavior.) + val bobBundle = aliceGroup.createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val addResult = alice.addMember(groupId, bobBundle.keyPackage.toTlsBytes()) + val bob = MlsGroup.processWelcome(addResult.welcomeBytes!!, bobBundle) + + // Alice sends generation 0 (no commit); Bob consumes it. + val ct0 = alice.encrypt(groupId, "msg0".encodeToByteArray()) + assertContentEquals("msg0".encodeToByteArray(), bob.decrypt(ct0).content) + + // Alice restarts from the store — only encrypt() has run since the + // last commit, so this proves encrypt persisted the ratchet. + val aliceRestarted = MlsGroupManager(aliceStore) + aliceRestarted.restoreAll() + + val ct1 = aliceRestarted.encrypt(groupId, "msg1".encodeToByteArray()) + assertContentEquals("msg1".encodeToByteArray(), bob.decrypt(ct1).content) + } + } + @Test fun testAddMemberPersistsState() { runBlocking { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt index 68cbfbe99e..34be9d3317 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/MlsGroupStateTest.kt @@ -173,12 +173,12 @@ class MlsGroupStateTest { val state = group.saveState() val bytes = state.encodeTls() - // First two bytes should be the version (uint16 = 1) + // First two bytes should be the version (uint16 = 2) val reader = com.vitorpamplona.quartz.marmot.mls.codec .TlsReader(bytes) val version = reader.readUint16() - assertEquals(1, version) + assertEquals(2, version) } @Test @@ -219,4 +219,114 @@ class MlsGroupStateTest { val decrypted = restoredGroup.decrypt(encrypted) assertContentEquals(plaintext, decrypted.content) } + + /** + * Regression: a restore must NOT rewind the SecretTree ratchet to + * generation 0. A peer that already consumed generation 0 in this epoch + * (like openmls / MDK / Whitenoise, which forbid generation reuse) would + * otherwise reject the restored sender's next message as a replay. + */ + @Test + fun testRestorePreservesSenderGeneration_peerAcceptsNextMessage() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = + MlsGroup + .create("bob".encodeToByteArray()) + .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + + // Alice sends generation 0; Bob consumes it. + val ct0 = alice.encrypt("msg0".encodeToByteArray()) + assertContentEquals("msg0".encodeToByteArray(), bob.decrypt(ct0).content) + + // Alice "restarts": persist then restore. + val aliceRestored = MlsGroup.restore(MlsGroupState.decodeTls(alice.saveState().encodeTls())) + + // Alice's next send must be generation 1, which Bob accepts. Before + // the fix this re-emitted generation 0 and Bob threw "Generation 0 + // already consumed". + val ct1 = aliceRestored.encrypt("msg1".encodeToByteArray()) + assertContentEquals("msg1".encodeToByteArray(), bob.decrypt(ct1).content) + } + + /** + * The ratchet position must survive several sends across a restore, not + * just one. Covers the case where the app persists (at a commit) after N + * application messages have already advanced the ratchet. + */ + @Test + fun testRestorePreservesSenderGenerationAfterMultipleSends() { + val alice = MlsGroup.create("alice".encodeToByteArray()) + val bobBundle = + MlsGroup + .create("bob".encodeToByteArray()) + .createKeyPackage("bob".encodeToByteArray(), ByteArray(0)) + val bob = MlsGroup.processWelcome(alice.addMember(bobBundle.keyPackage.toTlsBytes()).welcomeBytes!!, bobBundle) + + for (i in 0 until 5) { + val ct = alice.encrypt("m$i".encodeToByteArray()) + assertContentEquals("m$i".encodeToByteArray(), bob.decrypt(ct).content) + } + + val aliceRestored = MlsGroup.restore(MlsGroupState.decodeTls(alice.saveState().encodeTls())) + + // Continues at generation 5 — Bob (who consumed 0..4) accepts it. + val ct = aliceRestored.encrypt("m5".encodeToByteArray()) + assertContentEquals("m5".encodeToByteArray(), bob.decrypt(ct).content) + } + + /** + * Backward compatibility: a STATE_VERSION 1 blob (no persisted ratchet + * positions) must still decode, yielding an empty ratchet map and the + * legacy generation-0 restore behavior. + */ + @Test + fun testDecodeLegacyV1StateBlob() { + val group = MlsGroup.create("alice".encodeToByteArray()) + group.encrypt("advance the ratchet".encodeToByteArray()) + val state = group.saveState() + + val v1Bytes = encodeAsV1(state) + val decoded = MlsGroupState.decodeTls(v1Bytes) + + assertTrue(decoded.senderRatchetStates.isEmpty(), "v1 blob has no ratchet positions") + + // Restores and can still encrypt/decrypt (legacy behavior). + val restored = MlsGroup.restore(decoded) + val ct = restored.encrypt("post-restore".encodeToByteArray()) + assertContentEquals("post-restore".encodeToByteArray(), restored.decrypt(ct).content) + } + + /** + * Re-encode a state in the original STATE_VERSION 1 layout: identical to + * v2 but with the version tag set to 1 and no trailing ratchet section. + */ + private fun encodeAsV1(state: MlsGroupState): ByteArray { + val writer = + com.vitorpamplona.quartz.marmot.mls.codec + .TlsWriter() + writer.putUint16(1) + state.groupContext.encodeTls(writer) + writer.putOpaqueVarInt(state.treeBytes) + writer.putUint32(state.myLeafIndex.toLong()) + val es = state.epochSecrets + writer.putOpaqueVarInt(es.joinerSecret) + writer.putOpaqueVarInt(es.welcomeSecret) + writer.putOpaqueVarInt(es.epochSecret) + writer.putOpaqueVarInt(es.senderDataSecret) + writer.putOpaqueVarInt(es.encryptionSecret) + writer.putOpaqueVarInt(es.exporterSecret) + writer.putOpaqueVarInt(es.epochAuthenticator) + writer.putOpaqueVarInt(es.externalSecret) + writer.putOpaqueVarInt(es.confirmationKey) + writer.putOpaqueVarInt(es.membershipKey) + writer.putOpaqueVarInt(es.resumptionPsk) + writer.putOpaqueVarInt(es.initSecret) + writer.putOpaqueVarInt(state.initSecret) + writer.putOpaqueVarInt(state.signingPrivateKey) + writer.putOpaqueVarInt(state.encryptionPrivateKey) + writer.putOpaqueVarInt(state.interimTranscriptHash) + writer.putOpaqueVarInt(state.encryptionSecret) + return writer.toByteArray() + } } diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/AuthorsMissingOutboxBenchmark.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/AuthorsMissingOutboxBenchmark.kt new file mode 100644 index 0000000000..19de3b4ed7 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/prodbench/AuthorsMissingOutboxBenchmark.kt @@ -0,0 +1,251 @@ +/* + * 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.nip01Core.relay.prodbench + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Head-to-head for `IEventStore.authorsMissingOutbox()` — "give me every + * author with events but no NIP-65 relay list (kind 10002)" — at 1,000,000 + * events, comparing the two implementations that ship: + * + * - **generic** — the `IEventStore` interface default: query the 10002 + * owners into a set, then stream EVERY event (`query(Filter())`) and keep + * the authors not in that set. Correct for any store, but it decodes all + * 1M events off SQLite into `Event` objects. + * - **sqlite** — `EventStore.authorsMissingOutbox()`, an index-only `EXCEPT` + * over `event_headers` (all authors minus the 10002 owners) that never + * decodes an event, riding the `(kind, pubkey, created_at)` covering index. + * + * Corpus: the benchmark first **syncs a real sample from a popular relay** + * (kind 1 notes + kind 10002 relay lists from [RELAY]) so the pubkey + * cardinality, per-author event fan-out, tag/content sizes, and the fraction + * of authors that actually advertise relays are all real. It then replicates + * that sample — cloning each real event with a fresh id and timestamp but the + * SAME pubkey/kind/tags/content — up to [TARGET] rows. Replication preserves + * the real distinct-author set and the real 10002-owner set exactly (so the + * answer is unchanged), it only grows each author's history the way a + * long-lived relay would. A live 1M download is bandwidth-bound and isn't + * what we're measuring; the query is. + * + * The store keeps the `indexEventsByPubkeyAlone` index a relay actually keeps + * (this query is a relay / outbox-model concern) — that is the index the + * `DISTINCT pubkey ... NOT EXISTS` scan rides. NIP-50 full-text indexing is + * turned off: it is pure insert-path cost that neither query touches, so + * dropping it just makes seeding 1M rows fast without changing either timing. + * + * Network + heavy, so gated like the other prod benches: + * ./gradlew :quartz:jvmTest --tests "*.AuthorsMissingOutboxBenchmark" -PprodRelayBench=1 + */ +class AuthorsMissingOutboxBenchmark { + companion object { + const val RELAY = "wss://relay.damus.io" + const val TARGET = 1_000_000 + const val SAMPLE_NOTES = 25_000 + const val SAMPLE_RELAY_LISTS = 15_000 + const val FETCH_TIMEOUT_MS = 90_000L + const val INSERT_CHUNK = 2_000 + val SIG = "0".repeat(128) + } + + private fun idFor(counter: Long): String = "%064x".format(counter) + + /** The generic path — a verbatim copy of the `IEventStore` interface default. */ + private suspend fun genericAuthorsMissingOutbox(store: EventStore): List { + val withOutbox = HashSet() + store.query(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND))) { withOutbox.add(it.pubKey) } + + val missing = LinkedHashSet() + store.query(Filter()) { event -> + if (event.kind != GiftWrapEvent.KIND && event.pubKey !in withOutbox) missing.add(event.pubKey) + } + return missing.toList() + } + + @Test + fun authorsMissingOutboxScaling() { + if (System.getenv("PROD_RELAY_BENCH") == null && System.getProperty("prodRelayBench") == null) { + println("AuthorsMissingOutboxBenchmark skipped. Run with -PprodRelayBench=1 to enable.") + return + } + + val httpClient = + OkHttpClient + .Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + println("=== authorsMissingOutbox 1M benchmark === cores=${Runtime.getRuntime().availableProcessors()}") + + // ── 1. SYNC a real sample from a popular relay ────────────────────── + val notes = ArrayList(SAMPLE_NOTES) + val relayLists = ArrayList(SAMPLE_RELAY_LISTS) + val relay = RELAY.normalizeRelayUrl() + runBlocking { + val client = NostrClient(BasicOkHttpWebSocket.Builder { httpClient }) + try { + val t0 = System.nanoTime() + client.fetchAllPages(relay, listOf(Filter(kinds = listOf(1), limit = SAMPLE_NOTES)), FETCH_TIMEOUT_MS) { notes.add(it) } + client.fetchAllPages(relay, listOf(Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), limit = SAMPLE_RELAY_LISTS)), FETCH_TIMEOUT_MS) { relayLists.add(it) } + println(" synced from $RELAY in %.1fs: %,d notes + %,d relay-lists".format((System.nanoTime() - t0) / 1e9, notes.size, relayLists.size)) + } finally { + client.close() + } + } + httpClient.dispatcher.executorService.shutdown() + + val pool = (notes + relayLists).distinctBy { it.id } + require(pool.isNotEmpty()) { "relay returned no events — cannot build corpus" } + + val outboxOwners = relayLists.mapTo(HashSet()) { it.pubKey } + val allAuthors = pool.mapTo(HashSet()) { it.pubKey } + val expectedMissing = allAuthors - outboxOwners + println( + " real sample: %,d events, %,d distinct authors, %,d with a 10002 (%.1f%%) → %,d missing".format( + pool.size, + allAuthors.size, + outboxOwners.size, + 100.0 * outboxOwners.size / allAuthors.size, + expectedMissing.size, + ), + ) + + // ── 2. SCALE to TARGET by replicating the real sample ─────────────── + // kind 10002 is replaceable — one row survives per owner no matter how + // many times it is re-cloned — so the store is filled with note (kind 1) + // clones and each owner's relay list is inserted exactly once. That + // lands a genuine TARGET rows while keeping the real author set and + // outbox-owner set intact. + val notePool = notes.distinctBy { it.id } + require(notePool.isNotEmpty()) { "relay returned no kind-1 notes — cannot fill the corpus" } + val relayListPerOwner = relayLists.associateBy { it.pubKey }.values.toList() + val noteCloneTarget = (TARGET - relayListPerOwner.size).coerceAtLeast(0) + + // FS/FTS off, pubkey+created_at indexes on: representative of the query, + // fast to seed. See the class KDoc. + val strategy = + DefaultIndexingStrategy( + indexEventsByCreatedAtAlone = true, + indexEventsByPubkeyAlone = true, + useAndIndexIdOnOrderBy = true, + indexFullTextSearch = false, + ) + val dbFile = Files.createTempFile("authors-missing-outbox-", ".db") + Files.deleteIfExists(dbFile) + val store = EventStore(dbName = dbFile.toAbsolutePath().toString(), relay = null, indexStrategy = strategy) + try { + val baseTime = 1_600_000_000L + var counter = 0L + val seedT0 = System.nanoTime() + val batch = ArrayList(INSERT_CHUNK) + + suspend fun flush() { + if (batch.isNotEmpty()) { + store.batchInsert(batch) + batch.clear() + } + } + runBlocking { + // One relay list per owner (fresh id; content/tags preserved). + for (src in relayListPerOwner) { + batch.add(EventFactory.create(idFor(++counter), src.pubKey, baseTime + counter, src.kind, src.tags, src.content, SIG)) + if (batch.size == INSERT_CHUNK) flush() + } + // Fill the rest with note clones cycling the real notes. + var made = 0L + while (made < noteCloneTarget) { + val src = notePool[(made % notePool.size).toInt()] + batch.add(EventFactory.create(idFor(++counter), src.pubKey, baseTime + counter, src.kind, src.tags, src.content, SIG)) + made++ + if (batch.size == INSERT_CHUNK) flush() + } + flush() + } + val total = runBlocking { store.count(Filter()) } + println(" seeded %,d rows (stored %,d) in %.1fs".format(counter, total, (System.nanoTime() - seedT0) / 1e9)) + + // ── 3. MEASURE both implementations on the same store ────────── + // Warm the page cache with one throwaway pass of each so neither + // eats the cold-cache penalty for the other. + runBlocking { + store.authorsMissingOutbox() + genericAuthorsMissingOutbox(store) + } + + val runs = 3 + var sqliteResult: List = emptyList() + var genericResult: List = emptyList() + val sqliteMs = DoubleArray(runs) + val genericMs = DoubleArray(runs) + runBlocking { + repeat(runs) { i -> + var t = System.nanoTime() + sqliteResult = store.authorsMissingOutbox() + sqliteMs[i] = (System.nanoTime() - t) / 1e6 + + t = System.nanoTime() + genericResult = genericAuthorsMissingOutbox(store) + genericMs[i] = (System.nanoTime() - t) / 1e6 + } + } + + // Correctness: both must return the same author set, and it must + // match the ground truth computed from the real sample. + assertEquals(sqliteResult.toSet(), genericResult.toSet(), "sqlite and generic disagree") + assertEquals(expectedMissing, sqliteResult.toSet(), "result does not match the seeded distribution") + + val sqliteBest = sqliteMs.min() + val genericBest = genericMs.min() + println("\n result: %,d authors missing an outbox (of %,d distinct authors)".format(sqliteResult.size, allAuthors.size)) + println(" ── timings over $runs runs (best-of) ──") + println(" generic (decode all %,d events) best=%,9.1f ms runs=%s".format(total, genericBest, genericMs.joinToString { "%.0f".format(it) })) + println(" sqlite (index-only EXCEPT) best=%,9.1f ms runs=%s".format(sqliteBest, sqliteMs.joinToString { "%.1f".format(it) })) + println(" → sqlite is %.1f× faster at %,d events".format(genericBest / sqliteBest, total)) + } finally { + store.close() + listOf("", "-wal", "-shm").forEach { + Files.deleteIfExists( + java.nio.file.Path + .of(dbFile.toAbsolutePath().toString() + it), + ) + } + } + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt new file mode 100644 index 0000000000..8458efdd53 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsAuthorsMissingOutboxTest.kt @@ -0,0 +1,103 @@ +/* + * 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.nip01Core.store.fs + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.utils.EventFactory +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking +import java.nio.file.Files +import java.nio.file.Path +import kotlin.io.path.exists +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * `authorsMissingOutbox()` against [FsEventStore], which does NOT override the + * method — so this is the ONLY coverage of the `IEventStore` interface DEFAULT + * implementation (the SQLite tests always hit the override). It pins the + * default's behaviour, and asserts it agrees with the same scenarios the SQLite + * suite checks: 10002 exclusion, NIP-09 deletion re-exposing an author, and the + * giftwrap-sender carve-out. + */ +class FsAuthorsMissingOutboxTest { + private lateinit var root: Path + private lateinit var store: FsEventStore + + @BeforeTest + fun setup() { + Secp256k1Instance + root = Files.createTempDirectory("fs-missing-outbox-") + store = FsEventStore(root) + } + + @AfterTest + fun tearDown() { + store.close() + if (root.exists()) { + Files.walk(root).use { s -> s.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } } + } + } + + @Test + fun defaultImplReportsOnlyAuthorsWithoutOutbox() = + runBlocking { + val withOutbox = NostrSignerSync() + val noOutbox = NostrSignerSync() + + store.insert(withOutbox.sign(TextNoteEvent.build("a"))) + store.insert(AdvertisedRelayListEvent.create(emptyList(), withOutbox)) + store.insert(noOutbox.sign(TextNoteEvent.build("b"))) + + assertEquals(setOf(noOutbox.pubKey), store.authorsMissingOutbox().toSet()) + } + + @Test + fun defaultImplExcludesGiftWrapSenders() = + runBlocking { + val noteAuthor = NostrSignerSync() + store.insert(noteAuthor.sign(TextNoteEvent.build("hi"))) + store.insert( + EventFactory.create("bb".repeat(32), "aa".repeat(32), 1L, GiftWrapEvent.KIND, emptyArray(), "", "00".repeat(64)), + ) + + assertEquals(setOf(noteAuthor.pubKey), store.authorsMissingOutbox().toSet()) + } + + @Test + fun defaultImplReExposesAuthorAfterOutboxDeleted() = + runBlocking { + val signer = NostrSignerSync() + store.insert(signer.sign(TextNoteEvent.build("content"))) + val relayList = AdvertisedRelayListEvent.create(emptyList(), signer) + store.insert(relayList) + assertEquals(emptySet(), store.authorsMissingOutbox().toSet()) + + store.insert(signer.sign(DeletionEvent.build(listOf(relayList)))) + assertEquals(setOf(signer.pubKey), store.authorsMissingOutbox().toSet()) + } +} diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt new file mode 100644 index 0000000000..44ce0df4fc --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayReachabilityStoreTest.kt @@ -0,0 +1,117 @@ +/* + * 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.nip66RelayMonitor.reachability + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.store.sqlite.DefaultIndexingStrategy +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RelayReachabilityStoreTest { + private fun store() = + EventStore( + dbName = null, + indexStrategy = DefaultIndexingStrategy(), + ) + + private fun cache(store: EventStore) = + RelayReachabilityStore( + store = store, + signer = NostrSignerInternal(KeyPair()), + ttlSeconds = 3600, + ) + + private val live1 = RelayUrlNormalizer.normalize("wss://alive.example.com") + private val live2 = RelayUrlNormalizer.normalize("wss://also-alive.example.com") + private val dead1 = RelayUrlNormalizer.normalize("wss://dead.example.com") + private val dead2 = RelayUrlNormalizer.normalize("wss://gone.example.com") + private val onion = RelayUrlNormalizer.normalize("wss://abc.onion") + private val onionPath = RelayUrlNormalizer.normalize("wss://abc.onion/npub1x") + + // Contains the literal ".onion" as a substring but is NOT a Tor host — a loose + // `contains(".onion")` would misclassify it; the normalizer's isOnion must not. + private val fakeOnion = RelayUrlNormalizer.normalize("wss://relay.onionfake.com") + + @Test + fun recordsAndReloadsReachability() = + runBlocking { + Secp256k1Instance + val store = store() + val cache = cache(store) + val now = 1_000_000L + + cache.record(reachable = setOf(live1, live2), dead = setOf(dead1, dead2), now = now) + + val snap = cache.snapshot(now = now) + assertEquals(setOf(live1, live2), snap.live) + assertEquals(setOf(dead1, dead2), snap.dead) + assertTrue(snap.isKnownDead(dead1)) + assertFalse(snap.isKnownDead(live1)) + } + + @Test + fun aFreshSuccessfulOpenOverridesAnEarlierDeadMark() = + runBlocking { + Secp256k1Instance + val store = store() + val cache = cache(store) + + // Marked dead first, then seen alive a second later (addressable replace). + cache.record(reachable = emptySet(), dead = setOf(dead1), now = 1_000L) + cache.record(reachable = setOf(dead1), dead = emptySet(), now = 1_001L) + + val snap = cache.snapshot(now = 1_001L) + assertTrue(dead1 in snap.live) + assertFalse(snap.isKnownDead(dead1)) + } + + @Test + fun recordsOlderThanTheTtlAreIgnored() = + runBlocking { + Secp256k1Instance + val store = store() + val cache = cache(store) // ttl = 3600s + + cache.record(reachable = emptySet(), dead = setOf(dead1), now = 1_000L) + + // "now" is well past the 1h TTL from when dead1 was recorded. + val snap = cache.snapshot(now = 1_000L + 3601L) + assertFalse(snap.isKnownDead(dead1)) + assertEquals(0, snap.size) + } + + @Test + fun onionRelayIsTaggedTorNetwork() { + assertEquals(NetworkType.TOR, RelayReachabilityStore.networkTypeOf(onion)) + assertEquals(NetworkType.TOR, RelayReachabilityStore.networkTypeOf(onionPath)) + assertEquals(NetworkType.CLEARNET, RelayReachabilityStore.networkTypeOf(live1)) + // A host that merely contains ".onion" as a substring is clearnet, not Tor. + assertEquals(NetworkType.CLEARNET, RelayReachabilityStore.networkTypeOf(fakeOnion)) + } +} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt new file mode 100644 index 0000000000..de4ee540d8 --- /dev/null +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentMap.native.kt @@ -0,0 +1,80 @@ +/* + * 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.concurrent + +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +// Copy-on-write, mirroring ConcurrentHashCache.linux: correct and simple. The +// native targets never run the crawl this backs (it is JVM/Android-only work); +// they only compile it, so the O(n)-per-write cost is irrelevant. A CAS retry +// loop gives getOrPut/merge the same atomicity the JVM actual gets for free. +@OptIn(ExperimentalAtomicApi::class) +actual class ConcurrentMap { + private val ref = AtomicReference(HashMap()) + + actual operator fun get(key: K): V? = ref.load()[key] + + actual operator fun set( + key: K, + value: V, + ) { + while (true) { + val cur = ref.load() + val copy = HashMap(cur) + copy[key] = value + if (ref.compareAndSet(cur, copy)) return + } + } + + actual fun getOrPut( + key: K, + defaultValue: () -> V, + ): V { + while (true) { + val cur = ref.load() + cur[key]?.let { return it } + val value = defaultValue() + val copy = HashMap(cur) + copy[key] = value + if (ref.compareAndSet(cur, copy)) return value + } + } + + actual fun merge( + key: K, + value: V, + remap: (old: V, new: V) -> V, + ): V { + while (true) { + val cur = ref.load() + val old = cur[key] + val merged = if (old == null) value else remap(old, value) + val copy = HashMap(cur) + copy[key] = merged + if (ref.compareAndSet(cur, copy)) return merged + } + } + + actual fun size(): Int = ref.load().size + + actual fun snapshot(): Map = HashMap(ref.load()) +} diff --git a/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.native.kt b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.native.kt new file mode 100644 index 0000000000..70bf86f133 --- /dev/null +++ b/quartz/src/nativeMain/kotlin/com/vitorpamplona/quartz/utils/concurrent/ConcurrentSet.native.kt @@ -0,0 +1,46 @@ +/* + * 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.concurrent + +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +// Copy-on-write native actual — see ConcurrentMap.native for the rationale. +@OptIn(ExperimentalAtomicApi::class) +actual class ConcurrentSet { + private val ref = AtomicReference(HashSet()) + + actual fun add(element: E): Boolean { + while (true) { + val cur = ref.load() + if (element in cur) return false + val copy = HashSet(cur) + copy.add(element) + if (ref.compareAndSet(cur, copy)) return true + } + } + + actual operator fun contains(element: E): Boolean = element in ref.load() + + actual fun size(): Int = ref.load().size + + actual fun snapshot(): Set = HashSet(ref.load()) +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt index f57101547a..891869d11e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnection.kt @@ -2523,9 +2523,7 @@ class QuicConnection( PathValidator.RecordResult.Stored, PathValidator.RecordResult.Duplicate, PathValidator.RecordResult.AlreadyRetired, - -> { - Unit - } + -> {} PathValidator.RecordResult.PoolFull -> { // Peer over-issued past its own advertised @@ -2573,11 +2571,9 @@ class QuicConnection( // same path before the next outbound packet (which would // otherwise stamp a now-retired CID). when (val rotation = pathValidator.forceRotateToHigherSequence()) { - null -> { - Unit - } - // active CID is still valid; nothing to do. + null -> {} + PathValidator.ForcedRotationResult.NoSpareCid -> { // Watermark forced retirement of the active CID but // the pool is empty — we have nothing valid to use. @@ -2613,9 +2609,7 @@ class QuicConnection( when (val outcome = pathValidator.applyPathResponse(payload)) { PathValidator.ValidationOutcome.NotValidating, PathValidator.ValidationOutcome.PayloadMismatch, - -> { - Unit - } + -> {} is PathValidator.ValidationOutcome.Validated -> { // Bug-7 fix: a valid PATH_RESPONSE proves the peer diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt index e424c274d5..6816ebbd5e 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionParser.kt @@ -852,9 +852,7 @@ private fun dispatchFrames( // peer knows it just violated the spec instead of // having its bytes silently dropped. when (stream.receive.insert(frame.offset, frame.data, frame.fin)) { - com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> { - Unit - } + com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OK -> {} com.vitorpamplona.quic.stream.ReceiveBuffer.InsertResult.OFFSET_PAST_FIN -> { conn.markClosedExternally( diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt index f00816f0d1..8f27a84503 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/http3/Http3FrameReader.kt @@ -165,9 +165,7 @@ class Http3FrameReader( ) } when (context) { - StreamContext.UNCHECKED -> { - Unit - } + StreamContext.UNCHECKED -> {} StreamContext.CONTROL -> { // §7.2.4: SETTINGS MUST be the first frame on the diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt index 345298a8fd..9538fd5f20 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/webtransport/WtPeerStreamDemux.kt @@ -491,9 +491,7 @@ class WtPeerStreamDemux( } // no new requests; we don't enforce yet - else -> { - Unit - } + else -> {} } } } diff --git a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt index 7fb340ef63..d1ae574128 100644 --- a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt +++ b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/Main.kt @@ -246,6 +246,8 @@ private fun loadCorpus( ) } +private const val DOWNLOAD_FLAG = "--download" + private fun parseArgs(args: Array): Options? { val map = HashMap>() val flags = HashSet() @@ -260,7 +262,7 @@ private fun parseArgs(args: Array): Options? { "--base-time", "--corpus", "--limit", - "--download", + DOWNLOAD_FLAG, "--max-event-bytes", "--max-tags", "--samples", @@ -284,7 +286,7 @@ private fun parseArgs(args: Array): Options? { if (next != null && !next.startsWith("--")) { map.getOrPut(arg) { mutableListOf() }.add(next) i++ - } else if (arg == "--download") { + } else if (arg == DOWNLOAD_FLAG) { map.getOrPut(arg) { mutableListOf() }.add("") } else { System.err.println("Missing value for $arg") @@ -330,7 +332,7 @@ private fun parseArgs(args: Array): Options? { else -> t.toLongOrNull() ?: CorpusSpec.DEFAULT_BASE_TIME }, corpusFile = one("--corpus")?.let { File(it) }, - downloadFrom = map["--download"]?.lastOrNull()?.split(',')?.filter { it.isNotBlank() }, + downloadFrom = map[DOWNLOAD_FLAG]?.lastOrNull()?.split(',')?.filter { it.isNotBlank() }, limit = int("--limit", 0), maxEventBytes = int("--max-event-bytes", CorpusSource.DEFAULT_MAX_EVENT_BYTES), maxTags = int("--max-tags", CorpusSource.DEFAULT_MAX_TAGS), diff --git a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/corpus/CorpusDownloader.kt b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/corpus/CorpusDownloader.kt index 530b09ec5b..7a30334fca 100644 --- a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/corpus/CorpusDownloader.kt +++ b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/corpus/CorpusDownloader.kt @@ -99,7 +99,7 @@ object CorpusDownloader { } } if (checkpoint.exists()) { - runCatching { mapper.readTree(checkpoint.readText()) }.getOrNull()?.fields()?.forEach { (url, until) -> + runCatching { mapper.readTree(checkpoint.readText()) }.getOrNull()?.properties()?.forEach { (url, until) -> cursors[url] = until.asLong() } } @@ -137,7 +137,9 @@ object CorpusDownloader { val raw = CorpusIO.read(spill).events val corpus = CorpusSource.prepare(raw, target, "download:${relayUrls.joinToString(",")}", log) CorpusIO.write(cached, corpus) - checkpoint.delete() + if (!checkpoint.delete() && checkpoint.exists()) { + log(" ! could not delete stale checkpoint ${checkpoint.name}") + } log(" cached prepared corpus to ${cached.path}") return corpus } @@ -228,9 +230,9 @@ object CorpusDownloader { added += fresh val oldest = page.minOf { it.createdAt } until = - if (fresh == 0 && until != null && oldest >= until!!) { + if (fresh == 0 && until != null && oldest >= until) { // >PAGE_LIMIT events in this second and we have them all. - until!! - 1 + until - 1 } else { oldest } diff --git a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt index a4ee0fa9c8..5d2d0a67d2 100644 --- a/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt +++ b/relayBench/src/main/kotlin/com/vitorpamplona/relaybench/relays/RelayUnderTest.kt @@ -49,7 +49,11 @@ abstract class RelayUnderTest( open fun prepare( port: Int, dataDir: File, - ) {} + ) { + // No-op by default: most relays are configured entirely through + // command-line flags. Overridden by relays that need config files + // on disk before launch (e.g. StrfryRelay). + } fun start(workDir: File): RunningRelay { val port = ServerSocket(0).use { it.localPort }