From 12c6b7b08d5a3150bc2a8e7a3bb69e32806c3ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:50:04 +0000 Subject: [PATCH] feat(zap): gate the pay-to chip on discovery alone, default it on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip required the sender and the author to publish the same protocol, capped the row at two, and shipped opt-out. All three go. Symmetry was a proxy for "I can actually pay this way", and it is the wrong proxy: paying a Monero address needs a wallet, not a published address of one. What the sender happens to say about themselves never determined whether the hand-off would work — the installed-app probe does, and it was already running. So `PayToRailMatcher.match` no longer takes the sender's list, `selectFor` drops the `senderTargets` gate, and `canOpen` becomes the substantive filter with the rest as preconditions. Dropping symmetry moves the probe set. It used to be the sender's own target list, which is why `warm()` could replace the cache wholesale; it is now the targets of whichever author's picker is open. So `warm()` merges instead of replacing — replacing would evict what was learned about every other author the moment a second picker opened — and the `LaunchedEffect` keys on the author's observed kind:10133 rather than on `paymentTargetsState`. MAX_CHIPS existed because symmetry could pass several protocols at once with nothing else narrowing them. Discovery narrows them: a target with no installed app never reaches the picker, so the cap was bounding a row that discovery already bounds, and an arbitrary two-chip truncation would now hide a target the user can genuinely pay. `showPayToZapChip` defaults on for the same reason. The opt-out was justified by fiat handles carrying legal names, but the chip only ever surfaces a target its author chose to publish, to a device that can already open it. The setting's copy said "when you and the author both publish the same payment method" and the toggle read "Offer shared payment methods" — both described the gate that no longer exists, so both are rewritten. Tests follow the contract rather than the old shape: symmetry cases become capability cases, `everyOpenableTargetIsOfferedWithNoCap` replaces the cap assertion, and one new case pins the inverse of the rule that was removed — a target the sender does not publish is still offered. The lazy-read test keeps its guarantee, minus the sender-empty branch that no longer exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../2026-09-01-payment-targets-zap-rail.md | 55 ++++++++------- .../amethyst/model/UiSettings.kt | 2 +- .../amethyst/model/UiSettingsFlow.kt | 2 +- .../model/preferences/UISharedPreferences.kt | 2 +- .../amethyst/model/zap/RailCapability.kt | 11 ++- .../service/payments/PayToAppAvailability.kt | 29 ++++---- .../amethyst/ui/note/ReactionsRow.kt | 18 +++-- amethyst/src/main/res/values/strings.xml | 4 +- .../model/payments/PayToRailMatcher.kt | 49 ++++--------- .../model/payments/PaymentTargetTypesTest.kt | 68 ++++++++----------- 10 files changed, 109 insertions(+), 131 deletions(-) diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md index a921a2dc1e..ab46a9115c 100644 --- a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -2,9 +2,16 @@ **Status:** proposal **Modules:** `quartz`, `commons`, `amethyst` -**Scope:** when the sender and recipient both publish a NIP-A3 payment target -of the same protocol, **an installed app can handle it**, and the note carries -no NIP-57 zap split — show one amount-less chip that hands off to that app. +**Scope:** when a note's author publishes a NIP-A3 payment target, **an +installed app can handle it**, and the note carries no NIP-57 zap split — show +one amount-less chip per such target that hands off to that app. + +> **Revised after the first implementation.** This document originally gated the +> chip on *symmetry* — both parties publishing the same protocol — and capped the +> row at two chips. Both are gone: the gate is capability alone (can anything on +> this phone open the URI), there is no cap, and the setting now defaults **on**. +> Sections below that argue for symmetry are kept for the reasoning, but §5 is +> the current rule. Deliberately excluded from v1: amounts, in-app payment, receipts, fiat conversion, desktop. @@ -92,8 +99,8 @@ everything resolves, which is a strict superset of the gated behaviour. `cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always resolves — discovery would be a tautology. **Skip discovery for https targets and always show them**: opening `venmo.com/` in a browser is a -legitimate way to pay, so nothing is broken. The sender-symmetry gate (§5) is -the real filter there. +legitimate way to pay, so nothing is broken. For these three types the chip is +therefore gated only on the author having published one. **But §4.2 still needs the control probe here.** To tell a real app handler from a browser, resolve a control `https:///` and treat the @@ -102,22 +109,22 @@ control set. It never gates the chip — it decides whether the chip wears the app's icon or the brand-colour glyph, and a Chrome icon on a Venmo chip is worse than no icon at all. -### 3.3 The cache — keyed by scheme+host, warmed from the sender +### 3.3 The cache — keyed by scheme+host, warmed from the open picker -The naive cache is per-post and lazy. The better one falls out of the -symmetry gate: +The naive cache is per-post and lazy. With symmetry gone the probe set is the +author's target list, so: -> **Only protocols the sender themself declares can ever be shown.** So the -> probe set is the *sender's own* target list — typically 1–5 entries — not -> anything derived from posts. +> **Probe the targets of the one author whose picker is open** — typically 1–5 +> entries — and **merge** the answers into the cache. Merging matters: replacing +> would evict what was learned about every other author the moment a second +> picker opened. Feed rendering still never triggers a probe. - **Key:** `"://"`, e.g. `payto://iban`, `bitcoin://`. Scheme alone is too coarse — an app may declare `android:scheme="payto" android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi` is handled. -- **Warm:** collect `account.paymentTargetsState.flow` (already an eagerly - started `StateFlow`, `Account.kt:902`); on each emission, probe the handful - of keys off the main thread. Feed rendering never triggers a probe. +- **Warm:** a `LaunchedEffect` keyed on the author's observed kind:10133 probes + that handful of keys off the main thread when the picker opens. - **Read:** synchronous map lookup — required, because `RailCapabilityResolver.peek` is called from inside `remember {}`. - **Recomposition:** the map must be a `MutableStateFlow>`, @@ -222,18 +229,19 @@ its resolver set contains a package outside that control set) to decide ## 5. Gates (all must hold) -1. Setting `showPayToZapRail` — **default off**, opt-in. Fiat handles carry - legal names; this puts them one tap from every feed note. Mirrors - `showOnchainWallet` (`UiSettings.kt:62` → `UiSettingsFlow.kt:58` → - `UISharedPreferences.kt:190`). +1. Setting `showPayToZapChip` — **default on**. The chip only ever shows a + target its author chose to publish, to a device that can already open it, + so the discovery gate is doing the real narrowing (`UiSettings.kt:67` → + `UiSettingsFlow.kt:59` → `UISharedPreferences.kt:192`). 2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't fan out and returns no receipt. `RailCapabilityResolver.peek` **already computes `splits`** — one-line reuse. 3. Recipient (note author) publishes ≥1 handoff-class target. -4. Sender publishes a target of the **same canonical type**. -5. §3 says an app can handle it (or it's https). -6. Cap at **2 chips**; with discovery filtering, 0–1 is the normal case, so v1 - needs no overflow picker. +4. §3 says an app can handle it (or it's https). **This is the substantive + gate**; everything else is a precondition. + +No cap: every openable target is offered. Discovery is what bounds the row — +a target with nothing to open it never reaches the picker. **Handoff-class** excludes the wallet-covered types — `lightning`/`ln`/`lnurl` and `bitcoin`/`btc`/`onchain` *are* the existing LIGHTNING and ONCHAIN rails. @@ -330,7 +338,8 @@ new strings; changelog. tinted, so the chip will be the one full-colour thing in the popup. Recommend **accepting** it as the "this leaves the app" signal — but it is a visible break from the rail iconography and worth an explicit yes. -6. **Symmetry heuristic** — right for closed loops (Venmo, Cash App, UPI), +6. ~~**Symmetry heuristic**~~ — *removed; see the note at the top.* It was + right for closed loops (Venmo, Cash App, UPI), arguably too strict for open ones (Monero: a sender needs a wallet, not a published address). Ship strict; relaxing later is additive. Note that intent discovery already covers much of what symmetry was proxying for, so diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index bd7a6d0057..c8998243e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -64,7 +64,7 @@ data class UiSettings( // and recipient share a payment protocol. Defaults to false: those targets can // be bank or Venmo handles carrying legal names, and this puts them one tap // from every note in the feed. - val showPayToZapChip: Boolean = false, + val showPayToZapChip: Boolean = true, ) enum class ThemeType( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index baa3d31268..d2528689c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -56,7 +56,7 @@ class UiSettingsFlow( val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), val composeSignature: MutableStateFlow = MutableStateFlow(""), val showOnchainWallet: MutableStateFlow = MutableStateFlow(true), - val showPayToZapChip: MutableStateFlow = MutableStateFlow(false), + val showPayToZapChip: MutableStateFlow = MutableStateFlow(true), ) { val listOfFlows: List> = listOf>( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index e2371b53ee..8d075bf688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -189,7 +189,7 @@ class UiSharedPreferences( fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, - showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: false, + showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, ) } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 6128cbbada..f53cc2144a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -63,9 +63,9 @@ data class RailCapability( */ val onchainMaxSpendableSats: Long? = null, /** - * NIP-A3 targets the sender can hand off to: a protocol both parties publish, - * that no wallet rail already covers, that an installed app can open, on a note - * with no zap split. Empty by default so every existing caller is unchanged. + * NIP-A3 targets the sender can hand off to: the author's published targets + * that no wallet rail already covers and that an installed app can open, on a + * note with no zap split. Empty by default so every existing caller is unchanged. */ val payToTargets: List = emptyList(), ) { @@ -142,7 +142,6 @@ object RailCapabilityResolver { fun peek( baseNote: Note, cashuState: CashuWalletState, - senderPayToTargets: List = emptyList(), payToEnabled: Boolean = false, ): RailCapability { val author = baseNote.author?.pubkeyHex @@ -184,7 +183,7 @@ object RailCapabilityResolver { hasOnchain = hasOnchain, cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L, cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L, - payToTargets = payToTargets(baseNote, splits, senderPayToTargets, payToEnabled), + payToTargets = payToTargets(baseNote, splits, payToEnabled), ) } @@ -196,14 +195,12 @@ object RailCapabilityResolver { private fun payToTargets( baseNote: Note, splits: List, - senderTargets: List, enabled: Boolean, ): List = PayToRailMatcher.selectFor( enabled = enabled, hasAuthor = baseNote.author != null, hasZapSplit = splits.isNotEmpty(), - senderTargets = senderTargets, // An unresolvable URI would open nothing, so the chip is not offered. // Web targets always resolve; there the probe only decides the icon. canOpen = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 6ae52fd735..4970e9aec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap /** What the device can do with one `payto` target type. */ @@ -58,11 +59,10 @@ data class PayToAppInfo( * scheme. The declarations are deliberately `` filters rather than * `QUERY_ALL_PACKAGES`, which is policy-restricted on Play. * - * **Why this is not a per-post lookup.** The chip only ever appears for - * protocols the *sender themself* publishes, so [warm] probes the sender's own - * target list — a handful of entries, refreshed when that list changes or the - * app returns to the foreground. Feed rendering never triggers a probe; it only - * reads [peek]. + * **Why this is not a per-post lookup.** [warm] probes the targets of the one + * author whose zap picker is open — a handful of entries — and merges the answers + * into a cache keyed by scheme+host, so a type already probed for someone else is + * simply refreshed. Feed rendering never triggers a probe; it only reads [peek]. * * The result is a [StateFlow] rather than a plain map because a bare map write * is invisible to Compose: the chip would stay missing until some unrelated @@ -89,7 +89,12 @@ object PayToAppAvailability { fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)] /** - * Probes every distinct type in [myTargets] and replaces the cache. + * Probes every distinct type in [targets] and merges the answers into the cache. + * + * Merging rather than replacing: the probe set is one author's target list, so + * replacing would evict what was learned about every other author the moment a + * second picker opened. Re-probing an already-known type is the point — that is + * how a newly installed app becomes visible — and the merge just overwrites it. * * Blocking: `loadIcon` reads the target APK's resources. Call from `Dispatchers.IO`. * [iconPx] is the size the chip draws at — decoding once here is what keeps @@ -97,29 +102,27 @@ object PayToAppAvailability { */ fun warm( context: Context, - myTargets: List, + targets: List, iconPx: Int, ) { val pm = context.packageManager val keys = - myTargets + targets .asSequence() .map { it.type } .filterNot { PaymentTargetTypes.isWalletCovered(it) } .distinctBy { PaymentTargetTypes.probeKeyFor(it) } .toList() - if (keys.isEmpty()) { - state.value = emptyMap() - return - } + if (keys.isEmpty()) return // Only https targets need the control probe; skip the extra query otherwise. val browsers = if (keys.any(PaymentTargetTypes::isWebTarget)) browserPackages(pm) else emptySet() - state.value = + val probed = keys.associate { type -> PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx) } + state.update { it + probed } } private fun probe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index b942707d59..8304775453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2142,19 +2142,18 @@ fun observeZapRailCapability( // round-trip and only says *when* to re-run the resolver. val showPayToChip by accountViewModel.settings.uiSettingsFlow.showPayToZapChip .collectAsStateWithLifecycle() - val myPayToTargets by accountViewModel.account.paymentTargetsState.flow - .collectAsStateWithLifecycle() val recipientPayTo = author?.let { observeNoteEvent(it.paymentTargetsNote, accountViewModel).value } val payToApps by PayToAppAvailability.flow.collectAsStateWithLifecycle() - // The probe set is the *sender's* target list, so this is bounded by how many - // ways the user says they can be paid — not by anything that grows with the - // feed. It runs when the picker opens, never while scrolling. + // The probe set is this one author's target list — a handful of entries, and + // only for the author whose picker is open. It runs when the picker opens, + // never while scrolling. val context = LocalContext.current val iconPx = with(LocalDensity.current) { PayToIconSize.roundToPx() } - LaunchedEffect(myPayToTargets, showPayToChip) { - if (showPayToChip && myPayToTargets.isNotEmpty()) { - withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, myPayToTargets, iconPx) } + LaunchedEffect(recipientPayTo, showPayToChip) { + val targets = recipientPayTo?.paymentTargets().orEmpty() + if (showPayToChip && targets.isNotEmpty()) { + withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, targets, iconPx) } } } @@ -2178,11 +2177,10 @@ fun observeZapRailCapability( recipientInfo, nutzapInfo, showPayToChip, - myPayToTargets, recipientPayTo, payToApps, ) { - val rc = RailCapabilityResolver.peek(baseNote, cashuState, myPayToTargets, showPayToChip) + val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip) if (onchainEnabled) { rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats) } else { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 32049f645e..31c37ef9e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -879,8 +879,8 @@ No app installed to handle %1$s payments. Please install a compatible wallet. Pay in %1$s — you choose the amount there Pay-to hand-off - When you and the author both publish the same payment method (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change. - Offer shared payment methods + When the author publishes a payment method an app on this phone can open (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change. + Offer the author\'s payment methods Unable to open payment BOLT12 Offers diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt index fc51fb8d7c..fa8bccab2f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -23,42 +23,25 @@ package com.vitorpamplona.amethyst.commons.model.payments import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget /** - * Picks the NIP-A3 payment targets a sender can plausibly use to pay a - * recipient: the recipient's targets whose protocol the sender also publishes. + * Picks the NIP-A3 payment targets a sender can hand off to when paying a note's + * author. * - * The symmetry rule is a proxy for "I can actually pay this way" and is exactly - * right for closed loops — both parties need Venmo accounts for a Venmo - * transfer to mean anything. It is arguably too strict for open protocols - * (paying a Monero address needs a wallet, not a published address of one), but - * it starts conservative: relaxing it later only ever adds chips. - * - * It also bounds the installed-app probe. Because only protocols the *sender* - * declares can ever be shown, the probe set is the sender's own target list — - * a handful of entries — rather than anything that grows with the feed. + * The rule is capability, not symmetry: a target is offered when something on + * this device can actually open its URI. Paying a Monero address needs a wallet, + * not a published address of one — so what the sender happens to publish about + * themselves says nothing about whether they can pay, and is not consulted. */ object PayToRailMatcher { /** - * Recipient targets payable by symmetry, de-duplicated by canonical type and - * in the recipient's published order. + * The recipient's payable targets, de-duplicated by canonical type and in the + * recipient's published order. * * Wallet-covered types (lightning, bitcoin) are dropped: those are the * picker's existing Lightning and on-chain rails, and re-offering them as a * hand-off would draw a second bolt icon beside the first. */ - fun match( - senderTargets: List, - recipientTargets: List, - ): List { - if (senderTargets.isEmpty() || recipientTargets.isEmpty()) return emptyList() - - val senderTypes = - senderTargets - .asSequence() - .map { PaymentTargetTypes.canonical(it.type) } - .filterNot { it.isEmpty() || PaymentTargetTypes.isWalletCovered(it) } - .toSet() - - if (senderTypes.isEmpty()) return emptyList() + fun match(recipientTargets: List): List { + if (recipientTargets.isEmpty()) return emptyList() val seen = mutableSetOf() return recipientTargets.filter { target -> @@ -66,14 +49,10 @@ object PayToRailMatcher { type.isNotEmpty() && target.authority.isNotBlank() && !PaymentTargetTypes.isWalletCovered(type) && - type in senderTypes && seen.add(type) } } - /** With discovery filtering, 0-1 is the normal case; the cap stops a wide popup. */ - const val MAX_CHIPS = 2 - /** * Every gate on the hand-off chip, as one pure decision. * @@ -83,7 +62,8 @@ object PayToRailMatcher { * @param hasAuthor a note with no author pubkey has nobody to pay. * @param hasZapSplit a `payto` hand-off leaves with one authority and returns * no receipt, so it cannot honour a note that asks to divide the zap. - * @param canOpen whether an installed app resolves this target's URI. + * @param canOpen whether an installed app resolves this target's URI. This is + * the substantive gate: everything else here is a precondition. * @param recipientTargets read lazily. Parsing the recipient's kind:10133 walks * its tag array and allocates, and the common case is that a gate has already * failed — the setting is off, or the note carries a split — so the cheap @@ -93,11 +73,10 @@ object PayToRailMatcher { enabled: Boolean, hasAuthor: Boolean, hasZapSplit: Boolean, - senderTargets: List, canOpen: (PaymentTarget) -> Boolean, recipientTargets: () -> List, ): List { - if (!enabled || !hasAuthor || hasZapSplit || senderTargets.isEmpty()) return emptyList() - return match(senderTargets, recipientTargets()).filter(canOpen).take(MAX_CHIPS) + if (!enabled || !hasAuthor || hasZapSplit) return emptyList() + return match(recipientTargets()).filter(canOpen) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt index 3596f1cca5..3c63157b04 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -100,58 +100,54 @@ class PayToRailMatcherTest { authority: String = "handle", ) = PaymentTarget(type, authority) - @Test - fun noSenderTargetsMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(emptyList(), listOf(t("venmo")))) - } - @Test fun noRecipientTargetsMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), emptyList())) + assertEquals(emptyList(), PayToRailMatcher.match(emptyList())) } @Test - fun noSharedProtocolMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("paypal")))) - } - - @Test - fun sharedProtocolMatchesAcrossCaseAndWhitespace() { - val out = PayToRailMatcher.match(listOf(t(" VENMO ")), listOf(t("venmo", "vitor"))) - assertEquals(listOf(t("venmo", "vitor")), out) + fun aTargetIsOfferedRegardlessOfWhatTheSenderPublishes() { + // Capability, not symmetry: paying a Monero address needs a wallet, not a + // published address of one, so the sender's own list is never consulted. + val out = PayToRailMatcher.match(listOf(t("monero", "theirs"))) + assertEquals(listOf(t("monero", "theirs")), out) } @Test fun walletCoveredTypesNeverProduceAChip() { - // Both sides publish lightning and bitcoin, but those ARE the existing - // rails — matching them would draw a second bolt beside the first. - val both = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z")) - assertEquals(emptyList(), PayToRailMatcher.match(both, both)) + // Those ARE the existing rails — matching them would draw a second bolt + // beside the first. + val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z")) + assertEquals(emptyList(), PayToRailMatcher.match(wallets)) } @Test - fun aliasesOnEitherSideStillMatch() { - val out = PayToRailMatcher.match(listOf(t("xmr", "mine")), listOf(t("monero", "theirs"))) - assertEquals(listOf(t("monero", "theirs")), out) + fun aliasesCollapseToOneChip() { + val out = PayToRailMatcher.match(listOf(t("xmr", "first"), t("monero", "second"))) + assertEquals(listOf(t("xmr", "first")), out) } @Test fun oneChipPerProtocolKeepingTheFirst() { val recipient = listOf(t("venmo", "first"), t("venmo", "second"), t("monero", "xmr1")) - val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) - assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), out) + assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), PayToRailMatcher.match(recipient)) } @Test fun blankAuthoritiesAreSkipped() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("venmo", " ")))) + assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo", " ")))) } @Test fun recipientOrderIsPreserved() { val recipient = listOf(t("monero", "m"), t("venmo", "v")) - val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) - assertEquals(listOf("monero", "venmo"), out.map { it.type }) + assertEquals(listOf("monero", "venmo"), PayToRailMatcher.match(recipient).map { it.type }) + } + + @Test + fun typesAreNormalisedBeforeDeduping() { + val out = PayToRailMatcher.match(listOf(t(" VENMO ", "first"), t("venmo", "second"))) + assertEquals(listOf(t(" VENMO ", "first")), out) } } @@ -162,7 +158,6 @@ class PayToRailGateTest { authority: String = "handle", ) = PaymentTarget(type, authority) - private val mine = listOf(t("venmo", "me"), t("monero", "myxmr")) private val theirs = listOf(t("venmo", "them"), t("monero", "theirxmr")) private val anyAppOpens: (PaymentTarget) -> Boolean = { true } @@ -170,10 +165,9 @@ class PayToRailGateTest { enabled: Boolean = true, hasAuthor: Boolean = true, hasZapSplit: Boolean = false, - sender: List = mine, recipient: List = theirs, canOpen: (PaymentTarget) -> Boolean = anyAppOpens, - ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, canOpen) { recipient } + ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, canOpen) { recipient } @Test fun offeredWhenEveryGatePasses() { @@ -209,9 +203,9 @@ class PayToRailGateTest { } @Test - fun cappedSoThePopupCannotGrowWithoutBound() { + fun everyOpenableTargetIsOfferedWithNoCap() { val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban")) - assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, recipient = many).size) + assertEquals(many.size, select(recipient = many).size) } @Test @@ -224,19 +218,17 @@ class PayToRailGateTest { theirs } - PayToRailMatcher.selectFor(false, true, false, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, false, false, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, true, true, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, true, false, emptyList(), anyAppOpens, counted) + PayToRailMatcher.selectFor(false, true, false, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, false, false, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, true, anyAppOpens, counted) assertEquals(0, reads) - PayToRailMatcher.selectFor(true, true, false, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, false, anyAppOpens, counted) assertEquals(1, reads) } @Test fun lightningAndBitcoinStayWithTheirOwnRails() { - val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")) - assertEquals(emptyList(), select(sender = wallets, recipient = wallets)) + assertEquals(emptyList(), select(recipient = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")))) } }