feat(quartz): implement NIP-CC geocaching (kinds 37516, 7516, 7517, 37517)

Adds nipCCGeocaching/, laid out like nip88Polls: a folder per subject, each
with its event class, TagArrayExt (read), TagArrayBuilderExt (write) and
tags/ holding one class per tag with the usual TAG_NAME/isTag/parse/assemble
shape and `ensure { return null }` guards.

- listing/       37516, addressable + RootScope + SearchableEvent
- foundLog/      7516, AddressHintProvider + SearchableEvent
- verification/  7517 and the validator NIP-CC specifies
- curation/      37517, addressable + SearchableEvent
- comment/       the dnf/note/maintenance/archived `t` vocabulary on kind 1111
- firstToFind/   the claim resolver

Reuses what already exists rather than restating it: DTag, GeoHashTag,
nip23's ImageTag, nip51's TitleTag/DescriptionTag/RelayTag, ATag, and
CommentEvent itself — the non-found logs are plain NIP-22 comments rooted on
the listing, so no new kind was needed for them.

Decisions worth review:

- `t` means three things (cache type, the `archived` marker, and the comment
  log type). The parsers are scoped per kind so `archived` can never read as
  a cache type, and the builders use addUniqueValueIfNew so writing a type
  cannot silently un-archive a cache or drop an author's hashtags.
- `n` modifiers are a map keyed by category, not a string set: the spec makes
  the category the unit of exclusivity (one per category, first wins) and
  requires unknown values to be ignored rather than fatal.
- The 7517 `a` tag is NOT a NIP-01 `a` tag -- it is `<finder-hex>:<naddr>`,
  which Address.parse rejects and logs a warning for. FinderCacheTag parses it
  itself; it writes the naddr form and reads both that and a plain
  kind:pubkey:d, kept unambiguous by requiring 64 hex chars on the left.
- The embedded verification payload is attacker-controlled, so a malformed one
  is a null rather than an exception that would take a feed down with it.
- The validator checks signer, finder, cache, content and signature. Tests are
  written as the attack each check stops: replaying someone else's
  verification into your log, replaying one earned at an easier cache, and
  signing your own.
- created_at is forgeable, so first-to-find is provisional (earliest verified
  log, ties by ascending id) until the owner publishes `F`, after which `F`
  wins unconditionally. Both the theft and the lock-in are pinned by tests.
- The hint is deliberately NOT indexed for NIP-50: matching a hint hands out
  the answer. It gets a rot13() display transform instead, which the spec asks
  clients for and which did not exist anywhere in the repo.
- 37515 is referenced but never defined by the spec, so readers accept it and
  nothing writes it. NIP-GD is a dangling link, so `mission` stays free text.

GeoHashTag gains a bounded geoMipMap(geohash, min, max) overload -- purely
additive -- because listings want the 3..9 band, not every prefix from one
character up.

Registers all four kinds in EventFactory and KindNames, adds 7516/37516/37517
to SearchableKinds with their rows in the golden fixture and the
searchable-kinds skill table, and extends IndexableFieldVisitorTest.

