From f9063c8191b996788252d565929b11325e718fcb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 01:24:59 +0000 Subject: [PATCH 1/4] feat: split kind 38000 into mint recommendations, prediction markets and ballots Kind 38000 is NIP-87's mint recommendation, but BAO Markets (prediction markets), an auditable-voting app (ballots) and a flood of d-only "sybil test vote" spam share the number. EventFactory mapped every 38000 to MintRecommendationEvent, so markets, ballots and spam rendered as "Ecash Mint Recommendation" cards in feeds, threads and search. EventFactory now dispatches 38000 by tags: - MintRecommendationEvent iff the first `k` is 38172/38173, or, with no `k`, a non-blank `u` (old recommendations); - else BallotEvent on a non-blank `election`; - else PredictionMarketEvent on `market`, >=2 `outcome`s, or `type`+`end`; - else a plain Event (never consumed, never indexed). New read-only, searchable classes under quartz/experimental: predictionMarkets/PredictionMarketEvent (all three BAO shapes: tags + `data` JSON, BAO Fund, and the first shape; title, description, outcomes, status, resolution, end, category, demo network, bet limits, fee, cancel reason) and ballots/BallotEvent (election, answers from `responses`, `ballot` or `vote_choice`, proof hash). JSON is parsed lazily, once, and never throws. EventFactory.probe(kind) answers kind-level questions (isKnownKind, the SQLite/FS FTS reindex pre-filters, SearchableKindsTest) so 38000 still probes as searchable even though a tagless 38000 is now a plain Event. EventCache consumes the new classes as addressables; wallet code keeps seeing only real recommendations. commonsUI renders them with new cards (PredictionMarketCard: kicker, question, status pill, demo pill, category, outcomes with the winner marked, closing time, terms, cancel reason; BallotCard: election, question -> answer rows, proof hash), wired into NoteCompose and ThreadFeedView. GitStatusPill's filled badge is extracted as StatusPill for reuse. Tests use real production events, one per shape. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH --- .../references/searchable-kinds.md | 16 +- .../loggedIn/threadview/ThreadFeedView.kt | 8 + .../commons/model/cache/EventCache.kt | 5 + .../composeResources/values/strings.xml | 26 + .../amethyst/commons/ui/note/GitStatusPill.kt | 17 + .../amethyst/commons/ui/note/NoteCompose.kt | 12 + .../amethyst/commons/ui/note/types/Ballot.kt | 241 ++++++++ .../commons/ui/note/types/PredictionMarket.kt | 531 ++++++++++++++++++ .../experimental/ballots/BallotAnswer.kt | 30 + .../experimental/ballots/BallotContent.kt | 100 ++++ .../experimental/ballots/BallotEvent.kt | 86 +++ .../experimental/ballots/TagArrayExt.kt | 32 ++ .../PredictionMarketDetails.kt | 118 ++++ .../PredictionMarketEvent.kt | 172 ++++++ .../PredictionMarketStatus.kt | 49 ++ .../predictionMarkets/TagArrayExt.kt | 88 +++ .../store/sqlite/FullTextSearchModule.kt | 13 +- .../recommendation/MintRecommendationEvent.kt | 27 + .../quartz/utils/EventFactory.kt | 42 +- .../experimental/ballots/BallotEventTest.kt | 137 +++++ .../PredictionMarketEventTest.kt | 284 ++++++++++ .../quartz/nip50Search/SearchableKindsTest.kt | 4 +- .../quartz/utils/EventFactoryKind38000Test.kt | 184 ++++++ .../quartz/utils/Kind38000Fixtures.kt | 88 +++ .../quartz/nip01Core/store/fs/FsEventStore.kt | 11 +- .../nip50Search/IndexableContentGoldenTest.kt | 4 + 26 files changed, 2304 insertions(+), 21 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index 532ecc3d68..1a95ebd664 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -4,8 +4,8 @@ Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexabl expression. **Update this file in the same PR as any change to the searchable set or to an `indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17. -Counts: 137 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds; -kind 30063 is shared by two NIPs — see the footnote). File paths are under +Counts: 139 concrete classes covering 140 kind values (`GitStatusEvent` spans 4 kinds; +kind 30063 is shared by two NIPs and kind 38000 by three classes — see the footnotes). File paths are under `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`. Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. @@ -134,7 +134,9 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. | 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL | | 37516 | GeocacheListingEvent | nipCCGeocaching/listing | `listOfNotNull(cacheName(), content)` NL (the `hint` is deliberately not indexed — matching a hint is spoiling it) | | 37517 | GeocacheCurationListEvent | nipCCGeocaching/curation | `listOfNotNull(title(), description(), content)` NL | -| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` | +| 38000 | MintRecommendationEvent ‡ | nip87Ecash/recommendation | `content` | +| 38000 | BallotEvent ‡ | experimental/ballots | `(listOfNotNull(election()) + answers().map { it.answer })` NL | +| 38000 | PredictionMarketEvent ‡ | experimental/predictionMarkets | `listOfNotNull(title(), description())` NL | | 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL | | 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) | | 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL | @@ -155,6 +157,14 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. (the release notes) only when the event carries the NIP-82 `i` + `version` tags — a NIP-51 set may hold encrypted private items in `content`, which must never be indexed. +‡ **Kind 38000 is shared** by NIP-87 mint recommendations and two app formats, and `EventFactory` +picks the class by tags: `MintRecommendationEvent` when the first `k` is 38172/38173 (or, with no +`k`, a non-blank `u`); else `BallotEvent` on a non-blank `election`; else `PredictionMarketEvent` +on a `market`, ≥2 `outcome`s, or `type` + `end`; else a plain, unsearchable `Event` (the spam +votes that make up most of the kind). Kind-level probes (`EventFactory.probe`) answer as +`MintRecommendationEvent`. A market's `title()`/`description()` come from its `data` tag JSON, +the `title` tag, or JSON `content`, in that order. + ## Abstract bases (no kind of their own) | Base class | Body | Concrete kinds | diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 71492e7f99..86ff28599d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestation import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorRecommendation +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBallot import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBlossomPieceIndex import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory @@ -202,6 +203,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastEpisode import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPredictionMarket import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.commons.ui.note.types.RenderProfileCard import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPs1Save @@ -285,6 +287,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -303,6 +306,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.application.Softw import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAssetEvent import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent @@ -1140,6 +1144,10 @@ private fun FullBleedNoteCompose( RenderFedimint(noteEvent) } else if (noteEvent is MintRecommendationEvent) { RenderMintRecommendation(noteEvent) + } else if (noteEvent is PredictionMarketEvent) { + RenderPredictionMarket(baseNote, makeItShort = false, accountViewModel) + } else if (noteEvent is BallotEvent) { + RenderBallot(baseNote, accountViewModel) } else if (noteEvent is ChatEvent) { RenderChat( note = baseNote, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index eab486d0d5..ca70a9b4ff 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -163,6 +163,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent @@ -191,6 +192,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent @@ -3962,6 +3964,9 @@ open class EventCache : is CashuMintEvent, is FedimintEvent, is MintRecommendationEvent, + // Other apps' kind-38000 formats, split from mint recommendations by EventFactory. + is BallotEvent, + is PredictionMarketEvent, -> consumeBaseReplaceable(event, relay, wasVerified) // ============================================================ diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index b7db85531f..e09af425e4 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -6361,4 +6361,30 @@ This file type can run code. Check that the name matches what you meant to get. Save From %1$s + Prediction market + Open + Closed + Resolved + Cancelled + Demo · play money + Closes in %1$s + Closed %1$s + Bets %1$s–%2$s sats + Min bet %1$s sats + Max bet %1$s sats + Fee %1$s + Cancelled: %1$s + Winning outcome + + +%1$d more + +%1$d more + + Ballot + Election %1$s + Vote + Proof %1$s + + +%1$d more answer + +%1$d more answers + diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt index 5ad02ef4bf..f61655fc25 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/GitStatusPill.kt @@ -104,6 +104,22 @@ fun GitStatusPill( } } + StatusPill(label, symbol, container, content, modifier) +} + +/** + * The filled, GitHub-style state badge behind [GitStatusPill]: an icon and a short label in + * [content] on a [container] fill. Shared by the other cards that show a lifecycle state + * (prediction markets) so every status badge in a feed looks alike. + */ +@Composable +fun StatusPill( + label: String, + symbol: MaterialSymbol, + container: Color, + content: Color, + modifier: Modifier = Modifier, +) { Row( modifier = modifier @@ -124,6 +140,7 @@ fun GitStatusPill( style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = content, + maxLines = 1, ) } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt index 682ee75b5a..55adb2159f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt @@ -170,6 +170,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestationRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorProficiency import com.vitorpamplona.amethyst.commons.ui.note.types.RenderAttestorRecommendation import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBadgeAward +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBallot import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBlossomPieceIndex import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBolt12Zap import com.vitorpamplona.amethyst.commons.ui.note.types.RenderBookshelfDirectory @@ -211,6 +212,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastEpisode import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPodcastMetadata import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPostApproval +import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPredictionMarket import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPrivateMessage import com.vitorpamplona.amethyst.commons.ui.note.types.RenderProfileCard import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPs1Save @@ -309,6 +311,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward @@ -329,6 +332,7 @@ import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.asset.SoftwareAss import com.vitorpamplona.quartz.experimental.nip82SoftwareApps.release.isNip82SoftwareRelease import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent import com.vitorpamplona.quartz.experimental.publications.PublicationIndexEvent @@ -1492,6 +1496,14 @@ private fun RenderNoteRow( RenderMintRecommendation(noteEvent) } + is PredictionMarketEvent -> { + RenderPredictionMarket(baseNote, makeItShort, accountViewModel) + } + + is BallotEvent -> { + RenderBallot(baseNote, accountViewModel) + } + is ClassifiedsEvent -> { RenderClassifieds( noteEvent, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt new file mode 100644 index 0000000000..e13767902f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt @@ -0,0 +1,241 @@ +/* + * 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.note.types + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +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.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.ballot +import com.vitorpamplona.amethyst.commons.resources.ballot_election +import com.vitorpamplona.amethyst.commons.resources.ballot_more_answers +import com.vitorpamplona.amethyst.commons.resources.ballot_proof_hash +import com.vitorpamplona.amethyst.commons.resources.ballot_vote +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.ui.theme.grayText +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.experimental.ballots.BallotAnswer +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** How many answers a card lists before collapsing the rest into "+N more answers". */ +private const val MAX_ANSWERS = 4 + +/** + * Renders a kind-38000 ballot from an auditable-voting app: the election it was cast in, its + * answers as question → answer rows, and the proof hash that lets the vote be audited. + * + * Observed rather than read once: a ballot is addressable, so a re-cast vote replaces it in place. + */ +@Composable +fun RenderBallot( + note: Note, + accountViewModel: AccountViewModel, +) { + val observedEvent by observeNoteEvent(note, accountViewModel) + val noteEvent = observedEvent ?: return + + BallotCard(noteEvent) +} + +@Immutable +private class BallotCardState( + val election: String?, + val answers: ImmutableList, + val hiddenAnswers: Int, + val proofHash: String?, +) { + companion object { + fun from(event: BallotEvent): BallotCardState { + val answers = event.answers() + return BallotCardState( + election = event.election(), + answers = answers.take(MAX_ANSWERS).toImmutableList(), + hiddenAnswers = (answers.size - MAX_ANSWERS).coerceAtLeast(0), + proofHash = event.proofHash(), + ) + } + } +} + +@Composable +fun BallotCard(event: BallotEvent) { + val state = remember(event) { BallotCardState.from(event) } + + Column(MaterialTheme.colorScheme.replyModifier) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Checklist, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(Res.string.ballot), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp), + ) + } + + state.election?.let { + Text( + text = stringRes(Res.string.ballot_election, it), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.answers.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + state.answers.forEach { AnswerRow(it) } + + if (state.hiddenAnswers > 0) { + Text( + text = pluralStringRes(Res.plurals.ballot_more_answers, state.hiddenAnswers, state.hiddenAnswers), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + + state.proofHash?.let { ProofHash(it) } + } + } +} + +@Composable +private fun AnswerRow(answer: BallotAnswer) { + // A lone `vote_choice` has no question of its own; everything else is shown as published. + val question = + if (answer.question == BallotEvent.VOTE_QUESTION) { + stringRes(Res.string.ballot_vote) + } else { + answer.question + } + + Row(verticalAlignment = Alignment.Top) { + Text( + text = question, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.4f), + ) + Icon( + symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.padding(horizontal = 4.dp).size(16.dp), + ) + Text( + text = answer.answer, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(0.6f), + ) + } +} + +@Composable +private fun ProofHash(hash: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.Shield, + contentDescription = null, + tint = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.size(12.dp), + ) + Spacer(Modifier.width(4.dp)) + Text( + text = stringRes(Res.string.ballot_proof_hash, hash), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + } +} + +@Preview +@Composable +fun BallotCardPreview() { + val ballot = + BallotEvent( + id = "78f5f012a9518a0b39134e659ef180eae6df9f6b38ce3b4b86963409837e2776", + pubKey = "caf0b6ef4eb461677ed7bf7b569a0212443d6429f6cc555ca8f00a599e453bb2", + createdAt = 1773998101, + tags = + arrayOf( + arrayOf("election", "spring-2026-council"), + arrayOf("proof_hash", "bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95"), + ), + content = + "{\"ballot\":{\"funding_priority\":\"community-grants\",\"audit_policy\":\"every-election\"," + + "\"treasurer\":\"alice\",\"quorum\":\"60%\",\"term_length\":\"1 year\"}}", + sig = "", + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + BallotCard(ballot) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt new file mode 100644 index 0000000000..468069856b --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt @@ -0,0 +1,531 @@ +/* + * 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.note.types + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +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.model.Note +import com.vitorpamplona.amethyst.commons.relayClient.event.observeNoteEvent +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.prediction_market +import com.vitorpamplona.amethyst.commons.resources.prediction_market_bet_range +import com.vitorpamplona.amethyst.commons.resources.prediction_market_cancel_reason +import com.vitorpamplona.amethyst.commons.resources.prediction_market_closed_on +import com.vitorpamplona.amethyst.commons.resources.prediction_market_closes_in +import com.vitorpamplona.amethyst.commons.resources.prediction_market_demo +import com.vitorpamplona.amethyst.commons.resources.prediction_market_fee +import com.vitorpamplona.amethyst.commons.resources.prediction_market_max_bet +import com.vitorpamplona.amethyst.commons.resources.prediction_market_min_bet +import com.vitorpamplona.amethyst.commons.resources.prediction_market_more_outcomes +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_cancelled +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_closed +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_open +import com.vitorpamplona.amethyst.commons.resources.prediction_market_status_resolved +import com.vitorpamplona.amethyst.commons.resources.prediction_market_winning_outcome +import com.vitorpamplona.amethyst.commons.ui.note.DateTimeStyle +import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill +import com.vitorpamplona.amethyst.commons.ui.note.StatusPill +import com.vitorpamplona.amethyst.commons.ui.note.formatDateTime +import com.vitorpamplona.amethyst.commons.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.commons.ui.pluralStringRes +import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.commons.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.commons.ui.theme.grayText +import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText +import com.vitorpamplona.amethyst.commons.ui.theme.redColorOnSecondSurface +import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.commons.ui.theme.warningColorOnSecondSurface +import com.vitorpamplona.amethyst.commons.util.formatGrouped +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketStatus +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** How many outcomes a card lists before collapsing the rest into "+N more". */ +private const val MAX_OUTCOMES = 8 + +private val OutcomeShape = RoundedCornerShape(8.dp) + +/** + * Renders a kind-38000 prediction market (BAO Markets): the question, its status, whether real + * money is at stake, the outcomes with the winner marked once resolved, and when betting closes. + * + * Observed rather than read once: a market is addressable, and its publisher replaces it as it + * moves from open to closed to resolved, so a newer version lands on the same Note. + */ +@Composable +fun RenderPredictionMarket( + note: Note, + makeItShort: Boolean, + accountViewModel: AccountViewModel, +) { + val observedEvent by observeNoteEvent(note, accountViewModel) + val noteEvent = observedEvent ?: return + + PredictionMarketCard(noteEvent, makeItShort) +} + +/** Everything the card shows, read off the event once per event rather than on every recomposition. */ +@Immutable +private class PredictionMarketCardState( + val title: String?, + val description: String?, + val outcomes: ImmutableList, + val status: PredictionMarketStatus?, + val resolution: String?, + val endsAt: Long?, + val category: String?, + val isDemo: Boolean, + /** Bet limits and fee, already formatted for display. */ + val minBetSats: String?, + val maxBetSats: String?, + val feePercent: String?, + val cancelReason: String?, +) { + fun isWinner(outcome: String) = resolution != null && outcome.equals(resolution, ignoreCase = true) + + companion object { + fun from( + event: PredictionMarketEvent, + now: Long, + ) = PredictionMarketCardState( + title = event.title() ?: socialPostHeadline(event.content), + description = event.description(), + outcomes = event.outcomes().toImmutableList(), + status = event.status(now), + resolution = event.resolution(), + endsAt = event.endsAt(), + category = event.category()?.let(::humanizeCategory), + isDemo = event.isDemo(), + minBetSats = event.minBetSats()?.let(::formatGrouped), + maxBetSats = event.maxBetSats()?.let(::formatGrouped), + feePercent = event.feePercent()?.let(::formatPercent), + cancelReason = event.cancelReason(), + ) + + /** The first line of a social-post `content`, for a market with no title anywhere else. */ + private fun socialPostHeadline(content: String): String? { + if (content.trimStart().startsWith("{")) return null + return content.lineSequence().firstOrNull { it.isNotBlank() }?.trim() + } + + /** 4.21 → "4.21%", 2.0 → "2%". */ + private fun formatPercent(value: Double): String { + val asLong = value.toLong() + return if (value == asLong.toDouble()) "$asLong%" else "$value%" + } + + private fun humanizeCategory(category: String): String = + category + .replace('-', ' ') + .replace('_', ' ') + .replaceFirstChar { if (it.isLowerCase()) it.titlecaseChar() else it } + } +} + +@Composable +fun PredictionMarketCard( + event: PredictionMarketEvent, + makeItShort: Boolean, +) { + val state = remember(event) { PredictionMarketCardState.from(event, TimeUtils.now()) } + + Column(MaterialTheme.colorScheme.replyModifier) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + MarketKicker(state.status) + + state.title?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.isDemo || state.category != null) { + MarketTags(state.isDemo, state.category) + } + + state.description?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + maxLines = if (makeItShort) 3 else 8, + overflow = TextOverflow.Ellipsis, + ) + } + + if (state.outcomes.isNotEmpty()) { + MarketOutcomes(state) + } + + if (state.status == PredictionMarketStatus.CANCELLED) { + state.cancelReason?.let { CancelReason(it) } + } else { + state.endsAt?.let { ClosingTime(it, state.status) } + } + + MarketTerms(state.minBetSats, state.maxBetSats, state.feePercent) + } + } +} + +@Composable +private fun MarketKicker(status: PredictionMarketStatus?) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = MaterialSymbols.AutoMirrored.ShowChart, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text( + text = stringRes(Res.string.prediction_market), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp).weight(1f), + ) + status?.let { MarketStatusPill(it) } + } +} + +@Composable +private fun MarketStatusPill(status: PredictionMarketStatus) { + when (status) { + PredictionMarketStatus.OPEN -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_open), + symbol = MaterialSymbols.RadioButtonChecked, + container = Color(0xFF1F883D), + content = Color.White, + ) + } + + PredictionMarketStatus.CLOSED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_closed), + symbol = MaterialSymbols.Lock, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + PredictionMarketStatus.RESOLVED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_resolved), + symbol = MaterialSymbols.CheckCircle, + container = MaterialTheme.colorScheme.primary, + content = MaterialTheme.colorScheme.onPrimary, + ) + } + + PredictionMarketStatus.CANCELLED -> { + StatusPill( + label = stringRes(Res.string.prediction_market_status_cancelled), + symbol = MaterialSymbols.Block, + container = Color(0xFFCF222E), + content = Color.White, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MarketTags( + isDemo: Boolean, + category: String?, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isDemo) { + val warning = MaterialTheme.colorScheme.warningColorOnSecondSurface + StatusPill( + label = stringRes(Res.string.prediction_market_demo), + symbol = MaterialSymbols.Science, + container = warning.copy(alpha = 0.16f), + content = warning, + ) + } + category?.let { + HeaderPill(symbol = MaterialSymbols.Tag, text = it) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MarketOutcomes(state: PredictionMarketCardState) { + val shown = if (state.outcomes.size > MAX_OUTCOMES) state.outcomes.subList(0, MAX_OUTCOMES) else state.outcomes + val hidden = state.outcomes.size - shown.size + // A cancelled market has no winner and takes no bets: its outcomes are shown, but muted. + val muted = state.status == PredictionMarketStatus.CANCELLED + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + shown.forEach { outcome -> + OutcomeChip(outcome, isWinner = state.isWinner(outcome), muted = muted) + } + if (hidden > 0) { + Text( + text = pluralStringRes(Res.plurals.prediction_market_more_outcomes, hidden, hidden), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.placeholderText, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + } +} + +@Composable +private fun OutcomeChip( + outcome: String, + isWinner: Boolean, + muted: Boolean, +) { + val success = MaterialTheme.colorScheme.allGoodColor + val borderColor = if (isWinner) success else MaterialTheme.colorScheme.subtleBorder + val textColor = + when { + isWinner -> success + muted -> MaterialTheme.colorScheme.placeholderText + else -> MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = + Modifier + .clip(OutcomeShape) + .background(if (isWinner) success.copy(alpha = 0.14f) else Color.Transparent) + .border(1.dp, borderColor, OutcomeShape) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isWinner) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(Res.string.prediction_market_winning_outcome), + tint = success, + filled = true, + modifier = Modifier.size(14.dp), + ) + } + Text( + text = outcome, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isWinner) FontWeight.Bold else FontWeight.Medium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ClosingTime( + endsAt: Long, + status: PredictionMarketStatus?, +) { + val stillOpen = status == PredictionMarketStatus.OPEN && endsAt > TimeUtils.now() + val text = + if (stillOpen) { + stringRes(Res.string.prediction_market_closes_in, timeAheadNoDot(endsAt)) + } else { + val date = remember(endsAt) { formatDateTime(endsAt * 1000, DateTimeStyle.MEDIUM, DateTimeStyle.NONE) } + stringRes(Res.string.prediction_market_closed_on, date) + } + + MarketFootnote( + symbol = if (stillOpen) MaterialSymbols.Schedule else MaterialSymbols.EventAvailable, + text = text, + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun CancelReason(reason: String) { + MarketFootnote( + symbol = MaterialSymbols.Block, + text = stringRes(Res.string.prediction_market_cancel_reason, reason), + color = MaterialTheme.colorScheme.redColorOnSecondSurface, + ) +} + +@Composable +private fun MarketTerms( + minBetSats: String?, + maxBetSats: String?, + feePercent: String?, +) { + val limits = + when { + minBetSats != null && maxBetSats != null -> { + stringRes(Res.string.prediction_market_bet_range, minBetSats, maxBetSats) + } + + minBetSats != null -> { + stringRes(Res.string.prediction_market_min_bet, minBetSats) + } + + maxBetSats != null -> { + stringRes(Res.string.prediction_market_max_bet, maxBetSats) + } + + else -> { + null + } + } + val fee = feePercent?.let { stringRes(Res.string.prediction_market_fee, it) } + + if (limits == null && fee == null) return + + MarketFootnote( + symbol = MaterialSymbols.Paid, + text = listOfNotNull(limits, fee).joinToString(" · "), + color = MaterialTheme.colorScheme.placeholderText, + ) +} + +@Composable +private fun MarketFootnote( + symbol: MaterialSymbol, + text: String, + color: Color, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + symbol = symbol, + contentDescription = null, + tint = color, + modifier = Modifier.size(14.dp), + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +@Preview +@Composable +fun PredictionMarketCardPreview() { + val resolved = + PredictionMarketEvent( + id = "3de5a164159cd7b3925c3d7aa16a5c32f8f01e50d296e2a3821bfbf5c22db82e", + pubKey = "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", + createdAt = 1778605676, + tags = + arrayOf( + arrayOf("d", "caec08eca65442725a5bd8f77e6d2666"), + arrayOf("market", "caec08eca65442725a5bd8f77e6d2666"), + arrayOf("category", "ai-llm"), + arrayOf("status", "resolved"), + arrayOf("end", "1777680000"), + arrayOf("network", "demo"), + arrayOf("fee_percent", "4.21"), + arrayOf("min_bet", "100"), + arrayOf("max_bet", "100000"), + arrayOf("outcome", "YES"), + arrayOf("outcome", "NO"), + arrayOf("title", "Will an AI coding tool appear in GitHub top trending repos today?"), + arrayOf("resolution", "NO"), + ), + content = "", + sig = "", + ) + + val open = + PredictionMarketEvent( + id = "31da397fdc3a5c61aaa3fc12e8413279f99175ecdba65443d0a2e2495d73f5df", + pubKey = "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", + createdAt = 1786702119, + tags = + arrayOf( + arrayOf("d", "181f3a7acd28280a49e81bf70ec22998"), + arrayOf("market", "181f3a7acd28280a49e81bf70ec22998"), + arrayOf("status", "active"), + arrayOf("category", "mining"), + arrayOf("end", (TimeUtils.now() + TimeUtils.ONE_DAY * 3).toString()), + arrayOf("network", "mainnet"), + arrayOf("outcome", "Foundry USA"), + arrayOf("outcome", "AntPool"), + arrayOf("outcome", "F2Pool"), + arrayOf("outcome", "ViaBTC"), + arrayOf("outcome", "Other"), + arrayOf("title", "Which mining pool will mine the most Bitcoin blocks today?"), + ), + content = "{\"description\":\"Resolves based on the total block count at midnight UTC.\"}", + sig = "", + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + PredictionMarketCard(resolved, makeItShort = true) + PredictionMarketCard(open, makeItShort = true) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.kt new file mode 100644 index 0000000000..d1d45d920a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotAnswer.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.quartz.experimental.ballots + +import androidx.compose.runtime.Immutable + +/** One answer on a ballot: the [question] (as the ballot names it, often an id) and the [answer] chosen. */ +@Immutable +data class BallotAnswer( + val question: String, + val answer: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt new file mode 100644 index 0000000000..522977068f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotContent.kt @@ -0,0 +1,100 @@ +/* + * 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.ballots + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The JSON `content` of a [BallotEvent]. Three shapes are seen in the wild, tried in order: + * + * - `responses`: `[{question_id, value}]`, one entry per question; a value may be a string, a + * number or a boolean. + * - `ballot`: an object mapping each question to its choice. + * - `vote_choice`: a single choice with no question, read as [BallotEvent.VOTE_QUESTION]. + * + * Values that are objects, arrays or null are skipped rather than stringified. + */ +@Immutable +class BallotContent( + val answers: List, + val proofHash: String?, +) { + companion object { + /** Parses [json], or returns null when it is not a JSON object. Never throws. */ + fun parse(json: String): BallotContent? { + if (!looksLikeJsonObject(json)) return null + + val obj = + try { + Json.parseToJsonElement(json) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + + return BallotContent( + answers = answers(obj), + proofHash = (obj["proof_hash"] as? JsonPrimitive)?.takeIf { it.isString }?.content?.ifBlank { null }, + ) + } + + private fun looksLikeJsonObject(text: String): Boolean { + for (c in text) { + if (!c.isWhitespace()) return c == '{' + } + return false + } + + private fun answers(obj: JsonObject): List { + val responses = obj["responses"] as? JsonArray + if (responses != null) { + return responses.mapNotNull { response -> + val entry = response as? JsonObject ?: return@mapNotNull null + val question = scalar(entry["question_id"]) ?: scalar(entry["question"]) ?: return@mapNotNull null + val answer = scalar(entry["value"]) ?: return@mapNotNull null + BallotAnswer(question, answer) + } + } + + val ballot = obj["ballot"] as? JsonObject + if (ballot != null) { + return ballot.mapNotNull { (question, choice) -> + scalar(choice)?.let { BallotAnswer(question, it) } + } + } + + return scalar(obj["vote_choice"])?.let { listOf(BallotAnswer(BallotEvent.VOTE_QUESTION, it)) } ?: emptyList() + } + + /** A string, number or boolean as text; null for anything else, and for blank strings. */ + private fun scalar(element: JsonElement?): String? = + if (element is JsonPrimitive && element !is JsonNull) { + element.content.ifBlank { null } + } else { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.kt new file mode 100644 index 0000000000..9b6a5a884e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEvent.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.experimental.ballots + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +/** + * A ballot cast in an auditable-voting app (kind 38000). + * + * App-specific and **not** defined by any NIP: kind 38000 is NIP-87's mint recommendation, and + * the voting app reuses the number. [com.vitorpamplona.quartz.utils.EventFactory] tells them + * apart by the `election` tag ([isBallot]). Amethyst only reads these — it never publishes them. + * + * The schema is derived from events seen in the wild: an `election` tag naming the election, an + * optional proof hash (`proof-hash` or `proof_hash` tag, or `proof_hash` in the content), and the + * choices as JSON `content` in one of three shapes (see [BallotContent]). The content is parsed at + * most once per event, lazily, and a malformed blob just reads as a ballot with no answers. + */ +@Immutable +class BallotEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + private val parsedContent by lazy { BallotContent.parse(content) } + + override fun indexableContent() = + buildList { + election()?.let { add(it) } + answers().forEach { add(it.answer) } + }.joinToString("\n") + + // The read path: the same fields indexableContent() joins, handed over without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(election())) return + for (answer in answers()) { + if (!visitor.visit(answer.answer)) return + } + } + + fun election() = tags.election() + + /** The ballot's answers in the order they were cast; empty when the content says nothing readable. */ + fun answers(): List = parsedContent?.answers ?: emptyList() + + fun proofHash() = tags.proofHash() ?: parsedContent?.proofHash + + companion object { + const val KIND = 38000 + + /** The question a lone `vote_choice` answers; the UI shows its own label for it. */ + const val VOTE_QUESTION = "Vote" + + /** True when a kind-38000 event is a ballot: it names the `election` it is cast in. */ + fun isBallot(tags: TagArray): Boolean = tags.fastAny { it.size > 1 && it[0] == "election" && it[1].isNotBlank() } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt new file mode 100644 index 0000000000..5664964db1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/ballots/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * 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.ballots + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull + +private fun TagArray.firstNonBlankValue(name: String): String? = fastFirstNotNullOfOrNull { if (it.size > 1 && it[0] == name && it[1].isNotBlank()) it[1] else null } + +/** The election this ballot is cast in. */ +fun TagArray.election() = firstNonBlankValue("election") + +/** The ballot's proof hash, from either spelling of the tag (`proof-hash` or `proof_hash`). */ +fun TagArray.proofHash() = firstNonBlankValue("proof-hash") ?: firstNonBlankValue("proof_hash") diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt new file mode 100644 index 0000000000..b1a72b6463 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt @@ -0,0 +1,118 @@ +/* + * 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.predictionMarkets + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.longOrNull + +/** + * The market fields BAO Markets writes as JSON — in the `data` tag of its current shape, or as the + * whole `content` of its BAO Fund and first shapes. Every field is optional: the shapes disagree + * on which ones they carry, and nothing here is trusted to be well-formed. + */ +@Immutable +class PredictionMarketDetails( + val title: String?, + val description: String?, + val outcomes: List, + val category: String?, + val status: String?, + val reason: String?, + val resolution: String?, + val endDate: Long?, + val resolutionSource: String?, + val minBetSats: Long?, + val maxBetSats: Long?, + val feePercent: Double?, +) { + companion object { + /** Parses [json] into details, or null when it is not a JSON object. Never throws. */ + fun parse(json: String?): PredictionMarketDetails? { + if (json == null || !looksLikeJsonObject(json)) return null + + val obj = + try { + Json.parseToJsonElement(json) as? JsonObject + } catch (_: Exception) { + null + } ?: return null + + return PredictionMarketDetails( + title = obj.text("title") ?: obj.text("question"), + description = obj.text("description"), + outcomes = obj.outcomes(), + category = obj.text("category"), + status = obj.text("status"), + reason = obj.text("reason"), + resolution = obj.text("resolution"), + endDate = obj.long("endDate") ?: obj.long("end"), + resolutionSource = obj.text("resolutionSource"), + minBetSats = obj.long("minBetSats"), + maxBetSats = obj.long("maxBetSats"), + feePercent = obj.double("feePercent"), + ) + } + + /** Skips the parser for the social-post `content` of the current shape: JSON objects start with `{`. */ + private fun looksLikeJsonObject(text: String): Boolean { + for (c in text) { + if (!c.isWhitespace()) return c == '{' + } + return false + } + + private fun JsonObject.primitive(key: String): JsonPrimitive? = this[key] as? JsonPrimitive + + /** A string field, or null when absent, blank, null or not a string. */ + private fun JsonObject.text(key: String): String? = primitive(key)?.takeIf { it.isString }?.content?.ifBlank { null } + + private fun JsonObject.long(key: String): Long? = primitive(key)?.longOrNull + + private fun JsonObject.double(key: String): Double? = primitive(key)?.doubleOrNull + + /** + * `outcomes` as display strings: a plain string per outcome (current shape), or an object + * per outcome whose `label` (else `id`, `name`) names it (BAO Fund and first shapes). + */ + private fun JsonObject.outcomes(): List { + val array = this["outcomes"] as? JsonArray ?: return emptyList() + val result = ArrayList(array.size) + for (element in array) { + val name = outcomeName(element) + if (name != null && name !in result) result.add(name) + } + return result + } + + private fun outcomeName(element: JsonElement): String? = + when (element) { + is JsonPrimitive -> element.takeIf { it.isString }?.content?.ifBlank { null } + is JsonObject -> element.text("label") ?: element.text("id") ?: element.text("name") + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt new file mode 100644 index 0000000000..5b23c4f6ee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt @@ -0,0 +1,172 @@ +/* + * 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.predictionMarkets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastForEach +import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A prediction market (kind 38000), as published by BAO Markets. + * + * App-specific and **not** defined by any NIP: kind 38000 is NIP-87's mint recommendation, and + * BAO Markets reuses the number. [com.vitorpamplona.quartz.utils.EventFactory] tells the two + * apart by tags ([isPredictionMarket]). Amethyst only reads these — it never publishes them. + * + * The schema is derived from events seen in the wild, in three shapes: + * + * - **Current**: everything in tags — `market`, `status` (also `state`, `s`), `end`, `network`/`n` + * ("demo" is play money), `category`/`c`, repeated `outcome`, `resolution`, `resolution_source`, + * `min_bet`/`max_bet` (sats), `fee_percent`, `oracle`, `cancel_reason`, `client`, and a `data` + * tag holding JSON `{title, description, outcomes, …}`. `content` is a human-readable social + * post of the same words. + * - **BAO Fund**: a `title` tag, `c` = bao-fund, `n`, `pool_model`, `end`, `["outcome", id, + * label]` tags, and JSON `content` `{title, description, outcomes: [{id, label}], …}`. + * - **First**: `type`, `category`, `oracle`, `end`, `state`, `platform_fee` tags and JSON + * `content` `{title, description, outcomes: [{id, label, probability}], …}`. + * + * Tags win over JSON wherever both carry a field. The JSON is parsed at most once per event, + * lazily, and a malformed blob just reads as absent. + */ +@Immutable +class PredictionMarketEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + private val dataDetails by lazy { PredictionMarketDetails.parse(tags.marketData()) } + + private val contentDetails by lazy { PredictionMarketDetails.parse(content) } + + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + + // The read path: the same fields indexableContent() joins, handed over without + // building the joined string a scan would throw away. + override fun forEachIndexableField(visitor: IndexableFieldVisitor) { + if (!visitor.visit(title())) return + visitor.visit(description()) + } + + fun marketId() = tags.marketId() + + /** The question being predicted. */ + fun title() = dataDetails?.title ?: tags.marketTitle() ?: contentDetails?.title + + fun description() = dataDetails?.description ?: contentDetails?.description + + /** The outcomes one can bet on: the `outcome` tags, else the JSON `outcomes`. */ + fun outcomes(): List = + tags.marketOutcomes().ifEmpty { + dataDetails?.outcomes?.ifEmpty { null } ?: contentDetails?.outcomes ?: emptyList() + } + + /** The winning outcome, once resolved. */ + fun resolution() = tags.marketResolution() ?: dataDetails?.resolution ?: contentDetails?.resolution + + /** When betting closes, unix seconds. */ + fun endsAt() = tags.marketEnd() ?: dataDetails?.endDate ?: contentDetails?.endDate + + fun category() = tags.marketCategory() ?: dataDetails?.category ?: contentDetails?.category + + /** `demo` (play money, most markets), `mainnet`, or whatever else the publisher names. */ + fun network() = tags.marketNetwork() + + /** True for play-money markets: nothing real is at stake. */ + fun isDemo() = network().equals(DEMO_NETWORK, ignoreCase = true) + + fun resolutionSource() = tags.marketResolutionSource() ?: dataDetails?.resolutionSource ?: contentDetails?.resolutionSource + + fun minBetSats() = tags.marketMinBet() ?: dataDetails?.minBetSats ?: contentDetails?.minBetSats + + fun maxBetSats() = tags.marketMaxBet() ?: dataDetails?.maxBetSats ?: contentDetails?.maxBetSats + + fun feePercent() = tags.marketFeePercent() ?: dataDetails?.feePercent ?: contentDetails?.feePercent + + fun cancelReason() = tags.marketCancelReason() ?: contentDetails?.reason + + fun oracle() = tags.marketOracle() + + fun client() = tags.marketClient() + + /** + * The market's status at [now] (unix seconds), or null when nothing says. + * + * The declared `status`/`state`/`s` word decides when it is one we know; a declared RESOLVED + * or CANCELLED is final. Otherwise the event's own evidence refines it: a `resolution` means + * RESOLVED, a `cancel_reason` on an undeclared market means CANCELLED, and an open market + * whose `end` has passed is CLOSED — publishers leave "active" in place long after betting + * stops. + */ + fun status(now: Long = TimeUtils.now()): PredictionMarketStatus? { + val declared = declaredStatus() + if (declared == PredictionMarketStatus.RESOLVED || declared == PredictionMarketStatus.CANCELLED) return declared + if (resolution() != null) return PredictionMarketStatus.RESOLVED + if (declared == PredictionMarketStatus.CLOSED) return declared + if (declared == null && cancelReason() != null) return PredictionMarketStatus.CANCELLED + + val end = endsAt() + return when { + end != null -> if (end > now) PredictionMarketStatus.OPEN else PredictionMarketStatus.CLOSED + else -> declared + } + } + + private fun declaredStatus(): PredictionMarketStatus? = + tags.marketStatusValues().firstNotNullOfOrNull(PredictionMarketStatus::parse) + ?: contentDetails?.status?.let(PredictionMarketStatus::parse) + + companion object { + const val KIND = 38000 + const val DEMO_NETWORK = "demo" + + /** + * True when a kind-38000 event is shaped like a prediction market: it names a `market`, + * offers at least two `outcome`s, or declares a market `type` with an `end`. Checked + * after [com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent.isMintRecommendation] + * and [com.vitorpamplona.quartz.experimental.ballots.BallotEvent.isBallot]. + */ + fun isPredictionMarket(tags: TagArray): Boolean { + var outcomes = 0 + var hasType = false + var hasEnd = false + tags.fastForEach { + if (it.size > 1) { + when (it[0]) { + "market" -> if (it[1].isNotBlank()) return true + "outcome" -> outcomes++ + "type" -> hasType = true + "end" -> hasEnd = true + } + } + } + return outcomes >= 2 || (hasType && hasEnd) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.kt new file mode 100644 index 0000000000..44c595f070 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketStatus.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.quartz.experimental.predictionMarkets + +/** Where a prediction market is in its life, collapsed from the many words publishers use for it. */ +enum class PredictionMarketStatus { + /** Taking bets (or, before that, being funded). */ + OPEN, + + /** No longer taking bets, not resolved yet. */ + CLOSED, + + /** The oracle has named a winning outcome. */ + RESOLVED, + + /** Voided: bets are refunded and no outcome wins. */ + CANCELLED, + ; + + companion object { + /** Maps a published `status`/`state` word to a [PredictionMarketStatus], or null when it is not one we know. */ + fun parse(value: String): PredictionMarketStatus? = + when (value.trim().lowercase()) { + "active", "open", "funding" -> OPEN + "resolving", "ended", "closed" -> CLOSED + "resolved", "settled" -> RESOLVED + "voided", "void", "cancelled", "canceled" -> CANCELLED + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt new file mode 100644 index 0000000000..c4cdd43c4a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.predictionMarkets + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip01Core.core.fastForEach + +/** The first non-blank value of a tag named [name], or null. */ +private fun TagArray.firstNonBlankValue(name: String): String? = fastFirstNotNullOfOrNull { if (it.size > 1 && it[0] == name && it[1].isNotBlank()) it[1] else null } + +/** The first non-blank value among [first] and then [second], in that priority order. */ +private fun TagArray.firstNonBlankValue( + first: String, + second: String, +): String? = firstNonBlankValue(first) ?: firstNonBlankValue(second) + +fun TagArray.marketId() = firstNonBlankValue("market") + +fun TagArray.marketTitle() = firstNonBlankValue("title") + +/** The JSON blob of the `data` tag BAO Markets attaches to its current event shape. */ +fun TagArray.marketData() = firstNonBlankValue("data") + +fun TagArray.marketType() = firstNonBlankValue("type") + +/** + * The declared lifecycle state, tried by tag name in priority order: `status` first, then the + * older `state` and the one-letter `s`. BAO Markets writes all three but lets them drift apart + * (an "active" status next to an "ended" state), and `status` is the one it keeps current. + */ +fun TagArray.marketStatusValues(): List = listOfNotNull(firstNonBlankValue("status"), firstNonBlankValue("state"), firstNonBlankValue("s")) + +/** When betting closes, unix seconds. */ +fun TagArray.marketEnd() = firstNonBlankValue("end")?.trim()?.toLongOrNull() + +fun TagArray.marketNetwork() = firstNonBlankValue("network", "n") + +fun TagArray.marketCategory() = firstNonBlankValue("category", "c") + +/** + * Every `outcome` tag's value, in order. The BAO Fund shape writes `["outcome", id, label]`; + * the id is what a `resolution` names, so it is the one returned. + */ +fun TagArray.marketOutcomes(): List { + val result = ArrayList(2) + fastForEach { + if (it.size > 1 && it[0] == "outcome" && it[1].isNotBlank() && it[1] !in result) result.add(it[1]) + } + return result +} + +/** The winning outcome, once the market is resolved. */ +fun TagArray.marketResolution() = firstNonBlankValue("resolution") + +fun TagArray.marketResolutionSource() = firstNonBlankValue("resolution_source") + +fun TagArray.marketMinBet() = firstNonBlankValue("min_bet")?.trim()?.toLongOrNull() + +fun TagArray.marketMaxBet() = firstNonBlankValue("max_bet")?.trim()?.toLongOrNull() + +fun TagArray.marketFeePercent() = firstNonBlankValue("fee_percent")?.trim()?.toDoubleOrNull() + +fun TagArray.marketOracle() = firstNonBlankValue("oracle") + +fun TagArray.marketCancelReason() = firstNonBlankValue("cancel_reason") + +fun TagArray.marketPoolModel() = firstNonBlankValue("pool_model") + +fun TagArray.marketClient() = firstNonBlankValue("client") 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 b098e30d02..ed1735b0a1 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 @@ -521,8 +521,9 @@ class FullTextSearchModule( /** * The distinct kinds present in `event_headers` that currently parse - * to a [SearchableEvent]. Kind alone selects the event class in - * [EventFactory], so one probe per distinct kind is authoritative; + * to a [SearchableEvent]. One [EventFactory.probe] per distinct kind + * decides (a kind whose class is picked by tags answers as its + * primary class; the insert path still checks each event itself); * the result drives a `kind IN (...)` filter on the rebuild scan so * non-searchable rows are skipped at the SQL layer. */ @@ -537,15 +538,9 @@ class FullTextSearchModule( return out } - private fun isSearchableKind(kind: Int): Boolean = EventFactory.create(PROBE_ID, PROBE_ID, 0L, kind, EMPTY_TAGS, "", "") is SearchableEvent + private fun isSearchableKind(kind: Int): Boolean = EventFactory.probe(kind) is SearchableEvent companion object { - // A non-blank id keeps kinds that lazily hash a missing id (e.g. - // NIP-17 chat messages) from doing that work — the probe only - // 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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt index 467e9a2fd6..962b0142ed 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt @@ -23,10 +23,14 @@ package com.vitorpamplona.quartz.nip87Ecash.recommendation import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.core.fastAny +import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip87Ecash.MintUrlTag import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -62,6 +66,29 @@ class MintRecommendationEvent( companion object { const val KIND = 38000 + /** + * True when a kind-38000 event is actually a NIP-87 mint recommendation. + * + * Kind 38000 is shared with unrelated apps (prediction markets, ballots, spam votes), so + * the kind number alone does not say what an event is. A recommendation names the kind of + * the mint it recommends in its `k` tag — 38172 (Cashu) or 38173 (Fedimint). Some early + * recommendations carry no `k` at all; those are accepted when they point at a mint URL + * (`u`). A `k` naming anything else is not a mint recommendation, whatever else it carries. + */ + fun isMintRecommendation(tags: TagArray): Boolean { + val kTag = tags.fastFirstOrNull { it.size > 1 && it[0] == "k" } + if (kTag != null) { + val mintKind = kTag[1].toIntOrNull() + return mintKind == CashuMintEvent.KIND || mintKind == FedimintEvent.KIND + } + return tags.fastAny { it.size > 1 && it[0] == MintUrlTag.TAG_NAME && it[1].isNotBlank() } + } + + /** + * [mintKind] must be [CashuMintEvent.KIND] or [FedimintEvent.KIND]: the `k` tag it writes is + * what [isMintRecommendation] reads back, so any other value signs into a plain kind-38000 + * event rather than a [MintRecommendationEvent]. + */ fun build( mintIdentifier: String, mintKind: Int, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 8e42496847..c3d7d2969b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -124,6 +124,7 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent @@ -160,6 +161,7 @@ import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent import com.vitorpamplona.quartz.experimental.nns.NNSEvent import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent import com.vitorpamplona.quartz.experimental.publications.PublicationContentEvent @@ -755,7 +757,20 @@ class EventFactory { MeetingRoomPresenceEvent.KIND -> MeetingRoomPresenceEvent(id, pubKey, createdAt, tags, content, sig) MeetingSpaceEvent.KIND -> MeetingSpaceEvent(id, pubKey, createdAt, tags, content, sig) AdminCommandEvent.KIND -> AdminCommandEvent(id, pubKey, createdAt, tags, content, sig) - MintRecommendationEvent.KIND -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + // kind:38000 is NIP-87's mint recommendation, but unrelated apps reuse the number: + // BAO Markets publishes prediction markets on it, an auditable-voting app publishes + // ballots, and most of what a big relay holds for it is spam (`d`-only "sybil test + // votes"). Parsing all of it as a recommendation rendered markets, ballots and spam + // as ecash mint cards. Disambiguate by tags, recommendation first — its `k` (the + // recommended mint's kind) or, on old events without one, its `u` (mint URL) — and + // fall back to a plain Event so junk gets no card and no search index entry. + MintRecommendationEvent.KIND -> + when { + MintRecommendationEvent.isMintRecommendation(tags) -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) + BallotEvent.isBallot(tags) -> BallotEvent(id, pubKey, createdAt, tags, content, sig) + PredictionMarketEvent.isPredictionMarket(tags) -> PredictionMarketEvent(id, pubKey, createdAt, tags, content, sig) + else -> Event(id, pubKey, createdAt, kind, tags, content, sig) + } MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) MetadataEvent.KIND -> MetadataEvent(id, pubKey, createdAt, tags, content, sig) @@ -940,6 +955,29 @@ class EventFactory { * Used to decide whether a repost's inner (boosted) kind is something * Amethyst can parse and render at all. */ - fun isKnownKind(kind: Int): Boolean = create("", "", 0L, kind, emptyArray(), "", "")::class != Event::class + fun isKnownKind(kind: Int): Boolean = probe(kind)::class != Event::class + + /** + * A tagless, contentless instance of [kind], for questions asked of a kind rather than of + * an event: does it have a typed class ([isKnownKind]), is it searchable (the stores' + * reindex pre-filters, `SearchableKinds`)? + * + * For most kinds that is just [create] with no tags. It is not for kinds whose class is + * chosen by tags, where a tagless event can land on a different class than the kind's + * real events. Kind 38000 is the case that matters: with no tags it is a plain [Event] + * (junk), yet every typed shape of it — mint recommendation, ballot, prediction market — + * is a searchable, renderable class, so it answers as its primary class. (The other + * tag-split kinds, 39005 and 20001, already land on a typed class with no tags.) + * + * The id is non-blank so kinds that lazily hash a missing id (NIP-17 chat) skip that work + * — only the runtime type matters here. + */ + fun probe(kind: Int): Event = + when (kind) { + MintRecommendationEvent.KIND -> MintRecommendationEvent(PROBE_ID, PROBE_ID, 0L, emptyArray(), "", "") + else -> create(PROBE_ID, PROBE_ID, 0L, kind, emptyArray(), "", "") + } + + private const val PROBE_ID = "0" } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt new file mode 100644 index 0000000000..87529c7bea --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/ballots/BallotEventTest.kt @@ -0,0 +1,137 @@ +/* + * 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.ballots + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Kind38000Fixtures +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class BallotEventTest { + private fun parse(json: String) = assertIs(Event.fromJson(json)) + + private fun ballotWith( + content: String, + vararg extraTags: Array, + ) = BallotEvent("0".repeat(64), "1".repeat(64), 1L, arrayOf(arrayOf("election", "e1"), *extraTags), content, "") + + @Test + fun responsesShape() { + val event = parse(Kind38000Fixtures.BALLOT_RESPONSES) + + assertEquals("sec06-feedback-1774311582", event.election()) + val answers = event.answers() + assertEquals(10, answers.size) + assertEquals(BallotAnswer("q1", "Yes"), answers[0]) + // Numbers read as text. + assertEquals(BallotAnswer("q3", "5"), answers[2]) + assertEquals(BallotAnswer("q9", "NA"), answers[8]) + assertNull(event.proofHash()) + } + + @Test + fun responsesWithProofHashTag() { + val event = parse(Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH) + + assertEquals("spring-2026-council", event.election()) + assertEquals(listOf(BallotAnswer("proposal_approval", "Yes")), event.answers()) + assertEquals("361689b5ccd44f35eebba995129d8f2b00dd369aca4c51b55f00065b2b477ddd", event.proofHash()) + assertEquals("spring-2026-council\nYes", event.indexableContent()) + } + + @Test + fun ballotObjectShape() { + val event = parse(Kind38000Fixtures.BALLOT_OBJECT) + + assertEquals("spring-2026-council", event.election()) + assertEquals( + listOf( + BallotAnswer("funding_priority", "community-grants"), + BallotAnswer("audit_policy", "every-election"), + ), + event.answers(), + ) + assertEquals("bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95", event.proofHash()) + } + + @Test + fun voteChoiceShape() { + val event = parse(Kind38000Fixtures.BALLOT_VOTE_CHOICE) + + assertEquals("election1", event.election()) + assertEquals(listOf(BallotAnswer(BallotEvent.VOTE_QUESTION, "yes")), event.answers()) + assertNull(event.proofHash()) + } + + @Test + fun proofHashFallsBackToContent() { + assertEquals("abc", ballotWith("""{"proof_hash":"abc","vote_choice":"no"}""").proofHash()) + assertEquals("tag", ballotWith("""{"proof_hash":"abc"}""", arrayOf("proof_hash", "tag")).proofHash()) + assertEquals("dash", ballotWith("""{"proof_hash":"abc"}""", arrayOf("proof_hash", "underscore"), arrayOf("proof-hash", "dash")).proofHash()) + } + + @Test + fun nonScalarValuesAreSkipped() { + val responses = + ballotWith( + """{"responses":[1,{"question_id":"q","value":{"x":1}},{"question_id":"q2","value":null},{"value":"orphan"},""" + + """{"question_id":"q3","value":true},{"question_id":4,"value":2.5},{"question_id":"q5","value":" "}]}""", + ) + assertEquals(listOf(BallotAnswer("q3", "true"), BallotAnswer("4", "2.5")), responses.answers()) + + val ballotObject = ballotWith("""{"ballot":{"a":[1],"b":2,"c":null,"d":{"x":"y"},"e":"E"}}""") + assertEquals(listOf(BallotAnswer("b", "2"), BallotAnswer("e", "E")), ballotObject.answers()) + + assertEquals(emptyList(), ballotWith("""{"vote_choice":{"x":1}}""").answers()) + } + + @Test + fun junkJsonNeverThrows() { + listOf("", "not json", "{", "[]", "null", "{\"responses\":", "{\"responses\": \"x\", \"ballot\": 3}", "{\"a\":".repeat(5000)).forEach { + val event = ballotWith(it) + assertEquals(emptyList(), event.answers()) + assertNull(event.proofHash()) + assertEquals("e1", event.indexableContent()) + } + } + + @Test + fun visitorAgreesWithIndexedContentAndStops() { + listOf(Kind38000Fixtures.BALLOT_RESPONSES, Kind38000Fixtures.BALLOT_OBJECT, Kind38000Fixtures.BALLOT_VOTE_CHOICE).forEach { json -> + val event = parse(json) + val visited = mutableListOf() + event.forEachIndexableField { + if (it != null) visited.add(it) + true + } + assertEquals(event.indexableContent(), visited.joinToString(event.indexableSeparator())) + + var seen = 0 + event.forEachIndexableField { + seen++ + false + } + assertEquals(1, seen) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt new file mode 100644 index 0000000000..48eeb74508 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt @@ -0,0 +1,284 @@ +/* + * 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.predictionMarkets + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.Kind38000Fixtures +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PredictionMarketEventTest { + private fun market(json: String) = assertIs(Event.fromJson(json)) + + private fun market( + tags: Array>, + content: String = "", + ) = PredictionMarketEvent("0".repeat(64), "1".repeat(64), 1L, tags, content, "") + + @Test + fun currentShapeResolvedBinary() { + val event = market(Kind38000Fixtures.MARKET_RESOLVED_BINARY) + + assertEquals("caec08eca65442725a5bd8f77e6d2666", event.marketId()) + assertEquals("Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)", event.title()) + assertTrue(event.description()!!.startsWith("At least one repository in GitHub's daily trending page")) + assertEquals(listOf("YES", "NO"), event.outcomes()) + assertEquals("NO", event.resolution()) + assertEquals(PredictionMarketStatus.RESOLVED, event.status()) + assertEquals(1777680000L, event.endsAt()) + assertEquals("ai-llm", event.category()) + assertEquals("demo", event.network()) + assertTrue(event.isDemo()) + assertEquals("github.com/trending daily repos", event.resolutionSource()) + assertEquals(100L, event.minBetSats()) + assertEquals(100000L, event.maxBetSats()) + assertEquals(4.21, event.feePercent()) + assertNull(event.cancelReason()) + assertEquals("BAO Markets", event.client()) + assertEquals("2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", event.oracle()) + assertEquals("${event.title()}\n${event.description()}", event.indexableContent()) + } + + @Test + fun currentShapeActiveMarketClosesAtItsEnd() { + val event = market(Kind38000Fixtures.MARKET_ACTIVE_BINARY) + val end = 1777939200L + + assertEquals(end, event.endsAt()) + assertEquals("bitcoin", event.category()) + assertNull(event.resolution()) + // `status` says "active" (it wins over `state` = "ended")… + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 1)) + // …but once `end` passes, betting is over. + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end)) + assertEquals(PredictionMarketStatus.CLOSED, event.status()) + assertEquals("https://mempool.space/api/v1/blocks", event.resolutionSource()) + } + + @Test + fun currentShapeVoidedMultiOutcome() { + val event = market(Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME) + + assertEquals(listOf("Foundry USA", "AntPool", "F2Pool", "ViaBTC", "Other"), event.outcomes()) + assertEquals(PredictionMarketStatus.CANCELLED, event.status(now = 0)) + assertEquals("mining", event.category()) + assertTrue(event.title()!!.startsWith("Which mining pool will mine the most Bitcoin blocks today?")) + } + + @Test + fun currentShapeCancelled() { + val event = market(Kind38000Fixtures.MARKET_CANCELLED) + + assertEquals("Will BTC price be above \$60,000 at 2026-08-13 13:00 UTC?", event.title()) + assertNull(event.description()) + assertEquals(emptyList(), event.outcomes()) + assertEquals(PredictionMarketStatus.CANCELLED, event.status()) + assertEquals("bridge-test junk", event.cancelReason()) + assertEquals(1786626000L, event.endsAt()) + assertEquals("bitcoin", event.category()) + assertTrue(event.isDemo()) + assertEquals(event.title(), event.indexableContent()) + } + + @Test + fun baoFundShape() { + val event = market(Kind38000Fixtures.MARKET_BAO_FUND) + val end = 1790727795L + + assertEquals("Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?", event.title()) + assertTrue(event.description()!!.contains("CRITERIA: E2E milestone munbn1yg")) + assertEquals(listOf("YES", "NO"), event.outcomes()) + assertEquals("bao-fund", event.category()) + assertTrue(event.isDemo()) + assertEquals(end, event.endsAt()) + // No status tag at all: the end decides. + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 100)) + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end + 1)) + assertNull(event.minBetSats()) + assertNull(event.feePercent()) + } + + @Test + fun firstShape() { + val event = market(Kind38000Fixtures.MARKET_FIRST_SHAPE) + val end = 1769256240L + + assertEquals("will trump turn purple before May?", event.title()) + assertEquals("Trump is orange, but when purple? I think he hes been orange for far too long", event.description()) + // Outcome objects read by their label. + assertEquals(listOf("Yes", "No"), event.outcomes()) + assertEquals("nostr", event.category()) + assertEquals("community", event.oracle()) + assertNull(event.network()) + assertFalse(event.isDemo()) + assertEquals(end, event.endsAt()) + // `state` = "funding" is open, until the end passes. + assertEquals(PredictionMarketStatus.OPEN, event.status(now = end - 1)) + assertEquals(PredictionMarketStatus.CLOSED, event.status(now = end + 1)) + } + + @Test + fun statusWords() { + val expected = + mapOf( + "active" to PredictionMarketStatus.OPEN, + "Open" to PredictionMarketStatus.OPEN, + "funding" to PredictionMarketStatus.OPEN, + "resolving" to PredictionMarketStatus.CLOSED, + "ended" to PredictionMarketStatus.CLOSED, + "closed" to PredictionMarketStatus.CLOSED, + "resolved" to PredictionMarketStatus.RESOLVED, + "settled" to PredictionMarketStatus.RESOLVED, + "voided" to PredictionMarketStatus.CANCELLED, + "cancelled" to PredictionMarketStatus.CANCELLED, + " CANCELED " to PredictionMarketStatus.CANCELLED, + ) + expected.forEach { (word, status) -> assertEquals(status, PredictionMarketStatus.parse(word), word) } + assertNull(PredictionMarketStatus.parse("pending")) + assertNull(PredictionMarketStatus.parse("")) + } + + @Test + fun statusFallbacks() { + // No status at all, no end: unknown. + assertNull(market(arrayOf(arrayOf("market", "m"))).status(now = 10)) + // An unknown word falls through to the next tag name. + assertEquals( + PredictionMarketStatus.RESOLVED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "weird"), arrayOf("state", "resolved"))).status(now = 10), + ) + // A resolution resolves an undeclared or still-open market. + assertEquals(PredictionMarketStatus.RESOLVED, market(arrayOf(arrayOf("market", "m"), arrayOf("resolution", "YES"))).status(now = 10)) + assertEquals( + PredictionMarketStatus.RESOLVED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "active"), arrayOf("resolution", "YES"))).status(now = 10), + ) + // …but never un-cancels one. + assertEquals( + PredictionMarketStatus.CANCELLED, + market(arrayOf(arrayOf("market", "m"), arrayOf("status", "voided"), arrayOf("resolution", "YES"))).status(now = 10), + ) + // A cancel reason on an undeclared market cancels it. + assertEquals(PredictionMarketStatus.CANCELLED, market(arrayOf(arrayOf("market", "m"), arrayOf("cancel_reason", "oops"))).status(now = 10)) + // Undeclared: the end decides. + assertEquals(PredictionMarketStatus.OPEN, market(arrayOf(arrayOf("market", "m"), arrayOf("end", "20"))).status(now = 10)) + assertEquals(PredictionMarketStatus.CLOSED, market(arrayOf(arrayOf("market", "m"), arrayOf("end", "20"))).status(now = 20)) + // A status in JSON content counts when no tag declares one. + assertEquals(PredictionMarketStatus.CANCELLED, market(arrayOf(arrayOf("market", "m")), """{"status":"cancelled"}""").status(now = 10)) + } + + @Test + fun tagsWinOverJson() { + val event = + market( + arrayOf( + arrayOf("market", "m"), + arrayOf("title", "From tag"), + arrayOf("min_bet", "5"), + arrayOf("outcome", "A"), + arrayOf("outcome", "B"), + arrayOf("data", """{"title":"From data","minBetSats":7,"maxBetSats":9,"outcomes":["X","Y"]}"""), + ), + """{"title":"From content","description":"Content description"}""", + ) + + // `data` is BAO's canonical blob, so its title beats the tag; the tag beats JSON content. + assertEquals("From data", event.title()) + assertEquals("Content description", event.description()) + assertEquals(5L, event.minBetSats()) + assertEquals(9L, event.maxBetSats()) + assertEquals(listOf("A", "B"), event.outcomes()) + } + + @Test + fun junkJsonNeverThrows() { + val junk = + listOf( + "", + " ", + "not json at all", + "{", + "{\"title\":", + "[1,2,3]", + "null", + "{\"a\":".repeat(5000), + "{\"title\": 5, \"description\": null, \"outcomes\": \"nope\", \"minBetSats\": \"abc\", \"feePercent\": {}}", + "{\"outcomes\": [1, null, [], {\"id\": 7}, {\"label\": \"A\"}, \"B\", \"B\", \" \"]}", + ) + + junk.forEach { json -> + listOf( + market(arrayOf(arrayOf("market", "m"), arrayOf("data", json)), json), + market(arrayOf(arrayOf("type", "binary"), arrayOf("end", "not a number"), arrayOf("min_bet", "x"), arrayOf("fee_percent", "y")), json), + ).forEach { event -> + event.title() + event.description() + event.outcomes() + event.status(now = 10) + event.endsAt() + event.minBetSats() + event.maxBetSats() + event.feePercent() + event.cancelReason() + event.indexableContent() + } + } + + val odd = market(arrayOf(arrayOf("market", "m")), junk.last()) + assertEquals(listOf("A", "B"), odd.outcomes()) + + val typed = market(arrayOf(arrayOf("market", "m"), arrayOf("min_bet", "x")), junk[junk.size - 2]) + assertNull(typed.title()) + assertNull(typed.description()) + assertEquals(emptyList(), typed.outcomes()) + assertNull(typed.minBetSats()) + assertNull(typed.feePercent()) + assertEquals("", typed.indexableContent()) + } + + @Test + fun visitorAgreesWithIndexedContentAndStops() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + ).forEach { json -> + val event = market(json) + val visited = mutableListOf() + event.forEachIndexableField { + if (it != null) visited.add(it) + true + } + assertEquals(event.indexableContent(), visited.joinToString(event.indexableSeparator())) + + var seen = 0 + event.forEachIndexableField { + seen++ + false + } + assertEquals(1, seen) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt index c23e969128..c2852f1a74 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableKindsTest.kt @@ -37,7 +37,9 @@ import kotlin.test.assertEquals * being left out of everything downstream that reads this list. */ class SearchableKindsTest { - private fun build(kind: Int): Event = EventFactory.create("9".repeat(64), "a".repeat(64), 1L, kind, emptyArray(), "", "") + // Through the factory's kind probe, like the stores' reindex pre-filters: a kind whose class + // is picked by tags (38000) answers as its primary class rather than as a tagless plain Event. + private fun build(kind: Int): Event = EventFactory.probe(kind) @Test fun theRecordedSetIsExactlyWhatTheFactoryBuilds() { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt new file mode 100644 index 0000000000..f622681611 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt @@ -0,0 +1,184 @@ +/* + * 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 + +import com.vitorpamplona.quartz.experimental.ballots.BallotEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent +import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * Kind 38000 is NIP-87's mint recommendation, but prediction markets, ballots and spam share the + * number. The factory must hand each shape its own class, and junk none. + */ +class EventFactoryKind38000Test { + private fun parse(json: String): Event = Event.fromJson(json) + + private fun build(vararg tags: Array): Event = EventFactory.create("0".repeat(64), "1".repeat(64), 1L, 38000, arrayOf(*tags), "", "") + + @Test + fun fixturesAreRealSignedEvents() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_ACTIVE_BINARY, + Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_BAO_FUND, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + Kind38000Fixtures.BALLOT_RESPONSES, + Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH, + Kind38000Fixtures.BALLOT_OBJECT, + Kind38000Fixtures.BALLOT_VOTE_CHOICE, + Kind38000Fixtures.MINT_REVIEW_CASHU, + Kind38000Fixtures.MINT_REVIEW_FEDIMINT, + Kind38000Fixtures.MINT_REVIEW_NO_K, + Kind38000Fixtures.SPAM_SYBIL_VOTE, + Kind38000Fixtures.SPAM_APP_MANIFEST, + ).forEach { json -> + val event = parse(json) + assertEquals(38000, event.kind) + assertTrue(event.verify(), "fixture ${event.id} does not verify") + } + } + + @Test + fun mintReviewsAreMintRecommendations() { + val cashu = assertIs(parse(Kind38000Fixtures.MINT_REVIEW_CASHU)) + assertTrue(cashu.isCashuRecommendation()) + assertEquals(listOf("https://21mint.me"), cashu.mintUrls()) + + val fedimint = assertIs(parse(Kind38000Fixtures.MINT_REVIEW_FEDIMINT)) + assertTrue(fedimint.isFedimintRecommendation()) + + val noK = assertIs(parse(Kind38000Fixtures.MINT_REVIEW_NO_K)) + assertEquals(listOf("https://mint.lnpay.cz"), noK.mintUrls()) + } + + @Test + fun marketsArePredictionMarkets() { + listOf( + Kind38000Fixtures.MARKET_RESOLVED_BINARY, + Kind38000Fixtures.MARKET_ACTIVE_BINARY, + Kind38000Fixtures.MARKET_VOIDED_MULTI_OUTCOME, + Kind38000Fixtures.MARKET_CANCELLED, + Kind38000Fixtures.MARKET_BAO_FUND, + Kind38000Fixtures.MARKET_FIRST_SHAPE, + ).forEach { assertIs(parse(it)) } + } + + @Test + fun ballotsAreBallots() { + listOf( + Kind38000Fixtures.BALLOT_RESPONSES, + Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH, + Kind38000Fixtures.BALLOT_OBJECT, + Kind38000Fixtures.BALLOT_VOTE_CHOICE, + ).forEach { assertIs(parse(it)) } + } + + @Test + fun spamAndOneOffsArePlainEvents() { + listOf( + Kind38000Fixtures.SPAM_SYBIL_VOTE, + Kind38000Fixtures.SPAM_APP_MANIFEST, + ).forEach { + val event = parse(it) + assertEquals(Event::class, event::class) + assertFalse(event is SearchableEvent) + } + } + + @Test + fun aKNamingAnotherKindIsNotARecommendationEvenWithAMintUrl() { + assertEquals(Event::class, build(arrayOf("k", "1"), arrayOf("u", "https://mint.example"))::class) + // The first k decides. + assertEquals(Event::class, build(arrayOf("k", "1"), arrayOf("k", "38172"))::class) + assertIs(build(arrayOf("k", "38173"), arrayOf("k", "1"))) + } + + @Test + fun aBlankMintUrlWithoutKIsNotARecommendation() { + assertEquals(Event::class, build(arrayOf("d", "x"), arrayOf("u", " "))::class) + assertEquals(Event::class, build(arrayOf("u"))::class) + } + + @Test + fun recommendationWinsOverTheOtherShapes() { + assertIs(build(arrayOf("k", "38172"), arrayOf("election", "e1"), arrayOf("market", "m1"))) + } + + @Test + fun ballotWinsOverMarket() { + assertIs(build(arrayOf("election", "e1"), arrayOf("market", "m1"))) + } + + @Test + fun aBlankElectionIsNotABallot() { + assertEquals(Event::class, build(arrayOf("election", ""))::class) + } + + @Test + fun marketNeedsAMarketTwoOutcomesOrATypeAndAnEnd() { + assertIs(build(arrayOf("market", "m1"))) + assertIs(build(arrayOf("outcome", "YES"), arrayOf("outcome", "NO"))) + assertIs(build(arrayOf("type", "binary"), arrayOf("end", "1"))) + + assertEquals(Event::class, build(arrayOf("market", " "))::class) + assertEquals(Event::class, build(arrayOf("outcome", "YES"))::class) + assertEquals(Event::class, build(arrayOf("type", "binary"))::class) + assertEquals(Event::class, build(arrayOf("end", "1"))::class) + } + + @Test + fun signingARecommendationRoundTripsToARecommendation() { + val signer = NostrSignerSync() + listOf(CashuMintEvent.KIND, FedimintEvent.KIND).forEach { mintKind -> + val signed = + signer.sign( + MintRecommendationEvent.build( + mintIdentifier = "https://mint.example", + mintKind = mintKind, + review = "great", + ), + ) + assertIs(signed) + assertEquals(mintKind, signed.mintEventKind()) + assertIs(Event.fromJson(signed.toJson())) + } + } + + @Test + fun theKindStillProbesAsAKnownSearchableKind() { + assertTrue(EventFactory.isKnownKind(MintRecommendationEvent.KIND)) + assertIs(EventFactory.probe(MintRecommendationEvent.KIND)) + assertTrue(EventFactory.probe(MintRecommendationEvent.KIND) is SearchableEvent) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt new file mode 100644 index 0000000000..32e406b69a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Kind38000Fixtures.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Real kind-38000 events from a production search relay, one per shape seen in a 12-month census + * (15,347 events): NIP-87 mint recommendations, BAO Markets prediction markets, auditable-voting + * ballots, and the spam and one-offs that are none of these. + */ +object Kind38000Fixtures { + /** BAO Markets, current shape: a resolved YES/NO market (resolution = NO). */ + const val MARKET_RESOLVED_BINARY = + """{"id": "3de5a164159cd7b3925c3d7aa16a5c32f8f01e50d296e2a3821bfbf5c22db82e", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1778605676, "kind": 38000, "tags": [["d", "caec08eca65442725a5bd8f77e6d2666"], ["market", "caec08eca65442725a5bd8f77e6d2666"], ["m", "caec08eca65442725a5bd8f77e6d2666"], ["type", "binary"], ["w", "binary"], ["category", "ai-llm"], ["c", "ai-llm"], ["state", "resolved"], ["status", "resolved"], ["s", "resolved"], ["end", "1777680000"], ["yes_hash", "e7713ddb231bdd5fdb32eeb902786019894cfbb3d076992b5c3b9514be1870b5"], ["no_hash", "b57e7121377201283659de7144e78f87332e568142edaf24accbd97b445eff00"], ["yes_adaptor_point", "04188a0f14f632aa77b1aa8065ddf541556e5ddfe654a22304fb8661710cb1f6e48d5c606f280ec4a265e8ab45ff9c529a11d53da0ecb6aeaae285a977a4a81b3f"], ["no_adaptor_point", "04b43fe7b12d84d5c6ac9368447c85fda62b57c5bbe7e6acaf4de0e31728703b68cd977cc8e3a20a1c2a2919f3175317debfac1f85169c14f0f30802f182f151f8"], ["oracle", "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349"], ["expiry_blocks", "205"], ["network", "demo"], ["n", "demo"], ["flash", "true"], ["duration_blocks", "61"], ["oracle_tier", "2"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "ai-llm"], ["client", "BAO Markets"], ["resolution_source", "github.com/trending daily repos"], ["escrow_method", "htlc"], ["outcome", "YES"], ["outcome", "NO"], ["data", "{\"title\":\"Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)\",\"description\":\"At least one repository in GitHub's daily trending page is directly related to AI coding tools (e.g., Copilot alternatives, AI code generation, AI-powered IDEs). Resolution via github.com/trending.\",\"outcomes\":[\"YES\",\"NO\"],\"isFlash\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"github.com/trending daily repos\",\"resolutionLogic\":\"\"}"], ["poll", "9f1a858603ca85c2d52d2a63710c0c5162b8af6489b9309e547c93dcf09303bb"], ["resolution", "NO"]], "content": "🤖 Will an AI coding tool appear in GitHub top trending repos today? (UTC 2,May,2026)\n\nAt least one repository in GitHub's daily trending page is directly related to AI coding tools (e.g., Copilot alternatives, AI code generation, AI-powered IDEs). Resolution via github.com/trending.\n\n📈 🟢 YES | 🔴 NO\n\n⚡ Flash market (SMJ)\n\n⏱️ Closes: May 2 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "7e21243b38779711181ccde0a722dbb8e10264568605bd74a5024f0d650be2c925e18cc984aa57030213b6882c61cae2f9f18647c01ae0ed05e3cbc6831e0fdb"}""" + + /** BAO Markets, current shape: status "active" but state "ended", end in the past. */ + const val MARKET_ACTIVE_BINARY = + """{"id": "4f7146bcdd339f7c1f4a56f460c79cf56587a17c61ccdb5f43ea24eb7af494c0", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786702043, "kind": 38000, "tags": [["d", "812916327ded76bb2b4bba766596bac3"], ["market", "812916327ded76bb2b4bba766596bac3"], ["m", "812916327ded76bb2b4bba766596bac3"], ["type", "binary"], ["w", "binary"], ["category", "bitcoin"], ["c", "bitcoin"], ["state", "ended"], ["status", "active"], ["s", "ended"], ["end", "1777939200"], ["yes_hash", "494a22a6612b840ca5c7079fefa00b48e40fd33bc96718692ebf94af1b93779b"], ["no_hash", "e5ee4d1c1d10dc0f148cf2e54e8564b468694cd0ef94a7bd6085d20ba4b41be8"], ["yes_adaptor_point", "04763467fb0e756f204f7a53571103ec49aad8354118807edfeffc1ca1e5cc5921eb4b61cdee42810aff726a90ab162f6d605796e2687076190b5bac8aeb19c381"], ["no_adaptor_point", "04dd8f56d6d9b86abb6dade0a83f689c2286b4c8cf36168bc7465a6df5512124051ec402443c973e427730db4c462e598d4f0fadb947009d6ce4b131052391e719"], ["oracle", "4a468afe57e1c93291929d2ffe5f287976b1639f3545845655d4b447a289186d"], ["expiry_blocks", "223"], ["network", "demo"], ["n", "demo"], ["smj", "true"], ["duration_blocks", "79"], ["oracle_tier", "1"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "bitcoin"], ["client", "BAO Markets"], ["resolution_source", "https://mempool.space/api/v1/blocks"], ["escrow_method", "htlc"], ["outcome", "YES"], ["outcome", "NO"], ["data", "{\"title\":\"Will the Bitcoin network mine fewer than 140 blocks today? (May 4, 2026) (UTC 5,May,2026)\",\"description\":\"Target is 144 blocks per day. Resolves YES if total blocks mined between 00:00:00 and 23:59:59 UTC is 139 or less. \\n\\nSource of Truth: mempool.space/api/v1/blocks\",\"outcomes\":[\"YES\",\"NO\"],\"isSmj\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"https://mempool.space/api/v1/blocks\",\"resolutionLogic\":\"\"}"], ["poll", "a8e310da83e3a92c17a3ab72f32cac5bcac0dc53fa52cae0d24ba68d98504b98"]], "content": "₿ Will the Bitcoin network mine fewer than 140 blocks today? (May 4, 2026) (UTC 5,May,2026)\n\nTarget is 144 blocks per day. Resolves YES if total blocks mined between 00:00:00 and 23:59:59 UTC is 139 or less. \n\nSource of Truth: mempool.space/api/v1/blocks\n\n📈 🟢 YES | 🔴 NO\n\n⚡ Smj market (SMJ)\n\n⏱️ Closes: May 5 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "dd4bbbdf0b62d149d0f7bc512d9017d818ab758e2cf2c292d2307fc29519ac07782f683b7b2654d7b04f5747b73dc40bdb0b794ae07d6f45492fd24db1da374d"}""" + + /** BAO Markets, current shape: five named outcomes, status "voided". */ + const val MARKET_VOIDED_MULTI_OUTCOME = + """{"id": "31da397fdc3a5c61aaa3fc12e8413279f99175ecdba65443d0a2e2495d73f5df", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786702119, "kind": 38000, "tags": [["d", "181f3a7acd28280a49e81bf70ec22998"], ["market", "181f3a7acd28280a49e81bf70ec22998"], ["m", "181f3a7acd28280a49e81bf70ec22998"], ["type", "binary"], ["w", "binary"], ["state", "ended"], ["status", "voided"], ["s", "ended"], ["end", "1778889600"], ["yes_hash", "8379c8fb7f7c5fcf48b92e114bbade3e44cb9e918f9b529e4618f29d92c46f4b"], ["no_hash", "f978de5abf4264a4638e5d90da140f46a510c578ed916852729156f06bd5a45c"], ["yes_adaptor_point", "04ccddc9b30d3689a9478aa020155f5cacd833d304b3296cf4a9f65e8f0b76dea1993581ea15331502e4fc96641ad3c7d3c782d6e5aadfc83b97d3647989195f33"], ["no_adaptor_point", "0458bd7dcb9b57064489e79724f4ece3f0df725c91ffe6427318ec20d022f6e47ec8ef7f5f445d95a92a3228957334b47efabd6f17fe183c34e39a1839997af197"], ["oracle", "73d9e19ef07e0d098fc0fc5fb75db0f854824e8b4e43905acce638ddf6469960"], ["expiry_blocks", "287"], ["network", "demo"], ["n", "demo"], ["smj", "true"], ["duration_blocks", "143"], ["oracle_tier", "1"], ["pool_model", "smj"], ["duration_tier", "now"], ["fee_percent", "4.21"], ["min_bet", "100"], ["max_bet", "100000"], ["t", "smj-market"], ["t", "prediction-market"], ["t", "mining-pools"], ["client", "BAO Markets"], ["resolution_source", "https://mempool.space/api/v1/blocks"], ["escrow_method", "htlc"], ["outcome", "Foundry USA"], ["outcome", "AntPool"], ["outcome", "F2Pool"], ["outcome", "ViaBTC"], ["outcome", "Other"], ["data", "{\"title\":\"Which mining pool will mine the most Bitcoin blocks today? (May 15, 2026) (UTC 16,May,2026)\",\"description\":\"Resolves based on the total block count at midnight UTC. \\n\\nSource of Truth: mempool.space/api/v1/blocks\",\"outcomes\":[\"Foundry USA\",\"AntPool\",\"F2Pool\",\"ViaBTC\",\"Other\"],\"isSmj\":true,\"poolModel\":\"smj\",\"durationTier\":\"now\",\"feePercent\":4.21,\"minBetSats\":100,\"maxBetSats\":100000,\"resolutionSource\":\"https://mempool.space/api/v1/blocks\",\"resolutionLogic\":\"\"}"], ["poll", "5c7dc9874ddbfd350d4b4d5f984cf566d8895c4e24f958278ec4401fd038ba76"], ["t", "mining"], ["c", "mining"], ["category", "mining"]], "content": "🔮 Which mining pool will mine the most Bitcoin blocks today? (May 15, 2026) (UTC 16,May,2026)\n\nResolves based on the total block count at midnight UTC. \n\nSource of Truth: mempool.space/api/v1/blocks\n\n📈 🟢 Foundry USA | 🔴 AntPool | 🔵 F2Pool | 🟡 ViaBTC | ⚪ Other\n\n⚡ Smj market (SMJ)\n\n⏱️ Closes: May 16 at 00:00 UTC\n\n#baomarkets #predictionmarkets #truthmachine", "sig": "2046a2128d384ffd3b590bcbbd466f957c76bd9ab88fcdb7e6e80ca6baedd9a77f2183092f466102f392207adac5ef13c12779b7fe2fbe7837058c56637a728e"}""" + + /** BAO Markets, current shape: cancelled, with a cancel_reason and a JSON content. */ + const val MARKET_CANCELLED = + """{"id": "22c74e00e0d123f8cee351895a0a840a4473cab0935b86b79c02051979277363", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1786629062, "kind": 38000, "tags": [["d", "lt-0225a12b-msrhc3x6"], ["market", "lt-0225a12b-msrhc3x6"], ["m", "lt-0225a12b-msrhc3x6"], ["state", "cancelled"], ["status", "cancelled"], ["s", "cancelled"], ["n", "demo"], ["network", "demo"], ["c", "bitcoin"], ["t", "smj-market"], ["t", "prediction-market"], ["title", "Will BTC price be above $60,000 at 2026-08-13 13:00 UTC?"], ["end", "1786626000"], ["cancel_reason", "bridge-test junk"], ["e", "fdd7ccd4fb6dc6255d1b42d3b26b0b496aed5e7c275d6c26c5497376b84c01d0", "", "replaces"]], "content": "{\"title\":\"Will BTC price be above $60,000 at 2026-08-13 13:00 UTC?\",\"status\":\"cancelled\",\"reason\":\"bridge-test junk\",\"cancelledAt\":1786629062}", "sig": "df0998ce37ee7e90e925bb621faf5b40e8dc80ae579d0b7ec54e24e100f26e79c0c0bf7bd4ff304b1d927c2bf53aff0ad06ca07735d861796b05c8625368de86"}""" + + /** BAO Fund shape: title tag, ["outcome", id, label] tags, JSON content. */ + const val MARKET_BAO_FUND = + """{"id": "90ca7c1399f8309715f243e53f14c6ce7e4e71687b501290b5ea9553d9520e52", "pubkey": "2eaf781b3cabef3cbf17313bc43580761748651bc09273d50646651dd06c6349", "created_at": 1790725353, "kind": 38000, "tags": [["d", "baofund-fr_2450b9bc52e06b51-0"], ["title", "Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?"], ["c", "bao-fund"], ["n", "demo"], ["network", "demo"], ["pool_model", "amm"], ["end", "1790727795"], ["outcome", "YES", "YES"], ["outcome", "NO", "NO"]], "content": "{\"title\":\"Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?\",\"description\":\"Will E2E demo-rail liquid refund munbn1yg deliver: E2E milestone munbn1yg by 2026-09-30?\\n\\nCRITERIA: E2E milestone munbn1yg\\nTREASURY: 12000 sats locked (liquid)\\nRUNNER FEE: 2.14%\\nCHALLENGE WINDOW: 7 days after proof submission\\nDEMO: signet market, admin-resolved oracle.\",\"category\":\"bao-fund\",\"type\":\"binary\",\"outcomes\":[{\"id\":\"YES\",\"label\":\"YES\",\"price\":0.5},{\"id\":\"NO\",\"label\":\"NO\",\"price\":0.5}],\"endDate\":1790727795}", "sig": "280f2f6ea98789df8f77f4d1f2b194b2b76a6efede7513efffdd795de4dff4c372fc392cbb4322b53daffab171f35bf6d48fd08b72459ee488cc6c5ebbaba1df"}""" + + /** BAO Markets' first shape: type/end/state tags, outcomes as JSON objects in content. */ + const val MARKET_FIRST_SHAPE = + """{"id": "3c69cbbecbb2578283d3978e4dfc39774adb2bd5fa6f9642c0680b9fc0cae887", "pubkey": "fba1bbd8ab57f258673157defd5afc9ceda004c6845f99db3169fe4b61ba7416", "created_at": 1769205950, "kind": 38000, "tags": [["d", "baoMarkets-mkt-1769205950092-0b985e090"], ["type", "binary"], ["category", "nostr"], ["oracle", "community"], ["end", "1769256240"], ["state", "funding"], ["platform_fee", "200"], ["t", "trump"]], "content": "{\"title\":\"will trump turn purple before May?\",\"description\":\"Trump is orange, but when purple? I think he hes been orange for far too long\",\"outcomes\":[{\"id\":\"YES\",\"label\":\"Yes\",\"probability\":0.5,\"totalVolume\":0,\"price\":50},{\"id\":\"NO\",\"label\":\"No\",\"probability\":0.5,\"totalVolume\":0,\"price\":50}],\"minLiquidity\":100000,\"currentLiquidity\":0,\"resolutionTime\":1770984240}", "sig": "cb3230e903b7b1bd12b54cef3d7de79f41a5978ff8397b3b826c87a68d23065952d0b7b25b95a30004b62d21736d39a0ff2149b84ec6db829bf2023fad52d181"}""" + + /** Ballot with a responses array; values are strings and numbers. */ + const val BALLOT_RESPONSES = + """{"id": "abebb24cb12d96514726739fba359e1327eaa193649e8faa2909f4abb7ec907b", "pubkey": "7f8e147582c364ce91fbcdceabee0059a7d44b5caa0d51eb79fbfb7bd0c57346", "created_at": 1774357688, "kind": 38000, "tags": [["election", "sec06-feedback-1774311582"]], "content": "{\"election_id\":\"sec06-feedback-1774311582\",\"responses\":[{\"question_id\":\"q1\",\"value\":\"Yes\"},{\"question_id\":\"q2\",\"value\":\"Yes\"},{\"question_id\":\"q3\",\"value\":5},{\"question_id\":\"q4\",\"value\":\"9am. 10am would’ve been better. \"},{\"question_id\":\"q5\",\"value\":\"Staple food recommendations. It’s really hard to find food around Funchal. All of them are tourist traps. \"},{\"question_id\":\"q6\",\"value\":\"Sauna talks. It was promised. \"},{\"question_id\":\"q7\",\"value\":\"Filter water near the coffee area. \"},{\"question_id\":\"q8\",\"value\":\"4 weeks. \"},{\"question_id\":\"q9\",\"value\":\"NA\"},{\"question_id\":\"q10\",\"value\":\"The t shirts could be of better quality. Happy to pay for it. \"}],\"timestamp\":1774357688}", "sig": "501788604b03e1185d4f62232f2201c876dff570a3d795de993e2e90a92426361be7096b10edbf404753fd83a69e79d054202a64d4c80c9e053db6a43bd63c3f"}""" + + /** Ballot with a responses array and a proof-hash tag. */ + const val BALLOT_RESPONSES_PROOF_HASH = + """{"id": "3c3702d747bd27b59f5890b54f4e7f68402d4ead3893656fe003abf094fab780", "pubkey": "c908bcaaf6f2edf4edaab9c4b73c2b9ab0ffe6f6d76a960cbd1bbf6f94ee8248", "created_at": 1774910824, "kind": 38000, "tags": [["election", "spring-2026-council"], ["proof-hash", "361689b5ccd44f35eebba995129d8f2b00dd369aca4c51b55f00065b2b477ddd"]], "content": "{\"election_id\":\"spring-2026-council\",\"responses\":[{\"question_id\":\"proposal_approval\",\"value\":\"Yes\"}],\"timestamp\":1774910824}", "sig": "842f2f776960985bf4414d5c3770b3763c5cfa93ff31342e941117041e6684766e1100381ccc16aafa802439b1d2fbf0ac65c4d58de0c8793757699a8a032464"}""" + + /** Ballot with a ballot object, a proof_hash tag and a mint tag. */ + const val BALLOT_OBJECT = + """{"id": "78f5f012a9518a0b39134e659ef180eae6df9f6b38ce3b4b86963409837e2776", "pubkey": "caf0b6ef4eb461677ed7bf7b569a0212443d6429f6cc555ca8f00a599e453bb2", "created_at": 1773998101, "kind": 38000, "tags": [["t", "auditable-vote"], ["election", "spring-2026-council"], ["proof_hash", "bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95"], ["mint", "http://localhost:8787/mock-mint"]], "content": "{\"election_id\":\"spring-2026-council\",\"proof_hash\":\"bb64227a0b57fa6af765881b0952fe9d01a3a18faf52a3e34eeb7a896ee36b95\",\"ballot\":{\"funding_priority\":\"community-grants\",\"audit_policy\":\"every-election\"}}", "sig": "79b1722a28ab18634994d939963734551748eaa5f1ce1459b43ef6349242849b721dfb57beda91409984cb3e364d5be65173c0e9011bcfa2f68fb26fba456c50"}""" + + /** Ballot with a lone vote_choice. */ + const val BALLOT_VOTE_CHOICE = + """{"id": "0e6b7a37d9db693dfdc7b2d8b4747efb8cac7fa493cc4915d9f07e6da61732ba", "pubkey": "5e7debebfde8730583455268731e4f8d2079dbe01cba06542e3f4825e598e233", "created_at": 1773715079, "kind": 38000, "tags": [["election", "election1"]], "content": "{\"vote_choice\":\"yes\"}", "sig": "d3259dfa20846d70f7045bdd5b80a3dc65610a4ebd12657a21e835f9ffa6bfbd3dde520d19b19133bc17cf32cfc5702473b9afe605ed7f6c0207dee923b4ad03"}""" + + /** NIP-87 recommendation of a Cashu mint (k = 38172, u). */ + const val MINT_REVIEW_CASHU = + """{"id": "9086081aea72122e58fd9c058ce6dd8068454750ee69b9e92d51896deb6201cb", "pubkey": "9d9789c0212d5157c0ac1351e1588b055dff0b91394261eecfa276e390382c7e", "created_at": 1790670504, "kind": 38000, "tags": [["k", "38172"], ["u", "https://21mint.me"], ["d", "https://21mint.me"]], "content": "[5/5] Been using it since a year now, most reliable mint out there.", "sig": "fd461995093e15c95dd807ecab361e5ef4f78d7198229b5e9e69708edc0930924487369757265d04938834aa73198f9731b30677f63319dca68b8106536187e7"}""" + + /** NIP-87 recommendation of a Fedimint (k = 38173, no u). */ + const val MINT_REVIEW_FEDIMINT = + """{"id": "092ce058a30d09d9dbe73808a2c15fd0146a27ebfb58a3c0c5a408e3b3e09c5f", "pubkey": "d362b888c17926dd5188a5228779251d17744b8ebe9ec5d313eff52c4776fdf3", "created_at": 1790796532, "kind": 38000, "tags": [["d", "6b2bcd96ab83d6ba31e36b821c89f6cc7a57d2133cdf884018833d3770456fe6"], ["k", "38173"], ["rating", "5"]], "content": "[5/5]", "sig": "7424fdfda8c7af66b5108c90ded4b893faf5f538ec24ca09f2ce85ec0dd9484c93e7c7024493577384ad850bac230211516051845bae2e90e151ed92fd5fd06c"}""" + + /** Old NIP-87 recommendation without a k tag, pointing at a mint URL. */ + const val MINT_REVIEW_NO_K = + """{"id": "557f112e147877de3f956c86d04cb2feeded99287ad0d2e56e9990ac1eef2d82", "pubkey": "89c16b5e5ba539db0d8921e75100cc8161737c381600ce47d3788dce18083604", "created_at": 1790252206, "kind": 38000, "tags": [["d", "https://mint.lnpay.cz"], ["u", "https://mint.lnpay.cz"], ["rating", "5"], ["comment", "Stable and reliable"]], "content": "Stable and reliable", "sig": "9a126f18b435234f1c2e9bda3b4b61a155cf8060de24be42cc28250ef447c1e42bbfac2191160589515edadeaaf74857ae4b5644c92d6a60e66366ad5bc4a5da"}""" + + /** Spam: a d-only "sybil test vote" (most kind-38000 events on big relays). */ + const val SPAM_SYBIL_VOTE = + """{"id": "6c0fccc4bead821665d6f4ce6f57d8dc5d67e60277b24950040796aa6e3266ee", "pubkey": "9bd0c7b7420e312c83201fc462fbd2150ec36ef9eed3db93c9fd0f35a79d9f52", "created_at": 1789336638, "kind": 38000, "tags": [["d", "713d9b889b5e207a85d8632e2e5f6926cf74c532299bc313a5fc105534007fce"]], "content": "[1/5] fedimint-observer security PoC (PR #124) - sybil test vote, safe to delete mass2#210099", "sig": "c42fc32ea66ee311b1d68d298e4160ff389f9e08c7615ac7f769a3a276e615377147122ab21b19f244043432a8d8aaa0e5b1eac0e608e0128587d0b6b35a7006"}""" + + /** A one-off app manifest: not a format Amethyst knows. */ + const val SPAM_APP_MANIFEST = + """{"id": "3abc7024d399b8e2e1aa4cd519c60a0bbfb439f1267a2f956f3d7d975c5c3e34", "pubkey": "c99281bdc983fcba340a23a3ff6380e3b93fde8621795535f1220eafd4fa26d3", "created_at": 1779037402, "kind": 38000, "tags": [["d", "pulsar-tunnel"], ["t", "pulsar"], ["t", "pulsar-tunnel"]], "content": "{\"name\":\"Pulsar Tunnel\",\"version\":\"0.1.0\",\"pubkey\":\"c99281bdc983fcba340a23a3ff6380e3b93fde8621795535f1220eafd4fa26d3\",\"transport\":\"nostr\"}", "sig": "5498710238eb4f2bd4f7bb848db3faf2e896a0e42bf2f6a3698037e5a4ff9e3f883038df11260375c301990b111a7a1b76e0f731e2de495af31a7cace8f92c21"}""" +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt index 62616dd7ef..6dd19fd388 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/fs/FsEventStore.kt @@ -599,13 +599,12 @@ open class FsEventStore( } /** - * True when [kind] currently parses to a [SearchableEvent]. Kind - * alone selects the event class in [EventFactory], so a single probe - * per kind is authoritative. The id is non-blank so kinds that lazily - * hash a missing id (NIP-17 chat) skip that work — only the runtime - * type matters here. + * True when [kind] currently parses to a [SearchableEvent], decided by + * one [EventFactory.probe] per kind (a kind whose class is picked by + * tags answers as its primary class; [FsIndexer.linkFts] still checks + * each event itself). */ - private fun isSearchableKind(kind: Int): Boolean = EventFactory.create("0", "0", 0L, kind, emptyArray(), "", "") is SearchableEvent + private fun isSearchableKind(kind: Int): Boolean = EventFactory.probe(kind) is SearchableEvent private fun deleteRecursively(p: java.nio.file.Path) { if (!Files.exists(p)) return diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt index eb73ec3eba..e7efd459b7 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip50Search/IndexableContentGoldenTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip50Search +import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.EventFactory import org.junit.Assert.assertEquals import org.junit.Test @@ -69,6 +70,9 @@ class IndexableContentGoldenTest { arrayOf("t", "hashtag2"), arrayOf("image", "https://example.com/i.png"), arrayOf("published_at", "1700000000"), + // Kind 38000 picks its class by tags; without a mint `k` it would sample as the + // plain Event its junk parses to rather than as the mint recommendation it indexes. + *if (kind == MintRecommendationEvent.KIND) arrayOf(arrayOf("k", "38172")) else emptyArray(), ), "The content body.", "", From c4337ecd7d2b337a29d2814edce77ee3f2344c44 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 03:24:59 +0000 Subject: [PATCH 2/4] polish the prediction market and ballot cards Rendered offscreen (ImageComposeScene, phone width, both themes) against the real production events, the cards read flat. Now: - a yes/no market draws its two sides full width, green and red; once resolved the winner keeps its colour and a check, the loser goes quiet - Cancelled is a quiet pill, like Closed, and its reason is gray: called off is not an error - the category reads as published ("ai-llm", not "Ai llm") - blank lines in a description collapse, so a clipped description no longer leaves a hole above the outcomes - a ballot's answers are a small table: one bordered block, a hairline between rows, the answer right-aligned, no chevrons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH --- .../amethyst/commons/ui/note/types/Ballot.kt | 38 ++++++-- .../commons/ui/note/types/PredictionMarket.kt | 95 +++++++++++++++++-- 2 files changed, 113 insertions(+), 20 deletions(-) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt index e13767902f..8bff6dff38 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/Ballot.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.ui.note.types +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,6 +28,8 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,8 +38,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -56,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.grayText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier +import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.experimental.ballots.BallotAnswer import com.vitorpamplona.quartz.experimental.ballots.BallotEvent @@ -140,7 +146,17 @@ fun BallotCard(event: BallotEvent) { if (state.answers.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - state.answers.forEach { AnswerRow(it) } + // The answers as a small table: one bordered block, a hairline between rows. + Column( + Modifier + .clip(AnswersShape) + .border(1.dp, MaterialTheme.colorScheme.subtleBorder, AnswersShape), + ) { + state.answers.forEachIndexed { i, answer -> + if (i > 0) HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.subtleBorder) + AnswerRow(answer) + } + } if (state.hiddenAnswers > 0) { Text( @@ -167,25 +183,25 @@ private fun AnswerRow(answer: BallotAnswer) { answer.question } - Row(verticalAlignment = Alignment.Top) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( text = question, style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.grayText, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.4f), ) - Icon( - symbol = MaterialSymbols.AutoMirrored.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(horizontal = 4.dp).size(16.dp), - ) Text( text = answer.answer, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.End, maxLines = 3, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.6f), @@ -193,6 +209,8 @@ private fun AnswerRow(answer: BallotAnswer) { } } +private val AnswersShape = RoundedCornerShape(10.dp) + @Composable private fun ProofHash(hash: String) { Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt index 468069856b..bb2a22a4c7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt @@ -131,18 +131,21 @@ private class PredictionMarketCardState( ) { fun isWinner(outcome: String) = resolution != null && outcome.equals(resolution, ignoreCase = true) + /** Two outcomes, one a yes and one a no — "YES"/"NO", "Yes (stays online)"/"No (goes offline)". */ + val isBinary = outcomes.size == 2 && outcomes.any(::isYes) && outcomes.any(::isNo) + companion object { fun from( event: PredictionMarketEvent, now: Long, ) = PredictionMarketCardState( title = event.title() ?: socialPostHeadline(event.content), - description = event.description(), + description = event.description()?.let(::collapseBlankLines), outcomes = event.outcomes().toImmutableList(), status = event.status(now), resolution = event.resolution(), endsAt = event.endsAt(), - category = event.category()?.let(::humanizeCategory), + category = event.category(), isDemo = event.isDemo(), minBetSats = event.minBetSats()?.let(::formatGrouped), maxBetSats = event.maxBetSats()?.let(::formatGrouped), @@ -150,6 +153,13 @@ private class PredictionMarketCardState( cancelReason = event.cancelReason(), ) + fun isYes(outcome: String) = YES.containsMatchIn(outcome) + + fun isNo(outcome: String) = NO.containsMatchIn(outcome) + + private val YES = Regex("""^\s*yes\b""", RegexOption.IGNORE_CASE) + private val NO = Regex("""^\s*no\b""", RegexOption.IGNORE_CASE) + /** The first line of a social-post `content`, for a market with no title anywhere else. */ private fun socialPostHeadline(content: String): String? { if (content.trimStart().startsWith("{")) return null @@ -162,11 +172,13 @@ private class PredictionMarketCardState( return if (value == asLong.toDouble()) "$asLong%" else "$value%" } - private fun humanizeCategory(category: String): String = - category - .replace('-', ' ') - .replace('_', ' ') - .replaceFirstChar { if (it.isLowerCase()) it.titlecaseChar() else it } + /** + * BAO's descriptions carry paragraph breaks ("…UTC.\n\nSource of Truth: …"); clipped to a + * few lines, a blank line reads as a hole in the card. One line break each, never a gap. + */ + private fun collapseBlankLines(text: String): String = text.trim().replace(BLANK_LINES, "\n") + + private val BLANK_LINES = Regex("""\s*\n\s*\n\s*""") } } @@ -274,12 +286,13 @@ private fun MarketStatusPill(status: PredictionMarketStatus) { ) } + // Called off is not an error: quiet, like Closed. PredictionMarketStatus.CANCELLED -> { StatusPill( label = stringRes(Res.string.prediction_market_status_cancelled), symbol = MaterialSymbols.Block, - container = Color(0xFFCF222E), - content = Color.White, + container = MaterialTheme.colorScheme.surfaceVariant, + content = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -318,6 +331,11 @@ private fun MarketOutcomes(state: PredictionMarketCardState) { // A cancelled market has no winner and takes no bets: its outcomes are shown, but muted. val muted = state.status == PredictionMarketStatus.CANCELLED + if (state.isBinary) { + BinaryOutcomes(state, muted) + return + } + FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -336,6 +354,63 @@ private fun MarketOutcomes(state: PredictionMarketCardState) { } } +/** + * A yes/no market as its two sides, side by side and full width — the way betting apps lay out + * Yes and No: green and red while it is undecided; once it resolves the winner keeps its colour + * and a check, and the loser goes quiet. + */ +@Composable +private fun BinaryOutcomes( + state: PredictionMarketCardState, + muted: Boolean, +) { + val decided = state.resolution != null + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + state.outcomes.forEach { outcome -> + val isWinner = state.isWinner(outcome) + val quiet = muted || (decided && !isWinner) + val side = + if (PredictionMarketCardState.isYes(outcome)) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.redColorOnSecondSurface + } + val color = if (quiet) MaterialTheme.colorScheme.placeholderText else side + Row( + modifier = + Modifier + .weight(1f) + .clip(SideShape) + .background(if (quiet) Color.Transparent else side.copy(alpha = if (isWinner) 0.20f else 0.12f)) + .border(1.dp, if (quiet) MaterialTheme.colorScheme.subtleBorder else side.copy(alpha = 0.45f), SideShape) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + if (isWinner) { + Icon( + symbol = MaterialSymbols.CheckCircle, + contentDescription = stringRes(Res.string.prediction_market_winning_outcome), + tint = color, + filled = true, + modifier = Modifier.padding(end = 4.dp).size(16.dp), + ) + } + Text( + text = outcome, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private val SideShape = RoundedCornerShape(10.dp) + @Composable private fun OutcomeChip( outcome: String, @@ -407,7 +482,7 @@ private fun CancelReason(reason: String) { MarketFootnote( symbol = MaterialSymbols.Block, text = stringRes(Res.string.prediction_market_cancel_reason, reason), - color = MaterialTheme.colorScheme.redColorOnSecondSurface, + color = MaterialTheme.colorScheme.placeholderText, ) } From 70252ea08a4246ece601c2e218f55c9ed59ba812 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:02:32 +0000 Subject: [PATCH 3/4] fix: kind-38000 audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unrecognised 38000s fell back to a plain Event, which stores do not key by `d` (they test `is AddressableEvent`): a spam bot republishing one address kept every copy, an `a`-tag deletion missed them, and a newer junk version never replaced an older recommendation. They are now UnrecognizedKind38000Event — addressable, never indexed, no card — and EventCache files them as addressables instead of logging each one's JSON at WARN. - The card's blank-line regex rescanned long whitespace runs from every position: a crafted ~64 KB description froze the main thread for seconds. Collapsed line by line instead. - The fee printed Double.toString ("4.21%" beside German "100.000 sats", "NaN%" for junk). New locale-aware formatDecimal (expect/actual); non-finite or negative fees are dropped. - The status badge and the closing-time line read the clock at different moments, and a market declared closed before its end read "Closed ". Both now read the same moment; a closed-early market states no closing time. - Outcomes are (id, label): `resolution` names the id, the card shows the label — `["outcome","opt1","Foundry USA"]` and `{"id":"A", "label":"Foundry"}` resolved to "A" now mark their winner. Deduped by set and capped at 64 against crafted floods. - isMintRecommendation accepts any `k` naming a mint kind (not only the first) and a recommendation that names its mint only by `a`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH --- .../commons/model/cache/EventCache.kt | 3 ++ .../amethyst/commons/util/GroupedNumber.kt | 6 +++ .../commons/util/GroupedNumber.ios.kt | 13 ++++++ .../commons/util/GroupedNumber.jvmAndroid.kt | 5 +++ .../commons/ui/note/types/PredictionMarket.kt | 45 ++++++++++++------- .../PredictionMarketDetails.kt | 40 ++++++++++++----- .../PredictionMarketEvent.kt | 7 ++- .../PredictionMarketOutcome.kt | 42 +++++++++++++++++ .../predictionMarkets/TagArrayExt.kt | 14 +++--- .../recommendation/MintRecommendationEvent.kt | 25 ++++++++--- .../UnrecognizedKind38000Event.kt | 44 ++++++++++++++++++ .../quartz/utils/EventFactory.kt | 8 ++-- .../PredictionMarketEventTest.kt | 29 ++++++++++++ .../quartz/utils/EventFactoryKind38000Test.kt | 38 ++++++++++------ 14 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index ca70a9b4ff..3d05686f7e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -390,6 +390,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -3967,6 +3968,8 @@ open class EventCache : // Other apps' kind-38000 formats, split from mint recommendations by EventFactory. is BallotEvent, is PredictionMarketEvent, + // Unread junk on 38000 still supersedes an older version at its address. + is UnrecognizedKind38000Event, -> consumeBaseReplaceable(event, relay, wasVerified) // ============================================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt index 89e3f1a75e..3f3c034ad3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.kt @@ -22,3 +22,9 @@ package com.vitorpamplona.amethyst.commons.util /** [value] with the locale's digit grouping: `1,234,567` in English, `1.234.567` in German. */ expect fun formatGrouped(value: Long): String + +/** [value] with the locale's decimal mark and at most [maxFractionDigits] digits after it: `4.21` in English, `4,21` in German. */ +expect fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt index 22e852b588..8cb15ad3f1 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.ios.kt @@ -23,9 +23,22 @@ package com.vitorpamplona.amethyst.commons.util import platform.Foundation.NSNumber import platform.Foundation.NSNumberFormatter import platform.Foundation.NSNumberFormatterDecimalStyle +import platform.Foundation.numberWithDouble import platform.Foundation.numberWithLongLong actual fun formatGrouped(value: Long): String { val formatter = NSNumberFormatter().apply { numberStyle = NSNumberFormatterDecimalStyle } return formatter.stringFromNumber(NSNumber.numberWithLongLong(value)) ?: value.toString() } + +actual fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String { + val formatter = + NSNumberFormatter().apply { + numberStyle = NSNumberFormatterDecimalStyle + maximumFractionDigits = maxFractionDigits.toULong() + } + return formatter.stringFromNumber(NSNumber.numberWithDouble(value)) ?: value.toString() +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt index b2e00ab2b1..c145f8ecf0 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/GroupedNumber.jvmAndroid.kt @@ -23,3 +23,8 @@ package com.vitorpamplona.amethyst.commons.util import java.text.NumberFormat actual fun formatGrouped(value: Long): String = NumberFormat.getNumberInstance().format(value) + +actual fun formatDecimal( + value: Double, + maxFractionDigits: Int, +): String = NumberFormat.getNumberInstance().apply { maximumFractionDigits = maxFractionDigits }.format(value) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt index bb2a22a4c7..f811a041e7 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/PredictionMarket.kt @@ -80,9 +80,11 @@ import com.vitorpamplona.amethyst.commons.ui.theme.redColorOnSecondSurface import com.vitorpamplona.amethyst.commons.ui.theme.replyModifier import com.vitorpamplona.amethyst.commons.ui.theme.subtleBorder import com.vitorpamplona.amethyst.commons.ui.theme.warningColorOnSecondSurface +import com.vitorpamplona.amethyst.commons.util.formatDecimal import com.vitorpamplona.amethyst.commons.util.formatGrouped import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketOutcome import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketStatus import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList @@ -117,7 +119,7 @@ fun RenderPredictionMarket( private class PredictionMarketCardState( val title: String?, val description: String?, - val outcomes: ImmutableList, + val outcomes: ImmutableList, val status: PredictionMarketStatus?, val resolution: String?, val endsAt: Long?, @@ -128,11 +130,13 @@ private class PredictionMarketCardState( val maxBetSats: String?, val feePercent: String?, val cancelReason: String?, + /** The moment the status was read, so the closing-time line agrees with the status badge. */ + val now: Long, ) { - fun isWinner(outcome: String) = resolution != null && outcome.equals(resolution, ignoreCase = true) + fun isWinner(outcome: PredictionMarketOutcome) = outcome.isNamedBy(resolution) /** Two outcomes, one a yes and one a no — "YES"/"NO", "Yes (stays online)"/"No (goes offline)". */ - val isBinary = outcomes.size == 2 && outcomes.any(::isYes) && outcomes.any(::isNo) + val isBinary = outcomes.size == 2 && outcomes.any { isYes(it.label) } && outcomes.any { isNo(it.label) } companion object { fun from( @@ -141,7 +145,7 @@ private class PredictionMarketCardState( ) = PredictionMarketCardState( title = event.title() ?: socialPostHeadline(event.content), description = event.description()?.let(::collapseBlankLines), - outcomes = event.outcomes().toImmutableList(), + outcomes = event.outcomeOptions().toImmutableList(), status = event.status(now), resolution = event.resolution(), endsAt = event.endsAt(), @@ -151,6 +155,7 @@ private class PredictionMarketCardState( maxBetSats = event.maxBetSats()?.let(::formatGrouped), feePercent = event.feePercent()?.let(::formatPercent), cancelReason = event.cancelReason(), + now = now, ) fun isYes(outcome: String) = YES.containsMatchIn(outcome) @@ -166,19 +171,21 @@ private class PredictionMarketCardState( return content.lineSequence().firstOrNull { it.isNotBlank() }?.trim() } - /** 4.21 → "4.21%", 2.0 → "2%". */ - private fun formatPercent(value: Double): String { - val asLong = value.toLong() - return if (value == asLong.toDouble()) "$asLong%" else "$value%" - } + /** 4.21 → "4.21%" ("4,21%" in German), 2.0 → "2%"; nothing for NaN, infinities or a negative. */ + private fun formatPercent(value: Double): String? = if (value.isFinite() && value >= 0) "${formatDecimal(value, 2)}%" else null /** * BAO's descriptions carry paragraph breaks ("…UTC.\n\nSource of Truth: …"); clipped to a * few lines, a blank line reads as a hole in the card. One line break each, never a gap. */ - private fun collapseBlankLines(text: String): String = text.trim().replace(BLANK_LINES, "\n") - - private val BLANK_LINES = Regex("""\s*\n\s*\n\s*""") + private fun collapseBlankLines(text: String): String = + // Line by line rather than a regex: a run of whitespace in a crafted event made the + // obvious pattern rescan it from every position — seconds on the main thread. + text + .lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .joinToString("\n") } } @@ -227,7 +234,7 @@ fun PredictionMarketCard( if (state.status == PredictionMarketStatus.CANCELLED) { state.cancelReason?.let { CancelReason(it) } } else { - state.endsAt?.let { ClosingTime(it, state.status) } + state.endsAt?.let { ClosingTime(it, state.status, state.now) } } MarketTerms(state.minBetSats, state.maxBetSats, state.feePercent) @@ -341,7 +348,7 @@ private fun MarketOutcomes(state: PredictionMarketCardState) { verticalArrangement = Arrangement.spacedBy(6.dp), ) { shown.forEach { outcome -> - OutcomeChip(outcome, isWinner = state.isWinner(outcome), muted = muted) + OutcomeChip(outcome.label, isWinner = state.isWinner(outcome), muted = muted) } if (hidden > 0) { Text( @@ -370,7 +377,7 @@ private fun BinaryOutcomes( val isWinner = state.isWinner(outcome) val quiet = muted || (decided && !isWinner) val side = - if (PredictionMarketCardState.isYes(outcome)) { + if (PredictionMarketCardState.isYes(outcome.label)) { MaterialTheme.colorScheme.allGoodColor } else { MaterialTheme.colorScheme.redColorOnSecondSurface @@ -397,7 +404,7 @@ private fun BinaryOutcomes( ) } Text( - text = outcome, + text = outcome.label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = color, @@ -460,8 +467,12 @@ private fun OutcomeChip( private fun ClosingTime( endsAt: Long, status: PredictionMarketStatus?, + now: Long, ) { - val stillOpen = status == PredictionMarketStatus.OPEN && endsAt > TimeUtils.now() + // Read against the same moment as the status badge, so the two never disagree. A market + // declared closed or settled before its end has no closing time worth stating. + val stillOpen = status == PredictionMarketStatus.OPEN && endsAt > now + if (!stillOpen && endsAt > now) return val text = if (stillOpen) { stringRes(Res.string.prediction_market_closes_in, timeAheadNoDot(endsAt)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt index b1a72b6463..a8ee4c5204 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketDetails.kt @@ -38,7 +38,7 @@ import kotlinx.serialization.json.longOrNull class PredictionMarketDetails( val title: String?, val description: String?, - val outcomes: List, + val outcomes: List, val category: String?, val status: String?, val reason: String?, @@ -95,24 +95,40 @@ class PredictionMarketDetails( private fun JsonObject.double(key: String): Double? = primitive(key)?.doubleOrNull /** - * `outcomes` as display strings: a plain string per outcome (current shape), or an object - * per outcome whose `label` (else `id`, `name`) names it (BAO Fund and first shapes). + * `outcomes`: a plain string per outcome (current shape), or an object per outcome with an + * `id` and a `label` (else `name`) (BAO Fund and first shapes). Once per id, at most + * [PredictionMarketOutcome.MAX]. */ - private fun JsonObject.outcomes(): List { + private fun JsonObject.outcomes(): List { val array = this["outcomes"] as? JsonArray ?: return emptyList() - val result = ArrayList(array.size) + val result = LinkedHashMap(minOf(array.size, PredictionMarketOutcome.MAX)) for (element in array) { - val name = outcomeName(element) - if (name != null && name !in result) result.add(name) + if (result.size >= PredictionMarketOutcome.MAX) break + val outcome = outcomeOf(element) ?: continue + if (outcome.id !in result) result[outcome.id] = outcome } - return result + return result.values.toList() } - private fun outcomeName(element: JsonElement): String? = + private fun outcomeOf(element: JsonElement): PredictionMarketOutcome? = when (element) { - is JsonPrimitive -> element.takeIf { it.isString }?.content?.ifBlank { null } - is JsonObject -> element.text("label") ?: element.text("id") ?: element.text("name") - else -> null + is JsonPrimitive -> { + element + .takeIf { it.isString } + ?.content + ?.ifBlank { null } + ?.let { PredictionMarketOutcome(it, it) } + } + + is JsonObject -> { + val label = element.text("label") ?: element.text("name") + val id = element.text("id") ?: label + if (id == null) null else PredictionMarketOutcome(id, label ?: id) + } + + else -> { + null + } } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt index 5b23c4f6ee..a81262523f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEvent.kt @@ -81,12 +81,15 @@ class PredictionMarketEvent( fun description() = dataDetails?.description ?: contentDetails?.description - /** The outcomes one can bet on: the `outcome` tags, else the JSON `outcomes`. */ - fun outcomes(): List = + /** The outcomes one can bet on, id and label: the `outcome` tags, else the JSON `outcomes`. */ + fun outcomeOptions(): List = tags.marketOutcomes().ifEmpty { dataDetails?.outcomes?.ifEmpty { null } ?: contentDetails?.outcomes ?: emptyList() } + /** The outcomes as a reader sees them, by label. */ + fun outcomes(): List = outcomeOptions().map { it.label } + /** The winning outcome, once resolved. */ fun resolution() = tags.marketResolution() ?: dataDetails?.resolution ?: contentDetails?.resolution diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt new file mode 100644 index 0000000000..24ca9a1548 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketOutcome.kt @@ -0,0 +1,42 @@ +/* + * 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.predictionMarkets + +import androidx.compose.runtime.Immutable + +/** + * One answer a market can resolve to. BAO writes some as a bare word (`["outcome","YES"]`, a + * JSON string) and some as an id with a label (`["outcome","opt1","Foundry USA"]`, + * `{"id":"A","label":"Yes"}`): a `resolution` names the [id], a reader is shown the [label]. + */ +@Immutable +data class PredictionMarketOutcome( + val id: String, + val label: String, +) { + /** Whether [resolution] names this outcome — by its id, or by its label as some markets do. */ + fun isNamedBy(resolution: String?): Boolean = resolution != null && (id.equals(resolution, ignoreCase = true) || label.equals(resolution, ignoreCase = true)) + + companion object { + /** More than any market offers; a crafted event with thousands is cut here. */ + const val MAX = 64 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt index c4cdd43c4a..c5b308ec43 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/TagArrayExt.kt @@ -57,15 +57,17 @@ fun TagArray.marketNetwork() = firstNonBlankValue("network", "n") fun TagArray.marketCategory() = firstNonBlankValue("category", "c") /** - * Every `outcome` tag's value, in order. The BAO Fund shape writes `["outcome", id, label]`; - * the id is what a `resolution` names, so it is the one returned. + * Every `outcome` tag, in order, once per id and at most [PredictionMarketOutcome.MAX]. The BAO + * Fund shape writes `["outcome", id, label]`; the current shape a bare word, its own label. */ -fun TagArray.marketOutcomes(): List { - val result = ArrayList(2) +fun TagArray.marketOutcomes(): List { + val result = LinkedHashMap(4) fastForEach { - if (it.size > 1 && it[0] == "outcome" && it[1].isNotBlank() && it[1] !in result) result.add(it[1]) + if (result.size < PredictionMarketOutcome.MAX && it.size > 1 && it[0] == "outcome" && it[1].isNotBlank() && it[1] !in result) { + result[it[1]] = PredictionMarketOutcome(it[1], it.getOrNull(2)?.ifBlank { null } ?: it[1]) + } } - return result + return result.values.toList() } /** The winning outcome, once the market is resolved. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt index 962b0142ed..ec26ccff8e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/MintRecommendationEvent.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.fastAny -import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent @@ -73,17 +72,29 @@ class MintRecommendationEvent( * the kind number alone does not say what an event is. A recommendation names the kind of * the mint it recommends in its `k` tag — 38172 (Cashu) or 38173 (Fedimint). Some early * recommendations carry no `k` at all; those are accepted when they point at a mint URL - * (`u`). A `k` naming anything else is not a mint recommendation, whatever else it carries. + * (`u`) or at the mint's announcement (an `a` to a 38172/38173 address). A `k` naming + * only other kinds is not a mint recommendation, whatever else it carries. */ fun isMintRecommendation(tags: TagArray): Boolean { - val kTag = tags.fastFirstOrNull { it.size > 1 && it[0] == "k" } - if (kTag != null) { - val mintKind = kTag[1].toIntOrNull() - return mintKind == CashuMintEvent.KIND || mintKind == FedimintEvent.KIND + // Any `k` naming a mint's kind: a relay answering `#k=38172` may hand back an event + // whose first `k` is something else. + if (tags.fastAny { it.size > 1 && it[0] == "k" && it[1].toIntOrNull().isMintKind() }) return true + if (tags.fastAny { it.size > 1 && it[0] == "k" && it[1].isNotBlank() }) return false + return tags.fastAny { + it.size > 1 && + ( + (it[0] == MintUrlTag.TAG_NAME && it[1].isNotBlank()) || + // The mint's own announcement, by address: `38172::`. + (it[0] == "a" && (it[1].startsWith(CASHU_ADDRESS) || it[1].startsWith(FEDIMINT_ADDRESS))) + ) } - return tags.fastAny { it.size > 1 && it[0] == MintUrlTag.TAG_NAME && it[1].isNotBlank() } } + private fun Int?.isMintKind() = this == CashuMintEvent.KIND || this == FedimintEvent.KIND + + private val CASHU_ADDRESS = "${CashuMintEvent.KIND}:" + private val FEDIMINT_ADDRESS = "${FedimintEvent.KIND}:" + /** * [mintKind] must be [CashuMintEvent.KIND] or [FedimintEvent.KIND]: the `k` tag it writes is * what [isMintRecommendation] reads back, so any other value signs into a plain kind-38000 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.kt new file mode 100644 index 0000000000..5f39ac627e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip87Ecash/recommendation/UnrecognizedKind38000Event.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.quartz.nip87Ecash.recommendation + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * A kind-38000 event that is none of the formats Amethyst reads on that number — not a NIP-87 + * [MintRecommendationEvent], a ballot or a prediction market. Most of them are `d`-only spam. + * + * Still addressable, because the kind is: stores key it by `d` (`event is AddressableEvent`), so + * a newer version replaces an older one and an `a`-tag deletion reaches it, as NIP-01 requires + * of every kind in 30000–39999. Not a SearchableEvent, so it is never indexed, and no card is + * drawn for it. + */ +@Immutable +class UnrecognizedKind38000Event( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, MintRecommendationEvent.KIND, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index c3d7d2969b..b46d9aa31e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -372,6 +372,7 @@ import com.vitorpamplona.quartz.nip85TrustedAssertions.users.UserAssertionEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent @@ -762,14 +763,15 @@ class EventFactory { // ballots, and most of what a big relay holds for it is spam (`d`-only "sybil test // votes"). Parsing all of it as a recommendation rendered markets, ballots and spam // as ecash mint cards. Disambiguate by tags, recommendation first — its `k` (the - // recommended mint's kind) or, on old events without one, its `u` (mint URL) — and - // fall back to a plain Event so junk gets no card and no search index entry. + // recommended mint's kind) or, on old events without one, its `u` (mint URL) or the + // mint's `a` — and fall back to UnrecognizedKind38000Event: still addressable, so + // stores replace and delete it by `d`, but never indexed and drawn as no card. MintRecommendationEvent.KIND -> when { MintRecommendationEvent.isMintRecommendation(tags) -> MintRecommendationEvent(id, pubKey, createdAt, tags, content, sig) BallotEvent.isBallot(tags) -> BallotEvent(id, pubKey, createdAt, tags, content, sig) PredictionMarketEvent.isPredictionMarket(tags) -> PredictionMarketEvent(id, pubKey, createdAt, tags, content, sig) - else -> Event(id, pubKey, createdAt, kind, tags, content, sig) + else -> UnrecognizedKind38000Event(id, pubKey, createdAt, tags, content, sig) } MediaFollowListEvent.KIND -> MediaFollowListEvent(id, pubKey, createdAt, tags, content, sig) MediaStarterPackEvent.KIND -> MediaStarterPackEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt index 48eeb74508..b1b361a774 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/predictionMarkets/PredictionMarketEventTest.kt @@ -281,4 +281,33 @@ class PredictionMarketEventTest { assertEquals(1, seen) } } + + @Test + fun outcomesKeepTheirIdForTheWinnerAndShowTheirLabel() { + // `resolution` names the id; a reader is shown the label. + val tagged = + market( + arrayOf( + arrayOf("market", "m"), + arrayOf("outcome", "opt1", "Foundry USA"), + arrayOf("outcome", "opt2", "AntPool"), + arrayOf("resolution", "opt1"), + ), + "", + ) + assertEquals(listOf("Foundry USA", "AntPool"), tagged.outcomes()) + val winner = tagged.outcomeOptions().single { it.isNamedBy(tagged.resolution()) } + assertEquals(PredictionMarketOutcome("opt1", "Foundry USA"), winner) + + val json = market(arrayOf(arrayOf("market", "m")), """{"outcomes":[{"id":"A","label":"Foundry"},{"id":"B","label":"Other"}]}""") + assertEquals(listOf("Foundry", "Other"), json.outcomes()) + assertTrue(json.outcomeOptions().first().isNamedBy("a")) + } + + @Test + fun aCraftedFloodOfOutcomesIsCut() { + val flood = Array(5000) { arrayOf("outcome", "o$it") } + val event = market(arrayOf(arrayOf("market", "m"), *flood), "") + assertEquals(PredictionMarketOutcome.MAX, event.outcomeOptions().size) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt index f622681611..ddc351887c 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/EventFactoryKind38000Test.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.experimental.ballots.BallotEvent import com.vitorpamplona.quartz.experimental.predictionMarkets.PredictionMarketEvent +import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync @@ -29,6 +30,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchableEvent import com.vitorpamplona.quartz.nip87Ecash.cashu.CashuMintEvent import com.vitorpamplona.quartz.nip87Ecash.fedimint.FedimintEvent import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent +import com.vitorpamplona.quartz.nip87Ecash.recommendation.UnrecognizedKind38000Event import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -105,29 +107,39 @@ class EventFactoryKind38000Test { } @Test - fun spamAndOneOffsArePlainEvents() { + fun spamAndOneOffsAreUnrecognizedButStillAddressable() { listOf( Kind38000Fixtures.SPAM_SYBIL_VOTE, Kind38000Fixtures.SPAM_APP_MANIFEST, ).forEach { val event = parse(it) - assertEquals(Event::class, event::class) + assertIs(event) + // Stores key 30000–39999 by `d` only when the event is addressable: replacement and + // `a`-tag deletion must still reach the spam. + assertIs(event) assertFalse(event is SearchableEvent) } } @Test - fun aKNamingAnotherKindIsNotARecommendationEvenWithAMintUrl() { - assertEquals(Event::class, build(arrayOf("k", "1"), arrayOf("u", "https://mint.example"))::class) - // The first k decides. - assertEquals(Event::class, build(arrayOf("k", "1"), arrayOf("k", "38172"))::class) + fun aKNamingOnlyOtherKindsIsNotARecommendationEvenWithAMintUrl() { + assertIs(build(arrayOf("k", "1"), arrayOf("u", "https://mint.example"))) + // Any k naming a mint kind counts, wherever it sits. + assertIs(build(arrayOf("k", "1"), arrayOf("k", "38172"))) assertIs(build(arrayOf("k", "38173"), arrayOf("k", "1"))) } + @Test + fun aRecommendationNamingItsMintOnlyByAddressIsOne() { + assertIs(build(arrayOf("d", "x"), arrayOf("a", "38172:${"a".repeat(64)}:x"))) + assertIs(build(arrayOf("d", "x"), arrayOf("a", "38173:${"a".repeat(64)}:x"))) + assertIs(build(arrayOf("d", "x"), arrayOf("a", "30023:${"a".repeat(64)}:x"))) + } + @Test fun aBlankMintUrlWithoutKIsNotARecommendation() { - assertEquals(Event::class, build(arrayOf("d", "x"), arrayOf("u", " "))::class) - assertEquals(Event::class, build(arrayOf("u"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("d", "x"), arrayOf("u", " "))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("u"))::class) } @Test @@ -142,7 +154,7 @@ class EventFactoryKind38000Test { @Test fun aBlankElectionIsNotABallot() { - assertEquals(Event::class, build(arrayOf("election", ""))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("election", ""))::class) } @Test @@ -151,10 +163,10 @@ class EventFactoryKind38000Test { assertIs(build(arrayOf("outcome", "YES"), arrayOf("outcome", "NO"))) assertIs(build(arrayOf("type", "binary"), arrayOf("end", "1"))) - assertEquals(Event::class, build(arrayOf("market", " "))::class) - assertEquals(Event::class, build(arrayOf("outcome", "YES"))::class) - assertEquals(Event::class, build(arrayOf("type", "binary"))::class) - assertEquals(Event::class, build(arrayOf("end", "1"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("market", " "))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("outcome", "YES"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("type", "binary"))::class) + assertEquals(UnrecognizedKind38000Event::class, build(arrayOf("end", "1"))::class) } @Test From 89598b9f26ba3199b5a94bee774d00f24bc163f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:04:35 +0000 Subject: [PATCH 4/4] docs: kind-38000 fallback is UnrecognizedKind38000Event Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH --- .../searchable-events/references/searchable-kinds.md | 9 +++++---- .../com/vitorpamplona/quartz/utils/EventFactory.kt | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md index 1a95ebd664..02761064ca 100644 --- a/.claude/skills/searchable-events/references/searchable-kinds.md +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -158,10 +158,11 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`. may hold encrypted private items in `content`, which must never be indexed. ‡ **Kind 38000 is shared** by NIP-87 mint recommendations and two app formats, and `EventFactory` -picks the class by tags: `MintRecommendationEvent` when the first `k` is 38172/38173 (or, with no -`k`, a non-blank `u`); else `BallotEvent` on a non-blank `election`; else `PredictionMarketEvent` -on a `market`, ≥2 `outcome`s, or `type` + `end`; else a plain, unsearchable `Event` (the spam -votes that make up most of the kind). Kind-level probes (`EventFactory.probe`) answer as +picks the class by tags: `MintRecommendationEvent` when any `k` is 38172/38173 (or, with no +`k`, a non-blank `u` or an `a` to a 38172/38173 address); else `BallotEvent` on a non-blank +`election`; else `PredictionMarketEvent` on a `market`, ≥2 `outcome`s, or `type` + `end`; else +`UnrecognizedKind38000Event` — addressable (stores still key it by `d`) but unsearchable (the +spam votes that make up most of the kind). Kind-level probes (`EventFactory.probe`) answer as `MintRecommendationEvent`. A market's `title()`/`description()` come from its `data` tag JSON, the `title` tag, or JSON `content`, in that order. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index b46d9aa31e..8e4e8e53c9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -966,8 +966,8 @@ class EventFactory { * * For most kinds that is just [create] with no tags. It is not for kinds whose class is * chosen by tags, where a tagless event can land on a different class than the kind's - * real events. Kind 38000 is the case that matters: with no tags it is a plain [Event] - * (junk), yet every typed shape of it — mint recommendation, ballot, prediction market — + * real events. Kind 38000 is the case that matters: with no tags it is an + * [UnrecognizedKind38000Event] (junk, unsearchable), yet every typed shape of it — mint recommendation, ballot, prediction market — * is a searchable, renderable class, so it answers as its primary class. (The other * tag-split kinds, 39005 and 20001, already land on a typed class with no tags.) *