From 7df2e240ba5bca1242dfc49c7db0ccc1fb8aae31 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 16:17:08 +0000 Subject: [PATCH] feat(quartz): Bitchat geohash chat protocol (kind 20000/20001) + per-geohash identity Adds the wire-level protocol for interoperating with Bitchat's Nostr location channels: - GeohashChatEvent (kind 20000): plain-text public geohash message with a single exact ["g", geohash] tag plus optional ["n", nickname] and ["t","teleport"]. - GeohashPresenceEvent (kind 20001): presence heartbeat carrying only the g tag. - GeohashKeyDerivation: deterministic, unlinkable per-geohash ephemeral identity (HMAC-SHA256(deviceSeed, geohash||counter) with retry + SHA-256 fallback). - Registers both kinds in EventFactory; NIP-13 PoW reuses the existing PoWTag. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV --- .../bitchat/geohash/GeohashChatEvent.kt | 81 ++++++++++ .../bitchat/geohash/GeohashChatTagBuilders.kt | 41 +++++ .../bitchat/geohash/GeohashPresenceEvent.kt | 72 +++++++++ .../bitchat/geohash/tags/NicknameTag.kt | 44 ++++++ .../bitchat/geohash/tags/TeleportTag.kt | 41 +++++ .../bitchat/identity/GeohashKeyDerivation.kt | 80 ++++++++++ .../quartz/utils/EventFactory.kt | 4 + .../bitchat/GeohashChatEventTest.kt | 142 ++++++++++++++++++ .../bitchat/GeohashKeyDerivationTest.kt | 70 +++++++++ 9 files changed, 575 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatTagBuilders.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashPresenceEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/NicknameTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/TeleportTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/identity/GeohashKeyDerivation.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashChatEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashKeyDerivationTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatEvent.kt new file mode 100644 index 0000000000..fa4302fadb --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatEvent.kt @@ -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.experimental.bitchat.geohash + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.bitchat.geohash.tags.NicknameTag +import com.vitorpamplona.quartz.experimental.bitchat.geohash.tags.TeleportTag +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A public, ephemeral geohash chat message (Bitchat "location channel"). + * + * Wire format (interoperates with Bitchat iOS/Android): + * - kind [KIND] = 20000 (NIP-16 ephemeral range — relays broadcast but need not store). + * - content is the plain UTF-8 message text (no envelope, no prefix). + * - `["g", geohash]` (required) is the exact channel cell. + * - `["n", nickname]` (optional) is the sender's display name. + * - `["t", "teleport"]` (optional) flags a sender who is not physically in the cell. + * - `["nonce", …]` (optional) is a NIP-13 proof-of-work commitment; Bitchat mines + * 8 bits by default and uses it to relax per-sender relay rate limits. + * + * Each participant signs with a per-geohash ephemeral key that is unlinkable to + * their main Nostr identity (see GeohashKeyDerivation), so authorship inside a + * cell does not reveal who they are elsewhere. + */ +@Immutable +class GeohashChatEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun geohash() = tags.firstNotNullOfOrNull(GeoHashTag::parse) + + fun nickname() = tags.firstNotNullOfOrNull(NicknameTag::parse) + + fun isTeleported() = tags.any(TeleportTag::match) + + companion object { + const val KIND = 20000 + + fun build( + message: String, + geohash: String, + nickname: String? = null, + teleported: Boolean = false, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + geohashCell(geohash) + nickname?.let { nickname(it) } + if (teleported) teleport() + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatTagBuilders.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatTagBuilders.kt new file mode 100644 index 0000000000..7b7faa278c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashChatTagBuilders.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat.geohash + +import com.vitorpamplona.quartz.experimental.bitchat.geohash.tags.NicknameTag +import com.vitorpamplona.quartz.experimental.bitchat.geohash.tags.TeleportTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag + +/** + * Adds a single, exact `["g", geohash]` tag. + * + * Note: this deliberately does NOT use the [GeoHashTag.assemble] mip-map helper + * (which would emit every prefix `["g", u4pruy]`, `["g", u4pru]`, …). Bitchat + * location channels tag only the full cell and subscribe with an exact `#g` + * filter, so a single tag is required for wire-level interop. + */ +fun TagArrayBuilder.geohashCell(geohash: String) = add(GeoHashTag.assembleSingle(geohash)) + +fun TagArrayBuilder.nickname(nickname: String) = add(NicknameTag.assemble(nickname)) + +fun TagArrayBuilder.teleport() = add(TeleportTag.assemble()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashPresenceEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashPresenceEvent.kt new file mode 100644 index 0000000000..3d2ce9e8dc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/GeohashPresenceEvent.kt @@ -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.experimental.bitchat.geohash + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.bitchat.geohash.tags.NicknameTag +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.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHashTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * A presence heartbeat for a geohash chat channel (Bitchat "location channel"). + * + * Wire format (interoperates with Bitchat iOS/Android): + * - kind [KIND] = 20001 (ephemeral). + * - content is empty. + * - `["g", geohash]` (required) is the cell the sender is present in. + * + * Bitchat emits presence with only the `g` tag; the optional `["n", nickname]` + * here is a benign extra tag that lets peers show who is present without waiting + * for a chat message. Clients count distinct recent pubkeys (over 20000 + 20001) + * to estimate the number of people in the channel. + */ +@Immutable +class GeohashPresenceEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + fun geohash() = tags.firstNotNullOfOrNull(GeoHashTag::parse) + + fun nickname() = tags.firstNotNullOfOrNull(NicknameTag::parse) + + companion object { + const val KIND = 20001 + + fun build( + geohash: String, + nickname: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + geohashCell(geohash) + nickname?.let { nickname(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/NicknameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/NicknameTag.kt new file mode 100644 index 0000000000..4c6e3b6314 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/NicknameTag.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat.geohash.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The `n` tag carries the sender's display nickname on a geohash chat/presence + * event. It is Bitchat's convention for location channels: the nickname is not + * bound to the (ephemeral) key, so it can change freely from message to message. + */ +class NicknameTag { + companion object { + const val TAG_NAME = "n" + + fun parse(tag: Array): 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(nickname: String) = arrayOf(TAG_NAME, nickname) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/TeleportTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/TeleportTag.kt new file mode 100644 index 0000000000..403889f4a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/geohash/tags/TeleportTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat.geohash.tags + +import com.vitorpamplona.quartz.nip01Core.core.Tag +import com.vitorpamplona.quartz.nip01Core.core.has + +/** + * The `["t", "teleport"]` marker tag flags a geohash chat message whose sender + * is not physically inside the geohash cell but "teleported" into it (Bitchat's + * convention for jumping into a remote location channel). Clients use it to badge + * such messages so remote participants are distinguishable from local ones. + */ +class TeleportTag { + companion object { + const val TAG_NAME = "t" + const val TELEPORT_VALUE = "teleport" + + fun match(tag: Tag) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == TELEPORT_VALUE + + fun assemble() = arrayOf(TAG_NAME, TELEPORT_VALUE) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/identity/GeohashKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/identity/GeohashKeyDerivation.kt new file mode 100644 index 0000000000..3c63fe2bd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/bitchat/identity/GeohashKeyDerivation.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat.identity + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.mac.MacInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * Derives a stable-but-unlinkable Nostr identity per geohash channel, mirroring + * Bitchat's location-channel identity scheme. + * + * privKey = HMAC-SHA256(deviceSeed, geohashBytes || counterBE), incrementing the + * counter until the 32-byte output is a valid secp256k1 scalar; on the (astro- + * nomically unlikely) exhaustion of [MAX_ITERATIONS] it falls back to + * SHA-256(deviceSeed || geohashBytes). + * + * Properties this gives us: + * - Deterministic per (device seed, geohash), so a user keeps one identity per + * area across app restarts without persisting a key per channel. + * - Unlinkable: the same person in two different geohashes has two unrelated + * pubkeys, and neither is derivable from — or linkable to — their main npub. + * + * Interop note: the [seed] is a per-install random secret, so the derived keys do + * NOT match any Bitchat device's keys (nor need to). Each participant only ever + * publishes under, and is recognised by, their own pubkey — there is no shared + * key requirement between clients. + */ +object GeohashKeyDerivation { + const val ALGORITHM = "HmacSHA256" + const val MAX_ITERATIONS = 10 + const val SEED_SIZE = 32 + + fun derivePrivateKey( + seed: ByteArray, + geohash: String, + ): ByteArray { + val geoBytes = geohash.encodeToByteArray() + for (counter in 0 until MAX_ITERATIONS) { + val mac = MacInstance(ALGORITHM, seed) + mac.update(geoBytes) + mac.update(counterBytesBE(counter)) + val candidate = mac.doFinal() + if (Secp256k1Instance.isPrivateKeyValid(candidate)) return candidate + } + return sha256(seed + geoBytes) + } + + fun deriveKeyPair( + seed: ByteArray, + geohash: String, + ) = KeyPair(privKey = derivePrivateKey(seed, geohash)) + + private fun counterBytesBE(counter: Int) = + byteArrayOf( + (counter ushr 24).toByte(), + (counter ushr 16).toByte(), + (counter ushr 8).toByte(), + counter.toByte(), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4957e42c81..d4352ac173 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashPresenceEvent import com.vitorpamplona.quartz.experimental.clink.debits.DebitEvent import com.vitorpamplona.quartz.experimental.clink.manage.ManageEvent import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent @@ -443,7 +445,9 @@ 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) + GeohashChatEvent.KIND -> GeohashChatEvent(id, pubKey, createdAt, tags, content, sig) GeohashListEvent.KIND -> GeohashListEvent(id, pubKey, createdAt, tags, content, sig) + GeohashPresenceEvent.KIND -> GeohashPresenceEvent(id, pubKey, createdAt, tags, content, sig) GiftWrapEvent.KIND -> GiftWrapEvent(id, pubKey, createdAt, tags, content, sig) EphemeralGiftWrapEvent.KIND -> EphemeralGiftWrapEvent(id, pubKey, createdAt, tags, content, sig) GitAuthorListEvent.KIND -> GitAuthorListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashChatEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashChatEventTest.kt new file mode 100644 index 0000000000..c13aeeda64 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashChatEventTest.kt @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat + +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChatEvent +import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashPresenceEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class GeohashChatEventTest { + private fun sampleChat(): Event = + EventFactory.create( + id = "a099d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_000_000L, + kind = GeohashChatEvent.KIND, + tags = + arrayOf( + arrayOf("g", "u4pruyd"), + arrayOf("n", "satoshi"), + arrayOf("t", "teleport"), + arrayOf("nonce", "000000000000abcd", "8"), + ), + content = "gm from the block", + sig = "00".repeat(64), + ) + + @Test + fun factoryParsesChatAndPresence() { + assertIs(sampleChat()) + val presence = + EventFactory.create( + id = "b199d4db563041bb289d3704f983fc148fc805860303a4f479a8264dc6a2d7cc", + pubKey = "932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d", + createdAt = 1_700_000_000L, + kind = GeohashPresenceEvent.KIND, + tags = arrayOf(arrayOf("g", "u4pruyd")), + content = "", + sig = "00".repeat(64), + ) + assertIs(presence) + } + + @Test + fun kindsAreKnown() { + assertEquals(20000, GeohashChatEvent.KIND) + assertEquals(20001, GeohashPresenceEvent.KIND) + assertTrue(EventFactory.isKnownKind(GeohashChatEvent.KIND)) + assertTrue(EventFactory.isKnownKind(GeohashPresenceEvent.KIND)) + } + + @Test + fun parsesChatFields() { + val event = sampleChat() + assertIs(event) + + assertEquals("u4pruyd", event.geohash()) + assertEquals("satoshi", event.nickname()) + assertTrue(event.isTeleported()) + assertEquals("gm from the block", event.content) + } + + @Test + fun buildProducesSingleExactGeohashTag() { + val template = GeohashChatEvent.build("hello", "u4pruyd", nickname = "satoshi", createdAt = 1_700_000_000L) + + assertEquals(GeohashChatEvent.KIND, template.kind) + assertEquals("hello", template.content) + + // A single, exact g tag — NOT the mip-mapped prefix hierarchy. + val geohashes = template.tags.filter { it[0] == "g" }.map { it[1] } + assertEquals(listOf("u4pruyd"), geohashes) + + assertEquals("satoshi", template.tags.first { it[0] == "n" }[1]) + assertTrue(template.tags.none { it[0] == "t" }) + } + + @Test + fun buildOmitsOptionalTagsWhenAbsent() { + val template = GeohashChatEvent.build("hi", "u4pru", createdAt = 1_700_000_000L) + assertTrue(template.tags.none { it[0] == "n" }) + assertTrue(template.tags.none { it[0] == "t" }) + assertEquals(listOf("u4pru"), template.tags.filter { it[0] == "g" }.map { it[1] }) + } + + @Test + fun buildAddsTeleportMarkerWhenRequested() { + val template = GeohashChatEvent.build("hi", "u4pru", teleported = true, createdAt = 1_700_000_000L) + assertEquals("teleport", template.tags.first { it[0] == "t" }[1]) + } + + @Test + fun presenceHasOnlyGeohashByDefault() { + val template = GeohashPresenceEvent.build("u4pruyd", createdAt = 1_700_000_000L) + assertEquals(GeohashPresenceEvent.KIND, template.kind) + assertEquals("", template.content) + assertEquals(listOf("u4pruyd"), template.tags.filter { it[0] == "g" }.map { it[1] }) + assertTrue(template.tags.none { it[0] == "n" }) + } + + @Test + fun chatWithoutTeleportParsesFalse() { + val event = + EventFactory.create( + id = "00".repeat(32), + pubKey = "00".repeat(32), + createdAt = 1_700_000_000L, + kind = GeohashChatEvent.KIND, + tags = arrayOf(arrayOf("g", "u4pru")), + content = "no name", + sig = "00".repeat(64), + ) + assertIs(event) + assertEquals("u4pru", event.geohash()) + assertNull(event.nickname()) + assertFalse(event.isTeleported()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashKeyDerivationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashKeyDerivationTest.kt new file mode 100644 index 0000000000..0292e0248b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/bitchat/GeohashKeyDerivationTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.bitchat + +import com.vitorpamplona.quartz.experimental.bitchat.identity.GeohashKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class GeohashKeyDerivationTest { + private val seedA = ByteArray(32) { it.toByte() } + private val seedB = ByteArray(32) { (it + 1).toByte() } + + @Test + fun derivationIsDeterministicPerSeedAndGeohash() { + val a = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + val b = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + assertEquals(a.toHexKey(), b.toHexKey()) + } + + @Test + fun differentGeohashesYieldDifferentKeys() { + val a = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + val b = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruye") + assertNotEquals(a.toHexKey(), b.toHexKey()) + } + + @Test + fun differentSeedsYieldDifferentKeys() { + val a = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + val b = GeohashKeyDerivation.derivePrivateKey(seedB, "u4pruyd") + assertNotEquals(a.toHexKey(), b.toHexKey()) + } + + @Test + fun derivedKeyIsValidSecp256k1Scalar() { + val priv = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + assertEquals(32, priv.size) + assertTrue(Secp256k1Instance.isPrivateKeyValid(priv)) + } + + @Test + fun deriveKeyPairProducesMatchingPubKey() { + val pair = GeohashKeyDerivation.deriveKeyPair(seedA, "u4pruyd") + val expectedPriv = GeohashKeyDerivation.derivePrivateKey(seedA, "u4pruyd") + assertEquals(expectedPriv.toHexKey(), pair.privKey!!.toHexKey()) + assertEquals(32, pair.pubKey.size) + } +}