feat(zap): offer a NIP-A3 pay-to hand-off in the zap picker

When the sender and the note's author both publish a payment target of the
same protocol, the zap picker now offers a chip that hands off to the app that
owns it. Gated on a new opt-in setting (default off), on the note carrying no
NIP-57 zap split, and on an installed app actually resolving the URI.

The chip carries no amount. Zap presets are sats and there is no rate anywhere
in the repo to turn them into a Venmo or IBAN figure, so no number is shown and
no RFC-8905 amount= is emitted; the receiving app asks. It ends in OpenInNew
rather than the send arrow every amount segment uses, and long-press copies the
authority instead of opening the sat-preset editor, which would mean nothing
here. Nothing is published, so the zap counter does not move and none of the
zap progress state is touched.

It renders beside the amount pills rather than inside each pill's rail toggle.
Carrying no amount, it would otherwise repeat identically once per preset, and
keeping it out of the toggle leaves ZapRail a plain enum instead of forcing it
into a data-carrying sealed interface.

Discovery needs the new <queries> entries: targetSdk is 37, so Android 11+
package visibility returns nothing from queryIntentActivities for an undeclared
scheme, and the chip would be invisible on every modern device. Unknown types
all fall back to payto://<type>/<authority>, so one payto entry covers the
open-ended tail of the vocabulary. Specific <intent> filters, never
QUERY_ALL_PACKAGES.

The mark is the resolved app's own icon, from the same ResolveInfo the probe
already holds, decoded once at the chip's size during the warm step and masked
round the way a launcher draws it. It falls back to the brand-coloured glyph
paymentTargetStyleFor already assigns when the hand-off would open a chooser or
merely a browser: https targets resolve to any browser, so a control probe
against an unownable host separates a real app handler from Chrome.

The availability cache is keyed on scheme plus host, not scheme, because an app
may declare host="iban" and a scheme-only hit would wrongly claim payto://upi
is handled. It is warmed from the sender's own target list when the picker
opens, so it is bounded by how many ways the user says they can be paid rather
than growing with the feed, and it is a StateFlow because a plain map write is
invisible to Compose.

Shared plumbing moves to commons: PaymentTargetTypes now owns the alias and
scheme tables that were duplicated inside the profile UI file, and
PayToRailMatcher holds the matching and the gate decision as pure functions,
free of Note, Context and the availability singleton so the gates are testable
on their own. RailCapability gains a defaulted payToTargets, and peek gains
defaulted parameters so zapClick's one-tap fast path stays Lightning-only.
PaymentTarget becomes a data class: without value equality it compares by
identity, which breaks list keys and dedupe.

