mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 08:47:33 +00:00
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:
+75
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+182
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user