fix(commons): split-aware zap requests stop misrouting funds on multi-party notes

The previous ZapActions.buildEventZapRequest signed a single zap request
to a single recipient. Notes carrying NIP-57 zap-split tags, NIP-53
live-activity host tags, or NIP-89 app-definition metadata expect the
payment to be distributed across multiple parties — so `amy zap event`
silently overpaid one party and underpaid the rest. The correctness
review on the action-set flagged this as the only real bug in the
extracted verbs; this commit fixes it.

  * ZapSplitResolver — new commonMain object mirroring the resolution
    order in ZapPaymentHandler.kt (splits > live-activity hosts > app
    metadata > author fallback). Pure logic; pubkey→LN-address lookup
    is passed in as a suspend lambda so amy reads from its file store
    and Android reads from LocalCache, no shared cache-coupling.

  * ZapActions.buildEventZapRequestsForSplits — high-level helper that
    composes the resolver with per-share LnZapRequestEvent signing.
    Each request's `relays` tag unions sender + author + recipient
    inbox relays so the kind:9735 receipt routes to every interested
    party (matches signAllZapRequests in the Android handler).

  * amy zap event — rewired to the split-aware path. JSON output now
    enumerates each recipient with its share, LN address, request id,
    and BOLT11 invoice (or per-recipient invoice_error). Profile zaps
    (amy zap user) keep the simple single-recipient path since they
    have no split tags.

Tests: 12 new cases — LN-address splits, weighted pubkey splits, author
fallback, drop-silently-on-missing-LN, relay unioning, share rounding.
All 41 action tests green; both Android flavors compile.
This commit is contained in:
Claude
2026-05-24 21:10:30 +00:00
parent 17cee60aac
commit 54b09ea6e2
5 changed files with 760 additions and 12 deletions
@@ -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<NormalizedRelayUrl>,
lookupLnAddress: suspend (HexKey) -> String?,
lookupInboxRelays: suspend (HexKey) -> Set<NormalizedRelayUrl> = { emptySet() },
comment: String = "",
zapType: LnZapEvent.ZapType = LnZapEvent.ZapType.PUBLIC,
pollOption: Int? = null,
): List<ZapRequestForSplit> {
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)
}
}
}
@@ -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<Recipient> {
val splits = zappedEvent.zapSplitSetup()
val raw: List<Recipient?> =
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()
}
}