fix: kind-38000 audit findings

- 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 <a
  future date>". 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH
This commit is contained in:
Claude
2026-10-01 13:02:32 +00:00
parent 594eb948b3
commit 70252ea08a
14 changed files with 259 additions and 60 deletions
@@ -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)
// ============================================================
@@ -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
@@ -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()
}
@@ -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)
@@ -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<String>,
val outcomes: ImmutableList<PredictionMarketOutcome>,
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))
@@ -38,7 +38,7 @@ import kotlinx.serialization.json.longOrNull
class PredictionMarketDetails(
val title: String?,
val description: String?,
val outcomes: List<String>,
val outcomes: List<PredictionMarketOutcome>,
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<String> {
private fun JsonObject.outcomes(): List<PredictionMarketOutcome> {
val array = this["outcomes"] as? JsonArray ?: return emptyList()
val result = ArrayList<String>(array.size)
val result = LinkedHashMap<String, PredictionMarketOutcome>(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
}
}
}
}
@@ -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<String> =
/** The outcomes one can bet on, id and label: the `outcome` tags, else the JSON `outcomes`. */
fun outcomeOptions(): List<PredictionMarketOutcome> =
tags.marketOutcomes().ifEmpty {
dataDetails?.outcomes?.ifEmpty { null } ?: contentDetails?.outcomes ?: emptyList()
}
/** The outcomes as a reader sees them, by label. */
fun outcomes(): List<String> = outcomeOptions().map { it.label }
/** The winning outcome, once resolved. */
fun resolution() = tags.marketResolution() ?: dataDetails?.resolution ?: contentDetails?.resolution
@@ -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
}
}
@@ -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<String> {
val result = ArrayList<String>(2)
fun TagArray.marketOutcomes(): List<PredictionMarketOutcome> {
val result = LinkedHashMap<String, PredictionMarketOutcome>(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. */
@@ -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:<pubkey>:<d>`.
(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
@@ -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<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, MintRecommendationEvent.KIND, tags, content, sig)
@@ -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)
@@ -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)
}
}
@@ -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<UnrecognizedKind38000Event>(event)
// Stores key 30000–39999 by `d` only when the event is addressable: replacement and
// `a`-tag deletion must still reach the spam.
assertIs<AddressableEvent>(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<UnrecognizedKind38000Event>(build(arrayOf("k", "1"), arrayOf("u", "https://mint.example")))
// Any k naming a mint kind counts, wherever it sits.
assertIs<MintRecommendationEvent>(build(arrayOf("k", "1"), arrayOf("k", "38172")))
assertIs<MintRecommendationEvent>(build(arrayOf("k", "38173"), arrayOf("k", "1")))
}
@Test
fun aRecommendationNamingItsMintOnlyByAddressIsOne() {
assertIs<MintRecommendationEvent>(build(arrayOf("d", "x"), arrayOf("a", "38172:${"a".repeat(64)}:x")))
assertIs<MintRecommendationEvent>(build(arrayOf("d", "x"), arrayOf("a", "38173:${"a".repeat(64)}:x")))
assertIs<UnrecognizedKind38000Event>(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<PredictionMarketEvent>(build(arrayOf("outcome", "YES"), arrayOf("outcome", "NO")))
assertIs<PredictionMarketEvent>(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