mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+3
@@ -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
|
||||
|
||||
+13
@@ -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()
|
||||
}
|
||||
|
||||
+5
@@ -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)
|
||||
|
||||
+28
-17
@@ -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))
|
||||
|
||||
+28
-12
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -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
|
||||
|
||||
|
||||
+42
@@ -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
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -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. */
|
||||
|
||||
+18
-7
@@ -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
|
||||
|
||||
+44
@@ -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)
|
||||
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+25
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user