mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
feat(commons): add NIP-57 zap verbs in shared actions package
Third verb extraction alongside FollowActions / SearchActions, scoped
to event building so the action stays target-agnostic (commonMain,
no JVM/Android coupling).
* buildUserZapRequest / buildEventZapRequest wrap the two
LnZapRequestEvent.create overloads with a uniform call shape and
sensible defaults (PUBLIC zap, no LNURL, no poll).
* extractLnAddress pulls lud16 (preferred) or lud06 from a kind:0
metadata event, returning null when neither is set.
* satsToMillisats covers the sats→msats conversion that every
caller would otherwise duplicate.
Wires up amy zap user|event as the first consumer. The Lightning
round-trip (LNURL fetch + invoice retrieval) goes through the existing
LightningAddressResolver in commons/jvmAndroid; the BOLT11 invoice is
printed but not auto-paid since amy has no NWC wallet wired up yet.
This commit is contained in:
@@ -171,6 +171,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
||||
Commands.search(dataDir, tail)
|
||||
}
|
||||
|
||||
"zap" -> {
|
||||
Commands.zap(dataDir, tail)
|
||||
}
|
||||
|
||||
else -> {
|
||||
System.err.println("unknown subcommand: $head")
|
||||
printUsage()
|
||||
@@ -344,6 +348,14 @@ private fun printUsage() {
|
||||
| unfollow USER [--timeout SECS] remove USER from your contact list
|
||||
| (USER: npub|nprofile|hex|name@domain)
|
||||
|
|
||||
|Zaps (NIP-57):
|
||||
| zap user USER SATS build a profile zap-request, fetch a BOLT11
|
||||
| [--comment X] [--anon|--private] invoice from the recipient's LN service
|
||||
| [--timeout SECS] (no auto-payment — paste invoice into a wallet)
|
||||
| zap event EVENT-ID SATS same, but attribute the zap to a specific
|
||||
| [--comment X] [--anon|--private] event (must be in local store)
|
||||
| [--timeout SECS]
|
||||
|
|
||||
|Search (NIP-50):
|
||||
| search user QUERY [--limit N] search kind:0 profiles
|
||||
| [--timeout SECS]
|
||||
|
||||
@@ -110,4 +110,9 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = SearchCommand.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun zap(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = ZapCommand.dispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 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.amethyst.cli.commands
|
||||
|
||||
import com.vitorpamplona.amethyst.cli.Args
|
||||
import com.vitorpamplona.amethyst.cli.Context
|
||||
import com.vitorpamplona.amethyst.cli.DataDir
|
||||
import com.vitorpamplona.amethyst.cli.Output
|
||||
import com.vitorpamplona.amethyst.commons.actions.ZapActions
|
||||
import com.vitorpamplona.amethyst.commons.services.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* `amy zap <user|event> <target> <sats>` — build a NIP-57 zap request and
|
||||
* fetch a BOLT11 invoice from the recipient's Lightning service.
|
||||
*
|
||||
* Two subcommands:
|
||||
* * `zap user <user> <sats>` — profile zap (no event reference)
|
||||
* * `zap event <event-id> <sats>` — event zap (must be in local store)
|
||||
*
|
||||
* The flow is:
|
||||
* 1. Resolve recipient identifier → pubkey + kind:0 metadata.
|
||||
* 2. Extract LN address (`lud16` preferred, then `lud06` LNURL).
|
||||
* 3. Build + sign the NIP-57 kind:9734 zap-request event via
|
||||
* [ZapActions].
|
||||
* 4. POST it to the recipient's LNURL-pay callback via
|
||||
* [LightningAddressResolver] to receive a BOLT11 invoice.
|
||||
*
|
||||
* The invoice is printed but **not** auto-paid — amy has no NWC wallet
|
||||
* wired up yet. Paste the invoice into any LN wallet to settle.
|
||||
*/
|
||||
object ZapCommand {
|
||||
suspend fun dispatch(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "zap <user|event> <target> <sats> [--comment X] [--anon] [--timeout SECS]")
|
||||
val rest = tail.drop(1).toTypedArray()
|
||||
return when (tail[0]) {
|
||||
"user" -> zapUser(dataDir, rest)
|
||||
"event" -> zapEvent(dataDir, rest)
|
||||
else -> Output.error("bad_args", "zap ${tail[0]} — expected user|event")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun zapUser(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Output.error("bad_args", "zap user <user> <sats> [--comment X] [--anon] [--timeout SECS]")
|
||||
val userArg = rest[0]
|
||||
val sats =
|
||||
rest[1].toLongOrNull()?.takeIf { it > 0 }
|
||||
?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')")
|
||||
val args = Args(rest.drop(2).toTypedArray())
|
||||
val comment = args.flag("comment") ?: ""
|
||||
val zapType = parseZapType(args)
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val recipient = ctx.requireUserHex(userArg)
|
||||
val metadata =
|
||||
fetchLatestMetadata(ctx, recipient, ctx.bootstrapRelays(), timeoutMs)
|
||||
?: return Output.error("not_found", "no kind:0 metadata found for $recipient")
|
||||
val lnAddress =
|
||||
ZapActions.extractLnAddress(metadata)
|
||||
?: return Output.error("no_lightning", "recipient has no lud16 or lud06 in their profile")
|
||||
|
||||
val request =
|
||||
ZapActions.buildUserZapRequest(
|
||||
signer = ctx.signer,
|
||||
recipientPubkey = recipient,
|
||||
amountMillisats = ZapActions.satsToMillisats(sats),
|
||||
inboxRelays = ctx.outboxRelays(),
|
||||
comment = comment,
|
||||
zapType = zapType,
|
||||
)
|
||||
|
||||
emitZapResult(ctx, sats, lnAddress, comment, request, zapType)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun zapEvent(
|
||||
dataDir: DataDir,
|
||||
rest: Array<String>,
|
||||
): Int {
|
||||
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--comment X] [--anon] [--timeout SECS]")
|
||||
val eventId = rest[0]
|
||||
if (eventId.length != 64) return Output.error("bad_args", "event-id must be 64-hex (nevent bech32 not yet supported)")
|
||||
val sats =
|
||||
rest[1].toLongOrNull()?.takeIf { it > 0 }
|
||||
?: return Output.error("bad_args", "sats must be a positive integer (got '${rest[1]}')")
|
||||
val args = Args(rest.drop(2).toTypedArray())
|
||||
val comment = args.flag("comment") ?: ""
|
||||
val zapType = parseZapType(args)
|
||||
val timeoutMs = args.longFlag("timeout", 8L) * 1000
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
ctx.prepare()
|
||||
val zappedEvent =
|
||||
ctx.store.query<Event>(Filter(ids = listOf(eventId), limit = 1)).firstOrNull()
|
||||
?: return Output.error("not_found", "event $eventId not in local store; sync first or fetch by id")
|
||||
|
||||
val metadata =
|
||||
fetchLatestMetadata(ctx, zappedEvent.pubKey, ctx.bootstrapRelays(), timeoutMs)
|
||||
?: return Output.error("not_found", "no kind:0 metadata found for author ${zappedEvent.pubKey}")
|
||||
val lnAddress =
|
||||
ZapActions.extractLnAddress(metadata)
|
||||
?: return Output.error("no_lightning", "event author has no lud16 or lud06 in their profile")
|
||||
|
||||
val request =
|
||||
ZapActions.buildEventZapRequest(
|
||||
signer = ctx.signer,
|
||||
zappedEvent = zappedEvent,
|
||||
amountMillisats = ZapActions.satsToMillisats(sats),
|
||||
inboxRelays = ctx.outboxRelays(),
|
||||
comment = comment,
|
||||
zapType = zapType,
|
||||
)
|
||||
|
||||
emitZapResult(ctx, sats, lnAddress, comment, request, zapType, zappedEventId = zappedEvent.id)
|
||||
return 0
|
||||
} finally {
|
||||
ctx.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitZapResult(
|
||||
ctx: Context,
|
||||
sats: Long,
|
||||
lnAddress: String,
|
||||
comment: String,
|
||||
request: LnZapRequestEvent,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
zappedEventId: HexKey? = null,
|
||||
) {
|
||||
// Reuse the same OkHttp instance the Context uses for nip-05 / WS;
|
||||
// this respects any proxy/timeout config wired in there.
|
||||
val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx))
|
||||
|
||||
val result =
|
||||
resolver.fetchInvoice(
|
||||
lnAddress = lnAddress,
|
||||
milliSats = ZapActions.satsToMillisats(sats),
|
||||
message = comment,
|
||||
zapRequest = request,
|
||||
)
|
||||
|
||||
when (result) {
|
||||
is LightningAddressResolver.Result.Success -> {
|
||||
Output.emit(
|
||||
buildMap {
|
||||
put("ln_address", lnAddress)
|
||||
put("amount_sats", sats)
|
||||
put("zap_type", zapType.name.lowercase())
|
||||
put("comment", comment)
|
||||
put("zap_request_id", request.id)
|
||||
if (zappedEventId != null) put("zapped_event_id", zappedEventId)
|
||||
put("invoice", result.invoice)
|
||||
},
|
||||
)
|
||||
}
|
||||
is LightningAddressResolver.Result.Error -> {
|
||||
Output.error("invoice_failed", result.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseZapType(args: Args): LnZapEvent.ZapType =
|
||||
when {
|
||||
args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS
|
||||
args.bool("private") -> LnZapEvent.ZapType.PRIVATE
|
||||
else -> LnZapEvent.ZapType.PUBLIC
|
||||
}
|
||||
|
||||
private suspend fun fetchLatestMetadata(
|
||||
ctx: Context,
|
||||
pubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long,
|
||||
): MetadataEvent? {
|
||||
// Cache-first: try the local store before going to the network.
|
||||
ctx.profileOf(pubKey)?.let { return it }
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = Filter(kinds = listOf(MetadataEvent.KIND), authors = listOf(pubKey), limit = 1)
|
||||
val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs)
|
||||
return received
|
||||
.mapNotNull { (_, ev) -> ev as? MetadataEvent }
|
||||
.filter { it.pubKey == pubKey }
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-invocation OkHttpClient. Amy's [Context] also has its own OkHttp
|
||||
* (for WS + NIP-05); we keep this separate because [Context.okhttp] is
|
||||
* private — exposing it just to reuse here would widen the API more
|
||||
* than is warranted for a single LNURL fetch.
|
||||
*/
|
||||
private fun sharedOkHttp(
|
||||
@Suppress("UNUSED_PARAMETER") ctx: Context,
|
||||
): OkHttpClient = OkHttpClient.Builder().build()
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* 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.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
|
||||
/**
|
||||
* NIP-57 zap-request building + LN address extraction.
|
||||
*
|
||||
* Returns a signed [LnZapRequestEvent] (kind:9734) — the artifact a caller
|
||||
* hands to a LNURL-pay callback to receive a BOLT11 invoice. The Lightning
|
||||
* round-trip (LNURL fetch, invoice retrieval, optional NWC payment) is
|
||||
* intentionally out of scope here; callers compose this with
|
||||
* `LightningAddressResolver` (commons/jvmAndroid) or their own LN client.
|
||||
*
|
||||
* Pattern matches [FollowActions] and [SearchActions]: shared, pure logic
|
||||
* usable from amy CLI, the future Android App Functions adapter for Gemini,
|
||||
* and any other non-UI consumer.
|
||||
*/
|
||||
object ZapActions {
|
||||
/** Convert sats to millisats — LN-side amount unit. */
|
||||
fun satsToMillisats(sats: Long): Long = sats * 1000L
|
||||
|
||||
/**
|
||||
* Extract the LN address (Lightning Address or LNURL) from a kind:0
|
||||
* metadata event. Prefers `lud16` (Lightning Address, `user@domain`)
|
||||
* over `lud06` (raw LNURL). Returns null when the user has no LN
|
||||
* details published.
|
||||
*/
|
||||
fun extractLnAddress(metadata: MetadataEvent): String? = metadata.contactMetaData()?.lnAddress()
|
||||
|
||||
/**
|
||||
* Build a NIP-57 profile zap request — pays [recipientPubkey] directly,
|
||||
* not attached to any specific event.
|
||||
*
|
||||
* [inboxRelays] becomes the `["relays", ...]` tag of the zap request:
|
||||
* the LN provider publishes the kind:9735 zap *receipt* to these
|
||||
* relays. These should be the sender's read-side (NIP-65 inbox)
|
||||
* relays so the sender's clients see the receipt land.
|
||||
*
|
||||
* Pass [lnurl] when known to stamp it as a tag on the request — some
|
||||
* receipt validators key off it.
|
||||
*/
|
||||
suspend fun buildUserZapRequest(
|
||||
signer: NostrSigner,
|
||||
recipientPubkey: HexKey,
|
||||
amountMillisats: Long,
|
||||
inboxRelays: Set<NormalizedRelayUrl>,
|
||||
comment: String = "",
|
||||
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent =
|
||||
LnZapRequestEvent.create(
|
||||
userHex = recipientPubkey,
|
||||
relays = inboxRelays,
|
||||
signer = signer,
|
||||
message = comment,
|
||||
zapType = zapType,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
/**
|
||||
* Build a NIP-57 event-zap request — pays the author of
|
||||
* [zappedEvent] in the context of that specific event. Override
|
||||
* [toUserPubkey] when the payment should go to a co-author or
|
||||
* delegated recipient (zap splits); when null the zap targets
|
||||
* `zappedEvent.pubKey`.
|
||||
*/
|
||||
suspend fun buildEventZapRequest(
|
||||
signer: NostrSigner,
|
||||
zappedEvent: Event,
|
||||
amountMillisats: Long,
|
||||
inboxRelays: Set<NormalizedRelayUrl>,
|
||||
comment: String = "",
|
||||
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
|
||||
toUserPubkey: HexKey? = null,
|
||||
pollOption: Int? = null,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent =
|
||||
LnZapRequestEvent.create(
|
||||
zappedEvent = zappedEvent,
|
||||
relays = inboxRelays,
|
||||
signer = signer,
|
||||
pollOption = pollOption,
|
||||
message = comment,
|
||||
zapType = zapType,
|
||||
toUserPubHex = toUserPubkey,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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.amethyst.commons.actions
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ZapActionsTest {
|
||||
private val senderPriv = "0000000000000000000000000000000000000000000000000000000000000007"
|
||||
private val authorPriv = "000000000000000000000000000000000000000000000000000000000000000d"
|
||||
private val recipientPriv = "0000000000000000000000000000000000000000000000000000000000000011"
|
||||
private val signer = NostrSignerInternal(KeyPair(senderPriv.hexToByteArray()))
|
||||
private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray()))
|
||||
|
||||
// Use a real curve-point pubkey — PRIVATE / ANONYMOUS zaps internally do
|
||||
// NIP-04-style ECDH with the recipient, which rejects garbage pubkeys.
|
||||
private val recipientPubkey = xOnly(recipientPriv)
|
||||
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://inbox.example")!!
|
||||
|
||||
private fun xOnly(privHex: String) =
|
||||
Secp256k1Instance
|
||||
.compressedPubKeyFor(privHex.hexToByteArray())
|
||||
.copyOfRange(1, 33)
|
||||
.toHexKey()
|
||||
|
||||
@Test
|
||||
fun satsToMillisats_multipliesByThousand() {
|
||||
assertEquals(0L, ZapActions.satsToMillisats(0))
|
||||
assertEquals(1_000L, ZapActions.satsToMillisats(1))
|
||||
assertEquals(21_000_000L, ZapActions.satsToMillisats(21_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractLnAddress_prefersLud16OverLud06() =
|
||||
runTest {
|
||||
val metadata =
|
||||
signer.sign(
|
||||
MetadataEvent.createNew(
|
||||
name = "alice",
|
||||
lnAddress = "alice@walletofsatoshi.com",
|
||||
lnURL = "lnurl1somelongstring",
|
||||
),
|
||||
)
|
||||
assertEquals("alice@walletofsatoshi.com", ZapActions.extractLnAddress(metadata))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractLnAddress_fallsBackToLud06WhenNoLud16() =
|
||||
runTest {
|
||||
val metadata =
|
||||
signer.sign(
|
||||
MetadataEvent.createNew(
|
||||
name = "bob",
|
||||
lnURL = "lnurl1bobsLightning",
|
||||
),
|
||||
)
|
||||
assertEquals("lnurl1bobsLightning", ZapActions.extractLnAddress(metadata))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractLnAddress_returnsNullWhenNoLnDetails() =
|
||||
runTest {
|
||||
val metadata =
|
||||
signer.sign(
|
||||
MetadataEvent.createNew(name = "noln"),
|
||||
)
|
||||
assertNull(ZapActions.extractLnAddress(metadata))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildUserZapRequest_publicTypeStampsAllFields() =
|
||||
runTest {
|
||||
val request =
|
||||
ZapActions.buildUserZapRequest(
|
||||
signer = signer,
|
||||
recipientPubkey = recipientPubkey,
|
||||
amountMillisats = 21_000L,
|
||||
inboxRelays = setOf(relay),
|
||||
comment = "thanks!",
|
||||
zapType = LnZapEvent.ZapType.PUBLIC,
|
||||
lnurl = "lnurl1example",
|
||||
)
|
||||
|
||||
assertEquals(9734, request.kind)
|
||||
assertEquals(signer.pubKey, request.pubKey, "PUBLIC zap is signed by the sender")
|
||||
assertEquals("thanks!", request.content)
|
||||
|
||||
val tagMap = request.tags.groupBy { it[0] }
|
||||
assertEquals(recipientPubkey, tagMap["p"]?.first()?.get(1))
|
||||
assertEquals("21000", tagMap["amount"]?.first()?.get(1))
|
||||
assertEquals("lnurl1example", tagMap["lnurl"]?.first()?.get(1))
|
||||
assertTrue(tagMap["relays"]?.first()?.contains(relay.url) == true)
|
||||
assertNull(tagMap["anon"], "PUBLIC zap must not carry an anon tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildUserZapRequest_anonymousTypeUsesEphemeralKeyAndAnonTag() =
|
||||
runTest {
|
||||
val request =
|
||||
ZapActions.buildUserZapRequest(
|
||||
signer = signer,
|
||||
recipientPubkey = recipientPubkey,
|
||||
amountMillisats = 1_000L,
|
||||
inboxRelays = setOf(relay),
|
||||
zapType = LnZapEvent.ZapType.ANONYMOUS,
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
request.pubKey != signer.pubKey,
|
||||
"ANONYMOUS zaps are signed with a freshly-generated keypair, not the sender's",
|
||||
)
|
||||
assertNotNull(request.tags.firstOrNull { it[0] == "anon" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildUserZapRequest_privateTypeCarriesAnonTagWithEncryptedPayload() =
|
||||
runTest {
|
||||
val request =
|
||||
ZapActions.buildUserZapRequest(
|
||||
signer = signer,
|
||||
recipientPubkey = recipientPubkey,
|
||||
amountMillisats = 1_000L,
|
||||
inboxRelays = setOf(relay),
|
||||
zapType = LnZapEvent.ZapType.PRIVATE,
|
||||
)
|
||||
|
||||
// NIP-57 PRIVATE zaps use an ephemeral key derived from
|
||||
// (sender, recipient, zappedEvent) so the recipient can re-derive
|
||||
// and verify origin via NIP-04 decryption of the anon tag value.
|
||||
// The outer event is therefore NOT signed by the sender.
|
||||
val anon = request.tags.firstOrNull { it[0] == "anon" }
|
||||
assertNotNull(anon, "PRIVATE zap must carry an anon tag")
|
||||
assertTrue(
|
||||
(anon.getOrNull(1) ?: "").isNotEmpty(),
|
||||
"PRIVATE zap's anon tag carries the NIP-04-encrypted private payload",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEventZapRequest_carriesEventTagAndAuthorPTag() =
|
||||
runTest {
|
||||
val note = authorSigner.sign(TextNoteEvent.build("hello world"))
|
||||
|
||||
val request =
|
||||
ZapActions.buildEventZapRequest(
|
||||
signer = signer,
|
||||
zappedEvent = note,
|
||||
amountMillisats = 5_000L,
|
||||
inboxRelays = setOf(relay),
|
||||
comment = "great post",
|
||||
)
|
||||
|
||||
val tagMap = request.tags.groupBy { it[0] }
|
||||
assertEquals(note.id, tagMap["e"]?.first()?.get(1))
|
||||
assertEquals(note.pubKey, tagMap["p"]?.first()?.get(1))
|
||||
assertEquals("5000", tagMap["amount"]?.first()?.get(1))
|
||||
assertEquals("great post", request.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildEventZapRequest_toUserPubkeyOverridesAuthorTag() =
|
||||
runTest {
|
||||
val note = authorSigner.sign(TextNoteEvent.build("split me"))
|
||||
val splitTo = "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
|
||||
val request =
|
||||
ZapActions.buildEventZapRequest(
|
||||
signer = signer,
|
||||
zappedEvent = note,
|
||||
amountMillisats = 5_000L,
|
||||
inboxRelays = setOf(relay),
|
||||
toUserPubkey = splitTo,
|
||||
)
|
||||
|
||||
val pTag = request.tags.firstOrNull { it[0] == "p" }
|
||||
assertEquals(splitTo, pTag?.getOrNull(1), "explicit toUserPubkey wins over event.pubKey")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user