diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt new file mode 100644 index 000000000..eebbfddea --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/CashuSpendingHistoryEvent.kt @@ -0,0 +1,156 @@ +/* + * 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.nip60Cashu.history + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-60 Cashu Spending History Event (kind:7376). + * + * Records transaction history when the wallet balance changes. + * Content is NIP-44 encrypted and contains a tag array with: + * - ["direction", "in"|"out"] + * - ["amount", ""] + * - ["unit", "sat"|"usd"|"eur"] + * - ["e", "", "", "created"|"destroyed"|"redeemed"] + * + * The "e" tags with "redeemed" marker SHOULD be left unencrypted in the tags array. + * All other "e" tags should be encrypted in the content. + */ +@Immutable +class CashuSpendingHistoryEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the content to get the private spending history tags. + */ + suspend fun cachedPrivateTags(signer: NostrSigner): TagArray = PrivateTagsInContent.decrypt(content, signer) + + /** + * Gets the transaction direction from the decrypted content. + */ + suspend fun direction(signer: NostrSigner): SpendingDirection? { + val privateTags = cachedPrivateTags(signer) + val directionTag = privateTags.firstOrNull { it.size >= 2 && it[0] == "direction" } ?: return null + return SpendingDirection.fromCode(directionTag[1]) + } + + /** + * Gets the transaction amount from the decrypted content. + */ + suspend fun amount(signer: NostrSigner): Long? { + val privateTags = cachedPrivateTags(signer) + return privateTags + .firstOrNull { it.size >= 2 && it[0] == "amount" } + ?.get(1) + ?.toLongOrNull() + } + + /** + * Gets the unit from the decrypted content. Default: "sat". + */ + suspend fun unit(signer: NostrSigner): String { + val privateTags = cachedPrivateTags(signer) + return privateTags + .firstOrNull { it.size >= 2 && it[0] == "unit" } + ?.get(1) + ?: "sat" + } + + /** + * Gets token references from both encrypted content and public tags. + */ + suspend fun tokenReferences(signer: NostrSigner): List { + val privateTags = cachedPrivateTags(signer) + val privateRefs = privateTags.mapNotNull(TokenReference::parseFromTag) + val publicRefs = tags.mapNotNull(TokenReference::parseFromTag) + return privateRefs + publicRefs + } + + /** + * Gets unencrypted "redeemed" token references from public tags. + */ + fun redeemedReferences(): List = + tags + .mapNotNull(TokenReference::parseFromTag) + .filter { it.marker == TokenReference.MARKER_REDEEMED } + + companion object { + const val KIND = 7376 + const val ALT_DESCRIPTION = "Cashu spending history" + + suspend fun build( + direction: SpendingDirection, + amount: Long, + unit: String = "sat", + tokenReferences: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + // Unencrypted "e" tags for redeemed markers + tokenReferences + .filter { it.marker == TokenReference.MARKER_REDEEMED } + .forEach { ref -> + add(TokenReference.assemble(ref.eventId, ref.relay, ref.marker)) + } + initializer() + }.let { template -> + val privateTags = + buildList { + add(arrayOf("direction", direction.code)) + add(arrayOf("amount", amount.toString())) + add(arrayOf("unit", unit)) + // Encrypt non-redeemed token references + tokenReferences + .filter { it.marker != TokenReference.MARKER_REDEEMED } + .forEach { ref -> + add(TokenReference.assemble(ref.eventId, ref.relay, ref.marker)) + } + }.toTypedArray() + + val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) + + EventTemplate( + template.createdAt, + template.kind, + template.tags, + encryptedContent, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/SpendingDirection.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/SpendingDirection.kt new file mode 100644 index 000000000..e566914b5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/SpendingDirection.kt @@ -0,0 +1,38 @@ +/* + * 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.nip60Cashu.history + +enum class SpendingDirection( + val code: String, +) { + IN("in"), + OUT("out"), + ; + + companion object { + fun fromCode(code: String): SpendingDirection? = + when (code) { + IN.code -> IN + OUT.code -> OUT + else -> null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt new file mode 100644 index 000000000..fe16e631f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/history/TokenReference.kt @@ -0,0 +1,62 @@ +/* + * 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.nip60Cashu.history + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Represents a reference to a token event in the spending history. + * + * Markers: + * - "created" - A new token event was created + * - "destroyed" - A token event was destroyed + * - "redeemed" - A NIP-61 nutzap was redeemed + */ +@Immutable +data class TokenReference( + val eventId: HexKey, + val relay: String?, + val marker: String, +) { + companion object { + const val MARKER_CREATED = "created" + const val MARKER_DESTROYED = "destroyed" + const val MARKER_REDEEMED = "redeemed" + + fun parseFromTag(tag: Array): TokenReference? { + if (tag.size < 4 || tag[0] != "e") return null + val marker = tag[3] + if (marker !in listOf(MARKER_CREATED, MARKER_DESTROYED, MARKER_REDEEMED)) return null + return TokenReference( + eventId = tag[1], + relay = tag.getOrNull(2)?.ifEmpty { null }, + marker = marker, + ) + } + + fun assemble( + eventId: HexKey, + relay: String?, + marker: String, + ) = arrayOf("e", eventId, relay ?: "", marker) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt new file mode 100644 index 000000000..1e6d7cbd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/quote/CashuMintQuoteEvent.kt @@ -0,0 +1,95 @@ +/* + * 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.nip60Cashu.quote + +import androidx.compose.runtime.Immutable +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.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-60 Cashu Mint Quote Event (kind:7374). + * + * Optional event to keep the state of a mint quote ID, used to check when the + * quote has been paid. Should be created with an expiration tag (NIP-40) of ~2 weeks. + * + * The content is NIP-44 encrypted and contains the quote-id string. + * Public tags include: + * - ["expiration", ""] + * - ["mint", ""] + */ +@Immutable +class CashuMintQuoteEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the content to get the quote ID. + */ + suspend fun quoteId(signer: NostrSigner): String = signer.nip44Decrypt(content, pubKey) + + /** + * Gets the mint URL from the public tags. + */ + fun mint(): String? = + tags + .firstOrNull { it.size >= 2 && it[0] == "mint" } + ?.get(1) + + companion object { + const val KIND = 7374 + const val ALT_DESCRIPTION = "Cashu mint quote" + const val TWO_WEEKS_SECONDS = 14 * 24 * 60 * 60L + + suspend fun build( + quoteId: String, + mintUrl: String, + signer: NostrSigner, + expirationTimestamp: Long = TimeUtils.now() + TWO_WEEKS_SECONDS, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + expiration(expirationTimestamp) + add(arrayOf("mint", mintUrl)) + initializer() + }.let { template -> + val encryptedContent = signer.nip44Encrypt(quoteId, signer.pubKey) + + EventTemplate( + template.createdAt, + template.kind, + template.tags, + encryptedContent, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuProof.kt new file mode 100644 index 000000000..ac42349d1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuProof.kt @@ -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.nip60Cashu.token + +import androidx.compose.runtime.Immutable + +/** + * Represents an unencoded cashu proof. + */ +@Immutable +data class CashuProof( + val id: String, + val amount: Long, + val secret: String, + val c: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenEvent.kt new file mode 100644 index 000000000..924251172 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/CashuTokenEvent.kt @@ -0,0 +1,87 @@ +/* + * 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.nip60Cashu.token + +import androidx.compose.runtime.Immutable +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.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-60 Cashu Token Event (kind:7375). + * + * Records unspent cashu proofs. The content is NIP-44 encrypted JSON: + * { + * "mint": "https://mint.example.com", + * "unit": "sat", + * "proofs": [{"id": "...", "amount": 1, "secret": "...", "C": "..."}], + * "del": ["token-event-id-1", "token-event-id-2"] + * } + * + * When proofs are spent, this event should be NIP-09 deleted and unspent proofs + * rolled over into a new token event. + */ +@Immutable +class CashuTokenEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the content and parses it into a [TokenContent]. + */ + suspend fun tokenContent(signer: NostrSigner): TokenContent? { + val json = signer.nip44Decrypt(content, pubKey) + return TokenContentParser.fromJson(json) + } + + companion object { + const val KIND = 7375 + const val ALT_DESCRIPTION = "Cashu token" + + suspend fun build( + tokenContent: TokenContent, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + }.let { template -> + val encryptedContent = signer.nip44Encrypt(TokenContentParser.toJson(tokenContent), signer.pubKey) + + EventTemplate( + template.createdAt, + template.kind, + template.tags, + encryptedContent, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContent.kt new file mode 100644 index 000000000..7c43496b6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContent.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.nip60Cashu.token + +import androidx.compose.runtime.Immutable + +/** + * Represents the decrypted content of a CashuTokenEvent (kind:7375). + * + * @param mint The mint URL the proofs belong to + * @param proofs Unencoded cashu proofs + * @param unit The base unit the proofs are denominated in (e.g., "sat", "usd", "eur"). Default: "sat" + * @param del Token event IDs that were destroyed by the creation of this token + */ +@Immutable +data class TokenContent( + val mint: String, + val proofs: List, + val unit: String = "sat", + val del: List = emptyList(), +) { + fun totalAmount(): Long = proofs.sumOf { it.amount } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContentParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContentParser.kt new file mode 100644 index 000000000..1c35c8494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/token/TokenContentParser.kt @@ -0,0 +1,89 @@ +/* + * 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.nip60Cashu.token + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +internal data class CashuProofJson( + val id: String, + val amount: Long, + val secret: String, + @SerialName("C") val c: String, +) + +@Serializable +internal data class TokenContentJson( + val mint: String, + val proofs: List, + val unit: String = "sat", + val del: List = emptyList(), +) + +object TokenContentParser { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun fromJson(jsonString: String): TokenContent? = + try { + val parsed = json.decodeFromString(jsonString) + TokenContent( + mint = parsed.mint, + proofs = + parsed.proofs.map { proof -> + CashuProof( + id = proof.id, + amount = proof.amount, + secret = proof.secret, + c = proof.c, + ) + }, + unit = parsed.unit, + del = parsed.del, + ) + } catch (_: Exception) { + null + } + + fun toJson(tokenContent: TokenContent): String = + json.encodeToString( + TokenContentJson.serializer(), + TokenContentJson( + mint = tokenContent.mint, + proofs = + tokenContent.proofs.map { proof -> + CashuProofJson( + id = proof.id, + amount = proof.amount, + secret = proof.secret, + c = proof.c, + ) + }, + unit = tokenContent.unit, + del = tokenContent.del, + ), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt new file mode 100644 index 000000000..348864116 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip60Cashu/wallet/CashuWalletEvent.kt @@ -0,0 +1,101 @@ +/* + * 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.nip60Cashu.wallet + +import androidx.compose.runtime.Immutable +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.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-60 Cashu Wallet Event (kind:17375). + * + * A replaceable event that represents a cashu wallet. + * The content is NIP-44 encrypted and contains: + * - One or more "mint" tags with mint URLs + * - An optional "privkey" tag with a private key for P2PK ecash (used for NIP-61 nutzaps) + */ +@Immutable +class CashuWalletEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Decrypts the content to get the wallet's private tags (mints and privkey). + */ + suspend fun cachedPrivateTags(signer: NostrSigner) = PrivateTagsInContent.decrypt(content, signer) + + /** + * Extracts mint URLs from the decrypted private tags. + */ + suspend fun mints(signer: NostrSigner): List = + cachedPrivateTags(signer) + .filter { it.size >= 2 && it[0] == "mint" } + .map { it[1] } + + /** + * Extracts the P2PK private key from the decrypted private tags (used for NIP-61 nutzaps). + */ + suspend fun privkey(signer: NostrSigner): String? = + cachedPrivateTags(signer) + .firstOrNull { it.size >= 2 && it[0] == "privkey" } + ?.get(1) + + companion object { + const val KIND = 17375 + const val ALT_DESCRIPTION = "Cashu wallet" + + suspend fun build( + mints: List, + privkey: String? = null, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + initializer() + }.let { template -> + val privateTags = + buildList { + privkey?.let { add(arrayOf("privkey", it)) } + mints.forEach { add(arrayOf("mint", it)) } + }.toTypedArray() + + val encryptedContent = PrivateTagsInContent.encryptNip44(privateTags, signer) + + com.vitorpamplona.quartz.nip01Core.signers.EventTemplate( + template.createdAt, + template.kind, + template.tags, + encryptedContent, + ) + } + } +} 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 9601b25ac..cd6bd8b55 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -119,6 +119,10 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent +import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent +import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent +import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent @@ -214,6 +218,10 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + CashuMintQuoteEvent.KIND -> CashuMintQuoteEvent(id, pubKey, createdAt, tags, content, sig) + CashuTokenEvent.KIND -> CashuTokenEvent(id, pubKey, createdAt, tags, content, sig) + CashuSpendingHistoryEvent.KIND -> CashuSpendingHistoryEvent(id, pubKey, createdAt, tags, content, sig) + CashuWalletEvent.KIND -> CashuWalletEvent(id, pubKey, createdAt, tags, content, sig) ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) RelayFeedsListEvent.KIND -> RelayFeedsListEvent(id, pubKey, createdAt, tags, content, sig)