25 new tests in commons; amethyst, commons and quartz suites all pass.

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-02 00:12:17 +00:00
parent 4e067a13d7
commit 6cb485cda8
16 changed files with 1015 additions and 33 deletions
+73
View File
@@ -16,6 +16,79 @@
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
modern device. Specific <intent> filters rather than
QUERY_ALL_PACKAGES, which is policy-restricted on Play.
Unknown target types all fall back to payto://<type>/<authority>,
so the single payto entry covers the open-ended tail. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="payto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitcoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="lightning" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="liquidnetwork" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="ethereum" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="monero" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="zcash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitcoincash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="litecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dogecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="solana" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="tron" />
</intent>
</queries>
@@ -60,6 +60,11 @@ data class UiSettings(
// on-chain rail in the Send Payment screen. Defaults to true (shown) so the
// behavior is unchanged for everyone who doesn't turn it off.
val showOnchainWallet: Boolean = true,
// Whether the zap picker offers a NIP-A3 pay-to hand-off chip when the sender
// 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,
)
enum class ThemeType(
@@ -56,6 +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 listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -88,6 +89,7 @@ class UiSettingsFlow(
fontSize,
composeSignature,
showOnchainWallet,
showPayToZapChip,
)
// emits at every change in any of the propertyes.
@@ -124,6 +126,7 @@ class UiSettingsFlow(
flows[26] as FontSizeType,
flows[27] as String,
flows[28] as Boolean,
flows[29] as Boolean,
)
}
@@ -158,6 +161,7 @@ class UiSettingsFlow(
fontSize.value,
composeSignature.value,
showOnchainWallet.value,
showPayToZapChip.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -279,6 +283,10 @@ class UiSettingsFlow(
showOnchainWallet.tryEmit(torSettings.showOnchainWallet)
any = true
}
if (showPayToZapChip.value != torSettings.showPayToZapChip) {
showPayToZapChip.tryEmit(torSettings.showPayToZapChip)
any = true
}
return any
}
@@ -333,6 +341,7 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.fontSize),
MutableStateFlow(uiSettings.composeSignature),
MutableStateFlow(uiSettings.showOnchainWallet),
MutableStateFlow(uiSettings.showPayToZapChip),
)
}
}
@@ -147,6 +147,7 @@ class UiSharedPreferences(
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
val UI_SHOW_PAYTO_ZAP_CHIP = booleanPreferencesKey("ui.show_payto_zap_chip")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -188,6 +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,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -241,6 +243,7 @@ class UiSharedPreferences(
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
preferences[UI_SHOW_PAYTO_ZAP_CHIP] = sharedSettings.showPayToZapChip
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -21,10 +21,15 @@
package com.vitorpamplona.amethyst.model.zap
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.payments.PayToRailMatcher
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip57Zaps.splits.BaseZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup
@@ -50,6 +55,12 @@ data class RailCapability(
val cashuBestSingleMintSats: Long = 0L,
/** Total cashu balance across all our mints (reachable via reload/rebalance). */
val cashuTotalWalletSats: Long = 0L,
/**
* 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.
*/
val payToTargets: List<PaymentTarget> = emptyList(),
) {
/**
* Classify a cashu nutzap of [amountSats] for the unified amount chip.
@@ -105,6 +116,8 @@ object RailCapabilityResolver {
fun peek(
baseNote: Note,
cashuState: CashuWalletState,
senderPayToTargets: List<PaymentTarget> = emptyList(),
payToEnabled: Boolean = false,
): RailCapability {
val author = baseNote.author?.pubkeyHex
val splits = baseNote.event?.zapSplitSetup().orEmpty()
@@ -145,6 +158,32 @@ object RailCapabilityResolver {
hasOnchain = hasOnchain,
cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L,
cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L,
payToTargets = payToTargets(baseNote, splits, senderPayToTargets, payToEnabled),
)
}
/**
* Targets for the pay-to hand-off chip. Reads the note-derived inputs and hands
* the actual decision to [PayToRailMatcher.selectFor], which is pure and
* separately tested. [splits] is already computed by [peek]; don't recompute it.
*/
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,
recipientTargets = baseNote.author?.paymentTargets().orEmpty(),
// An unresolvable URI would open nothing, so the chip is not offered.
// Web targets always resolve; there the probe only decides the icon.
canOpen = {
PayToAppAvailability.peek(it.type)?.resolves == true ||
PaymentTargetTypes.isWebTarget(it.type)
},
)
}
@@ -0,0 +1,199 @@
/*
* 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.service.payments
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** What the device can do with one `payto` target type. */
@Immutable
data class PayToAppInfo(
/** An installed activity accepts the hand-off URI. */
val resolves: Boolean,
/** The chosen app's own name, null when no single default app applies. */
val label: String? = null,
/** The chosen app's launcher icon, already masked and sized. */
val icon: ImageBitmap? = null,
)
/**
* Answers "can anything on this phone open this payment target, and what does it
* look like?" for the zap picker's hand-off chip.
*
* **Package visibility.** `targetSdk` is 37, so on Android 11+ every query here
* returns nothing unless `AndroidManifest.xml`'s `<queries>` block declares the
* 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].
*
* 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
* recomposition happened to run.
*/
object PayToAppAvailability {
/** A host no registrar can delegate (RFC 2606), so only catch-all browsers match it. */
private const val CONTROL_URL = "https://probe.invalid/"
private val state = MutableStateFlow<Map<String, PayToAppInfo>>(emptyMap())
val flow: StateFlow<Map<String, PayToAppInfo>> = state.asStateFlow()
/** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */
fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)]
/**
* Probes every distinct type in [myTargets] and replaces the cache.
*
* 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
* the icon out of the composition path.
*/
fun warm(
context: Context,
myTargets: List<PaymentTarget>,
iconPx: Int,
) {
val pm = context.packageManager
val keys =
myTargets
.asSequence()
.map { it.type }
.filterNot { PaymentTargetTypes.isWalletCovered(it) }
.distinctBy { PaymentTargetTypes.probeKeyFor(it) }
.toList()
if (keys.isEmpty()) {
state.value = emptyMap()
return
}
val browsers = browserPackages(pm)
state.value =
keys.associate { type ->
PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx)
}
}
/** Drops everything, so the next [warm] re-reads a changed set of installed apps. */
fun clear() {
state.value = emptyMap()
}
private fun probe(
pm: PackageManager,
rawType: String,
browsers: Set<String>,
iconPx: Int,
): PayToAppInfo {
val intent = viewIntent(PaymentTargetTypes.probeKeyFor(rawType))
val handlers = queryActivities(pm, intent)
val isWeb = PaymentTargetTypes.isWebTarget(rawType)
// A browser resolves any https:// URI, so for web targets "something
// resolves" is trivially true and tells us nothing. Gate them open, but
// only claim an app — and therefore an icon — when a handler exists that
// is not merely a browser. Chrome's icon on a Venmo chip is worse than none.
val appHandlers = if (isWeb) handlers.filterNot { it.packageName() in browsers } else handlers
val resolves = isWeb || handlers.isNotEmpty()
if (appHandlers.isEmpty()) return PayToAppInfo(resolves = resolves)
val chosen = defaultActivity(pm, intent, appHandlers) ?: return PayToAppInfo(resolves = resolves)
return PayToAppInfo(
resolves = resolves,
label = runCatching { chosen.loadLabel(pm).toString() }.getOrNull(),
icon = loadIcon(pm, chosen, iconPx),
)
}
/**
* The single app the hand-off would open, or null when the system would show
* a chooser instead. With several handlers and no user default, Android hands
* back its own `ResolverActivity` — there is no app to name there, so the chip
* falls back to the brand-coloured glyph.
*/
private fun defaultActivity(
pm: PackageManager,
intent: Intent,
handlers: List<ResolveInfo>,
): ResolveInfo? {
if (handlers.size == 1) return handlers.first()
val preferred =
runCatching { pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) }.getOrNull()
?: return null
val pkg = preferred.packageName()
if (pkg == "android" || preferred.activityInfo?.name?.contains("ResolverActivity") == true) return null
return handlers.firstOrNull { it.packageName() == pkg }
}
/**
* minSdk is 26, so any icon may be an `AdaptiveIconDrawable`: a 108x108 canvas
* whose outer margin the launcher masks away. Rasterising it at the chip's own
* size — rather than at its intrinsic size — is what stops the logo from
* arriving as a speck floating in that bleed. The circular mask is applied by
* the composable, matching how a launcher presents the same icon.
*/
private fun loadIcon(
pm: PackageManager,
info: ResolveInfo,
px: Int,
): ImageBitmap? =
runCatching {
info.loadIcon(pm).toBitmap(px, px).asImageBitmap()
}.onFailure {
Log.w("PayToAppAvailability") { "Could not load icon for ${info.packageName()}: ${it.message}" }
}.getOrNull()
@SuppressLint("QueryPermissionsNeeded")
private fun queryActivities(
pm: PackageManager,
intent: Intent,
): List<ResolveInfo> = runCatching { pm.queryIntentActivities(intent, 0) }.getOrDefault(emptyList())
/** Packages that answer a URL nobody can own — i.e. general-purpose browsers. */
@SuppressLint("QueryPermissionsNeeded")
private fun browserPackages(pm: PackageManager): Set<String> = queryActivities(pm, viewIntent(CONTROL_URL)).mapNotNull { it.packageName() }.toSet()
private fun viewIntent(uri: String) =
Intent(Intent.ACTION_VIEW, uri.toUri()).apply {
addCategory(Intent.CATEGORY_BROWSABLE)
}
private fun ResolveInfo.packageName(): String? = activityInfo?.packageName
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.widget.Toast
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
@@ -42,6 +43,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -59,6 +61,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -93,8 +96,10 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
@@ -116,6 +121,7 @@ import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.commons.ui.components.AnimatedBorderTextCornerRadius
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.model.MIN_ONCHAIN_ZAP_SATS
@@ -127,6 +133,7 @@ import com.vitorpamplona.amethyst.model.zap.CashuRailStatus
import com.vitorpamplona.amethyst.model.zap.RailCapability
import com.vitorpamplona.amethyst.model.zap.RailCapabilityResolver
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.payments.PayToAppAvailability
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteReactionCount
@@ -146,6 +153,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer
import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo
@@ -153,6 +161,7 @@ import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet
import com.vitorpamplona.amethyst.ui.note.types.EditState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.paymentTargetStyleFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.navigateToReloadMint
import com.vitorpamplona.amethyst.ui.stringRes
@@ -190,6 +199,7 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.reactionBox
import com.vitorpamplona.amethyst.ui.theme.ripple24dp
import com.vitorpamplona.amethyst.ui.theme.selectedReactionBoxModifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip10Notes.BaseThreadedEvent
@@ -209,6 +219,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlin.math.roundToInt
import kotlin.uuid.ExperimentalUuidApi
@@ -2118,8 +2129,42 @@ fun observeZapRailCapability(
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
.collectAsStateWithLifecycle()
return remember(baseNote, onchainSupported, showOnchainWallet, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
// Pay-to hand-off inputs. Same "read only to drive the recompute" contract as
// the four above: the recipient's kind:10133 already rides in
// UserMetadataForKeyKinds beside kind:0, so observing it here costs no extra
// 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.
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) }
}
}
return remember(
baseNote,
onchainSupported,
showOnchainWallet,
cashuMints,
cashuEntries,
recipientInfo,
nutzapInfo,
showPayToChip,
myPayToTargets,
recipientPayTo,
payToApps,
) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState, myPayToTargets, showPayToChip)
if (onchainSupported && showOnchainWallet) rc else rc.copy(hasOnchain = false)
}
}
@@ -2252,6 +2297,9 @@ fun ZapAmountChoicePopup(
visibilityState.targetState = false
},
onChangeAmount = onChangeAmount,
// The hand-off sends the user to another app; leaving the popup
// stacked behind it would be waiting for a tap that never comes.
onHandedOff = { visibilityState.targetState = false },
)
}
}
@@ -2267,6 +2315,7 @@ fun ZapAmountChoicePopupContent(
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
onHandedOff: () -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
@@ -2282,6 +2331,7 @@ fun ZapAmountChoicePopupContent(
onOnchainAmount = onOnchainAmount,
onChangeAmount = onChangeAmount,
onReloadNutzap = onReloadNutzap,
onHandedOff = onHandedOff,
)
}
}
@@ -2302,6 +2352,7 @@ fun ZapAmountChoiceGrid(
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
onHandedOff: () -> Unit = {},
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
@@ -2320,6 +2371,14 @@ fun ZapAmountChoiceGrid(
onChangeAmount = onChangeAmount,
)
}
// Rendered once, beside the amount pills rather than inside each one's rail
// toggle: it carries no amount, so repeating it per preset would say the
// same thing four times — and keeping it out of the toggle leaves ZapRail a
// plain enum instead of a data-carrying sealed interface.
railCapability.payToTargets.forEach { target ->
PayToHandoffChip(target = target, onHandedOff = onHandedOff)
}
ClickableBox(
modifier =
Modifier
@@ -2338,6 +2397,116 @@ fun ZapAmountChoiceGrid(
}
}
/** The size the hand-off mark draws at, and the size its bitmap is decoded to. */
internal val PayToIconSize = 18.dp
/**
* The NIP-A3 hand-off chip: pay this person through a protocol you both publish,
* in the app that owns it.
*
* Three things deliberately set it apart from the amount pills beside it, because
* it is not a zap and must not read as one:
* - **No amount.** Presets are sats and there is no rate to turn them into a
* Venmo or IBAN figure, so no number is shown and no RFC-8905 `amount=` is
* emitted — the receiving app asks.
* - **[MaterialSymbols.OpenInNew], not the send arrow** every amount segment
* ends in. This leaves Amethyst.
* - **No zap receipt.** Nothing is published, so the note's zap counter will not
* move. Nothing here touches the zap progress state.
*
* The mark is the installed app's own icon when one app owns the URI — the same
* `ResolveInfo` the availability probe already keeps — masked round the way a
* launcher draws it. It falls back to the brand-coloured glyph
* [paymentTargetStyleFor] already assigns when the hand-off would open a chooser
* or merely a browser.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PayToHandoffChip(
target: PaymentTarget,
onHandedOff: () -> Unit,
) {
val style = remember(target.type) { paymentTargetStyleFor(target.type) }
val app = remember(target.type) { PayToAppAvailability.peek(target.type) }
val uri = remember(target) { PaymentTargetTypes.uriFor(target.type, target.authority) }
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val copiedMessage = stringRes(R.string.copied_to_clipboard)
val noAppMessage = stringRes(R.string.no_payment_app_found_for_type, style.label)
val amountElsewhere = stringRes(R.string.payto_amount_set_in_app, app?.label ?: style.label)
Surface(
shape = ButtonBorder,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier.padding(horizontal = 4.dp, vertical = 6.dp),
) {
Row(
modifier = Modifier.padding(3.dp),
verticalAlignment = CenterVertically,
) {
Row(
modifier =
Modifier
.clip(RoundedCornerShape(percent = 50))
.background(MaterialTheme.colorScheme.primaryContainer)
.combinedClickable(
onClickLabel = amountElsewhere,
onClick = {
// The probe can go stale between warming and this tap
// (the app was uninstalled), so keep the catch.
runCatching { uriHandler.openUri(uri) }
.onSuccess { onHandedOff() }
.onFailure { Toast.makeText(context, noAppMessage, Toast.LENGTH_SHORT).show() }
},
// NOT onChangeAmount: a sat-preset editor means nothing
// here. Copies the authority, like the profile chip does.
onLongClick = {
scope.launch {
clipboard.setText(target.authority)
Toast.makeText(context, copiedMessage, Toast.LENGTH_SHORT).show()
}
},
).padding(horizontal = 8.dp, vertical = 5.dp),
verticalAlignment = CenterVertically,
) {
val icon = app?.icon
if (icon != null) {
Image(
bitmap = icon,
contentDescription = null,
modifier = Modifier.size(PayToIconSize).clip(CircleShape),
)
} else {
Icon(
symbol = style.symbol,
contentDescription = null,
tint = style.color,
modifier = Modifier.size(PayToIconSize),
)
}
Spacer(Modifier.width(5.dp))
Text(
text = style.label,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.width(3.dp))
Icon(
symbol = MaterialSymbols.AutoMirrored.OpenInNew,
contentDescription = null,
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
}
/**
* One pill per amount, showing a tappable logo for every rail that can pay it:
* - **Cashu** as a solid logo when a single shared mint already covers the
@@ -682,6 +682,8 @@ private fun QuickZapAmountRow(
nav.nav(Route.UpdateZapAmount())
onDismiss()
},
// Hands off to another app; the sheet must not stay stacked behind it.
onHandedOff = onDismiss,
)
}
}
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.util.setText
@@ -63,13 +64,7 @@ import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
import kotlinx.coroutines.launch
/** Lightning-family target types Amethyst can pay in-app through the Send Payment screen. */
private val LIGHTNING_TARGET_TYPES = setOf("lightning", "ln", "lnurl")
/** Bitcoin-family target types the in-app on-chain wallet can pay directly. */
private val BITCOIN_TARGET_TYPES = setOf("bitcoin", "btc", "onchain")
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in LIGHTNING_TARGET_TYPES
fun isLightningPaymentTarget(rawType: String): Boolean = rawType.trim().lowercase() in PaymentTargetTypes.LIGHTNING_TYPES
/**
* Route into the in-app Send Payment screen when one of the user's wallets can
@@ -85,10 +80,10 @@ fun inAppPaymentRouteFor(
): Route.SendPayment? {
val type = target.type.trim().lowercase()
return when {
type in LIGHTNING_TARGET_TYPES ->
type in PaymentTargetTypes.LIGHTNING_TYPES ->
Route.SendPayment(userHex, ProfilePaymentMethod.LIGHTNING.routeKey, lnAddressOverride = target.authority)
type in BITCOIN_TARGET_TYPES &&
type in PaymentTargetTypes.BITCOIN_TYPES &&
LocalCache.onchainBackend != null &&
SegwitAddress.isPayableMainnetAddress(target.authority.trim()) ->
Route.SendPayment(userHex, ProfilePaymentMethod.ONCHAIN.routeKey, btcAddressOverride = target.authority.trim())
@@ -132,7 +127,7 @@ fun PaymentTargetChip(
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
runCatching { uriHandler.openUri(PaymentTargetTypes.uriFor(target.type, target.authority)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
@@ -180,52 +175,51 @@ fun PaymentTargetChip(
}
}
private data class PaymentTargetStyle(
data class PaymentTargetStyle(
val symbol: MaterialSymbol,
val color: Color,
val label: String,
val uriFor: (String) -> String,
)
private fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
fun paymentTargetStyleFor(rawType: String): PaymentTargetStyle {
val type = rawType.trim().lowercase()
val walletIcon = MaterialSymbols.AccountBalanceWallet
return when (type) {
"bitcoin", "btc", "onchain" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN") { "bitcoin:$it" }
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "BITCOIN")
"lightning", "ln" ->
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING") { "lightning:$it" }
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LIGHTNING")
"lnurl" ->
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LNURL") { "lightning:$it" }
PaymentTargetStyle(MaterialSymbols.Bolt, BitcoinOrange, "LNURL")
"liquid" ->
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "LIQUID") { "liquidnetwork:$it" }
PaymentTargetStyle(MaterialSymbols.CurrencyBitcoin, BitcoinOrange, "LIQUID")
"ethereum", "eth" ->
PaymentTargetStyle(walletIcon, ETHEREUM_PURPLE, "ETHEREUM") { "ethereum:$it" }
PaymentTargetStyle(walletIcon, ETHEREUM_PURPLE, "ETHEREUM")
"monero", "xmr" ->
PaymentTargetStyle(walletIcon, MONERO_ORANGE, "MONERO") { "monero:$it" }
PaymentTargetStyle(walletIcon, MONERO_ORANGE, "MONERO")
"dash" ->
PaymentTargetStyle(walletIcon, DASH_BLUE, "DASH") { "dash:$it" }
PaymentTargetStyle(walletIcon, DASH_BLUE, "DASH")
"zcash", "zec" ->
PaymentTargetStyle(walletIcon, ZCASH_YELLOW, "ZCASH") { "zcash:$it" }
PaymentTargetStyle(walletIcon, ZCASH_YELLOW, "ZCASH")
"bitcoincash", "bch" ->
PaymentTargetStyle(walletIcon, BITCOINCASH_GREEN, "BITCOINCASH") { "bitcoincash:$it" }
PaymentTargetStyle(walletIcon, BITCOINCASH_GREEN, "BITCOINCASH")
"litecoin", "ltc" ->
PaymentTargetStyle(walletIcon, LITECOIN_STEEL_BLUE, "LITECOIN") { "litecoin:$it" }
PaymentTargetStyle(walletIcon, LITECOIN_STEEL_BLUE, "LITECOIN")
"dogecoin", "doge" ->
PaymentTargetStyle(walletIcon, DOGECOIN_SAND, "DOGECOIN") { "dogecoin:$it" }
PaymentTargetStyle(walletIcon, DOGECOIN_SAND, "DOGECOIN")
"solana", "sol" ->
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA") { "solana:$it" }
PaymentTargetStyle(walletIcon, SOLANA_PURPLE, "SOLANA")
"tron", "trx" ->
PaymentTargetStyle(walletIcon, TRON_RED, "TRON") { "tron:$it" }
PaymentTargetStyle(walletIcon, TRON_RED, "TRON")
"cashapp" ->
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP") { "https://cash.app/$it" }
PaymentTargetStyle(walletIcon, CASHAPP_LIME, "CASHAPP")
"venmo" ->
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO") { "https://venmo.com/$it" }
PaymentTargetStyle(walletIcon, VENMO_BLUE, "VENMO")
"paypal" ->
PaymentTargetStyle(walletIcon, PAYPAL_DEEP_BLUE, "PAYPAL") { "https://paypal.me/$it" }
PaymentTargetStyle(walletIcon, PAYPAL_DEEP_BLUE, "PAYPAL")
else -> {
val label = rawType.trim().ifEmpty { "PAY" }.uppercase()
PaymentTargetStyle(walletIcon, GENERIC_TARGET_COLOR, label) { "payto://$type/$it" }
PaymentTargetStyle(walletIcon, GENERIC_TARGET_COLOR, label)
}
}
}
@@ -80,6 +80,7 @@ fun ProfileUiSettingsContent(
val showZapReceived by ui.showProfileZapReceivedFeed.collectAsStateWithLifecycle()
val showFollowers by ui.showProfileFollowersFeed.collectAsStateWithLifecycle()
val showOnchainWallet by ui.showOnchainWallet.collectAsStateWithLifecycle()
val showPayToZapChip by ui.showPayToZapChip.collectAsStateWithLifecycle()
val gallery by ui.gallerySet.collectAsStateWithLifecycle()
Column(
@@ -125,6 +126,14 @@ fun ProfileUiSettingsContent(
checked = showOnchainWallet,
onCheckedChange = { ui.showOnchainWallet.tryEmit(it) },
)
SettingsDivider()
SettingsSwitchTile(
icon = MaterialSymbols.AutoMirrored.OpenInNew,
title = R.string.profile_ui_setting_payto_zap_chip,
description = R.string.profile_ui_setting_payto_zap_chip_description,
checked = showPayToZapChip,
onCheckedChange = { ui.showPayToZapChip.tryEmit(it) },
)
}
SettingsSection(R.string.settings_section_appearance) {
+3
View File
@@ -1866,6 +1866,9 @@
<string name="delete_payment_target">Delete payment target</string>
<string name="no_payment_app_found">No app found to handle this payment. Please install a compatible wallet.</string>
<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="profile_ui_setting_payto_zap_chip">Pay-to shortcut in the zap picker</string>
<string name="profile_ui_setting_payto_zap_chip_description">When you and the author both publish the same payment method, offer it in the zap picker. Opens the other app, which asks for the amount — no zap receipt is created.</string>
<string name="error_dialog_payment_error">Unable to open payment</string>
<string name="bolt12_offers">BOLT12 Offers</string>
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.UserCards
import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
@@ -80,6 +82,8 @@ class User(
val bolt12OfferListNote: Note = context.addressableNote(Bolt12OfferListEvent.createAddress(pubkeyHex))
val paymentTargetsNote: Note = context.addressableNote(PaymentTargetsEvent.createAddress(pubkeyHex))
// These objects are designed to keep the cache
// while this user obj is being used anywhere.
//
@@ -116,6 +120,12 @@ class User(
fun nutzapInfo() = nutzapInfoNote.event as? NutzapInfoEvent
/** This user's published NIP-A3 payment targets (kind:10133), or null if none seen. */
fun paymentTargetsEvent() = paymentTargetsNote.event as? PaymentTargetsEvent
/** The `payto` targets this user publishes, empty when none. */
fun paymentTargets(): List<PaymentTarget> = paymentTargetsEvent()?.paymentTargets().orEmpty()
/** This user's published BOLT12 offer list (NIP-B1 kind 10058), or null if none seen. */
fun bolt12OfferList() = bolt12OfferListNote.event as? Bolt12OfferListEvent
@@ -0,0 +1,99 @@
/*
* 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.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.
*
* 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.
*/
object PayToRailMatcher {
/**
* Recipient targets payable by symmetry, 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()
val seen = mutableSetOf<String>()
return recipientTargets.filter { target ->
val type = PaymentTargetTypes.canonical(target.type)
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.
*
* Kept free of `Note`, `Context` and the availability singleton so the gates
* are testable on their own — the caller supplies what it read from those.
*
* @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.
*/
fun selectFor(
enabled: Boolean,
hasAuthor: Boolean,
hasZapSplit: Boolean,
senderTargets: List<PaymentTarget>,
recipientTargets: List<PaymentTarget>,
canOpen: (PaymentTarget) -> Boolean,
): List<PaymentTarget> {
if (!enabled || !hasAuthor || hasZapSplit) return emptyList()
return match(senderTargets, recipientTargets).filter(canOpen).take(MAX_CHIPS)
}
}
@@ -0,0 +1,146 @@
/*
* 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.model.payments
/**
* The NIP-A3 (`payto`) target-type vocabulary: how a free-text type string is
* normalized, which types the app's own wallets already cover, and the URI a
* type hands off to.
*
* Type strings come from user input ([PaymentTargetsViewModel.addTarget] only
* trims and lowercases), so the space is unbounded — `iban`, `upi`, `pix`,
* `swish` and whatever comes next all land here as themselves. The tables below
* only collapse the aliases we know about; everything else passes through and
* falls back to `payto://<type>/<authority>`.
*
* Single source of truth for three callers that would otherwise each keep their
* own copy: the profile chips (which pay a target directly), the zap picker's
* hand-off chip (which must exclude the types the wallet rails already own),
* and the installed-app probe (which needs the URI before it has an authority).
*/
object PaymentTargetTypes {
/** Lightning-family types Amethyst can pay in-app through the Send Payment screen. */
val LIGHTNING_TYPES = setOf("lightning", "ln", "lnurl")
/** Bitcoin-family types the in-app on-chain wallet can pay directly. */
val BITCOIN_TYPES = setOf("bitcoin", "btc", "onchain")
/**
* Alias -> family. Only collapses spellings of the *same* rail, so matching a
* sender's `btc` against a recipient's `bitcoin` succeeds while `monero` and
* `bitcoin` stay apart.
*/
private val ALIASES =
mapOf(
"btc" to "bitcoin",
"onchain" to "bitcoin",
"ln" to "lightning",
"lnurl" to "lightning",
"eth" to "ethereum",
"xmr" to "monero",
"zec" to "zcash",
"bch" to "bitcoincash",
"ltc" to "litecoin",
"doge" to "dogecoin",
"sol" to "solana",
"trx" to "tron",
)
/** Types whose hand-off is a web page rather than a registered URI scheme. */
private val WEB_TYPES = setOf("cashapp", "venmo", "paypal")
/** Types with a dedicated URI scheme, keyed by canonical name. */
private val SCHEMES =
mapOf(
"bitcoin" to "bitcoin",
"lightning" to "lightning",
"liquid" to "liquidnetwork",
"ethereum" to "ethereum",
"monero" to "monero",
"dash" to "dash",
"zcash" to "zcash",
"bitcoincash" to "bitcoincash",
"litecoin" to "litecoin",
"dogecoin" to "dogecoin",
"solana" to "solana",
"tron" to "tron",
)
private val WEB_HOSTS =
mapOf(
"cashapp" to "https://cash.app/",
"venmo" to "https://venmo.com/",
"paypal" to "https://paypal.me/",
)
/** Trims, lowercases and collapses known aliases onto one family name. */
fun canonical(rawType: String): String {
val trimmed = rawType.trim().lowercase()
return ALIASES[trimmed] ?: trimmed
}
/**
* True when a wallet rail already on the zap picker owns this type. Lightning
* and bitcoin targets ARE the Lightning and on-chain rails, so offering them
* again as a hand-off would just draw a second bolt beside the first.
*/
fun isWalletCovered(rawType: String): Boolean {
val type = canonical(rawType)
return type in LIGHTNING_TYPES || type in BITCOIN_TYPES
}
/**
* True when the hand-off is an `https://` page. Any browser resolves those, so
* they are never gated on an installed app — but for the same reason
* `resolveActivity` would hand back the browser, so they cannot take an app
* icon without the control probe.
*/
fun isWebTarget(rawType: String): Boolean = canonical(rawType) in WEB_TYPES
/**
* The URI this target hands off to. Unknown types fall back to RFC 8905
* `payto://<type>/<authority>`, which is why a single `payto` entry in the
* manifest's `<queries>` covers the whole open-ended tail of the vocabulary.
*
* No `amount=` is ever emitted: zap presets are sats and there is no rate to
* convert them with, so the amount is named in the receiving app.
*/
fun uriFor(
rawType: String,
authority: String,
): String {
val type = canonical(rawType)
val value = authority.trim()
SCHEMES[type]?.let { return "$it:$value" }
WEB_HOSTS[type]?.let { return "$it$value" }
return "payto://$type/$value"
}
/**
* Cache key for "can any installed app open this type?" — the URI with the
* authority stripped, i.e. scheme plus host.
*
* Scheme alone would be too coarse: an app may declare
* `android:scheme="payto" android:host="iban"`, and a scheme-only hit would
* then wrongly claim `payto://upi/...` is handled too.
*/
fun probeKeyFor(rawType: String): String = uriFor(rawType, "")
}
@@ -0,0 +1,222 @@
/*
* 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.model.payments
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PaymentTargetTypesTest {
@Test
fun canonicalTrimsAndLowercases() {
assertEquals("venmo", PaymentTargetTypes.canonical(" VenMo "))
assertEquals("iban", PaymentTargetTypes.canonical("IBAN"))
}
@Test
fun canonicalCollapsesAliasesWithinARailOnly() {
assertEquals("bitcoin", PaymentTargetTypes.canonical("btc"))
assertEquals("bitcoin", PaymentTargetTypes.canonical("onchain"))
assertEquals("lightning", PaymentTargetTypes.canonical("ln"))
assertEquals("lightning", PaymentTargetTypes.canonical("lnurl"))
assertEquals("monero", PaymentTargetTypes.canonical("xmr"))
// Different rails must never collapse together.
assertTrue(PaymentTargetTypes.canonical("monero") != PaymentTargetTypes.canonical("bitcoin"))
}
@Test
fun unknownTypesPassThroughUntouched() {
assertEquals("pix", PaymentTargetTypes.canonical("pix"))
assertEquals("upi", PaymentTargetTypes.canonical("upi"))
}
@Test
fun walletCoveredIsExactlyTheLightningAndBitcoinFamilies() {
listOf("lightning", "ln", "LNURL", "bitcoin", "btc", " onchain ").forEach {
assertTrue(PaymentTargetTypes.isWalletCovered(it), "$it should be wallet covered")
}
listOf("venmo", "monero", "iban", "liquid", "ethereum").forEach {
assertFalse(PaymentTargetTypes.isWalletCovered(it), "$it should not be wallet covered")
}
}
@Test
fun uriUsesTheDedicatedSchemeWhenThereIsOne() {
assertEquals("bitcoin:bc1qxyz", PaymentTargetTypes.uriFor("btc", "bc1qxyz"))
assertEquals("lightning:me@ln.tips", PaymentTargetTypes.uriFor("lnurl", "me@ln.tips"))
assertEquals("liquidnetwork:lq1abc", PaymentTargetTypes.uriFor("liquid", "lq1abc"))
assertEquals("monero:4Aaddr", PaymentTargetTypes.uriFor("XMR", "4Aaddr"))
}
@Test
fun webTypesBecomeHttpsPages() {
assertEquals("https://venmo.com/vitor", PaymentTargetTypes.uriFor("venmo", "vitor"))
assertEquals("https://cash.app/\$vitor", PaymentTargetTypes.uriFor("cashapp", "\$vitor"))
assertTrue(PaymentTargetTypes.isWebTarget("PayPal"))
assertFalse(PaymentTargetTypes.isWebTarget("iban"))
}
@Test
fun unknownTypesFallBackToPayto() {
assertEquals("payto://iban/DE75512108001245126199", PaymentTargetTypes.uriFor("IBAN", "DE75512108001245126199"))
assertEquals("payto://upi/vitor@bank", PaymentTargetTypes.uriFor("upi", " vitor@bank "))
}
@Test
fun probeKeyKeepsHostSoPaytoTypesDoNotShareOneAnswer() {
// An app may declare scheme="payto" host="iban"; a scheme-only key would
// then wrongly report that payto://upi is handled too.
assertEquals("payto://iban/", PaymentTargetTypes.probeKeyFor("iban"))
assertEquals("payto://upi/", PaymentTargetTypes.probeKeyFor("upi"))
assertTrue(PaymentTargetTypes.probeKeyFor("iban") != PaymentTargetTypes.probeKeyFor("upi"))
// Aliases of one rail share a key, as they share a scheme.
assertEquals(PaymentTargetTypes.probeKeyFor("btc"), PaymentTargetTypes.probeKeyFor("bitcoin"))
}
}
class PayToRailMatcherTest {
private fun t(
type: String,
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()))
}
@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)
}
@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))
}
@Test
fun aliasesOnEitherSideStillMatch() {
val out = PayToRailMatcher.match(listOf(t("xmr", "mine")), listOf(t("monero", "theirs")))
assertEquals(listOf(t("monero", "theirs")), 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)
}
@Test
fun blankAuthoritiesAreSkipped() {
assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), 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 })
}
}
/** The gates on the hand-off chip, exercised without a Note or a PackageManager. */
class PayToRailGateTest {
private fun t(
type: String,
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 }
private fun select(
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, recipient, canOpen)
@Test
fun offeredWhenEveryGatePasses() {
assertEquals(listOf("venmo", "monero"), select().map { it.type })
}
@Test
fun settingOffHidesIt() {
assertEquals(emptyList(), select(enabled = false))
}
@Test
fun aZapSplitHidesIt() {
// payto leaves with one authority and returns no receipt, so it cannot
// honour a note that asks for the zap to be divided.
assertEquals(emptyList(), select(hasZapSplit = true))
}
@Test
fun noAuthorHidesIt() {
assertEquals(emptyList(), select(hasAuthor = false))
}
@Test
fun aTargetNoInstalledAppCanOpenIsDropped() {
val out = select(canOpen = { it.type == "venmo" })
assertEquals(listOf("venmo"), out.map { it.type })
}
@Test
fun noInstalledAppAtAllHidesIt() {
assertEquals(emptyList(), select(canOpen = { false }))
}
@Test
fun cappedSoThePopupCannotGrowWithoutBound() {
val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban"))
assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, recipient = many).size)
}
@Test
fun lightningAndBitcoinStayWithTheirOwnRails() {
val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"))
assertEquals(emptyList(), select(sender = wallets, recipient = wallets))
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.nipA3
class PaymentTarget(
data class PaymentTarget(
val type: String,
val authority: String,
)