feat: Add NIP-60 Cashu wallet support in quartz

Implements the NIP-60 specification for cashu-based wallets with four event types:
- CashuWalletEvent (kind:17375) - replaceable wallet definition with encrypted mints/privkey
- CashuTokenEvent (kind:7375) - unspent cashu proofs with NIP-44 encrypted JSON content
- CashuSpendingHistoryEvent (kind:7376) - transaction history with encrypted tag array
- CashuMintQuoteEvent (kind:7374) - optional mint quote state with NIP-40 expiration

Follows the same structural patterns as the NIP-88 polls implementation.

https://claude.ai/code/session_018UfHPzrQCAFMwB1zB1ftDM
This commit is contained in:
Claude
2026-03-29 04:34:41 +00:00
parent 48da76c6a3
commit 32324e5bbe
10 changed files with 711 additions and 0 deletions
@@ -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", "<amount>"]
* - ["unit", "sat"|"usd"|"eur"]
* - ["e", "<event-id>", "<relay>", "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<Array<String>>,
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<TokenReference> {
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<TokenReference> =
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<TokenReference>,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CashuSpendingHistoryEvent>.() -> 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<CashuSpendingHistoryEvent>(
template.createdAt,
template.kind,
template.tags,
encryptedContent,
)
}
}
}
@@ -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
}
}
}
@@ -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<String>): 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)
}
}
@@ -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", "<timestamp>"]
* - ["mint", "<mint-url>"]
*/
@Immutable
class CashuMintQuoteEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
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<CashuMintQuoteEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
expiration(expirationTimestamp)
add(arrayOf("mint", mintUrl))
initializer()
}.let { template ->
val encryptedContent = signer.nip44Encrypt(quoteId, signer.pubKey)
EventTemplate<CashuMintQuoteEvent>(
template.createdAt,
template.kind,
template.tags,
encryptedContent,
)
}
}
}
@@ -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,
)
@@ -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<Array<String>>,
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<CashuTokenEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
alt(ALT_DESCRIPTION)
initializer()
}.let { template ->
val encryptedContent = signer.nip44Encrypt(TokenContentParser.toJson(tokenContent), signer.pubKey)
EventTemplate<CashuTokenEvent>(
template.createdAt,
template.kind,
template.tags,
encryptedContent,
)
}
}
}
@@ -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<CashuProof>,
val unit: String = "sat",
val del: List<String> = emptyList(),
) {
fun totalAmount(): Long = proofs.sumOf { it.amount }
}
@@ -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<CashuProofJson>,
val unit: String = "sat",
val del: List<String> = emptyList(),
)
object TokenContentParser {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
fun fromJson(jsonString: String): TokenContent? =
try {
val parsed = json.decodeFromString<TokenContentJson>(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,
),
)
}
@@ -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<Array<String>>,
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<String> =
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<String>,
privkey: String? = null,
signer: NostrSigner,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CashuWalletEvent>.() -> 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<CashuWalletEvent>(
template.createdAt,
template.kind,
template.tags,
encryptedContent,
)
}
}
}
@@ -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)