feat(zap): gate the pay-to chip on discovery alone, default it on

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS
This commit is contained in:
Claude
2026-09-03 16:50:04 +00:00
parent 5c661e046c
commit 12c6b7b08d
10 changed files with 109 additions and 131 deletions
@@ -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/<handle>` 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://<nonexistent-host>/` 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:** `"<scheme>://<host>"`, 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<Map<String, Boolean>>`,
@@ -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
@@ -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(
@@ -56,7 +56,7 @@ class UiSettingsFlow(
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(false),
val showPayToZapChip: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -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
@@ -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<PaymentTarget> = emptyList(),
) {
@@ -142,7 +142,6 @@ object RailCapabilityResolver {
fun peek(
baseNote: Note,
cashuState: CashuWalletState,
senderPayToTargets: List<PaymentTarget> = 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<BaseZapSplitSetup>,
senderTargets: List<PaymentTarget>,
enabled: Boolean,
): List<PaymentTarget> =
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 = {
@@ -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 `<intent>` 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<PaymentTarget>,
targets: List<PaymentTarget>,
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(
@@ -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<PaymentTargetsEvent>(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 {
+2 -2
View File
@@ -879,8 +879,8 @@
<string name="no_payment_app_found_for_type">No app installed to handle %1$s payments. Please install a compatible wallet.</string>
<string name="payto_amount_set_in_app">Pay in %1$s — you choose the amount there</string>
<string name="zap_payto_section">Pay-to hand-off</string>
<string name="zap_payto_section_explainer">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.</string>
<string name="zap_payto_toggle">Offer shared payment methods</string>
<string name="zap_payto_section_explainer">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.</string>
<string name="zap_payto_toggle">Offer the author\'s payment methods</string>
<string name="error_dialog_payment_error">Unable to open payment</string>
<string name="bolt12_offers">BOLT12 Offers</string>
@@ -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<PaymentTarget>,
recipientTargets: List<PaymentTarget>,
): List<PaymentTarget> {
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<PaymentTarget>): List<PaymentTarget> {
if (recipientTargets.isEmpty()) return emptyList()
val seen = mutableSetOf<String>()
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<PaymentTarget>,
canOpen: (PaymentTarget) -> Boolean,
recipientTargets: () -> List<PaymentTarget>,
): List<PaymentTarget> {
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)
}
}
@@ -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<PaymentTarget> = mine,
recipient: List<PaymentTarget> = 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"))))
}
}