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:
Claude
2026-05-24 16:33:15 +00:00
parent cde609203c
commit 2e47cb7110
5 changed files with 575 additions and 0 deletions
@@ -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()
}
@@ -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,
)
}
@@ -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")
}
}