mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +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:
@@ -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
|
||||
|
||||
+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()
|
||||
}
|
||||
}
|
||||
+170
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+236
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user