mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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 |
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+5
@@ -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>
|
||||
|
||||
+17
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+241
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+531
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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,
|
||||
)
|
||||
+100
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -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() }
|
||||
}
|
||||
}
|
||||
+32
@@ -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")
|
||||
+118
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -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")
|
||||
+4
-9
@@ -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"
|
||||
|
||||
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+284
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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() {
|
||||
|
||||
+184
@@ -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"}"""
|
||||
}
|
||||
+5
-6
@@ -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
|
||||
|
||||
+4
@@ -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.",
|
||||
"",
|
||||
|
||||
Reference in New Issue
Block a user