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 index 0e22df61ab..9d864840a6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ZapCommand.kt @@ -114,7 +114,7 @@ object ZapCommand { dataDir: DataDir, rest: Array, ): Int { - if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--timeout SECS]") + if (rest.size < 2) return Output.error("bad_args", "zap event [--comment X] [--anon] [--private] [--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 = @@ -132,24 +132,50 @@ object ZapCommand { 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 bootstrap = ctx.bootstrapRelays() - val request = - ZapActions.buildEventZapRequest( + // Resolves a pubkey to an LN address by reading the latest + // kind:0 from the local store, falling back to a relay drain + // when never seen. Mirrors what the Amethyst foreground UI + // pulls out of User.lnAddress(). + val lookupLnAddress: suspend (HexKey) -> String? = { pk -> + fetchLatestMetadata(ctx, pk, bootstrap, timeoutMs) + ?.let(ZapActions::extractLnAddress) + } + + // Recipient's NIP-65 read ("inbox") relays — read-side flag on + // their advertised kind:10002. These get unioned into each + // zap request's `relays` tag so the kind:9735 receipt routes + // to the recipient's clients. Matches `User.inboxRelays()` in + // the Android Account. + val lookupInboxRelays: suspend (HexKey) -> Set = { pk -> + ctx + .relaysOf(pk) + ?.readRelaysNorm() + ?.toSet() + .orEmpty() + } + + val requests = + ZapActions.buildEventZapRequestsForSplits( signer = ctx.signer, zappedEvent = zappedEvent, - amountMillisats = ZapActions.satsToMillisats(sats), - inboxRelays = ctx.outboxRelays(), + totalAmountMillisats = ZapActions.satsToMillisats(sats), + senderInboxRelays = ctx.outboxRelays(), + lookupLnAddress = lookupLnAddress, + lookupInboxRelays = lookupInboxRelays, comment = comment, zapType = zapType, ) - emitZapResult(ctx, sats, lnAddress, comment, request, zapType, zappedEventId = zappedEvent.id) + if (requests.isEmpty()) { + return Output.error( + "no_lightning", + "no payable recipients — neither the author nor any zap-split recipient has a usable LN address", + ) + } + + emitSplitZapResult(ctx, sats, comment, zappedEvent.id, zapType, requests) return 0 } finally { ctx.close() @@ -197,6 +223,65 @@ object ZapCommand { } } + /** + * Multi-recipient (split-aware) event-zap result emitter. Fetches one + * BOLT11 invoice per [ZapActions.ZapRequestForSplit] and writes a + * single JSON object enumerating each recipient + its invoice (or + * per-recipient `invoice_error` when the LNURL fetch fails). Total + * sat sum may be a few millisats below the requested amount due to + * whole-sat rounding in the split shares. + */ + private suspend fun emitSplitZapResult( + ctx: Context, + sats: Long, + comment: String, + zappedEventId: HexKey, + zapType: LnZapEvent.ZapType, + requests: List, + ) { + val resolver = LightningAddressResolver(httpClient = sharedOkHttp(ctx)) + + val recipientEntries = + requests.map { req -> + val shareSats = req.amountMillisats / 1000 + val result = + resolver.fetchInvoice( + lnAddress = req.recipient.lnAddress, + milliSats = req.amountMillisats, + message = comment, + zapRequest = req.request, + ) + val entry = + mutableMapOf( + "ln_address" to req.recipient.lnAddress, + "pubkey" to req.recipient.pubkey, + "weight" to req.recipient.weight, + "amount_sats" to shareSats, + "zap_request_id" to req.request.id, + ) + when (result) { + is LightningAddressResolver.Result.Success -> + entry["invoice"] = result.invoice + + is LightningAddressResolver.Result.Error -> + entry["invoice_error"] = result.message + } + entry + } + + Output.emit( + mapOf( + "zapped_event_id" to zappedEventId, + "zap_type" to zapType.name.lowercase(), + "comment" to comment, + "requested_sats" to sats, + "billed_sats" to recipientEntries.sumOf { (it["amount_sats"] as? Long) ?: 0L }, + "recipient_count" to recipientEntries.size, + "recipients" to recipientEntries, + ), + ) + } + private fun parseZapType(args: Args): LnZapEvent.ZapType = when { args.bool("anon") -> LnZapEvent.ZapType.ANONYMOUS 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 index 54136a2e76..36f9cc96a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActions.kt @@ -90,6 +90,12 @@ object ZapActions { * [toUserPubkey] when the payment should go to a co-author or * delegated recipient (zap splits); when null the zap targets * `zappedEvent.pubKey`. + * + * **Caller beware:** This builds a single zap request to a single + * recipient. Notes carrying NIP-57 zap-split tags, NIP-53 + * live-activity hosts, or NIP-89 app metadata expect the payment to + * be divided across multiple parties. Use [buildEventZapRequestsForSplits] + * for the split-aware path; that's what the Amethyst foreground UI does. */ suspend fun buildEventZapRequest( signer: NostrSigner, @@ -113,4 +119,73 @@ object ZapActions { amountMillisats = amountMillisats, lnurl = lnurl, ) + + /** + * One signed zap request for one split recipient, with the share of + * the total payment already computed. + */ + data class ZapRequestForSplit( + val recipient: ZapSplitResolver.Recipient, + val amountMillisats: Long, + val request: LnZapRequestEvent, + ) + + /** + * Split-aware version of [buildEventZapRequest]: resolves the recipient + * list via [ZapSplitResolver], computes per-recipient shares with + * [ZapSplitResolver.shareMillisats] (rounded to whole sats — matches the + * Amethyst UI), and signs one zap request per recipient. + * + * Each request's relay-list tag includes [senderInboxRelays] union the + * recipient's own inbox relays (resolved via [lookupInboxRelays]), so + * the eventual kind:9735 zap receipt is published to both parties' + * read-side relays. This matches `ZapPaymentHandler.signAllZapRequests`. + * + * Sum of returned `amountMillisats` may differ from [totalAmountMillisats] + * by a few hundred millisats due to whole-sat rounding — same drift the + * in-app flow has. + * + * Recipients with no resolvable LN address are dropped at the resolver + * step; callers that want to surface "missing LN" warnings should call + * [ZapSplitResolver.resolve] separately first. + */ + suspend fun buildEventZapRequestsForSplits( + signer: NostrSigner, + zappedEvent: Event, + totalAmountMillisats: Long, + senderInboxRelays: Set, + lookupLnAddress: suspend (HexKey) -> String?, + lookupInboxRelays: suspend (HexKey) -> Set = { emptySet() }, + comment: String = "", + zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC, + pollOption: Int? = null, + ): List { + val recipients = ZapSplitResolver.resolve(zappedEvent, lookupLnAddress) + if (recipients.isEmpty()) return emptyList() + val totalWeight = recipients.sumOf { it.weight } + + // Author inbox always travels with the zap so the author's clients + // see the receipt even when paying a split recipient. Mirrors the + // `authorRelayList + userRelayList` union in ZapPaymentHandler. + val authorInbox = lookupInboxRelays(zappedEvent.pubKey) + + return recipients.map { recipient -> + val share = ZapSplitResolver.shareMillisats(totalAmountMillisats, recipient.weight, totalWeight) + val recipientInbox = recipient.pubkey?.let { lookupInboxRelays(it) }.orEmpty() + val allRelays = senderInboxRelays + recipientInbox + authorInbox + val request = + LnZapRequestEvent.create( + zappedEvent = zappedEvent, + relays = allRelays, + signer = signer, + pollOption = pollOption, + message = comment, + zapType = zapType, + toUserPubHex = recipient.pubkey, + amountMillisats = share, + lnurl = null, + ) + ZapRequestForSplit(recipient = recipient, amountMillisats = share, request = request) + } + } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt new file mode 100644 index 0000000000..06f9484e19 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolver.kt @@ -0,0 +1,182 @@ +/* + * 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.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress +import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent +import kotlin.math.round + +/** + * Resolves the set of recipients for a NIP-57 zap on a given event. + * + * Mirrors the split-resolution logic in Amethyst's + * `service/ZapPaymentHandler.kt` (Android) so non-UI callers — amy CLI, + * Gemini App Functions adapter, automation scripts — pay the same + * recipients the in-app flow would. Without this resolver, a naive + * "zap the event author" path silently misroutes funds on any note that + * carries `zap` tags, live-activity host tags, or app-definition metadata. + * + * The resolution order matches Amethyst: + * 1. NIP-57 zap-split tags on the event (`["zap", ...]`). + * 2. NIP-53 live-activity hosts (kind:30311 only). + * 3. NIP-89 app definition's own LN address (kind:31990 only). + * 4. The event author as the sole recipient. + * + * Recipients without a resolvable LN address are dropped silently — the + * caller is responsible for surfacing that to the user. This matches the + * `mapNotNull` shape of the in-app flow. + */ +object ZapSplitResolver { + /** + * One zap recipient. The total payment is divided among recipients + * proportional to [weight] / sum(weights); [shareMillisats] applies + * the same rounding the in-app flow does. + */ + data class Recipient( + /** LN address ready to hand to [shareMillisats] + an LNURL-pay flow. */ + val lnAddress: String, + /** Pubkey of the recipient, or null when the split tag carried only an LN address. */ + val pubkey: HexKey?, + /** Relative weight in the split. 1.0 when not otherwise specified. */ + val weight: Double, + /** Relay hint the split tag carried, if any — for receipt routing. */ + val relay: NormalizedRelayUrl?, + ) + + /** + * Rounds a per-split share to whole sats (millisats granularity of 1_000). + * Matches `ZapPaymentHandler.calculateZapValue` so sums line up exactly + * with what an Amethyst user would see on-screen. + */ + fun shareMillisats( + totalMillisats: Long, + weight: Double, + totalWeight: Double, + ): Long { + if (totalWeight <= 0.0) return 0L + val shareValue = totalMillisats * (weight / totalWeight) + return round(shareValue / 1000f).toLong() * 1000 + } + + /** + * Resolve the list of zap recipients for [zappedEvent]. + * + * @param lookupLnAddress called to resolve a pubkey to an LN address. For + * amy this reads kind:0 metadata from the local store; for the Android + * adapter it reads `User.lnAddress()` from the live cache. Return null + * when no LN address is known — the recipient is dropped. + * + * @return ordered list of recipients with LN addresses resolved. Empty + * list when no recipient has a usable LN address. + */ + suspend fun resolve( + zappedEvent: Event, + lookupLnAddress: suspend (HexKey) -> String?, + ): List { + val splits = zappedEvent.zapSplitSetup() + + val raw: List = + when { + splits.isNotEmpty() -> + splits.map { setup -> + when (setup) { + is ZapSplitSetupLnAddress -> + Recipient( + lnAddress = setup.lnAddress, + pubkey = null, + weight = setup.weight, + relay = null, + ) + is ZapSplitSetup -> { + val ln = lookupLnAddress(setup.pubKeyHex) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = setup.pubKeyHex, + weight = setup.weight, + relay = setup.relay, + ) + } else { + null + } + } + } + } + + zappedEvent is LiveActivitiesEvent && zappedEvent.hasHost() -> + zappedEvent.hosts().map { host -> + val ln = lookupLnAddress(host.pubKey) + if (ln != null) { + Recipient( + lnAddress = ln, + pubkey = host.pubKey, + weight = 1.0, + relay = host.relayHint, + ) + } else { + null + } + } + + zappedEvent is AppDefinitionEvent -> { + val appLn = zappedEvent.appMetaData()?.lnAddress() + val ln = appLn ?: lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + // appMetaData has no pubkey association; only attribute when we fell back to the author. + pubkey = if (appLn == null) zappedEvent.pubKey else null, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + + else -> { + val ln = lookupLnAddress(zappedEvent.pubKey) + if (ln != null) { + listOf( + Recipient( + lnAddress = ln, + pubkey = zappedEvent.pubKey, + weight = 1.0, + relay = null, + ), + ) + } else { + listOf(null) + } + } + } + + return raw.filterNotNull() + } +} 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 index acb37eda87..eaa21809b1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapActionsTest.kt @@ -206,4 +206,174 @@ class ZapActionsTest { val pTag = request.tags.firstOrNull { it[0] == "p" } assertEquals(splitTo, pTag?.getOrNull(1), "explicit toUserPubkey wins over event.pubKey") } + + // ------------------------------------------------------------------ + // buildEventZapRequestsForSplits — covers the correctness bug the + // single-recipient buildEventZapRequest has for split notes. + // ------------------------------------------------------------------ + + @Test + fun buildEventZapRequestsForSplits_lnAddressSplitTagsProduceOneRequestPerRecipient() = + runTest { + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", "alice@wallet.example"), + arrayOf("zap", "bob@wallet.example"), + ), + content = "split me 50/50", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertEquals(2, requests.size) + assertEquals(setOf("alice@wallet.example", "bob@wallet.example"), requests.map { it.recipient.lnAddress }.toSet()) + // LnAddress-style splits are always weight 1.0 (per quartz parser), + // so 10000 msats / 2 = 5000 msats each. + assertEquals(setOf(5_000L), requests.map { it.amountMillisats }.toSet()) + } + + @Test + fun buildEventZapRequestsForSplits_pubkeySplitsRespectWeights() = + runTest { + val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitAPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitAPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + val splitBPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitBPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = + arrayOf( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "4.0"), + ), + content = "20/80 split", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 100_000L, // 100 sats + senderInboxRelays = setOf(relay), + lookupLnAddress = { pk -> + when (pk) { + splitAPub -> "a@wallet" + splitBPub -> "b@wallet" + else -> null + } + }, + ) + + val byPub = requests.associateBy { it.recipient.pubkey } + assertEquals(20_000L, byPub[splitAPub]?.amountMillisats, "1/5 of 100 sats") + assertEquals(80_000L, byPub[splitBPub]?.amountMillisats, "4/5 of 100 sats") + // Sum matches input within rounding. + assertEquals(100_000L, requests.sumOf { it.amountMillisats }) + } + + @Test + fun buildEventZapRequestsForSplits_unionsAuthorAndRecipientInboxRelays() = + runTest { + // Use a key distinct from authorPriv/senderPriv so the split + // recipient and the note author are different pubkeys — otherwise + // their inbox-relay lookups collide and we can't tell which one + // ended up in the relays tag. + val splitPriv = "0000000000000000000000000000000000000000000000000000000000000019" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "test inbox unioning", + ) + + val senderRelay = relay + val authorRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://author-inbox.example")!! + val recipientRelay = + com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + .normalizeOrNull("wss://recipient-inbox.example")!! + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 1_000L, + senderInboxRelays = setOf(senderRelay), + lookupLnAddress = { _ -> "x@wallet" }, + lookupInboxRelays = { pk -> + when (pk) { + authorSigner.pubKey -> setOf(authorRelay) + splitPub -> setOf(recipientRelay) + else -> emptySet() + } + }, + ) + + assertEquals(1, requests.size) + val relaysTag = requests[0].request.tags.firstOrNull { it[0] == "relays" } + assertNotNull(relaysTag) + val relayUrls = relaysTag.drop(1).toSet() + // All three sources end up in the kind:9734 `relays` tag. + assertTrue(senderRelay.url in relayUrls, "sender inbox missing") + assertTrue(authorRelay.url in relayUrls, "author inbox missing") + assertTrue(recipientRelay.url in relayUrls, "recipient inbox missing") + } + + @Test + fun buildEventZapRequestsForSplits_emptyWhenNoRecipientHasLnAddress() = + runTest { + val splitPriv = "000000000000000000000000000000000000000000000000000000000000000d" + val splitPub = + com.vitorpamplona.quartz.utils.Secp256k1Instance + .compressedPubKeyFor(splitPriv.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + val note = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = com.vitorpamplona.quartz.nip10Notes.TextNoteEvent.KIND, + tags = arrayOf(arrayOf("zap", splitPub, "", "1.0")), + content = "no recipient ln", + ) + + val requests = + ZapActions.buildEventZapRequestsForSplits( + signer = signer, + zappedEvent = note, + totalAmountMillisats = 10_000L, + senderInboxRelays = setOf(relay), + lookupLnAddress = { null }, + ) + + assertTrue(requests.isEmpty()) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt new file mode 100644 index 0000000000..7fdca3f94d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ZapSplitResolverTest.kt @@ -0,0 +1,236 @@ +/* + * 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.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ZapSplitResolverTest { + private val authorPriv = "0000000000000000000000000000000000000000000000000000000000000007" + private val splitAPriv = "000000000000000000000000000000000000000000000000000000000000000d" + private val splitBPriv = "0000000000000000000000000000000000000000000000000000000000000011" + + private val authorSigner = NostrSignerInternal(KeyPair(authorPriv.hexToByteArray())) + private val authorPub = xOnly(authorPriv) + private val splitAPub = xOnly(splitAPriv) + private val splitBPub = xOnly(splitBPriv) + + private fun xOnly(privHex: String) = + Secp256k1Instance + .compressedPubKeyFor(privHex.hexToByteArray()) + .copyOfRange(1, 33) + .toHexKey() + + /** Build a kind:1 note with the given extra tags, signed by the author. */ + private suspend fun noteWithTags(vararg tags: Array): Event = + authorSigner.sign( + createdAt = 1_700_000_000L, + kind = TextNoteEvent.KIND, + tags = arrayOf(*tags), + content = "hello world", + ) + + // ------------------------------------------------------------------ + // shareMillisats + // ------------------------------------------------------------------ + + @Test + fun shareMillisats_distributesProportionallyAndRoundsToSats() { + val total = 10_000L // 10 sats — millisats + val a = ZapSplitResolver.shareMillisats(total, weight = 1.0, totalWeight = 4.0) + val b = ZapSplitResolver.shareMillisats(total, weight = 3.0, totalWeight = 4.0) + + // Always a multiple of 1000 (whole sats). + assertEquals(0L, a % 1000) + assertEquals(0L, b % 1000) + // 1/4 + 3/4 = full sat total (within rounding). + assertTrue(a + b in (total - 1000)..(total + 1000)) + } + + @Test + fun shareMillisats_zeroTotalWeightReturnsZero() { + assertEquals(0L, ZapSplitResolver.shareMillisats(1_000L, 1.0, 0.0)) + } + + @Test + fun shareMillisats_roundsHalfUpToWholeSat() { + // 1234 msats with weight 1/1 → 1234 msats, rounds to 1000 msats (1 sat). + val r = ZapSplitResolver.shareMillisats(1_234L, 1.0, 1.0) + assertEquals(1_000L, r) + } + + // ------------------------------------------------------------------ + // resolve — author fallback + // ------------------------------------------------------------------ + + @Test + fun resolve_authorFallbackWhenNoSplitsAndNoSpecialEventKind() = + runTest { + val note = noteWithTags() + val lookup: suspend (HexKey) -> String? = { pk -> + if (pk == authorPub) "author@wallet.example" else null + } + + val recipients = ZapSplitResolver.resolve(note, lookup) + + assertEquals(1, recipients.size) + assertEquals("author@wallet.example", recipients[0].lnAddress) + assertEquals(authorPub, recipients[0].pubkey) + assertEquals(1.0, recipients[0].weight) + } + + @Test + fun resolve_authorWithoutLnAddressReturnsEmpty() = + runTest { + val note = noteWithTags() + val recipients = ZapSplitResolver.resolve(note) { null } + assertTrue(recipients.isEmpty(), "author has no LN address → no recipients") + } + + // ------------------------------------------------------------------ + // resolve — LN-address split tags (legacy variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_lnAddressSplitTagsUsedDirectlyWithoutLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", "carol@damus.io"), + arrayOf("zap", "dave@wallet.io"), + ) + // Lookup should never be consulted for LnAddress-style splits. + var lookupCalls = 0 + val recipients = + ZapSplitResolver.resolve(note) { _ -> + lookupCalls++ + null + } + + assertEquals(0, lookupCalls) + assertEquals(2, recipients.size) + assertEquals(setOf("carol@damus.io", "dave@wallet.io"), recipients.map { it.lnAddress }.toSet()) + // LnAddress splits never carry a pubkey. + assertTrue(recipients.all { it.pubkey == null }) + // The legacy LnAddress format is always weight 1.0 per ZapSplitSetupParser. + assertTrue(recipients.all { it.weight == 1.0 }) + } + + // ------------------------------------------------------------------ + // resolve — pubkey split tags (current variant) + // ------------------------------------------------------------------ + + @Test + fun resolve_pubkeySplitTagsResolvedViaLookup() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val knownAddresses = + mapOf( + splitAPub to "split-a@wallet.example", + splitBPub to "split-b@wallet.example", + ) + + val recipients = ZapSplitResolver.resolve(note) { pk -> knownAddresses[pk] } + + assertEquals(2, recipients.size) + val byPub = recipients.associateBy { it.pubkey } + assertEquals("split-a@wallet.example", byPub[splitAPub]?.lnAddress) + assertEquals(2.0, byPub[splitAPub]?.weight) + assertEquals("split-b@wallet.example", byPub[splitBPub]?.lnAddress) + assertEquals(3.0, byPub[splitBPub]?.weight) + } + + @Test + fun resolve_pubkeySplitWithoutLnAddressIsDroppedSilently() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "1.0"), + arrayOf("zap", splitBPub, "", "1.0"), + ) + // Only split A has an LN address; B is silently dropped — same + // behavior as the in-app `mapNotNull` after error display. + val recipients = + ZapSplitResolver.resolve(note) { pk -> + if (pk == splitAPub) "split-a@wallet.example" else null + } + + assertEquals(1, recipients.size) + assertEquals(splitAPub, recipients[0].pubkey) + } + + @Test + fun resolve_pubkeySplitsDoNotFallBackToAuthor() = + runTest { + // When split tags are present but none resolve to an LN address, + // we get empty — we do NOT silently bill the author. + val note = noteWithTags(arrayOf("zap", splitAPub, "", "1.0")) + + val recipients = ZapSplitResolver.resolve(note) { null } + + assertTrue(recipients.isEmpty()) + } + + // ------------------------------------------------------------------ + // shareMillisats integration: weighted distribution sums correctly + // ------------------------------------------------------------------ + + @Test + fun resolveAndShare_weighted2to3SplitMatchesUiBehavior() = + runTest { + val note = + noteWithTags( + arrayOf("zap", splitAPub, "", "2.0"), + arrayOf("zap", splitBPub, "", "3.0"), + ) + val recipients = + ZapSplitResolver.resolve(note) { pk -> + when (pk) { + splitAPub -> "a@x" + splitBPub -> "b@x" + else -> null + } + } + val totalWeight = recipients.sumOf { it.weight } + val totalMsats = 100_000L // 100 sats + + val shares = recipients.map { ZapSplitResolver.shareMillisats(totalMsats, it.weight, totalWeight) } + + // 2/5 of 100 sats = 40 sats; 3/5 = 60 sats. + assertEquals(40_000L, shares[0]) + assertEquals(60_000L, shares[1]) + assertEquals(totalMsats, shares.sum()) + } +}