diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 850c5a5ac2..6d9c0d8fec 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -171,6 +171,10 @@ private suspend fun dispatch(argv: Array): 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] diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index f5dc36b462..446b3acf1c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -110,4 +110,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = SearchCommand.dispatch(dataDir, tail) + + suspend fun zap( + dataDir: DataDir, + tail: Array, + ): Int = ZapCommand.dispatch(dataDir, tail) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt new file mode 100644 index 0000000000..0e22df61ab --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -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 ` — build a NIP-57 zap request and + * fetch a BOLT11 invoice from the recipient's Lightning service. + * + * Two subcommands: + * * `zap user ` — profile zap (no event reference) + * * `zap event ` — 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, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "zap [--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, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap user [--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, + ): Int { + if (rest.size < 2) return Output.error("bad_args", "zap event [--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(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, + 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() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt new file mode 100644 index 0000000000..54136a2e76 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -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, + 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, + 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, + ) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt new file mode 100644 index 0000000000..acb37eda87 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -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") + } +}