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
@@ -114,7 +114,7 @@ object ZapCommand {
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]")
if (rest.size < 2) return Output.error("bad_args", "zap event <event-id> <sats> [--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<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 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<NormalizedRelayUrl> = { 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<ZapActions.ZapRequestForSplit>,
) {
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<String, Any?>(
"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
@@ -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()
}
}
@@ -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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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<com.vitorpamplona.quartz.nip10Notes.TextNoteEvent>(
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())
}
}
@@ -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<String>): Event =
authorSigner.sign<TextNoteEvent>(
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())
}
}