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:
+156
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
+95
@@ -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,
|
||||
)
|
||||
+87
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
@@ -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 }
|
||||
}
|
||||
+89
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+101
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user