mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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?>>(
|
||||
|
||||
+1
-1
@@ -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 = {
|
||||
|
||||
+16
-13
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-38
@@ -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"))))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user