quartz:jvmTest: 5133 tests, 0 failures (79 of them new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WG7T9hAHDMKPaHsWvqySEr
This commit is contained in:
Claude
2026-09-17 20:20:16 +00:00
parent c4be09250a
commit cd6bcf0e83
49 changed files with 3921 additions and 7 deletions
@@ -2,9 +2,9 @@
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
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-08-25.
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-09-17.
Counts: 130 concrete classes covering 133 kind values (`GitStatusEvent` spans 4 kinds;
Counts: 133 concrete classes covering 136 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
@@ -55,6 +55,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
| 7516 | GeocacheFoundLogEvent | nipCCGeocaching/foundLog | `content` |
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
@@ -129,6 +130,8 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 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` |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
+12 -5
View File
@@ -1,6 +1,8 @@
# NIP-CC (Geocaching) — gap analysis for Quartz and Amethyst
Status: **analysis / not started**. Nothing in the repo implements NIP-CC today.
Status: **step 1 done** — the Quartz protocol package (`quartz/…/nipCCGeocaching/`) and its
tests are in. Nothing in `amethyst/` or `commons/` touches NIP-CC yet, so the app is still blind
to geocaches; §3 onwards is unstarted.
Spec: <https://github.com/nostr-protocol/nips/blob/master/CC.md> (merged into
`master`; listed in the NIPs README at line 117 and in the kind tables for
@@ -23,11 +25,13 @@ ROT13 hints so they don't spoil.
### Confirmed with a grep, not from memory
At the time of the survey,
```
grep -rn "37516\|7517\|37517\|[Gg]eocach" --include=*.kt
```
returns only incidental hex substrings in unrelated tests. There is **no**
geocaching code in `quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`.
returned only incidental hex substrings in unrelated tests — no geocaching code anywhere in
`quartz`, `commons`, `commonsUI`, `amethyst`, or `cli`. The Quartz half of that gap is now
closed; the rest stands.
## 2. Quartz — new code
@@ -214,8 +218,11 @@ These are the parts worth a careful reviewer, not the event codecs:
## 5. Suggested sequencing
1. Quartz protocol package + tests + the three registration files. Self-contained,
verifiable with `./gradlew :quartz:test`, no UI decisions.
1. ~~Quartz protocol package + tests + the three registration files.~~ **Done** — 79 tests in
`quartz/src/commonTest/…/nipCCGeocaching/`, plus the three new rows in the
`indexable-content.golden` fixture and the `searchable-kinds.md` table. The layout follows
nip88Polls: a folder per subject, each with its event, `TagArrayExt`, `TagArrayBuilderExt`
and `tags/`.
2. Read path in Amethyst: `LocalCache` + `GeocacheCard` + `NoteCompose` dispatch
+ the one-line `PostsByGeohashKinds` addition. Caches become visible in the
existing geohash/AroundMe feeds with zero new navigation.
@@ -319,6 +319,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
@@ -401,6 +405,10 @@ object KindNames {
ChatMessageRelayListEvent.KIND to KindName("DM Relays", "17"),
ClassifiedsEvent.KIND to KindName("Classifieds", "99"),
CommentEvent.KIND to KindName("Comments", "22"),
GeocacheListingEvent.KIND to KindName("Geocache", "CC"),
GeocacheFoundLogEvent.KIND to KindName("Geocache Found Log", "CC"),
GeocacheVerificationEvent.KIND to KindName("Geocache Verification", "CC"),
GeocacheCurationListEvent.KIND to KindName("Geocache Curation List", "CC"),
CommunityDefinitionEvent.KIND to KindName("Community Def", "72"),
CommunityListEvent.KIND to KindName("Community List", "72"),
CommunityPostApprovalEvent.KIND to KindName("Community Post", "72"),
@@ -40,6 +40,27 @@ class GeoHashTag {
fun geoMipMap(geohash: String): List<String> = geohash.indices.map { geohash.substring(0, it + 1) }.reversed()
/**
* The prefixes of [geohash] between [minPrecision] and [maxPrecision] characters,
* coarse-to-fine.
*
* [geoMipMap] starts at one character and runs fine-to-coarse, which is what the geohash
* chat channels want. Specs that pin a precision band instead — NIP-CC asks geocache
* listings for 3 to 9 characters — want this: a 1- or 2-character geohash spans thousands
* of kilometres, so tagging one is noise on the relay and useless for proximity search.
*
* Returns an empty list when [geohash] is shorter than [minPrecision].
*/
fun geoMipMap(
geohash: String,
minPrecision: Int,
maxPrecision: Int,
): List<String> {
val finest = minOf(maxPrecision, geohash.length)
if (minPrecision > finest) return emptyList()
return (minPrecision..finest).map { geohash.substring(0, it) }
}
fun geohashMipMap(geohash: String): TagArray = geoMipMap(geohash).map { assembleSingle(it) }.toTypedArray()
fun assemble(geohash: String) = geohashMipMap(geohash)
@@ -103,6 +103,7 @@ object SearchableKinds {
5302, // NIP90ContentSearchRequestEvent
5303, // NIP90PeopleSearchRequestEvent
6969, // ZapPollEvent
7516, // GeocacheFoundLogEvent
8333, // OnchainZapEvent
9002, // EditMetadataEvent
9041, // GoalEvent
@@ -183,6 +184,8 @@ object SearchableKinds {
35128, // NamedSiteEvent
35129, // NamedNappletEvent
36787, // MusicTrackEvent
37516, // GeocacheListingEvent
37517, // GeocacheCurationListEvent
38000, // MintRecommendationEvent
38192, // Ps1SaveEvent
38383, // P2POrderEvent
@@ -0,0 +1,81 @@
/*
* 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.nipCCGeocaching.comment
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The non-found logs of NIP-CC: did-not-find, notes and maintenance reports.
*
* These are not a geocaching kind at all — they are ordinary NIP-22 comments (kind 1111) rooted
* on the geocache listing, with a `t` tag naming the log type. That is the whole point of the
* design: a client that already renders comment threads renders a cache's history for free, and
* the `dnf` pile-up that tells you a cache has gone missing is just a thread.
*
* The root and the parent are both the listing, which is what
* [CommentEvent.replyBuilder] produces for an addressable event: `A`/`K`/`P` and `a`/`k`/`p`
* pointing at the cache. It also adds `E`/`e` for the listing's event id, which NIP-22 permits
* and the rest of this codebase relies on.
*/
object GeocacheLogComment {
fun build(
message: String,
cache: EventHintBundle<GeocacheListingEvent>,
type: GeocacheLogType,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
) = CommentEvent.replyBuilder(
message,
// EventHintBundle is invariant, so the listing's bundle is rebuilt as a bundle of Event.
EventHintBundle<Event>(cache.event, cache.relay, cache.authorHomeRelay),
createdAt,
) {
geocacheLogType(type)
initializer()
}
fun didNotFind(
message: String,
cache: EventHintBundle<GeocacheListingEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
) = build(message, cache, GeocacheLogType.DNF, createdAt, initializer)
fun note(
message: String,
cache: EventHintBundle<GeocacheListingEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
) = build(message, cache, GeocacheLogType.NOTE, createdAt, initializer)
fun needsMaintenance(
message: String,
cache: EventHintBundle<GeocacheListingEvent>,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CommentEvent>.() -> Unit = {},
) = build(message, cache, GeocacheLogType.MAINTENANCE, createdAt, initializer)
}
@@ -0,0 +1,34 @@
/*
* 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.nipCCGeocaching.comment
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag
/**
* Adds the log type.
*
* [addUniqueValueIfNew] rather than `addUnique` because `t` is shared with hashtags on this kind
* — replacing every `t` would drop the author's topics along with it.
*/
fun TagArrayBuilder<CommentEvent>.geocacheLogType(type: GeocacheLogType) = addUniqueValueIfNew(GeocacheLogTypeTag.assemble(type))
@@ -0,0 +1,36 @@
/*
* 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.nipCCGeocaching.comment
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag
/**
* The log type of a kind 1111 comment on a geocache.
*
* NIP-CC: "If no `t` tag is present, the comment is assumed to be a general note." Hashtags in
* `t` are skipped rather than mistaken for a type, because only the four defined codes parse.
*/
fun TagArray.geocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse) ?: GeocacheLogType.NOTE
/** The declared log type, or null where the comment left it to the default. */
fun TagArray.declaredGeocacheLogType() = firstNotNullOfOrNull(GeocacheLogTypeTag::parse)
@@ -0,0 +1,72 @@
/*
* 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.nipCCGeocaching.comment.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The log types NIP-CC defines for a kind 1111 comment on a geocache. */
enum class GeocacheLogType(
val code: String,
) {
/** Did not find — the searcher looked and came away empty-handed. */
DNF("dnf"),
/** Helpful or neutral context. The default when a comment does not say. */
NOTE("note"),
/** The cache needs attention: wet, full, damaged, muggled. */
MAINTENANCE("maintenance"),
/** The owner retiring the cache, preserving its history rather than deleting it. */
ARCHIVED("archived"),
;
companion object {
fun fromCode(code: String?): GeocacheLogType? = entries.firstOrNull { it.code == code }
}
}
/**
* The `t` tag of a geocache log comment (kind 1111).
*
* `t` is also NIP-01's hashtag tag, and a log comment may carry both. Parsing to the enum rather
* than to the raw value is what keeps them apart: a hashtag is simply not one of these four codes
* and falls through.
*
* Not to be confused with the `t` on a *listing*, which carries the cache type and the archived
* marker — see [com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag].
*/
class GeocacheLogTypeTag {
companion object {
const val TAG_NAME = "t"
fun isTag(tag: Array<String>) = parse(tag) != null
fun parse(tag: Array<String>): GeocacheLogType? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return GeocacheLogType.fromCode(tag[1])
}
fun assemble(type: GeocacheLogType) = arrayOf(TAG_NAME, type.code)
}
}
@@ -0,0 +1,143 @@
/*
* 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.nipCCGeocaching.curation
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* A geocache curation list (kind 37517): an ordered collection of caches, presented as an
* adventure, a trail, a treasure hunt, or whatever the creator has in mind.
*
* The `a` references may point at caches by any author — a list is not limited to its creator's
* own caches — and their order is meaningful, so [geocaches] preserves it.
*
* `theme` and `map` are presentation *defaults* the viewer is allowed to override; see
* [com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag].
*
* Required tags: `d`, `title`, and at least one `a`.
*/
@Immutable
class GeocacheCurationListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
SearchableEvent {
override fun indexableContent() = listOfNotNull(title(), description(), content).joinToString("\n")
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(title())) return
if (!visitor.visit(description())) return
visitor.visit(content)
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseAddressId)
fun title() = tags.listTitle()
fun description() = tags.listDescription()
fun image() = tags.listImage()
fun geohashes() = tags.geohashes()
/** The list's default page theme, or null when it names one this version does not know. */
fun theme() = tags.listTheme()
fun themeCode() = tags.listThemeCode()
/** The list's initial map style, or null when it names one this version does not know. */
fun mapStyle() = tags.listMapStyle()
fun mapStyleCode() = tags.listMapStyleCode()
/** The curated caches, in list order. */
fun geocaches() = tags.curatedGeocaches()
/** Every `a` reference in order, including any that do not point at a cache. */
fun addresses() = tags.curatedAddresses()
fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS) && geocaches().isNotEmpty()
companion object {
const val KIND = 37517
/** Coarsest `g` tag worth publishing for a list (~156km). */
const val MIN_TAGGED_PRECISION = 3
/** Finest `g` tag NIP-CC asks a list for (~1.2km) — a trail centre, not a hiding spot. */
const val MAX_TAGGED_PRECISION = 6
val REQUIRED_FIELDS = setOf(DTag.TAG_NAME, TitleTag.TAG_NAME, ATag.TAG_NAME)
@OptIn(ExperimentalUuidApi::class)
fun build(
title: String,
geocaches: List<Address>,
content: String = "",
description: String? = null,
image: String? = null,
geohash: String? = null,
theme: ListTheme? = null,
mapStyle: MapStyle? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeocacheCurationListEvent>.() -> Unit = {},
) = eventTemplate(KIND, content, createdAt) {
dTag(dTag)
listTitle(title)
geocaches(geocaches)
description?.let { listDescription(it) }
image?.let { listImage(it) }
geohash?.let { listLocation(it) }
theme?.let { listTheme(it) }
mapStyle?.let { listMapStyle(it) }
initializer()
}
}
}
@@ -0,0 +1,63 @@
/*
* 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.nipCCGeocaching.curation
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag
fun TagArrayBuilder<GeocacheCurationListEvent>.listTitle(title: String) = addUnique(TitleTag.assemble(title))
fun TagArrayBuilder<GeocacheCurationListEvent>.listDescription(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<GeocacheCurationListEvent>.listImage(url: String) = addUnique(ImageTag.assemble(url))
fun TagArrayBuilder<GeocacheCurationListEvent>.listTheme(theme: ListTheme) = addUnique(ListThemeTag.assemble(theme))
fun TagArrayBuilder<GeocacheCurationListEvent>.listMapStyle(style: MapStyle) = addUnique(MapStyleTag.assemble(style))
/**
* The `g` ladder for a list centred on [geohash], from 3 to 6 characters.
*
* Coarser than a listing's 3..9 on purpose: NIP-CC asks a list for discovery precision, and a
* trail's centre point is not a place anybody walks to.
*/
fun TagArrayBuilder<GeocacheCurationListEvent>.listLocation(geohash: String) =
addAll(
GeoHashTag.geoMipMap(geohash, GeocacheCurationListEvent.MIN_TAGGED_PRECISION, GeocacheCurationListEvent.MAX_TAGGED_PRECISION).map(GeoHashTag::assembleSingle),
)
/** Appends one cache. Call order is the list order NIP-CC says to preserve. */
fun TagArrayBuilder<GeocacheCurationListEvent>.geocache(
cache: Address,
relayHint: NormalizedRelayUrl? = null,
) = addUniqueValueIfNew(ATag.assemble(cache, relayHint))
fun TagArrayBuilder<GeocacheCurationListEvent>.geocaches(caches: List<Address>) = addAllUniqueValueIfNew(caches.map { ATag.assemble(it, null) })
@@ -0,0 +1,59 @@
/*
* 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.nipCCGeocaching.curation
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListThemeTag
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyleTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
fun TagArray.listTitle() = firstNotNullOfOrNull(TitleTag::parse)
fun TagArray.listDescription() = firstNotNullOfOrNull(DescriptionTag::parse)
fun TagArray.listImage() = firstNotNullOfOrNull(ImageTag::parse)
fun TagArray.listTheme() = firstNotNullOfOrNull(ListThemeTag::parse)
fun TagArray.listThemeCode() = firstNotNullOfOrNull(ListThemeTag::parseCode)
fun TagArray.listMapStyle() = firstNotNullOfOrNull(MapStyleTag::parse)
fun TagArray.listMapStyleCode() = firstNotNullOfOrNull(MapStyleTag::parseCode)
/** Every `a` tag, in publication order — which NIP-CC says is meaningful. */
fun TagArray.curatedAddresses() = mapNotNull(ATag::parseAddress)
/**
* The curated geocaches, in order, filtered to the kinds NIP-CC allows a list to reference.
*
* A list can point at anything; only 37516 (and the referenced-but-undefined 37515) are caches.
* Dropping the rest keeps an unrelated address out of a treasure-hunt itinerary.
*/
fun TagArray.curatedGeocaches(): List<Address> =
curatedAddresses().filter {
it.kind == GeocacheListingEvent.KIND || it.kind == GeocacheListingEvent.LEGACY_KIND
}
@@ -0,0 +1,66 @@
/*
* 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.nipCCGeocaching.curation.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The page themes NIP-CC defines for a curation list. */
enum class ListTheme(
val code: String,
) {
ADVENTURE("adventure"),
MOJAVE("mojave"),
;
companion object {
fun fromCode(code: String?): ListTheme? = entries.firstOrNull { it.code == code }
}
}
/**
* The `theme` tag of a curation list (kind 37517).
*
* A *default*, not an instruction: NIP-CC says to apply it "unless the user has explicitly chosen
* a different theme".
*/
class ListThemeTag {
companion object {
const val TAG_NAME = "theme"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): ListTheme? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return ListTheme.fromCode(tag[1])
}
fun parseCode(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(theme: ListTheme) = arrayOf(TAG_NAME, theme.code)
}
}
@@ -0,0 +1,67 @@
/*
* 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.nipCCGeocaching.curation.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The map styles NIP-CC defines for a curation list. */
enum class MapStyle(
val code: String,
) {
ORIGINAL("original"),
DARK("dark"),
SATELLITE("satellite"),
ADVENTURE("adventure"),
;
companion object {
fun fromCode(code: String?): MapStyle? = entries.firstOrNull { it.code == code }
}
}
/**
* The `map` tag of a curation list (kind 37517).
*
* The *initial* style, which NIP-CC explicitly says the user must be able to change.
*/
class MapStyleTag {
companion object {
const val TAG_NAME = "map"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): MapStyle? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return MapStyle.fromCode(tag[1])
}
fun parseCode(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(style: MapStyle) = arrayOf(TAG_NAME, style.code)
}
}
@@ -0,0 +1,116 @@
/*
* 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.nipCCGeocaching.firstToFind
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator
/**
* Who claimed a `first-to-find` cache.
*
* NIP-CC decides this in two stages, and the second one exists because the first cannot be
* trusted:
*
* 1. **Provisionally**, the winner is the verified found log with the earliest `created_at`, ties
* broken by ascending lexicographic event id.
* 2. **Finally**, once the owner publishes an `F` tag on the listing, that pubkey is the winner —
* "regardless of which verified found log currently appears earliest".
*
* Stage 2 is not a convenience. `created_at` is author-supplied, so anyone who finds the cache a
* month late can sign a log dated before the real winner's and take the claim by stage 1 alone.
* Locking the winner in is what makes the claim stick, which is why [winnerPubKey] reads `F`
* first and only falls back to timestamps while none has been published.
*
* Every function here filters to *verified* logs first. An unverified found log on a
* `first-to-find` cache is somebody's word; the claim is reserved for logs that carry a kind 7517
* that holds up against the listing.
*/
object FirstToFindResolver {
/** The logs that are about [listing] and carry a verification that checks out against it. */
fun verifiedLogs(
listing: GeocacheListingEvent,
logs: List<GeocacheFoundLogEvent>,
): List<GeocacheFoundLogEvent> =
logs.filter {
it.geocache() == listing.address() && GeocacheVerificationValidator.isValid(it, listing)
}
/**
* The earliest verified log, ties broken by ascending event id.
*
* "Provisional" is literal: this is only the winner while the owner has not locked one in,
* and a log with a forged `created_at` wins it.
*/
fun provisionalWinningLog(
listing: GeocacheListingEvent,
logs: List<GeocacheFoundLogEvent>,
): GeocacheFoundLogEvent? = verifiedLogs(listing, logs).minWithOrNull(compareBy({ it.createdAt }, { it.id }))
/**
* The winning log: the owner's locked-in winner's earliest verified log where `F` is
* published, the provisional winner otherwise.
*
* Null on a cache that is not `first-to-find` — there is no exclusive claim to award.
*/
fun winningLog(
listing: GeocacheListingEvent,
logs: List<GeocacheFoundLogEvent>,
): GeocacheFoundLogEvent? {
if (!listing.isFirstToFind()) return null
val lockedIn = listing.firstToFindWinner() ?: return provisionalWinningLog(listing, logs)
return verifiedLogs(listing, logs)
.filter { it.pubKey == lockedIn }
.minWithOrNull(compareBy({ it.createdAt }, { it.id }))
}
/**
* The pubkey holding the exclusive claim, or null if nobody does yet.
*
* An `F` tag wins even when no log for it is in [logs] — the owner has confirmed the claim
* and the log may simply not have been fetched.
*/
fun winnerPubKey(
listing: GeocacheListingEvent,
logs: List<GeocacheFoundLogEvent>,
): HexKey? {
if (!listing.isFirstToFind()) return null
return listing.firstToFindWinner() ?: provisionalWinningLog(listing, logs)?.pubKey
}
/** Whether the owner has locked the winner in, as opposed to it resting on timestamps. */
fun isLockedIn(listing: GeocacheListingEvent) = listing.firstToFindWinner() != null
/**
* Whether the claim is taken.
*
* NIP-CC: once it is, clients should render the listing as effectively archived and hide the
* find-submission affordances. Later verified logs stay valid records of physical presence —
* they are simply not additional claims.
*/
fun isClaimed(
listing: GeocacheListingEvent,
logs: List<GeocacheFoundLogEvent>,
) = winnerPubKey(listing, logs) != null
}
@@ -0,0 +1,111 @@
/*
* 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.nipCCGeocaching.foundLog
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A found log (kind 7516): a claim that the author found a geocache.
*
* `content` is the log message. The `a` tag names the cache. A log may carry a kind 7517
* verification event inline, which is what turns "I say I found it" into "I can prove I was
* there" — but only after [com.vitorpamplona.quartz.nipCCGeocaching.verification
* .GeocacheVerificationValidator] has checked it against the listing. [isVerified] on its own
* means nothing more than "a verification event was attached".
*
* Only *found* logs are this kind. Did-not-find, notes and maintenance reports are NIP-22
* comments rooted on the listing — see [com.vitorpamplona.quartz.nipCCGeocaching.comment
* .GeocacheLogComment].
*/
@Immutable
class GeocacheFoundLogEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
SearchableEvent {
override fun indexableContent() = content
override fun addressHints() = tags.mapNotNull(GeocacheTag::parseAsHint)
override fun linkedAddressIds() = tags.mapNotNull(GeocacheTag::parseAddressId)
fun geocache() = tags.geocache()
fun geocacheId() = tags.geocacheId()
fun images() = tags.logImages()
fun embeddedVerification() = tags.embeddedVerification()
/** Whether a verification event is attached at all. Says nothing about whether it is valid. */
fun isVerified() = tags.embeddedVerification() != null
companion object {
const val KIND = 7516
fun build(
message: String,
cache: EventHintBundle<GeocacheListingEvent>,
verification: GeocacheVerificationEvent? = null,
images: List<String>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeocacheFoundLogEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
geocache(cache)
verification?.let { embeddedVerification(it) }
images?.let { logImages(it) }
initializer()
}
fun build(
message: String,
cache: Address,
relayHint: NormalizedRelayUrl? = null,
verification: GeocacheVerificationEvent? = null,
images: List<String>? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeocacheFoundLogEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
geocache(cache, relayHint)
verification?.let { embeddedVerification(it) }
images?.let { logImages(it) }
initializer()
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipCCGeocaching.foundLog
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
fun TagArrayBuilder<GeocacheFoundLogEvent>.geocache(cache: EventHintBundle<GeocacheListingEvent>) = addUnique(GeocacheTag.assemble(cache))
fun TagArrayBuilder<GeocacheFoundLogEvent>.geocache(
cache: Address,
relayHint: NormalizedRelayUrl? = null,
) = addUnique(GeocacheTag.assemble(cache, relayHint))
fun TagArrayBuilder<GeocacheFoundLogEvent>.embeddedVerification(verification: GeocacheVerificationEvent) = addUnique(EmbeddedVerificationTag.assemble(verification))
fun TagArrayBuilder<GeocacheFoundLogEvent>.logImages(urls: List<String>) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble))
@@ -0,0 +1,34 @@
/*
* 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.nipCCGeocaching.foundLog
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.EmbeddedVerificationTag
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.tags.GeocacheTag
fun TagArray.geocache() = firstNotNullOfOrNull(GeocacheTag::parseAddress)
fun TagArray.geocacheId() = firstNotNullOfOrNull(GeocacheTag::parseAddressId)
fun TagArray.logImages() = mapNotNull(ImageTag::parse)
fun TagArray.embeddedVerification() = firstNotNullOfOrNull(EmbeddedVerificationTag::parse)
@@ -0,0 +1,64 @@
/*
* 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.nipCCGeocaching.foundLog.tags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ensure
/**
* The `verification` tag of a found log (kind 7516): a whole kind 7517 event, as JSON, inline.
*
* NIP-CC allows the verification to be embedded, published standalone, or both; embedding is what
* makes a log self-contained, so this is the common case.
*
* The payload is attacker-controlled — anybody can publish a found log with any string in this
* tag — so a parse failure has to be an ordinary null rather than an exception that takes the
* surrounding feed down with it.
*
* Note this returns a [GeocacheVerificationEvent] only because kind 7517 is registered with
* [com.vitorpamplona.quartz.utils.EventFactory]. Unregistered, the same JSON would parse to a
* plain [Event] and every embedded verification in the wild would read as absent.
*/
class EmbeddedVerificationTag {
companion object {
const val TAG_NAME = "verification"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): GeocacheVerificationEvent? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return try {
Event.fromJson(tag[1]) as? GeocacheVerificationEvent
} catch (e: Exception) {
Log.w("EmbeddedVerificationTag") { "Could not parse the embedded verification event: ${e.message}" }
null
}
}
fun assemble(verification: GeocacheVerificationEvent) = arrayOf(TAG_NAME, verification.toJson())
}
}
@@ -0,0 +1,57 @@
/*
* 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.nipCCGeocaching.foundLog.tags
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
/**
* The `a` tag of a found log (kind 7516): the geocache listing the log is about.
*
* An ordinary NIP-01 addressable reference — unlike the composite `a` on a kind 7517 — so the
* parsing delegates to [ATag]. The named wrapper exists so the package reads in its own
* vocabulary and so the assemble overload can take a [GeocacheListingEvent] directly.
*/
class GeocacheTag {
companion object {
const val TAG_NAME = ATag.TAG_NAME
fun isTag(tag: Array<String>) = ATag.isTagged(tag)
fun parse(tag: Array<String>) = ATag.parse(tag)
fun parseAddress(tag: Array<String>) = ATag.parseAddress(tag)
fun parseAddressId(tag: Array<String>) = ATag.parseAddressId(tag)
fun parseAsHint(tag: Array<String>) = ATag.parseAsHint(tag)
fun assemble(
cache: Address,
relayHint: NormalizedRelayUrl?,
) = ATag.assemble(cache, relayHint)
fun assemble(cache: EventHintBundle<GeocacheListingEvent>) = assemble(cache.event.address(), cache.relay)
}
}
@@ -0,0 +1,60 @@
/*
* 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.nipCCGeocaching.listing
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
/**
* The geohash precision rules NIP-CC puts on geocache listings (kind 37516).
*
* Two different numbers are involved and they are easy to confuse:
*
* - [MIN_TAGGED]..[MAX_TAGGED] is the band of `g` tags a listing publishes, so that relays can
* answer proximity queries at several zoom levels.
* - [minPublishPrecision] is how precise the *location the owner picked* must be before a client
* accepts the submission at all. A cache you cannot walk up to is not a cache.
*/
object GeocacheGeohash {
/** Coarsest `g` tag worth publishing (~156km). */
const val MIN_TAGGED = 3
/** Finest `g` tag NIP-CC asks for (~5m). */
const val MAX_TAGGED = 9
/** NIP-CC: "Validate geohash precision meets minimum requirements (8+ characters …)". */
const val MIN_PUBLISH = 8
/** "… 9+ for micro caches" — a 38m box does not find a film canister. */
const val MIN_PUBLISH_MICRO = 9
/** The precision a submission of [size] must reach before a client accepts it. */
fun minPublishPrecision(size: CacheSize?) = if (size == CacheSize.MICRO) MIN_PUBLISH_MICRO else MIN_PUBLISH
/** Whether [geohash] is precise enough to publish as a cache of [size]. */
fun isPreciseEnough(
geohash: String,
size: CacheSize?,
) = geohash.length >= minPublishPrecision(size)
/** The `g` values a listing at [geohash] should publish, coarse-to-fine. */
fun ladder(geohash: String) = GeoHashTag.geoMipMap(geohash, MIN_TAGGED, MAX_TAGGED)
}
@@ -0,0 +1,200 @@
/*
* 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.nipCCGeocaching.listing
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.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.containsAllTagNamesWithValues
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes
import com.vitorpamplona.quartz.nip22Comments.RootScope
import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* A geocache listing (kind 37516), as defined by
* [NIP-CC](https://github.com/nostr-protocol/nips/blob/master/CC.md).
*
* Addressable, so the owner keeps editing the same cache — that is how a cache gets archived,
* how its hint is corrected, and how a first-to-find winner is locked in. `content` is the cache
* description.
*
* Community history about the cache does *not* live here: found logs are kind
* [com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent] and everything else
* (did-not-find, notes, maintenance) is a NIP-22 comment rooted on this listing — see
* [com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment].
*
* Required tags: `d`, `name`, `g`, `D`, `T`, `S`. See [isWellFormed].
*/
@Immutable
class GeocacheListingEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
RootScope,
SearchableEvent {
// The hint and the mission are deliberately absent: a cache is found by walking to it, and a
// search that matches on "in the branches" hands out the answer to anyone who types it.
override fun indexableContent() = listOfNotNull(cacheName(), content).joinToString("\n")
// The read path: the same fields indexableContent() joins, without building the join a scan
// would throw away.
override fun forEachIndexableField(visitor: IndexableFieldVisitor) {
if (!visitor.visit(cacheName())) return
visitor.visit(content)
}
fun cacheName() = tags.cacheName()
/** Every `g` tag as published, coarse-to-fine. */
fun geohashes() = tags.geohashes()
/** The finest `g` tag — the one that actually points at the cache. */
fun location() = tags.geohashes().maxByOrNull { it.length }
fun difficulty() = tags.difficulty()
fun terrain() = tags.terrain()
fun cacheSize() = tags.cacheSize()
fun cacheSizeCode() = tags.cacheSizeCode()
/** The `t` cache type, defaulting to `traditional` when the listing does not say. */
fun cacheTypeCode() = tags.cacheTypeCode()
fun cacheType() = tags.cacheType()
fun isArchived() = tags.isArchived()
fun typeModifiers() = tags.typeModifiers()
fun typeModifierCodes() = tags.typeModifierCodes()
fun hasTypeModifier(modifier: TypeModifier) = tags.hasTypeModifier(modifier)
fun isFirstToFind() = tags.isFirstToFind()
fun hint() = tags.hint()
/** The hint, ROT13'd for display so a reader does not spoil themselves by scrolling past it. */
fun hintRot13() = tags.hint()?.let(::rot13)
fun mission() = tags.mission()
fun hasMission() = tags.mission() != null
fun verificationKey() = tags.verificationKey()
/** Whether finders can prove physical presence at this cache. */
fun requiresVerification() = tags.verificationKey() != null
/**
* The locked-in first-to-find winner, or null.
*
* Only honoured on a listing that carries the `first-to-find` modifier: NIP-CC scopes `F` to
* that modifier, and an `F` on any other cache would silently invent a claim nobody made.
*/
fun firstToFindWinner() = if (isFirstToFind()) tags.firstToFindWinner() else null
fun images() = tags.cacheImages()
fun logRelays() = tags.logRelays()
fun isWellFormed() = tags.containsAllTagNamesWithValues(REQUIRED_FIELDS)
companion object {
const val KIND = 37516
/**
* A kind NIP-CC references once — the curation list accepts "kind 37516 or 37515" — but
* never defines. Readers accept it; nothing here ever writes it.
*/
const val LEGACY_KIND = 37515
val REQUIRED_FIELDS =
setOf(
DTag.TAG_NAME,
CacheNameTag.TAG_NAME,
GeoHashTag.TAG_NAME,
DifficultyTag.TAG_NAME,
TerrainTag.TAG_NAME,
CacheSizeTag.TAG_NAME,
)
@OptIn(ExperimentalUuidApi::class)
fun build(
name: String,
description: String,
geohash: String,
difficulty: Int,
terrain: Int,
size: CacheSize,
type: CacheType? = null,
modifiers: Collection<TypeModifier> = emptySet(),
hint: String? = null,
mission: String? = null,
verificationPubKey: HexKey? = null,
images: List<String>? = null,
relays: List<NormalizedRelayUrl>? = null,
dTag: String = Uuid.random().toString(),
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeocacheListingEvent>.() -> Unit = {},
) = eventTemplate(KIND, description, createdAt) {
dTag(dTag)
cacheName(name)
cacheLocation(geohash)
difficulty(difficulty)
terrain(terrain)
cacheSize(size)
type?.let { cacheType(it) }
if (modifiers.isNotEmpty()) typeModifiers(modifiers)
hint?.let { hint(it) }
mission?.let { mission(it) }
verificationPubKey?.let { verificationKey(it) }
images?.let { cacheImages(it) }
relays?.let { logRelays(it) }
initializer()
}
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipCCGeocaching.listing
/**
* ROT13, the encoding geocaching has always used to keep a hint from spoiling itself.
*
* NIP-CC asks clients to "support hint encoding, such as ROT13, to prevent spoilers". The `hint`
* tag itself travels as plaintext, so this is a display transform: show the rotated text, and
* rotate it back when the reader asks for it. Rotating twice returns the original, so one
* function serves both directions.
*
* Only ASCII letters move; digits, punctuation and every non-ASCII letter are left alone, which
* is what every other geocaching implementation does and what makes the round trip exact.
*/
fun rot13(text: String): String =
text
.map { char ->
when (char) {
in 'a'..'z' -> 'a' + ((char - 'a') + 13) % 26
in 'A'..'Z' -> 'A' + ((char - 'A') + 13) % 26
else -> char
}
}.joinToString("")
@@ -0,0 +1,84 @@
/*
* 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.nipCCGeocaching.listing
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag
fun TagArrayBuilder<GeocacheListingEvent>.cacheName(name: String) = addUnique(CacheNameTag.assemble(name))
fun TagArrayBuilder<GeocacheListingEvent>.difficulty(rating: Int) = addUnique(DifficultyTag.assemble(rating))
fun TagArrayBuilder<GeocacheListingEvent>.terrain(rating: Int) = addUnique(TerrainTag.assemble(rating))
fun TagArrayBuilder<GeocacheListingEvent>.cacheSize(size: CacheSize) = addUnique(CacheSizeTag.assemble(size))
/**
* The `g` ladder for a cache at [geohash], from 3 to 9 characters.
*
* Deliberately not [com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash], which publishes
* every prefix from one character up.
*/
fun TagArrayBuilder<GeocacheListingEvent>.cacheLocation(geohash: String) = addAll(GeocacheGeohash.ladder(geohash).map(GeoHashTag::assembleSingle))
/**
* The cache type. Kept off [addUnique] because `t` also carries the `archived` marker — replacing
* every `t` here would un-retire a cache the owner had archived.
*/
fun TagArrayBuilder<GeocacheListingEvent>.cacheType(type: CacheType) = addUniqueValueIfNew(CacheTypeTag.assemble(type))
fun TagArrayBuilder<GeocacheListingEvent>.cacheType(code: String) = addUniqueValueIfNew(CacheTypeTag.assemble(code))
/** Retires the cache, preserving its history instead of deleting it. */
fun TagArrayBuilder<GeocacheListingEvent>.archived() = addUniqueValueIfNew(CacheTypeTag.assembleArchived())
fun TagArrayBuilder<GeocacheListingEvent>.typeModifier(modifier: TypeModifier) = addUniqueValueIfNew(TypeModifierTag.assemble(modifier))
fun TagArrayBuilder<GeocacheListingEvent>.typeModifiers(modifiers: Collection<TypeModifier>) = addAllUniqueValueIfNew(TypeModifierTag.assemble(modifiers))
fun TagArrayBuilder<GeocacheListingEvent>.hint(hint: String) = addUnique(HintTag.assemble(hint))
fun TagArrayBuilder<GeocacheListingEvent>.mission(mission: String) = addUnique(MissionTag.assemble(mission))
fun TagArrayBuilder<GeocacheListingEvent>.verificationKey(pubKey: HexKey) = addUnique(VerificationKeyTag.assemble(pubKey))
fun TagArrayBuilder<GeocacheListingEvent>.firstToFindWinner(winnerPubKey: HexKey) = addUnique(FirstToFindWinnerTag.assemble(winnerPubKey))
fun TagArrayBuilder<GeocacheListingEvent>.cacheImages(urls: List<String>) = addAllUniqueValueIfNew(urls.map(ImageTag::assemble))
fun TagArrayBuilder<GeocacheListingEvent>.logRelays(relays: List<NormalizedRelayUrl>) = addAllUniqueValueIfNew(relays.map(RelayTag::assemble))
@@ -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.nipCCGeocaching.listing
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip51Lists.tags.RelayTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheNameTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSizeTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheTypeTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.DifficultyTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.FirstToFindWinnerTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.HintTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.MissionTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TerrainTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.VerificationKeyTag
fun TagArray.cacheName() = firstNotNullOfOrNull(CacheNameTag::parse)
fun TagArray.difficulty() = firstNotNullOfOrNull(DifficultyTag::parse)
fun TagArray.terrain() = firstNotNullOfOrNull(TerrainTag::parse)
fun TagArray.cacheSize() = firstNotNullOfOrNull(CacheSizeTag::parse)
/** The raw `S` value, which may be outside the [CacheSize] vocabulary. */
fun TagArray.cacheSizeCode() = firstNotNullOfOrNull(CacheSizeTag::parseCode)
/** The `t` cache type as published, defaulting to `traditional` per NIP-CC when absent. */
fun TagArray.cacheTypeCode() = firstNotNullOfOrNull(CacheTypeTag::parseTypeCode) ?: CacheType.TRADITIONAL.code
/** The parsed [CacheType], or null when the listing names a client-defined type. */
fun TagArray.cacheType() = CacheType.fromCode(cacheTypeCode())
/** Whether the owner retired this cache with `["t", "archived"]`. */
fun TagArray.isArchived() = fastAny(CacheTypeTag::isArchived)
/**
* The known `n` modifiers, at most one per category.
*
* NIP-CC rule 2: where a listing carries several values from one category, the first occurrence
* wins and the rest are ignored. Unknown values are dropped (rule 4) — see [typeModifierCodes]
* to see them anyway.
*/
fun TagArray.typeModifiers(): Map<TypeModifierCategory, TypeModifier> {
val byCategory = mutableMapOf<TypeModifierCategory, TypeModifier>()
fastForEach { tag ->
TypeModifierTag.parse(tag)?.let {
if (!byCategory.containsKey(it.category)) byCategory[it.category] = it
}
}
return byCategory
}
/** Every raw `n` value in publication order, unknown modifiers included. */
fun TagArray.typeModifierCodes() = mapNotNull(TypeModifierTag::parseCode)
fun TagArray.hasTypeModifier(modifier: TypeModifier) = typeModifiers()[modifier.category] == modifier
fun TagArray.isFirstToFind() = hasTypeModifier(TypeModifier.FIRST_TO_FIND)
fun TagArray.hint() = firstNotNullOfOrNull(HintTag::parse)
/** The first `mission` tag. A listing must not carry more than one; extras are ignored. */
fun TagArray.mission() = firstNotNullOfOrNull(MissionTag::parse)
fun TagArray.verificationKey() = firstNotNullOfOrNull(VerificationKeyTag::parse)
/** The first `F` tag. Only meaningful on a `first-to-find` listing; extras are ignored. */
fun TagArray.firstToFindWinner() = firstNotNullOfOrNull(FirstToFindWinnerTag::parse)
fun TagArray.cacheImages() = mapNotNull(ImageTag::parse)
/** The `r` tags: relays the owner prefers logs to be published to. */
fun TagArray.logRelays() = mapNotNull(RelayTag::parse)
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The `name` tag of a geocache listing (kind 37516): the cache's human-readable name. */
class CacheNameTag {
companion object {
const val TAG_NAME = "name"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(name: String) = arrayOf(TAG_NAME, name)
}
}
@@ -0,0 +1,66 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/** The container sizes NIP-CC defines for the `S` tag. */
enum class CacheSize(
val code: String,
) {
MICRO("micro"),
SMALL("small"),
REGULAR("regular"),
LARGE("large"),
OTHER("other"),
;
companion object {
fun fromCode(code: String?): CacheSize? = entries.firstOrNull { it.code == code }
}
}
/** The `S` tag of a geocache listing (kind 37516): the container size. */
class CacheSizeTag {
companion object {
const val TAG_NAME = "S"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** The parsed [CacheSize], or null when the tag is missing or names a size we don't know. */
fun parse(tag: Array<String>): CacheSize? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return CacheSize.fromCode(tag[1])
}
/** The raw `S` value, even when it is outside the vocabulary above. */
fun parseCode(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(size: CacheSize) = arrayOf(TAG_NAME, size.code)
}
}
@@ -0,0 +1,85 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The cache types NIP-CC names as common. The vocabulary is deliberately open — the spec says
* types "are determined by individual clients" — so [CacheTypeTag.parseTypeCode] hands back
* unknown codes unchanged and only [CacheTypeTag.parse] narrows to this set.
*/
enum class CacheType(
val code: String,
) {
TRADITIONAL("traditional"),
MULTI("multi"),
MYSTERY("mystery"),
;
companion object {
fun fromCode(code: String?): CacheType? = entries.firstOrNull { it.code == code }
}
}
/**
* The `t` tag of a geocache listing (kind 37516).
*
* `t` carries two unrelated things on this kind: the cache *type* (`traditional`, `multi`,
* `mystery`, or anything a client invents) and the retirement marker [ARCHIVED], which owners
* add to preserve a cache's history instead of deleting it. A third meaning — the log type on
* a kind 1111 comment — lives in
* [com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogTypeTag]. The parsers are
* kept apart per kind on purpose: sharing one would let `archived` read as a cache type.
*/
class CacheTypeTag {
companion object {
const val TAG_NAME = "t"
/** `["t", "archived"]` retires a listing. It is a lifecycle marker, never a cache type. */
const val ARCHIVED = "archived"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** The raw `t` value, including [ARCHIVED]. */
fun parseCode(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
/** The raw `t` value when it names a cache type — never [ARCHIVED]. */
fun parseTypeCode(tag: Array<String>): String? = parseCode(tag)?.takeIf { it != ARCHIVED }
/** The parsed [CacheType], or null when the value is [ARCHIVED], missing, or client-defined. */
fun parse(tag: Array<String>): CacheType? = CacheType.fromCode(parseTypeCode(tag))
fun isArchived(tag: Array<String>) = parseCode(tag) == ARCHIVED
fun assemble(type: CacheType) = arrayOf(TAG_NAME, type.code)
fun assemble(code: String) = arrayOf(TAG_NAME, code)
fun assembleArchived() = arrayOf(TAG_NAME, ARCHIVED)
}
}
@@ -0,0 +1,52 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `D` tag of a geocache listing (kind 37516): how hard the cache is to work out, 1..5.
*
* Values outside the range do not parse. NIP-CC states the range without saying what an
* out-of-range value means, and a 9 read as "very hard" would sort above every legitimate
* cache in a client that ranks on it — so it is treated as absent instead.
*/
class DifficultyTag {
companion object {
const val TAG_NAME = "D"
const val MIN = 1
const val MAX = 5
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val rating = tag[1].toIntOrNull() ?: return null
ensure(rating in MIN..MAX) { return null }
return rating
}
/** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */
fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString())
}
}
@@ -0,0 +1,57 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* The `F` tag of a geocache listing (kind 37516): the locked-in first-to-find winner.
*
* The owner publishes it in a revision of the listing once they have confirmed a claim, normally
* alongside `["t", "archived"]`. It exists because `created_at` is author-supplied and forgeable:
* without it, a later log carrying a backdated timestamp would displace the real winner. Once
* present, clients MUST attribute the claim to this pubkey regardless of timestamps — which is
* what [com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver] implements.
*
* Only meaningful on a listing carrying the `first-to-find` `n` modifier.
*/
class FirstToFindWinnerTag {
companion object {
const val TAG_NAME = "F"
/** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1])
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(isPubKey(tag[1])) { return null }
return tag[1]
}
fun assemble(winnerPubKey: HexKey) = arrayOf(TAG_NAME, winnerPubKey)
}
}
@@ -0,0 +1,48 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `hint` tag of a geocache listing (kind 37516): plaintext help for finding the cache.
*
* The tag is plaintext on the wire. NIP-CC asks clients to obscure it in the UI — see
* [com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13] — so a reader does not spoil
* themselves by scrolling past it.
*/
class HintTag {
companion object {
const val TAG_NAME = "hint"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(hint: String) = arrayOf(TAG_NAME, hint)
}
}
@@ -0,0 +1,51 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `mission` tag of a geocache listing (kind 37516): the "Key Quest" a finder is expected to
* complete to legitimately claim the cache.
*
* A listing must not carry more than one; where one does, the spec says to use the first, which
* is what [com.vitorpamplona.quartz.nipCCGeocaching.listing.mission] does.
*
* NIP-CC says completions may be recorded as NIP-GD "Good Deed" events referencing the cache.
* No such NIP exists in the repository yet, so nothing here models it — `mission` is free text.
*/
class MissionTag {
companion object {
const val TAG_NAME = "mission"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(mission: String) = arrayOf(TAG_NAME, mission)
}
}
@@ -0,0 +1,50 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The `T` tag of a geocache listing (kind 37516): how hard the cache is to reach, 1..5.
*
* Out-of-range values do not parse, for the same reason as [DifficultyTag].
*/
class TerrainTag {
companion object {
const val TAG_NAME = "T"
const val MIN = 1
const val MAX = 5
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Int? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val rating = tag[1].toIntOrNull() ?: return null
ensure(rating in MIN..MAX) { return null }
return rating
}
/** Clamped rather than rejected: a composer's off-by-one should not throw mid-publish. */
fun assemble(rating: Int) = arrayOf(TAG_NAME, rating.coerceIn(MIN, MAX).toString())
}
}
@@ -0,0 +1,91 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* The category a [TypeModifier] belongs to.
*
* NIP-CC allows at most one `n` modifier per category and lets modifiers from different
* categories compose freely, so the category — not the modifier — is the unit of exclusivity.
* That is why this is modelled as a map keyed by category rather than a set of strings.
*/
enum class TypeModifierCategory {
/** How claims on the treasure are interpreted. */
CLAIM_SEMANTICS,
/** What the physical treasure *is*. */
PRIZE_NATURE,
}
/** The `n` type modifiers NIP-CC defines today. New ones are expected; see [TypeModifierTag]. */
enum class TypeModifier(
val code: String,
val category: TypeModifierCategory,
) {
/** Single-claim cache: the earliest verified found log is the exclusive claim. */
FIRST_TO_FIND("first-to-find", TypeModifierCategory.CLAIM_SEMANTICS),
/** The cache itself is a physical work of art. */
ART("art", TypeModifierCategory.PRIZE_NATURE),
;
companion object {
fun fromCode(code: String?): TypeModifier? = entries.firstOrNull { it.code == code }
}
}
/**
* The `n` tag of a geocache listing (kind 37516): a type modifier.
*
* Unknown values parse to null rather than making the listing unreadable — forward
* compatibility is an explicit requirement of the spec, which reserves the right to define new
* modifiers and new categories. [parseCode] exposes the raw value for clients that want to show
* a modifier they don't understand.
*/
class TypeModifierTag {
companion object {
const val TAG_NAME = "n"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** The parsed [TypeModifier], or null for a modifier this version does not know. */
fun parse(tag: Array<String>): TypeModifier? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return TypeModifier.fromCode(tag[1])
}
/** The raw `n` value, whether or not it is a known modifier. */
fun parseCode(tag: Array<String>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
fun assemble(modifier: TypeModifier) = arrayOf(TAG_NAME, modifier.code)
fun assemble(modifiers: Collection<TypeModifier>) = modifiers.map { assemble(it) }
}
}
@@ -0,0 +1,54 @@
/*
* 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.nipCCGeocaching.listing.tags
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* The `verification` tag of a geocache listing (kind 37516): the public half of the keypair a
* finder proves physical presence with.
*
* The matching private key lives at the cache itself, normally behind a QR code. It is not the
* owner's key and is not an account key — see
* [com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent].
*/
class VerificationKeyTag {
companion object {
const val TAG_NAME = "verification"
/** [Hex.isHex64] does not check the length itself, so a 70-char value would pass it alone. */
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && isPubKey(tag[1])
fun parse(tag: Array<String>): HexKey? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(isPubKey(tag[1])) { return null }
return tag[1]
}
fun assemble(pubKey: HexKey) = arrayOf(TAG_NAME, pubKey)
}
}
@@ -0,0 +1,84 @@
/*
* 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.nipCCGeocaching.verification
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* A geocache verification event (kind 7517): cryptographic proof that someone stood at a cache.
*
* **Signed by the cache's verification key, not by the finder and not by the cache owner.** The
* private half lives at the cache itself, normally behind a QR code, so producing one of these
* requires having physically been there. A finder signs it with an ephemeral signer built from
* the scanned key, embeds the result in their found log, and throws the key away — it is not an
* account key and must never reach a keystore, an account, or a log line.
*
* Read [GeocacheVerificationValidator] before trusting one: a verification event on its own
* proves only that *somebody* was at the cache. It becomes evidence about a particular finder
* only once the signer, the finder and the cache address have all been checked against the
* listing and the log.
*/
@Immutable
class GeocacheVerificationEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun finderCache() = tags.finderCache()
/** The pubkey this verification was issued to. */
fun finder() = tags.finder()
/** The cache it was issued at. */
fun cache() = tags.verifiedCache()
/** Whether `content` matches the static format NIP-CC mandates for [finder]. */
fun hasExpectedContent() = finder()?.let { content == contentFor(it) } ?: false
companion object {
const val KIND = 7517
/** NIP-CC fixes the content exactly: `"Geocache verification for <finder-npub>"`. */
fun contentFor(finderPubKey: HexKey) = "Geocache verification for ${NPub.create(finderPubKey)}"
fun build(
finderPubKey: HexKey,
cache: Address,
relayHint: NormalizedRelayUrl? = null,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<GeocacheVerificationEvent>.() -> Unit = {},
) = eventTemplate(KIND, contentFor(finderPubKey), createdAt) {
finderCache(finderPubKey, cache, relayHint)
initializer()
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.nipCCGeocaching.verification
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
/** Why a kind 7517 does not prove what it claims to prove. Ordered cheapest check first. */
enum class VerificationFailure {
/** The found log carries no `verification` tag, or its payload did not parse as a 7517. */
NO_EMBEDDED_VERIFICATION,
/** The listing has no `verification` tag, so nothing at this cache can be verified. */
CACHE_HAS_NO_VERIFICATION_KEY,
/** The `a` tag is missing or is not the `<finder-hex>:<cache>` pair NIP-CC defines. */
MALFORMED_FINDER_CACHE_TAG,
/** The 7517 names a different cache than the listing it is being checked against. */
CACHE_MISMATCH,
/** The 7517 was issued to somebody other than the author of the found log. */
FINDER_MISMATCH,
/** `content` is not the static `"Geocache verification for <npub>"` string. */
CONTENT_MISMATCH,
/** The 7517 was signed by some other key than the cache's verification key. */
WRONG_SIGNER,
/** The id or the signature does not check out. */
BAD_SIGNATURE,
}
/**
* The four checks NIP-CC lists under "Verification Validation", plus the two the spec leaves
* implicit.
*
* Each one exists because skipping it breaks something concrete:
*
* - **signer** — without it, anybody signs their own "verification" and every cache is verified.
* - **finder** — without it, a verification issued to somebody else can be lifted out of their
* log and replayed into yours. This is the check that makes the proof about *a person* rather
* than about the cache.
* - **cache** — without it, a verification earned at an easy cache is replayed at a hard one.
* - **signature** — without it, all three of the above are checks against unauthenticated text.
* - **content** — NIP-CC fixes the content string, so a mismatch means the event was not
* produced by a conforming client and its other fields are not worth trusting either.
*
* Note what a valid result does and does not mean. It means somebody who had access to the
* private key at the cache issued this to that finder for that cache. It does not mean the finder
* went there in person — a key that has leaked verifies just as well, which is why NIP-CC treats
* these as evidence of presence rather than proof of identity, and why first-to-find claims get
* locked in by the owner rather than decided by timestamps.
*/
object GeocacheVerificationValidator {
/**
* Validates [verification] as proof that [finderPubKey] was at [listing].
*
* @return null when it checks out, or the first [VerificationFailure] found.
*/
fun validate(
verification: GeocacheVerificationEvent,
listing: GeocacheListingEvent,
finderPubKey: HexKey,
): VerificationFailure? {
val expectedSigner = listing.verificationKey() ?: return VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY
val claim = verification.finderCache() ?: return VerificationFailure.MALFORMED_FINDER_CACHE_TAG
if (claim.cache != listing.address()) return VerificationFailure.CACHE_MISMATCH
if (claim.finderPubKey != finderPubKey) return VerificationFailure.FINDER_MISMATCH
if (verification.content != GeocacheVerificationEvent.contentFor(claim.finderPubKey)) return VerificationFailure.CONTENT_MISMATCH
if (verification.pubKey != expectedSigner) return VerificationFailure.WRONG_SIGNER
// Last because it is the only check that costs a curve operation.
if (!verification.verify()) return VerificationFailure.BAD_SIGNATURE
return null
}
/** Validates the verification embedded in [log], attributing it to the log's own author. */
fun validate(
log: GeocacheFoundLogEvent,
listing: GeocacheListingEvent,
): VerificationFailure? {
val verification = log.embeddedVerification() ?: return VerificationFailure.NO_EMBEDDED_VERIFICATION
return validate(verification, listing, log.pubKey)
}
fun isValid(
verification: GeocacheVerificationEvent,
listing: GeocacheListingEvent,
finderPubKey: HexKey,
) = validate(verification, listing, finderPubKey) == null
/** Whether [log] carries a verification that holds up against [listing]. */
fun isValid(
log: GeocacheFoundLogEvent,
listing: GeocacheListingEvent,
) = validate(log, listing) == null
}
@@ -0,0 +1,33 @@
/*
* 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.nipCCGeocaching.verification
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag
fun TagArrayBuilder<GeocacheVerificationEvent>.finderCache(
finderPubKey: HexKey,
cache: Address,
relayHint: NormalizedRelayUrl? = null,
) = addUnique(FinderCacheTag.assemble(finderPubKey, cache, relayHint))
@@ -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.nipCCGeocaching.verification
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag
fun TagArray.finderCache() = firstNotNullOfOrNull(FinderCacheTag::parse)
fun TagArray.finder() = firstNotNullOfOrNull(FinderCacheTag::parseFinder)
fun TagArray.verifiedCache() = firstNotNullOfOrNull(FinderCacheTag::parseCache)
@@ -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.nipCCGeocaching.verification.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.ensure
/**
* The `a` tag of a geocache verification event (kind 7517): who was at which cache.
*
* **This is not a NIP-01 `a` tag.** NIP-CC defines its value as
* `"<finder-pubkey-hex>:<geocache-naddr>"` — a colon-joined pair, where a NIP-01 address is
* `kind:pubkey:d`. [com.vitorpamplona.quartz.nip01Core.core.Address.parse] rejects it (two
* segments fail its three-segment guard, and the remainder does not start with `naddr1`) and logs
* a warning on the way out, so a generic `a`-tag reader that meets one of these gets nothing
* useful and a noisy log. Hence a parser of its own.
*
* Reading is slightly more generous than writing: the cache half goes through
* [Address.parse], which accepts both the spec's `naddr1…` and a plain `kind:pubkey:d`. The
* finder half must be exactly 64 hex characters, which is what keeps the two forms unambiguous —
* a bare NIP-01 address would have `37516` on the left and is rejected.
*/
@Immutable
data class FinderCacheTag(
val finderPubKey: HexKey,
val cache: Address,
) {
fun toTagArray() = assemble(finderPubKey, cache, null)
companion object {
const val TAG_NAME = "a"
private fun isPubKey(value: String) = value.length == 64 && Hex.isHex64(value)
fun isTag(tag: Array<String>) = parse(tag) != null
fun parse(tag: Array<String>): FinderCacheTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
val separator = tag[1].indexOf(':')
ensure(separator > 0) { return null }
val finder = tag[1].substring(0, separator)
ensure(isPubKey(finder)) { return null }
val cache = Address.parse(tag[1].substring(separator + 1)) ?: return null
return FinderCacheTag(finder, cache)
}
fun parseFinder(tag: Array<String>) = parse(tag)?.finderPubKey
fun parseCache(tag: Array<String>) = parse(tag)?.cache
fun assemble(
finderPubKey: HexKey,
cache: Address,
relayHint: NormalizedRelayUrl?,
) = arrayOf(TAG_NAME, "$finderPubKey:${NAddress.create(cache.kind, cache.pubKeyHex, cache.dTag, relayHint)}")
}
}
@@ -416,6 +416,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
@@ -643,6 +647,10 @@ class EventFactory {
FollowListEvent.KIND -> FollowListEvent(id, pubKey, createdAt, tags, content, sig)
FundraiserEvent.KIND -> FundraiserEvent(id, pubKey, createdAt, tags, content, sig)
GenericRepostEvent.KIND -> GenericRepostEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheListingEvent.KIND -> GeocacheListingEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheFoundLogEvent.KIND -> GeocacheFoundLogEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheVerificationEvent.KIND -> GeocacheVerificationEvent(id, pubKey, createdAt, tags, content, sig)
GeocacheCurationListEvent.KIND -> GeocacheCurationListEvent(id, pubKey, createdAt, tags, content, sig)
GeohashChatEvent.KIND -> GeohashChatEvent(id, pubKey, createdAt, tags, content, sig)
GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig)
// kind:20001 is shared by BitChat's GeohashPresenceEvent and Buzz's
@@ -89,6 +89,16 @@ class IndexableFieldVisitorTest {
// Community definition: name + description + rules + content.
assertAgrees(34550, arrayOf(arrayOf("name", "N"), arrayOf("description", "D"), arrayOf("rules", "R")), "c")
assertAgrees(34550, emptyArray(), "c")
// Geocache listing: name + content. The `hint` is deliberately not indexed, so a listing
// that has one must still rejoin to exactly name + content.
assertAgrees(37516, arrayOf(arrayOf("name", "First Treasure"), arrayOf("hint", "In the branches")), "a cache")
assertAgrees(37516, emptyArray(), "a cache")
// Geocache curation list: title + description + content.
assertAgrees(37517, arrayOf(arrayOf("title", "T"), arrayOf("description", "D")), "c")
assertAgrees(37517, arrayOf(arrayOf("title", "T")), "c")
assertAgrees(37517, emptyArray(), "c")
}
@Test
@@ -0,0 +1,230 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nipCCGeocaching.firstToFind.FirstToFindResolver
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The first-to-find claim rules.
*
* The interesting case is the forged timestamp: `created_at` is author-supplied, so the
* provisional ordering can be stolen by anyone willing to backdate a log. The `F` tag is what
* takes it back, and these tests pin that it actually does.
*/
class FirstToFindResolverTest {
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
private val cacheKey = NostrSignerInternal(KeyPair())
private val winner = NostrSignerInternal(KeyPair())
private val latecomer = NostrSignerInternal(KeyPair())
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "linocut-aftermath")
private fun listing(
firstToFind: Boolean = true,
lockedInWinner: String? = null,
withVerificationKey: Boolean = true,
) = GeocacheListingEvent(
"id",
owner,
1_748_619_568L,
buildList {
add(arrayOf("d", cacheAddress.dTag))
add(arrayOf("name", "Aftermath"))
add(arrayOf("g", "u4xsu6ryb"))
add(arrayOf("D", "2"))
add(arrayOf("T", "2"))
add(arrayOf("S", "small"))
if (firstToFind) add(arrayOf("n", "first-to-find"))
if (withVerificationKey) add(arrayOf("verification", cacheKey.pubKey))
lockedInWinner?.let { add(arrayOf("F", it)) }
}.toTypedArray(),
"",
"sig",
)
private suspend fun verifiedLog(
finder: NostrSignerInternal,
createdAt: Long,
id: String = createdAt.toString().padStart(64, '0'),
): GeocacheFoundLogEvent {
val verification = cacheKey.sign(GeocacheVerificationEvent.build(finder.pubKey, cacheAddress, createdAt = createdAt))
return GeocacheFoundLogEvent(
id,
finder.pubKey,
createdAt,
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", verification.toJson())),
"Found it!",
"sig",
)
}
private fun unverifiedLog(
finder: NostrSignerInternal,
createdAt: Long,
id: String = createdAt.toString().padStart(64, '0'),
) = GeocacheFoundLogEvent(
id,
finder.pubKey,
createdAt,
arrayOf(arrayOf("a", cacheAddress.toValue())),
"Found it!",
"sig",
)
@Test
fun theEarliestVerifiedLogHoldsTheProvisionalClaim() =
runTest {
val first = verifiedLog(winner, 1_000L)
val second = verifiedLog(latecomer, 2_000L)
val cache = listing()
assertEquals(first.id, FirstToFindResolver.provisionalWinningLog(cache, listOf(second, first))?.id)
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(second, first)))
assertTrue(FirstToFindResolver.isClaimed(cache, listOf(second, first)))
}
@Test
fun tiesOnCreatedAtBreakOnAscendingEventId() =
runTest {
val a = verifiedLog(winner, 1_000L, id = "a".repeat(64))
val b = verifiedLog(latecomer, 1_000L, id = "b".repeat(64))
assertEquals(a.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(b, a))?.id)
}
@Test
fun anUnverifiedLogIsNotAClaimHoweverEarlyItIs() =
runTest {
// "I got here first" without a 7517 is somebody's word. The exclusive claim is
// reserved for logs that prove physical presence.
val wordOfMouth = unverifiedLog(latecomer, 1L)
val real = verifiedLog(winner, 9_000L)
assertEquals(real.id, FirstToFindResolver.provisionalWinningLog(listing(), listOf(wordOfMouth, real))?.id)
}
@Test
fun aLogAboutAnotherCacheIsNotACandidate() =
runTest {
val elsewhere =
GeocacheFoundLogEvent(
"e".repeat(64),
latecomer.pubKey,
1L,
arrayOf(arrayOf("a", Address(GeocacheListingEvent.KIND, owner, "some-other-cache").toValue())),
"Found it!",
"sig",
)
val real = verifiedLog(winner, 9_000L)
assertEquals(listOf(real.id), FirstToFindResolver.verifiedLogs(listing(), listOf(elsewhere, real)).map { it.id })
}
@Test
fun aForgedEarlierTimestampStealsTheProvisionalClaim() =
runTest {
// Not a bug to fix here — this is exactly the weakness NIP-CC calls out, and the
// reason the owner gets to lock a winner in. Pinned so the next test means something.
val real = verifiedLog(winner, 5_000L)
val backdated = verifiedLog(latecomer, 1L)
assertEquals(latecomer.pubKey, FirstToFindResolver.winnerPubKey(listing(), listOf(real, backdated)))
}
@Test
fun theFTagBeatsAnyTimestamp() =
runTest {
// "clients MUST attribute the exclusive claim to the pubkey in the `F` tag,
// regardless of which verified found log currently appears earliest."
val real = verifiedLog(winner, 5_000L)
val backdated = verifiedLog(latecomer, 1L)
val cache = listing(lockedInWinner = winner.pubKey)
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, listOf(real, backdated)))
assertEquals(real.id, FirstToFindResolver.winningLog(cache, listOf(real, backdated))?.id)
assertTrue(FirstToFindResolver.isLockedIn(cache))
}
@Test
fun theLockedInWinnerHoldsEvenWithNoLogFetchedForThem() =
runTest {
// The owner has confirmed the claim; the winning log may simply not be in hand yet.
val cache = listing(lockedInWinner = winner.pubKey)
assertEquals(winner.pubKey, FirstToFindResolver.winnerPubKey(cache, emptyList()))
assertNull(FirstToFindResolver.winningLog(cache, emptyList()))
assertTrue(FirstToFindResolver.isClaimed(cache, emptyList()))
}
@Test
fun theWinningLogIsTheWinnersEarliestVerifiedOne() =
runTest {
val early = verifiedLog(winner, 5_000L, id = "1".repeat(64))
val later = verifiedLog(winner, 6_000L, id = "2".repeat(64))
val cache = listing(lockedInWinner = winner.pubKey)
assertEquals(early.id, FirstToFindResolver.winningLog(cache, listOf(later, early))?.id)
}
@Test
fun aCacheThatIsNotFirstToFindHasNoExclusiveClaimToAward() =
runTest {
// Later verified logs stay valid records of presence on any cache; they are only
// *claims* where the modifier says the cache is single-claim.
val logs = listOf(verifiedLog(winner, 1_000L))
val cache = listing(firstToFind = false, lockedInWinner = winner.pubKey)
assertNull(FirstToFindResolver.winnerPubKey(cache, logs))
assertNull(FirstToFindResolver.winningLog(cache, logs))
assertFalse(FirstToFindResolver.isClaimed(cache, logs))
}
@Test
fun aFirstToFindCacheWithNoVerificationKeyCanNeverBeClaimed() =
runTest {
// NIP-CC: `first-to-find` "Requires a `verification` tag". With none, no log can be
// verified, so no log is a claim.
val logs = listOf(verifiedLog(winner, 1_000L))
val cache = listing(withVerificationKey = false)
assertNull(FirstToFindResolver.provisionalWinningLog(cache, logs))
assertFalse(FirstToFindResolver.isClaimed(cache, logs))
}
@Test
fun anUnclaimedFirstToFindCacheReportsNoWinner() {
assertNull(FirstToFindResolver.winnerPubKey(listing(), emptyList()))
assertFalse(FirstToFindResolver.isClaimed(listing(), emptyList()))
assertFalse(FirstToFindResolver.isLockedIn(listing()))
}
}
@@ -0,0 +1,200 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nipCCGeocaching.curation.GeocacheCurationListEvent
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.ListTheme
import com.vitorpamplona.quartz.nipCCGeocaching.curation.tags.MapStyle
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Kind 37517, checked against the spec's Ren Fest example. */
class GeocacheCurationListEventTest {
private val curator = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
private val otherAuthor = "b".repeat(64)
private fun list(vararg tags: Array<String>) = GeocacheCurationListEvent("id", curator, 1_748_619_568L, arrayOf(*tags), "Explore the grounds!", "sig")
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
@Test
fun theKindIsRegisteredWithTheEventFactory() {
assertTrue(EventFactory.isKnownKind(GeocacheCurationListEvent.KIND))
}
@Test
fun parsesTheSpecsCurationListExample() {
val curated =
list(
arrayOf("d", "ren-fest-hunt-1748619568670"),
arrayOf("title", "Texas Ren Fest Treasure Hunt"),
arrayOf("description", "Find all the hidden treasures at the festival!"),
arrayOf("image", "https://blossom.primal.net/banner-example.jpg"),
arrayOf("g", "9vk"),
arrayOf("g", "9vk5"),
arrayOf("g", "9vk5b"),
arrayOf("g", "9vk5b7"),
arrayOf("theme", "adventure"),
arrayOf("map", "adventure"),
arrayOf("a", "37516:$curator:first-treasure-1748619568668"),
arrayOf("a", "37516:$curator:verified-treasure-1748619568669"),
)
assertEquals("Texas Ren Fest Treasure Hunt", curated.title())
assertEquals("Find all the hidden treasures at the festival!", curated.description())
assertEquals("https://blossom.primal.net/banner-example.jpg", curated.image())
assertEquals(ListTheme.ADVENTURE, curated.theme())
assertEquals(MapStyle.ADVENTURE, curated.mapStyle())
assertEquals(4, curated.geohashes().size)
assertEquals(2, curated.geocaches().size)
assertTrue(curated.isWellFormed())
}
@Test
fun theOrderOfTheCachesIsPreserved() {
// "Order is preserved and meaningful" — a trail rendered out of order is a different
// trail.
val curated =
list(
arrayOf("d", "x"),
arrayOf("title", "t"),
arrayOf("a", "37516:$curator:third"),
arrayOf("a", "37516:$curator:first"),
arrayOf("a", "37516:$curator:second"),
)
assertEquals(listOf("third", "first", "second"), curated.geocaches().map { it.dTag })
}
@Test
fun aListCanSpanCachesFromSeveralAuthors() {
val curated =
list(
arrayOf("d", "x"),
arrayOf("title", "t"),
arrayOf("a", "37516:$curator:mine"),
arrayOf("a", "37516:$otherAuthor:theirs"),
)
assertEquals(listOf(curator, otherAuthor), curated.geocaches().map { it.pubKeyHex })
}
@Test
fun anAddressThatIsNotACacheIsNotInTheItinerary() {
// A list may reference anything; only the cache kinds belong in a treasure hunt.
val curated =
list(
arrayOf("d", "x"),
arrayOf("title", "t"),
arrayOf("a", "30023:$curator:a-blog-post"),
arrayOf("a", "37516:$curator:a-cache"),
)
assertEquals(listOf("a-cache"), curated.geocaches().map { it.dTag })
assertEquals(2, curated.addresses().size)
}
@Test
fun theUndefinedLegacyKindIsAcceptedOnRead() {
// NIP-CC says a list references "kind 37516 or 37515" but never defines 37515. Readers
// accept it; the builder never writes it.
val curated = list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "37515:$curator:old-cache"))
assertEquals(listOf("old-cache"), curated.geocaches().map { it.dTag })
}
@Test
fun anUnknownThemeOrMapStyleIsNotSilentlyADefault() {
val curated = list(arrayOf("theme", "brutalist"), arrayOf("map", "topographic"))
assertNull(curated.theme())
assertNull(curated.mapStyle())
assertEquals("brutalist", curated.themeCode())
assertEquals("topographic", curated.mapStyleCode())
}
@Test
fun aListWithNoCachesIsNotWellFormed() {
assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t")).isWellFormed())
assertFalse(list(arrayOf("d", "x"), arrayOf("a", "37516:$curator:c")).isWellFormed())
assertFalse(list(arrayOf("title", "t"), arrayOf("a", "37516:$curator:c")).isWellFormed())
}
@Test
fun aListOfOnlyNonCachesIsNotWellFormed() {
// The `a` tag is there, so the tag-name gate passes; the list is still empty.
assertFalse(list(arrayOf("d", "x"), arrayOf("title", "t"), arrayOf("a", "30023:$curator:post")).isWellFormed())
}
@Test
fun buildProducesTheTagsTheSpecRequires() {
val template =
GeocacheCurationListEvent.build(
title = "Texas Ren Fest Treasure Hunt",
geocaches =
listOf(
Address(GeocacheListingEvent.KIND, curator, "first-treasure-1748619568668"),
Address(GeocacheListingEvent.KIND, curator, "verified-treasure-1748619568669"),
),
content = "Explore the festival grounds!",
description = "Find all the hidden treasures at the festival!",
image = "https://blossom.primal.net/banner-example.jpg",
geohash = "9vk5b7xy",
theme = ListTheme.ADVENTURE,
mapStyle = MapStyle.ADVENTURE,
dTag = "ren-fest-hunt-1748619568670",
createdAt = 1_748_619_568L,
)
assertEquals(GeocacheCurationListEvent.KIND, template.kind)
assertEquals(listOf("ren-fest-hunt-1748619568670"), template.tags.values("d"))
assertEquals(listOf("Texas Ren Fest Treasure Hunt"), template.tags.values("title"))
assertEquals(listOf("adventure"), template.tags.values("theme"))
assertEquals(listOf("adventure"), template.tags.values("map"))
assertEquals(
listOf(
"37516:$curator:first-treasure-1748619568668",
"37516:$curator:verified-treasure-1748619568669",
),
template.tags.values("a"),
)
}
@Test
fun aListsGeohashLadderStopsAtSix() {
// A trail's centre point is not a place anybody walks to, so it is published coarser
// than a listing's 3..9.
val template =
GeocacheCurationListEvent.build(
title = "t",
geocaches = listOf(Address(GeocacheListingEvent.KIND, curator, "c")),
geohash = "9vk5b7xyz",
)
assertEquals(listOf("9vk", "9vk5", "9vk5b", "9vk5b7"), template.tags.values("g"))
}
}
@@ -0,0 +1,177 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheGeohash
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.rot13
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** The write path for kind 37516, plus the two helpers NIP-CC asks clients for. */
class GeocacheListingBuilderTest {
private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07"
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
@Test
fun buildProducesTheTagsTheSpecRequires() {
val template =
GeocacheListingEvent.build(
name = "First Treasure",
description = "The first Nostr treasure",
geohash = "u4xsu6ryb",
difficulty = 1,
terrain = 1,
size = CacheSize.SMALL,
type = CacheType.TRADITIONAL,
hint = "In the branches",
dTag = "first-treasure-1748619568668",
createdAt = 1_748_619_568L,
)
assertEquals(GeocacheListingEvent.KIND, template.kind)
assertEquals("The first Nostr treasure", template.content)
assertEquals(listOf("first-treasure-1748619568668"), template.tags.values("d"))
assertEquals(listOf("First Treasure"), template.tags.values("name"))
assertEquals(listOf("1"), template.tags.values("D"))
assertEquals(listOf("1"), template.tags.values("T"))
assertEquals(listOf("small"), template.tags.values("S"))
assertEquals(listOf("traditional"), template.tags.values("t"))
assertEquals(listOf("In the branches"), template.tags.values("hint"))
}
@Test
fun theGeohashLadderRunsThreeToNineCoarseToFine() {
// The spec's own example publishes u4x .. u4xsu6ryb. A 1- or 2-character geohash spans
// thousands of kilometres and is noise on the relay, so the ladder starts at 3.
val template =
GeocacheListingEvent.build(
name = "n",
description = "",
geohash = "u4xsu6ryb",
difficulty = 1,
terrain = 1,
size = CacheSize.SMALL,
)
assertEquals(
listOf("u4x", "u4xs", "u4xsu", "u4xsu6", "u4xsu6r", "u4xsu6ry", "u4xsu6ryb"),
template.tags.values("g"),
)
}
@Test
fun theLadderStopsAtNineEvenForAFinerGeohash() {
// Seven rungs, 3 through 9 — the three extra characters of precision are dropped rather
// than published, so the finest `g` tag is never finer than the band NIP-CC defines.
assertEquals(7, GeocacheGeohash.ladder("u4xsu6rybxyz").size)
assertEquals("u4x", GeocacheGeohash.ladder("u4xsu6rybxyz").first())
assertEquals("u4xsu6ryb", GeocacheGeohash.ladder("u4xsu6rybxyz").last())
}
@Test
fun aGeohashTooCoarseForTheBandProducesNoTags() {
assertEquals(emptyList(), GeocacheGeohash.ladder("u4"))
assertEquals(emptyList(), GeoHashTag.geoMipMap("u4", 3, 9))
}
@Test
fun theBoundedMipMapLeavesTheOriginalOneAlone() {
// The unbounded form feeds the geohash chat channels and must keep running
// fine-to-coarse from one character.
assertEquals(listOf("u4x", "u4", "u"), GeoHashTag.geoMipMap("u4x"))
}
@Test
fun microCachesNeedOneMoreCharacterThanEveryoneElse() {
// "Validate geohash precision meets minimum requirements (8+ characters, 9+ for micro
// caches)". A 38m box does not find a film canister.
assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.REGULAR))
assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6ry", CacheSize.MICRO))
assertTrue(GeocacheGeohash.isPreciseEnough("u4xsu6ryb", CacheSize.MICRO))
assertFalse(GeocacheGeohash.isPreciseEnough("u4xsu6r", CacheSize.REGULAR))
}
@Test
fun anOutOfRangeRatingIsClampedRatherThanThrown() {
val template =
GeocacheListingEvent.build(
name = "n",
description = "",
geohash = "u4xsu6ryb",
difficulty = 9,
terrain = 0,
size = CacheSize.SMALL,
)
assertEquals(listOf("5"), template.tags.values("D"))
assertEquals(listOf("1"), template.tags.values("T"))
}
@Test
fun modifiersAndVerificationRoundTripThroughBuild() {
val template =
GeocacheListingEvent.build(
name = "Aftermath",
description = "linocut",
geohash = "u4xsu6ryb",
difficulty = 2,
terrain = 2,
size = CacheSize.SMALL,
modifiers = listOf(TypeModifier.FIRST_TO_FIND, TypeModifier.ART),
verificationPubKey = verificationKey,
)
val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, template.tags, template.content, "sig")
assertTrue(cache.isFirstToFind())
assertTrue(cache.hasTypeModifier(TypeModifier.ART))
assertEquals(verificationKey, cache.verificationKey())
assertTrue(cache.isWellFormed())
}
@Test
fun rot13IsItsOwnInverseAndTouchesOnlyAsciiLetters() {
// The hint travels as plaintext; ROT13 is the display transform that stops a reader
// spoiling themselves by scrolling past it.
assertEquals("Va gur oenapurf", rot13("In the branches"))
assertEquals("In the branches", rot13(rot13("In the branches")))
assertEquals("123 !?-é中", rot13("123 !?-é中"))
assertEquals("nomAZ", rot13("abzNM"))
}
@Test
fun theHintAccessorOffersBothForms() {
val cache = GeocacheListingEvent("id", "a".repeat(64), 1L, arrayOf(arrayOf("hint", "In the branches")), "", "sig")
assertEquals("In the branches", cache.hint())
assertEquals("Va gur oenapurf", cache.hintRot13())
assertNull(GeocacheListingEvent("id", "a".repeat(64), 1L, emptyArray(), "", "sig").hintRot13())
}
}
@@ -0,0 +1,272 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheSize
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.CacheType
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifier
import com.vitorpamplona.quartz.nipCCGeocaching.listing.tags.TypeModifierCategory
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Kind 37516, checked against the examples in NIP-CC itself. */
class GeocacheListingEventTest {
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
private val verificationKey = "6805d4e5c0df48b4f76e2fdcb67a2acb1d97567b01c6fe17a236dc32f34f1c07"
private fun listing(vararg tags: Array<String>) = GeocacheListingEvent("id", owner, 1_748_619_568L, arrayOf(*tags), "a cache", "sig")
/** The "Basic Cache" example from NIP-CC, tag for tag. */
private fun basicCacheTags(): TagArray =
arrayOf(
arrayOf("d", "first-treasure-1748619568668"),
arrayOf("name", "First Treasure"),
arrayOf("g", "u4x"),
arrayOf("g", "u4xs"),
arrayOf("g", "u4xsu"),
arrayOf("g", "u4xsu6"),
arrayOf("g", "u4xsu6r"),
arrayOf("g", "u4xsu6ry"),
arrayOf("g", "u4xsu6ryb"),
arrayOf("D", "1"),
arrayOf("T", "1"),
arrayOf("S", "small"),
arrayOf("t", "traditional"),
arrayOf("hint", "In the branches"),
arrayOf("image", "https://blossom.primal.net/74efe.jpg"),
)
@Test
fun theKindIsRegisteredWithTheEventFactory() {
// Without this the class above is dead code: 37516 parses as a plain Event and no
// accessor on it is ever reached.
assertTrue(EventFactory.isKnownKind(GeocacheListingEvent.KIND))
assertTrue(EventFactory.create<Event>("id", owner, 1L, GeocacheListingEvent.KIND, emptyArray(), "", "") is GeocacheListingEvent)
}
@Test
fun parsesTheSpecsBasicCacheExample() {
val cache = listing(*basicCacheTags())
assertEquals("first-treasure-1748619568668", cache.dTag())
assertEquals("First Treasure", cache.cacheName())
assertEquals(1, cache.difficulty())
assertEquals(1, cache.terrain())
assertEquals(CacheSize.SMALL, cache.cacheSize())
assertEquals(CacheType.TRADITIONAL, cache.cacheType())
assertEquals("In the branches", cache.hint())
assertEquals(listOf("https://blossom.primal.net/74efe.jpg"), cache.images())
assertEquals("u4xsu6ryb", cache.location())
assertEquals(7, cache.geohashes().size)
assertTrue(cache.isWellFormed())
}
@Test
fun parsesTheSpecsVerifiedCacheExample() {
val cache =
listing(
arrayOf("d", "verified-treasure-1748619568669"),
arrayOf("name", "Verified Treasure"),
arrayOf("g", "u4xsu6ry"),
arrayOf("D", "3"),
arrayOf("T", "2"),
arrayOf("S", "small"),
arrayOf("t", "traditional"),
arrayOf("hint", "Look for the secret code"),
arrayOf("verification", verificationKey),
)
assertEquals(verificationKey, cache.verificationKey())
assertTrue(cache.requiresVerification())
}
@Test
fun parsesTheSpecsFirstToFindArtExample() {
val cache =
listing(
arrayOf("d", "linocut-aftermath-1748619568671"),
arrayOf("name", "Aftermath (Linocut #1)"),
arrayOf("g", "u4xsu6ry"),
arrayOf("D", "2"),
arrayOf("T", "2"),
arrayOf("S", "small"),
arrayOf("t", "traditional"),
arrayOf("n", "first-to-find"),
arrayOf("n", "art"),
arrayOf("verification", verificationKey),
)
assertTrue(cache.isFirstToFind())
assertTrue(cache.hasTypeModifier(TypeModifier.ART))
assertEquals(
mapOf(
TypeModifierCategory.CLAIM_SEMANTICS to TypeModifier.FIRST_TO_FIND,
TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART,
),
cache.typeModifiers(),
)
}
@Test
fun parsesTheSpecsKeyQuestExample() {
val cache =
listing(
arrayOf("d", "key-quest-treasure-1748619568670"),
arrayOf("name", "Riddle of the Old Oak"),
arrayOf("g", "u4xsu6ry"),
arrayOf("D", "4"),
arrayOf("T", "2"),
arrayOf("S", "small"),
arrayOf("t", "mystery"),
arrayOf("mission", "Bring a token of nature you found along the way"),
arrayOf("verification", verificationKey),
)
assertEquals(CacheType.MYSTERY, cache.cacheType())
assertTrue(cache.hasMission())
assertEquals("Bring a token of nature you found along the way", cache.mission())
}
@Test
fun theCacheTypeDefaultsToTraditional() {
// "The type of cache (`t`) is optional and defaults to `traditional` if not specified."
val cache = listing(arrayOf("d", "x"), arrayOf("name", "n"), arrayOf("g", "u4xsu6ryb"), arrayOf("D", "1"), arrayOf("T", "1"), arrayOf("S", "micro"))
assertEquals(CacheType.TRADITIONAL, cache.cacheType())
assertEquals("traditional", cache.cacheTypeCode())
}
@Test
fun aClientDefinedCacheTypeSurvivesAsItsCode() {
// "Cache types are determined by individual clients" — an unknown type must not read as
// `traditional`, which would hide a puzzle cache behind a walk-up label.
val cache = listing(arrayOf("t", "earthcache"))
assertNull(cache.cacheType())
assertEquals("earthcache", cache.cacheTypeCode())
}
@Test
fun archivedIsALifecycleMarkerNotACacheType() {
// `t` carries both on this kind. Reading `archived` as the type would make every retired
// cache an unknown type, and reading the type as a lifecycle state would un-retire it.
val cache = listing(arrayOf("t", "mystery"), arrayOf("t", "archived"))
assertTrue(cache.isArchived())
assertEquals(CacheType.MYSTERY, cache.cacheType())
}
@Test
fun aCacheWithNoArchivedTagIsNotArchived() {
assertFalse(listing(arrayOf("t", "traditional")).isArchived())
}
@Test
fun difficultyAndTerrainRejectValuesOutsideOneToFive() {
assertNull(listing(arrayOf("D", "0")).difficulty())
assertNull(listing(arrayOf("D", "6")).difficulty())
assertNull(listing(arrayOf("D", "")).difficulty())
assertNull(listing(arrayOf("D", "hard")).difficulty())
assertNull(listing(arrayOf("T", "-1")).terrain())
assertEquals(5, listing(arrayOf("D", "5")).difficulty())
assertEquals(1, listing(arrayOf("T", "1")).terrain())
}
@Test
fun anUnknownSizeIsNotSilentlyACategory() {
val cache = listing(arrayOf("S", "nano"))
assertNull(cache.cacheSize())
assertEquals("nano", cache.cacheSizeCode())
}
@Test
fun onlyOneModifierPerCategoryCountsAndTheFirstWins() {
// NIP-CC rule 2. Both values here are CLAIM_SEMANTICS-shaped in a future where more
// exist; today only one category has two candidates, so the rule is exercised with
// a repeat.
val cache = listing(arrayOf("n", "first-to-find"), arrayOf("n", "first-to-find"), arrayOf("n", "art"))
assertEquals(2, cache.typeModifiers().size)
}
@Test
fun anUnknownModifierIsIgnoredRatherThanFatal() {
// NIP-CC rule 4, the forward-compatibility rule: a listing using a modifier from a future
// revision must still parse as a cache.
val cache = listing(*basicCacheTags(), arrayOf("n", "time-limited"), arrayOf("n", "art"))
assertTrue(cache.isWellFormed())
assertEquals(mapOf(TypeModifierCategory.PRIZE_NATURE to TypeModifier.ART), cache.typeModifiers())
assertEquals(listOf("time-limited", "art"), cache.typeModifierCodes())
}
@Test
fun theFTagIsOnlyHonouredOnAFirstToFindCache() {
// "Only valid when the treasure carries the `first-to-find` `n` modifier." Honouring it
// anywhere else invents an exclusive claim on a cache that never had one.
val winner = "b".repeat(64)
assertNull(listing(arrayOf("F", winner)).firstToFindWinner())
assertEquals(winner, listing(arrayOf("n", "first-to-find"), arrayOf("F", winner)).firstToFindWinner())
}
@Test
fun aMalformedPubKeyIsNotAVerificationKey() {
assertNull(listing(arrayOf("verification", "not-hex")).verificationKey())
assertNull(listing(arrayOf("verification", "")).verificationKey())
// isHex64 does not check the length itself; a longer string must still be rejected.
assertNull(listing(arrayOf("verification", "a".repeat(70))).verificationKey())
assertEquals(verificationKey, listing(arrayOf("verification", verificationKey)).verificationKey())
}
@Test
fun aListingMissingARequiredTagIsNotWellFormed() {
GeocacheListingEvent.REQUIRED_FIELDS.forEach { missing ->
val tags = basicCacheTags().filterNot { it[0] == missing }.toTypedArray()
assertFalse(listing(*tags).isWellFormed(), "a listing without `$missing` should not be well formed")
}
}
@Test
fun theAddressIsTheKindPubkeyAndDTag() {
val cache = listing(*basicCacheTags())
assertEquals("37516:$owner:first-treasure-1748619568668", cache.address().toValue())
}
@Test
fun aListingRoundTripsThroughJson() {
val cache = listing(*basicCacheTags())
val reparsed = Event.fromJson(cache.toJson())
assertTrue(reparsed is GeocacheListingEvent)
assertEquals("First Treasure", reparsed.cacheName())
assertEquals(CacheSize.SMALL, reparsed.cacheSize())
}
}
@@ -0,0 +1,177 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nipCCGeocaching.comment.GeocacheLogComment
import com.vitorpamplona.quartz.nipCCGeocaching.comment.declaredGeocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.comment.geocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.comment.tags.GeocacheLogType
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** Kind 7516 found logs, and the kind 1111 comments that carry everything else. */
class GeocacheLogsTest {
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
private val finder = "a".repeat(64)
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "first-treasure-1748619568668")
private val listing =
GeocacheListingEvent(
"9".repeat(64),
owner,
1_748_619_568L,
arrayOf(
arrayOf("d", cacheAddress.dTag),
arrayOf("name", "First Treasure"),
arrayOf("g", "u4xsu6ryb"),
arrayOf("D", "1"),
arrayOf("T", "1"),
arrayOf("S", "small"),
),
"",
"sig",
)
private fun Array<Array<String>>.values(name: String) = filter { it.isNotEmpty() && it[0] == name }.map { it[1] }
@Test
fun parsesTheSpecsFoundLogExample() {
val log =
GeocacheFoundLogEvent(
"1".repeat(64),
finder,
1_748_619_700L,
arrayOf(arrayOf("a", "37516:$owner:first-treasure-1748619568668")),
"Found it! Great hiding spot.",
"sig",
)
assertEquals(cacheAddress, log.geocache())
assertEquals("37516:$owner:first-treasure-1748619568668", log.geocacheId())
assertEquals(listOf("37516:$owner:first-treasure-1748619568668"), log.linkedAddressIds())
assertTrue(!log.isVerified())
}
@Test
fun aFoundLogBuiltFromAHintCarriesTheRelay() {
val template = GeocacheFoundLogEvent.build("Found it!", EventHintBundle(listing, relay), createdAt = 1L)
assertEquals(GeocacheFoundLogEvent.KIND, template.kind)
assertEquals("Found it!", template.content)
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a"))
assertEquals(listOf(relay.url), template.tags.first { it[0] == "a" }.drop(2))
}
@Test
fun aFoundLogIsIndexedByItsMessage() {
val log = GeocacheFoundLogEvent("1".repeat(64), finder, 1L, emptyArray(), "Great hiding spot", "sig")
assertEquals("Great hiding spot", log.indexableContent())
}
@Test
fun aDnfCommentCarriesTheRootAndParentTheSpecShows() {
// NIP-CC's DNF example: the listing is both root and parent, so A/K/P and a/k/p all
// point at the cache.
val template = GeocacheLogComment.didNotFind("Searched for 30 minutes.", EventHintBundle(listing), createdAt = 1L)
assertEquals(CommentEvent.KIND, template.kind)
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("A"))
assertEquals(listOf("37516"), template.tags.values("K"))
assertEquals(listOf(owner), template.tags.values("P"))
assertEquals(listOf(cacheAddress.toValue()), template.tags.values("a"))
assertEquals(listOf("37516"), template.tags.values("k"))
assertEquals(listOf(owner), template.tags.values("p"))
assertEquals(listOf("dnf"), template.tags.values("t"))
}
@Test
fun theLogTypeReadsBackOffTheComment() {
val template = GeocacheLogComment.needsMaintenance("Cache is soaked.", EventHintBundle(listing), createdAt = 1L)
val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig")
assertEquals(GeocacheLogType.MAINTENANCE, comment.tags.geocacheLogType())
}
@Test
fun aCommentWithNoTypeIsANote() {
// "If no `t` tag is present, the comment is assumed to be a general note."
val comment = CommentEvent("1".repeat(64), finder, 1L, arrayOf(arrayOf("A", cacheAddress.toValue())), "nice spot", "sig")
assertEquals(GeocacheLogType.NOTE, comment.tags.geocacheLogType())
assertNull(comment.tags.declaredGeocacheLogType())
}
@Test
fun aHashtagIsNotMistakenForALogType() {
// `t` is NIP-01's hashtag tag as well. Only the four defined codes parse, so a topic
// falls through to the one that follows it.
val comment =
CommentEvent(
"1".repeat(64),
finder,
1L,
arrayOf(arrayOf("t", "geocaching"), arrayOf("t", "hiking"), arrayOf("t", "dnf")),
"",
"sig",
)
assertEquals(GeocacheLogType.DNF, comment.tags.geocacheLogType())
}
@Test
fun addingALogTypeDoesNotWipeTheAuthorsHashtags() {
val template =
GeocacheLogComment.note("nice spot", EventHintBundle(listing), createdAt = 1L) {
add(arrayOf("t", "geocaching"))
}
assertEquals(listOf("note", "geocaching"), template.tags.values("t"))
}
@Test
fun everyDefinedLogTypeRoundTrips() {
GeocacheLogType.entries.forEach { type ->
val template = GeocacheLogComment.build("log", EventHintBundle(listing), type, createdAt = 1L)
val comment = CommentEvent("1".repeat(64), finder, 1L, template.tags, template.content, "sig")
assertEquals(type, comment.tags.geocacheLogType())
}
}
@Test
fun anOwnerCanRetireACacheThroughTheCommentVocabularyToo() {
// NIP-CC lets owners retire a cache with an `archived` log type, keeping its history.
val template = GeocacheLogComment.build("Retiring this one.", EventHintBundle(listing), GeocacheLogType.ARCHIVED, createdAt = 1L)
assertEquals(listOf("archived"), template.tags.values("t"))
}
}
@@ -0,0 +1,314 @@
/*
* 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.nipCCGeocaching
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nipCCGeocaching.foundLog.GeocacheFoundLogEvent
import com.vitorpamplona.quartz.nipCCGeocaching.listing.GeocacheListingEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationEvent
import com.vitorpamplona.quartz.nipCCGeocaching.verification.GeocacheVerificationValidator
import com.vitorpamplona.quartz.nipCCGeocaching.verification.VerificationFailure
import com.vitorpamplona.quartz.nipCCGeocaching.verification.tags.FinderCacheTag
import com.vitorpamplona.quartz.utils.EventFactory
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Kind 7517 and the validation NIP-CC puts around it.
*
* Each negative case here is a replay or a forgery that a missing check would let through, so
* they are written as the attack rather than as "returns null".
*/
class GeocacheVerificationTest {
private val owner = "0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"
// The keypair that lives at the cache, behind the QR code.
private val cacheKey = NostrSignerInternal(KeyPair())
private val someoneElsesCacheKey = NostrSignerInternal(KeyPair())
private val finder = NostrSignerInternal(KeyPair())
private val otherFinder = NostrSignerInternal(KeyPair())
private val cacheAddress = Address(GeocacheListingEvent.KIND, owner, "verified-treasure-1748619568669")
private val otherCacheAddress = Address(GeocacheListingEvent.KIND, owner, "some-other-treasure")
private fun listing(verificationKey: String? = cacheKey.pubKey) =
GeocacheListingEvent(
"id",
owner,
1_748_619_568L,
buildList {
add(arrayOf("d", cacheAddress.dTag))
add(arrayOf("name", "Verified Treasure"))
add(arrayOf("g", "u4xsu6ryb"))
add(arrayOf("D", "3"))
add(arrayOf("T", "2"))
add(arrayOf("S", "small"))
verificationKey?.let { add(arrayOf("verification", it)) }
}.toTypedArray(),
"",
"sig",
)
private suspend fun verification(
signer: NostrSignerInternal = cacheKey,
forFinder: String = finder.pubKey,
atCache: Address = cacheAddress,
) = signer.sign(GeocacheVerificationEvent.build(forFinder, atCache, createdAt = 1_748_619_600L))
private fun foundLog(
author: String,
verification: GeocacheVerificationEvent?,
cache: Address = cacheAddress,
createdAt: Long = 1_748_619_700L,
id: String = "1".repeat(64),
) = GeocacheFoundLogEvent(
id,
author,
createdAt,
buildList {
add(arrayOf("a", cache.toValue()))
verification?.let { add(arrayOf("verification", it.toJson())) }
}.toTypedArray(),
"Found it!",
"sig",
)
@Test
fun theKindsAreRegisteredWithTheEventFactory() {
assertTrue(EventFactory.isKnownKind(GeocacheVerificationEvent.KIND))
assertTrue(EventFactory.isKnownKind(GeocacheFoundLogEvent.KIND))
}
@Test
fun theContentIsTheStaticStringTheSpecMandates() {
val pubKey = "a".repeat(64)
assertEquals("Geocache verification for ${NPub.create(pubKey)}", GeocacheVerificationEvent.contentFor(pubKey))
assertTrue(GeocacheVerificationEvent.contentFor(pubKey).startsWith("Geocache verification for npub1"))
}
@Test
fun theATagIsTheFinderAndAnNaddrJoinedByAColon() =
runTest {
val event = verification()
val raw = event.tags.first { it[0] == "a" }[1]
assertEquals("${finder.pubKey}:", raw.substring(0, 65))
assertTrue(raw.substring(65).startsWith("naddr1"))
assertEquals(cacheAddress, NAddress.parse(raw.substring(65))?.address())
}
@Test
fun theCompositeATagIsNotSomethingAddressParseCanRead() =
runTest {
// The reason FinderCacheTag exists. A generic `a`-tag reader gets nothing from a 7517
// (and logs a warning on the way out), so the package parses it itself.
val raw = verification().tags.first { it[0] == "a" }[1]
assertNull(Address.parse(raw))
assertNotNull(FinderCacheTag.parse(arrayOf("a", raw)))
}
@Test
fun readingAlsoAcceptsThePlainAddressFormOfTheCacheHalf() {
// Writing always emits the naddr NIP-CC specifies; reading tolerates `kind:pubkey:d`
// because Address.parse handles both and the 64-hex finder keeps the two unambiguous.
val tag = arrayOf("a", "${finder.pubKey}:${cacheAddress.toValue()}")
val parsed = FinderCacheTag.parse(tag)
assertEquals(finder.pubKey, parsed?.finderPubKey)
assertEquals(cacheAddress, parsed?.cache)
}
@Test
fun aBareNip01AddressIsNotAFinderCachePair() {
// `37516` on the left is not a 64-char pubkey, so there is no way to mistake one for the
// other and no way to end up with a verification attributed to nobody.
assertNull(FinderCacheTag.parse(arrayOf("a", cacheAddress.toValue())))
assertNull(FinderCacheTag.parse(arrayOf("a", finder.pubKey)))
assertNull(FinderCacheTag.parse(arrayOf("a", ":${cacheAddress.toValue()}")))
assertNull(FinderCacheTag.parse(arrayOf("a", "${finder.pubKey}:not-an-address")))
}
@Test
fun aWellFormedVerificationValidates() =
runTest {
assertNull(GeocacheVerificationValidator.validate(verification(), listing(), finder.pubKey))
}
@Test
fun aVerificationSignedByAnyOtherKeyIsRejected() =
runTest {
// Without the signer check, anyone signs their own "verification" and every cache in
// the world is verified by whoever wants to claim it.
val forged = verification(signer = someoneElsesCacheKey)
assertEquals(
VerificationFailure.WRONG_SIGNER,
GeocacheVerificationValidator.validate(forged, listing(), finder.pubKey),
)
}
@Test
fun aVerificationIssuedToSomeoneElseCannotBeReplayedIntoYourLog() =
runTest {
// This is the check that makes the proof about a person. Lift a real verification out
// of another finder's log, paste it into yours, and without it you are verified.
val theirs = verification(forFinder = otherFinder.pubKey)
assertEquals(
VerificationFailure.FINDER_MISMATCH,
GeocacheVerificationValidator.validate(theirs, listing(), finder.pubKey),
)
}
@Test
fun aVerificationEarnedAtAnotherCacheCannotBeReplayedHere() =
runTest {
val elsewhere = verification(atCache = otherCacheAddress)
assertEquals(
VerificationFailure.CACHE_MISMATCH,
GeocacheVerificationValidator.validate(elsewhere, listing(), finder.pubKey),
)
}
@Test
fun aTamperedSignatureIsRejected() =
runTest {
val real = verification()
val tampered =
GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, real.content, "0".repeat(128))
assertEquals(
VerificationFailure.BAD_SIGNATURE,
GeocacheVerificationValidator.validate(tampered, listing(), finder.pubKey),
)
}
@Test
fun aRewrittenContentIsRejected() =
runTest {
val real = verification()
val rewritten =
GeocacheVerificationEvent(real.id, real.pubKey, real.createdAt, real.tags, "Geocache verification for someone", real.sig)
assertEquals(
VerificationFailure.CONTENT_MISMATCH,
GeocacheVerificationValidator.validate(rewritten, listing(), finder.pubKey),
)
}
@Test
fun aCacheWithoutAVerificationKeyCannotVerifyAnything() =
runTest {
assertEquals(
VerificationFailure.CACHE_HAS_NO_VERIFICATION_KEY,
GeocacheVerificationValidator.validate(verification(), listing(verificationKey = null), finder.pubKey),
)
}
@Test
fun aFoundLogValidatesAgainstItsOwnAuthor() =
runTest {
val log = foundLog(finder.pubKey, verification())
assertNull(GeocacheVerificationValidator.validate(log, listing()))
assertTrue(GeocacheVerificationValidator.isValid(log, listing()))
}
@Test
fun aFoundLogCarryingSomeoneElsesVerificationDoesNotValidate() =
runTest {
// The same replay as above, entered through the log rather than the raw event: the
// author is taken from the log, never from the verification's own `a` tag.
val log = foundLog(otherFinder.pubKey, verification(forFinder = finder.pubKey))
assertEquals(VerificationFailure.FINDER_MISMATCH, GeocacheVerificationValidator.validate(log, listing()))
}
@Test
fun aLogWithNoVerificationSaysSoRatherThanFailingSomewhereElse() {
val log = foundLog(finder.pubKey, null)
assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing()))
}
@Test
fun aGarbageVerificationPayloadIsNullRatherThanAnException() {
// The payload is whatever a stranger put in a public event. A parse failure that threw
// would take the feed rendering the log down with it.
listOf("", "not json", "{", "[1,2,3]", """{"kind":"seven"}""").forEach { payload ->
val log =
GeocacheFoundLogEvent(
"1".repeat(64),
finder.pubKey,
1L,
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", payload)),
"Found it!",
"sig",
)
assertNull(log.embeddedVerification(), "payload <$payload> should not parse")
assertEquals(VerificationFailure.NO_EMBEDDED_VERIFICATION, GeocacheVerificationValidator.validate(log, listing()))
}
}
@Test
fun aVerificationOfAnotherKindEmbeddedInALogIsNotAVerification() =
runTest {
// Well-formed JSON for a real event of the wrong kind must not be read as a 7517.
val notAVerification = finder.sign(GeocacheFoundLogEvent.build("hi", cacheAddress))
val log =
GeocacheFoundLogEvent(
"1".repeat(64),
finder.pubKey,
1L,
arrayOf(arrayOf("a", cacheAddress.toValue()), arrayOf("verification", notAVerification.toJson())),
"Found it!",
"sig",
)
assertNull(log.embeddedVerification())
}
@Test
fun anEmbeddedVerificationSurvivesTheJsonRoundTrip() =
runTest {
val original = verification()
val template = GeocacheFoundLogEvent.build("Found it!", cacheAddress, verification = original)
val log = finder.sign(template)
val reparsed = Event.fromJson(log.toJson()) as GeocacheFoundLogEvent
assertEquals(original.id, reparsed.embeddedVerification()?.id)
assertNull(GeocacheVerificationValidator.validate(reparsed, listing()))
}
}
@@ -48,6 +48,7 @@
5302
5303
6969 The content body.
7516 The content body.
8333 The content body.
9002 The Name\nThe About\nhashtag1\nhashtag2
9041 The Summary\nThe content body.
@@ -128,6 +129,8 @@
35128 The Title\nThe Description
35129 The Title\nThe Description
36787 The Title\nThe content body.
37516 The Name\nThe content body.
37517 The Title\nThe Description\nThe content body.
38000 The content body.
38192 The Alt\nThe Title
38383 The Name