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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CsFEX4ZT8mL1r8h2M6qfH
This commit is contained in:
Claude
2026-10-01 01:24:59 +00:00
parent dc44cd4fb6
commit f9063c8191
26 changed files with 2304 additions and 21 deletions
@@ -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 |
@@ -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,
@@ -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)
// ============================================================
@@ -6361,4 +6361,30 @@
<string name="browser_pill_download_risky">This file type can run code. Check that the name matches what you meant to get.</string>
<string name="browser_pill_download_save">Save</string>
<string name="browser_pill_download_from">From %1$s</string>
<string name="prediction_market">Prediction market</string>
<string name="prediction_market_status_open">Open</string>
<string name="prediction_market_status_closed">Closed</string>
<string name="prediction_market_status_resolved">Resolved</string>
<string name="prediction_market_status_cancelled">Cancelled</string>
<string name="prediction_market_demo">Demo · play money</string>
<string name="prediction_market_closes_in">Closes in %1$s</string>
<string name="prediction_market_closed_on">Closed %1$s</string>
<string name="prediction_market_bet_range">Bets %1$s–%2$s sats</string>
<string name="prediction_market_min_bet">Min bet %1$s sats</string>
<string name="prediction_market_max_bet">Max bet %1$s sats</string>
<string name="prediction_market_fee">Fee %1$s</string>
<string name="prediction_market_cancel_reason">Cancelled: %1$s</string>
<string name="prediction_market_winning_outcome">Winning outcome</string>
<plurals name="prediction_market_more_outcomes">
<item quantity="one">+%1$d more</item>
<item quantity="other">+%1$d more</item>
</plurals>
<string name="ballot">Ballot</string>
<string name="ballot_election">Election %1$s</string>
<string name="ballot_vote">Vote</string>
<string name="ballot_proof_hash">Proof %1$s</string>
<plurals name="ballot_more_answers">
<item quantity="one">+%1$d more answer</item>
<item quantity="other">+%1$d more answers</item>
</plurals>
</resources>
@@ -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,
)
}
}
@@ -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,
@@ -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<BallotEvent>(note, accountViewModel)
val noteEvent = observedEvent ?: return
BallotCard(noteEvent)
}
@Immutable
private class BallotCardState(
val election: String?,
val answers: ImmutableList<BallotAnswer>,
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)
}
}
}
@@ -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<PredictionMarketEvent>(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<String>,
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)
}
}
}
@@ -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,
)
@@ -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<BallotAnswer>,
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<BallotAnswer> {
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
}
}
}
@@ -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<Array<String>>,
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<BallotAnswer> = 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() }
}
}
@@ -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")
@@ -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<String>,
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<String> {
val array = this["outcomes"] as? JsonArray ?: return emptyList()
val result = ArrayList<String>(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
}
}
}
@@ -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<Array<String>>,
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<String> =
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)
}
}
}
@@ -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
}
}
}
@@ -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<String> = 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<String> {
val result = ArrayList<String>(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")
@@ -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<Event>(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<Array<String>>()
/** 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"
@@ -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,
@@ -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<Event>("", "", 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"
}
}
@@ -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<BallotEvent>(Event.fromJson(json))
private fun ballotWith(
content: String,
vararg extraTags: Array<String>,
) = 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<String>()
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)
}
}
}
@@ -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<PredictionMarketEvent>(Event.fromJson(json))
private fun market(
tags: Array<Array<String>>,
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<String>()
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)
}
}
}
@@ -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() {
@@ -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<String>): 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<MintRecommendationEvent>(parse(Kind38000Fixtures.MINT_REVIEW_CASHU))
assertTrue(cashu.isCashuRecommendation())
assertEquals(listOf("https://21mint.me"), cashu.mintUrls())
val fedimint = assertIs<MintRecommendationEvent>(parse(Kind38000Fixtures.MINT_REVIEW_FEDIMINT))
assertTrue(fedimint.isFedimintRecommendation())
val noK = assertIs<MintRecommendationEvent>(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<PredictionMarketEvent>(parse(it)) }
}
@Test
fun ballotsAreBallots() {
listOf(
Kind38000Fixtures.BALLOT_RESPONSES,
Kind38000Fixtures.BALLOT_RESPONSES_PROOF_HASH,
Kind38000Fixtures.BALLOT_OBJECT,
Kind38000Fixtures.BALLOT_VOTE_CHOICE,
).forEach { assertIs<BallotEvent>(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<MintRecommendationEvent>(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<MintRecommendationEvent>(build(arrayOf("k", "38172"), arrayOf("election", "e1"), arrayOf("market", "m1")))
}
@Test
fun ballotWinsOverMarket() {
assertIs<BallotEvent>(build(arrayOf("election", "e1"), arrayOf("market", "m1")))
}
@Test
fun aBlankElectionIsNotABallot() {
assertEquals(Event::class, build(arrayOf("election", ""))::class)
}
@Test
fun marketNeedsAMarketTwoOutcomesOrATypeAndAnEnd() {
assertIs<PredictionMarketEvent>(build(arrayOf("market", "m1")))
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)
}
@Test
fun signingARecommendationRoundTripsToARecommendation() {
val signer = NostrSignerSync()
listOf(CashuMintEvent.KIND, FedimintEvent.KIND).forEach { mintKind ->
val signed =
signer.sign<MintRecommendationEvent>(
MintRecommendationEvent.build(
mintIdentifier = "https://mint.example",
mintKind = mintKind,
review = "great",
),
)
assertIs<MintRecommendationEvent>(signed)
assertEquals(mintKind, signed.mintEventKind())
assertIs<MintRecommendationEvent>(Event.fromJson(signed.toJson()))
}
}
@Test
fun theKindStillProbesAsAKnownSearchableKind() {
assertTrue(EventFactory.isKnownKind(MintRecommendationEvent.KIND))
assertIs<MintRecommendationEvent>(EventFactory.probe(MintRecommendationEvent.KIND))
assertTrue(EventFactory.probe(MintRecommendationEvent.KIND) is SearchableEvent)
}
}
@@ -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"}"""
}
@@ -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<Event>("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
@@ -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.",
"",