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