From f9399008918f51d3d11af68ae82109f31fe9ddec Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 17 Jun 2026 16:56:58 +0700 Subject: [PATCH] custom unwrap gift wrap process --- composeApp/build.gradle.kts | 2 +- shared/build.gradle.kts | 2 +- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 102 +++++++++++------- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 7f3b884..2a3dcc3 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.androidx.core.splashscreen) - implementation("su.reya:nostr-sdk-kmp:0.2.7") + implementation("su.reya:nostr-sdk-kmp:0.3") implementation("io.coil-kt.coil3:coil-compose:3.4.0") implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") implementation("io.github.kalinjul.easyqrscan:scanner:0.7.0") diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index f8e160e..ed524c4 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -33,7 +33,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") - implementation("su.reya:nostr-sdk-kmp:0.2.7") + implementation("su.reya:nostr-sdk-kmp:0.3") implementation("com.squareup.okio:okio:3.16.2") } androidMain.dependencies { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 6c88227..cf31a16 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -50,11 +50,11 @@ import rust.nostr.sdk.SubscribeAutoCloseOptions import rust.nostr.sdk.Tag import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent -import rust.nostr.sdk.UnwrappedGift import rust.nostr.sdk.extractRelayList import rust.nostr.sdk.initLogger import rust.nostr.sdk.nip17ExtractRelayList import rust.nostr.sdk.nip59MakeGiftWrapAsync +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -78,8 +78,6 @@ class Nostr { private set var signer: UniversalSigner = UniversalSigner(Keys.generate()) private set - var deviceSigner: AsyncNostrSigner? = null - private set var sentEvents: MutableMap> = mutableMapOf() private set var rumorMap: MutableMap = mutableMapOf() @@ -310,26 +308,22 @@ class Nostr { } if (event.kind().asStd()?.equals(KindStandard.GIFT_WRAP) == true) { - try { - val rumor = extractRumor(event) + val rumor = extractRumor(event) - // Logic to notify UI after processing - // Cancel previous tracker if it exists - eoseTrackerJob?.cancel() - // Start a new tracker - eoseTrackerJob = launch { - delay(10000.milliseconds) // Wait for 10 seconds - onSubscriptionClose() - } + // Logic to notify UI after processing + // Cancel previous tracker if it exists + eoseTrackerJob?.cancel() + // Start a new tracker + eoseTrackerJob = launch { + delay(10000.milliseconds) // Wait for 10 seconds + onSubscriptionClose() + } - // Handle new message - rumor?.createdAt()?.asSecs()?.let { - if (it >= now.asSecs()) { - onNewMessage(rumor) - } + // Handle new message + rumor?.createdAt()?.asSecs()?.let { + if (it >= now.asSecs()) { + onNewMessage(rumor) } - } catch (e: Exception) { - println("Failed to extract rumor: $e") } } } @@ -372,7 +366,7 @@ class Nostr { val event = client?.database()?.query(filter)?.first() return event?.content()?.let { UnsignedEvent.fromJson(it) } - } catch (e: Exception) { + } catch (e: Throwable) { throw IllegalStateException("Failed to get cached rumor: ${e.message}", e) } } @@ -392,7 +386,7 @@ class Nostr { ) // Set event kind - val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA); + val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) // Construct event val event = EventBuilder(kind, rumor.asJson()) @@ -400,36 +394,64 @@ class Nostr { .finalizeAsync(Keys.generate()) client?.database()?.saveEvent(event) - } catch (e: Exception) { + } catch (e: Throwable) { println("Failed to set cached rumor: ${e.message}") } } private suspend fun extractRumor(event: Event): UnsignedEvent? { try { + // Gift wrap must have at least one 'p' tag + if (event.tags().publicKeys().isEmpty()) { + println("No recipient tags found.") + return null + } + + // Event must be a gift wrap + if (event.kind().asStd().let { it != KindStandard.GIFT_WRAP }) { + println("Event is not a gift wrap.") + return null + } + // Check if the rumor is already cached val cachedRumor = getCachedRumor(event.id()) if (cachedRumor != null) return cachedRumor - // Unwrap the gift with current signer - val gift = UnwrappedGift.fromGiftWrapAsync(signer = signer, giftWrap = event) - val rumor = gift.rumor() + // Decrypt the gift wrap event + val seal = signer.nip44DecryptAsync(event.author(), event.content()) + val sealEvent = Event.fromJson(seal) - // Save the rumor to the database - setCachedRumor(event.id(), rumor) + // Verify seal event + if (!sealEvent.verify()) { + println("Failed to verify seal event.") + return null + } - // Return the rumor - return rumor - } catch (e: Exception) { - println("Failed to unwrap gift: ${e.message}") + // Decrypt the rumor + val rumor = signer.nip44DecryptAsync(sealEvent.author(), sealEvent.content()) + val unsignedEvent = UnsignedEvent.fromJson(rumor) + + // Ensure the rumor author matches the seal + if (unsignedEvent.author() != sealEvent.author()) { + println("Author mismatch.") + return null + } + + // Cache the rumor for later use + setCachedRumor(event.id(), unsignedEvent) + + return unsignedEvent + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + println("Failed to unwrap gift ${event.id().toHex()}: ${e.message}") + return null } - - return null } private suspend fun getDefaultRelayList(): Map { // Construct a list of relays - val relayList = mapOf( + val relayList = mapOf( RelayUrl.parse("wss://relay.damus.io") to RelayMetadata.READ, RelayUrl.parse("wss://relay.primal.net") to RelayMetadata.READ, RelayUrl.parse("wss://relay.nostr.net") to RelayMetadata.WRITE, @@ -471,7 +493,7 @@ class Nostr { suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) { // Send relay list event val relayList = getDefaultRelayList() - val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys); + val relayListEvent = EventBuilder.relayList(relayList).finalizeAsync(keys) client?.sendEvent( event = relayListEvent, @@ -546,7 +568,7 @@ class Nostr { private suspend fun getLatestMetadata(pubkey: PublicKey): Metadata? { return try { - val kind = Kind.fromStd(KindStandard.METADATA); + val kind = Kind.fromStd(KindStandard.METADATA) val filter = Filter().kind(kind).author(pubkey).limit(1u) val event = client?.database()?.query(filter)?.first() ?: return null @@ -581,7 +603,7 @@ class Nostr { suspend fun fetchMetadataBatch(keys: List) { try { - val limit = keys.size.toULong() * 4u; + val limit = keys.size.toULong() * 4u val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) // Construct a filter for metadata events @@ -615,7 +637,7 @@ class Nostr { ackPolicy = AckPolicy.none(), ) - val kind = Kind.fromStd(KindStandard.INBOX_RELAYS); + val kind = Kind.fromStd(KindStandard.INBOX_RELAYS) val filter = Filter().kind(kind).author(signer.currentUser!!).limit(1u) val target = ReqTarget.auto(listOf(filter)) val opts = SubscribeAutoCloseOptions().exitPolicy(ReqExitPolicy.ExitOnEose) @@ -781,7 +803,7 @@ class Nostr { suspend fun connectMsgRelays(event: Event) { try { - val urls = nip17ExtractRelayList(event); + val urls = nip17ExtractRelayList(event) for (url in urls) { client?.addRelay(url, RelayCapabilities.gossip()) client?.connectRelay(url)