Merge pull request #4041 from vitorpamplona/claude/payment-targets-zap-integration-q52kn6

NIP-A3 payment targets in zap picker — v1
This commit is contained in:
Vitor Pamplona
2026-09-03 13:55:58 -04:00
committed by GitHub
18 changed files with 1433 additions and 36 deletions
@@ -0,0 +1,346 @@
# NIP-A3 Payment Targets in the zap picker — v1
**Status:** proposal
**Modules:** `quartz`, `commons`, `amethyst`
**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.
---
## 1. Why v1 has no amounts
Zap presets are **sats**. A `venmo` / `iban` / `upi` chip cannot send 1000
sats, and there is **no FX or bitcoin-price service anywhere in this repo**
(grepped `quartz`, `commons`, `amethyst`). So v1 does not pretend: the chip
carries no number, emits no RFC-8905 `amount=`, and the amount is named in the
external app. The UI has to *say* that rather than leave a suspicious blank —
see §4.
Corollary: **the note's zap counter will not move.** No kind:9735, nothing to
count. In code it is a rail; to the user it must read as *pay*, not *zap*.
---
## 2. The layout decision — and the refactor it deletes
> This is the one place v1 diverges from the sketch, and the reason is that it
> makes the change roughly half the size.
The sketch was "add the icons to the toggle." The toggle is the segmented
control **inside each amount pill** (`UnifiedZapAmountChip`,
`ReactionsRow.kt:2362`). Putting an amount-less rail there has two costs:
1. **It repeats.** With presets of 1000/5000/10000, the identical amount-less
Venmo segment renders three times and means the same thing each time.
2. **It forces `ZapRail` to become a sealed interface.** The enum
(`ReactionsRow.kt:2481`) is payload-free, so a segment can't know *which*
target it opens. Making it data-carrying drags in `present`, `preferred`,
`selectedRail`, `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor`
and the settings preview row — and, because `PaymentTarget` has no
`equals`, breaks the `remember(preferred, present)` key so the user's
selection resets on recompose.
**Instead: render the chip as a sibling of the amount pills**, appended to the
existing `FlowRow` in `ZapAmountChoiceGrid` (`ReactionsRow.kt:2297`), next to
the `Tune` preset-editor button. It wraps for free, it renders **once**, and
`ZapRail`, `UnifiedZapAmountChip` and every preview stay **completely
untouched**. Same popup, same place the user is already looking.
If an FX service ever lands and the amount becomes expressible, the chip moves
into the toggle then — that is the natural migration, not a reason to pay for
it now.
---
## 3. Intent discovery — the constraint that decides it
`targetSdk = 37`. Under Android 11+ package visibility,
`queryIntentActivities` returns **empty** for any intent not covered by a
`<queries>` declaration — so *without a manifest change this feature silently
shows nothing on every modern device*. The existing `<queries>` block
(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and
Tor.
### 3.1 Manifest
Add one `<intent>` per scheme we probe. The important economy: an arbitrary
user-typed type (`iban`, `upi`, `pix`, …) always falls back to
`payto://<type>/<authority>`, so **one `payto` entry covers every generic
type**. Only the ~12 special-cased crypto schemes in `paymentTargetStyleFor`
(`DisplayPaymentTargets.kt:190`) need their own entries.
```xml
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<!-- + one each: bitcoin, lightning, liquidnetwork, ethereum, monero, dash,
zcash, bitcoincash, litecoin, dogecoin, solana, tron -->
```
Use **`<queries>`, never `QUERY_ALL_PACKAGES`** — the latter is a
policy-restricted permission on Play and would need a declaration; specific
`<intent>` filters need nothing. On minSdk 26–29 `<queries>` is ignored and
everything resolves, which is a strict superset of the gated behaviour.
### 3.2 https targets are exempt
`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. 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
target as app-backed only if its resolver set contains a package outside that
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 open picker
The naive cache is per-post and lazy. With symmetry gone the probe set is the
author's target list, so:
> **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:** 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>>`,
not a bare `ConcurrentHashMap`. A plain map write is invisible to Compose and
the chip would not appear until something else recomposed.
- **Invalidation:** clear on app foreground (`ProcessLifecycleOwner`
`ON_START`) and re-warm — this is exactly the "user left, installed Venmo,
came back" flow. A `PACKAGE_ADDED`/`REMOVED` receiver is more precise but is
more moving parts than v1 needs.
Home: `amethyst/…/service/payments/PayToAppAvailability.kt` (Android-only;
`PackageManager` has no KMP equivalent). The scheme mapping it needs moves out
of the UI file into `commons` (§6.0).
---
## 4. The chip's face
### 4.1 Saying "the app decides the amount"
An amount-less chip beside pills that all show numbers reads as a bug unless
it is visibly a *different kind of thing*. Three cues, no extra layout:
1. **No number.** Icon + protocol label only (`VENMO`).
2. **A different terminal glyph.** `MaterialSymbols.OpenInNew` instead of the
`ArrowForward` every amount segment uses — "this leaves the app."
3. **A string that says it outright**, e.g. *"Amount set in %1$s"*, shown as
the chip's `contentDescription` and as a toast on long-press.
**Both glyphs are already in `MaterialSymbols.kt`** (`OpenInNew:280`,
`AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh`
run is needed**, and §4.2 adds no new glyphs either.
Long-press must **not** inherit `onChangeAmount` (the sat-preset editor is
meaningless here); it copies the authority, matching `PaymentTargetChip`'s
long-press on the profile.
### 4.2 Which icon it wears — the installed app's, not a bundled logo
**This already works in this codebase.** `ExternalSignerButton.kt:118` renders
installed NIP-55 signers with `it.loadLabel(pm)` / `it.loadIcon(pm)` →
`toBitmap()` → Coil's `rememberAsyncImagePainter`, off the back of
`getExternalSignersInstalled` (`quartz/…/IsExternalSignerInstalled.kt`), which
is `queryIntentActivities(ACTION_VIEW, "nostrsigner:")` — **the same call
§3 already makes for discovery.** The `ResolveInfo` we keep to answer "can
anything open this?" also carries the icon and the app's own name. The icon is
therefore very close to free; what it costs is care.
**Do not bundle brand logos.** Three reasons, in order of weight:
1. **Trademark, not licence.** `CLAUDE.md`'s dependency gate covers *code*
licences; a Venmo or PayPal mark shipped inside an MIT APK is a separate
trademark question. Referential use is usually permitted, redistribution of
the mark often is not. That is a maintainer's call, not a silent one.
2. **The type space is unbounded.** `PaymentTargetsViewModel.addTarget` accepts
any `type.trim().lowercase()`, so a bundled set can never be complete —
`pix`, `upi`, `swish`, `interac` and the next one all miss.
3. **The codebase already decided this.** `paymentTargetStyleFor` pairs brand
*colours* (`VENMO_BLUE #008CFF`, `PAYPAL_DEEP_BLUE #003087`,
`CASHAPP_LIME #00E64D`) with the generic `AccountBalanceWallet` glyph.
Brand colour + generic glyph is the established pattern; keep it as the
fallback. Brand marks are also absent from Material Symbols, so each would
be a hand-authored `ImageVector` like `CustomHashTagIcons.Cashu`.
So: **the installed app's icon *is* the brand icon**, sourced from the device
instead of shipped. It is self-limiting in the right direction — the "popular
options" are exactly the ones with an app installed.
**Four things the precedent gets away with and we would not:**
- **Load once, in the warm step.** `ExternalSignerButton` calls `loadIcon()` +
`toBitmap()` inside a `LazyColumn` item, so it re-runs on recomposition —
tolerable in a one-shot dialog, not in the zap popup. `loadIcon` reads the
target APK's resources, so it is I/O: do it in §3.3's off-main warm and
cache the **`ImageBitmap`**, never the `Drawable`.
- **Size and mask it.** minSdk is 26, so any icon may be an
`AdaptiveIconDrawable`: a 108×108 canvas whose outer margin the launcher
masks away. A bare `toBitmap()` drawn at 18dp shows a small logo floating in
padding. Use `toBitmap(px, px)` at the target size plus
`Modifier.clip(CircleShape)` — what a launcher does. The precedent renders
at 48dp and gets away with it.
- **Pick one app, or none.** `payto://` can resolve to several. Ask
`resolveActivity(intent, MATCH_DEFAULT_ONLY)` for the user's default; when
Android hands back its `ResolverActivity` (no default set) there is no app
to name — fall back to the glyph rather than showing the chooser's icon.
- **Accept that it cannot be tinted.** Every other rail is a monochrome glyph
tinted `BitcoinOrange` / `onSurface`. A full-colour raster can't join that
scheme — which is arguably the point: it is the visual signal that this
segment leaves the app. It needs the circular clip and a slightly smaller
optical size to sit beside 18dp glyphs.
**This promotes the https control-probe from a nicety to v1 work.** §3.2 exempts
`venmo` / `paypal` / `cashapp` from discovery because a browser always resolves
`https://`. That is fine for *gating*, but not for *icons*: with only a browser
installed, `resolveActivity` returns **Chrome**, and a Chrome icon on a Venmo
chip is worse than no icon. So an https target needs the control probe
(resolve `https://<nonexistent-host>/`, treat the target as app-backed only if
its resolver set contains a package outside that control set) to decide
**icon vs brand-colour glyph**, even though it never gates the chip.
---
## 5. Gates (all must hold)
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. §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.
Without this exclusion the picker grows a second Bolt icon beside the first.
---
## 6. Implementation
### 6.0 Prep — no behaviour change
- `quartz`: `PaymentTarget` → `data class` (it has no `equals` today; needed
for list keys and dedupe, and it fixes the hand-rolled field-by-field
compare in `PaymentTargetsViewModel.addTarget`).
- `commons/…/model/payments/PaymentTargetTypes.kt` (package exists, holds
`PaymentSourceResolver`): `canonical(raw)`, `isWalletCovered(canonical)`,
`schemeFor(canonical)`. Move `LIGHTNING_TARGET_TYPES` /
`BITCOIN_TARGET_TYPES` (`DisplayPaymentTargets.kt:67,70`) and the scheme half
of `paymentTargetStyleFor` here — today they are duplicated twice inside one
Android UI file, and discovery needs them too.
- `commons/…/model/User.kt`: `paymentTargetsNote` + `paymentTargets()`,
mirroring `nutzapInfoNote` (`User.kt:79`).
**No new relay subscription:** kind 10133 already rides in
`UserMetadataForKeyKinds` beside kind:0 and kind:10019
(`FilterUserMetadataForKey.kt:50`), so the recipient's targets are in cache by
the time the note renders — same as the nutzap rail.
### 6.1 Matcher — pure, headless
`commons/…/model/payments/PayToRailMatcher.kt`: canonicalize both sides, drop
wallet-covered types, intersect on type, dedupe by type. No Android, no
Compose.
### 6.2 Discovery
`amethyst/…/service/payments/PayToAppAvailability.kt` per §3.3 + the manifest
`<queries>` entries per §3.1. Each cache entry holds what §4.2 needs as well as
the yes/no: `{ resolves: Boolean, label: String?, icon: ImageBitmap? }` —
decoded once in the warm step at the 18dp target size, never per composition.
Icon and label are null for the no-default (`ResolverActivity`) and
browser-only cases, and the chip falls back to the brand-colour glyph.
### 6.3 Capability
- `RailCapability` += `payToTargets: List<PaymentTarget> = emptyList()` —
defaulted, so `RailCapabilityCashuStatusTest` and every existing call site
compile untouched.
- `peek(..., senderTargets = emptyList(), payToEnabled = false, available = emptyMap())`
— **defaulted, because `zapClick` also calls `peek`**
(`ReactionsRow.kt:1464`) for the one-tap fast path, which must stay
Lightning-only. Returns empty when splits exist.
- `observeZapRailCapability` (`ReactionsRow.kt:2098`) adds four inputs, each
both a subscription trigger and a `remember` key — the contract spelled out
in the "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`:
`paymentTargetsState.flow`, the author's `paymentTargetsNote`,
`uiSettingsFlow.showPayToZapRail`, and the availability `StateFlow`.
### 6.4 UI
One new `PayToHandoffChip` composable appended to `ZapAmountChoiceGrid`'s
`FlowRow`. Action: `uriHandler.openUri(...)`; keep the existing try/catch →
`no_payment_app_found_for_type` toast (string exists) as a belt-and-braces
fallback for the race where the app is uninstalled between warm and tap. It
must not touch `zappingProgress`, `zapStartingTime` or `accountViewModel.zap`.
### 6.5 Settings + strings
`showPayToZapRail` through the `showOnchainWallet` chain + `SettingsCatalogBuilder`;
new strings; changelog.
---
## 7. Tests
| Level | Test | Asserts |
|---|---|---|
| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set, scheme mapping |
| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` → empty (wallet-covered); `Venmo` vs `venmo` → match; dedupe by type |
| `amethyst/test` | sibling of `RailCapabilityCashuStatusTest` | split present → empty; setting off → empty; no author → empty; unavailable scheme → empty; https target → shown without probe; **existing rails unaffected** |
| `amethyst/test` | `PayToAppAvailabilityTest` | key is scheme+host, not scheme; probe count == sender's target count, independent of post count; `ResolverActivity` default → null icon; browser-only https → null icon (control probe) |
| Manual | | chip appears once (not per pill); tap opens the app; **counter does not move**; split note shows no chip; install app → background → foreground → chip appears; adaptive icon is masked round, not floating in padding; https target with no app shows the glyph, not Chrome |
---
## 8. Open decisions
1. **Chip placement** — sibling vs inside the toggle (§2). Recommend sibling:
renders once and deletes the whole `ZapRail` refactor. Flagged because it
diverges from the original sketch.
2. **Default for `showPayToZapRail`** — recommend **off**, matching how
`ReactionRowAction.Pay` already ships disabled.
3. **Private rumors** — on-chain is suppressed there (it would e-tag the
rumor). A payto handoff publishes nothing, so it is arguably safe.
Recommend **allow**, noting the divergence from the on-chain precedent.
4. **`ReactionRowAction.Pay` overlap** — recommend keeping both, `Pay`
disabled by default: `Pay` browses *all* of a recipient's targets, this
chip is the *matched, installed, splitless* shortcut.
5. **Colour icon beside monochrome glyphs** (§4.2). The app icon can't be
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**~~ — *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
dropping symmetry for scheme-based types is a live option.
+63
View File
@@ -16,6 +16,69 @@
<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.
No <category>: a category here narrows visibility the same way it
narrows an intent match, and would hide any app whose filter declares
only DEFAULT - which is what our ACTION_VIEW hand-off actually uses. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="payto" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="lightning" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="liquidnetwork" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="ethereum" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="monero" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="zcash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="bitcoincash" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="litecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="dogecoin" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="solana" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<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 = true,
)
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(true),
) {
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] ?: true,
)
} 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
@@ -22,10 +22,15 @@ package com.vitorpamplona.amethyst.model.zap
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.Note
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.MIN_ONCHAIN_ZAP_SATS
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
@@ -57,6 +62,12 @@ data class RailCapability(
* balance hasn't loaded yet or the explorer is unreachable — not *empty*.
*/
val onchainMaxSpendableSats: Long? = null,
/**
* 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(),
) {
/**
* Classify a cashu nutzap of [amountSats] for the unified amount chip.
@@ -131,6 +142,7 @@ object RailCapabilityResolver {
fun peek(
baseNote: Note,
cashuState: CashuWalletState,
payToEnabled: Boolean = false,
): RailCapability {
val author = baseNote.author?.pubkeyHex
val splits = baseNote.event?.zapSplitSetup().orEmpty()
@@ -171,6 +183,31 @@ object RailCapabilityResolver {
hasOnchain = hasOnchain,
cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L,
cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L,
payToTargets = payToTargets(baseNote, splits, 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>,
enabled: Boolean,
): List<PaymentTarget> =
PayToRailMatcher.selectFor(
enabled = enabled,
hasAuthor = baseNote.author != null,
hasZapSplit = splits.isNotEmpty(),
// 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)
},
// Lazy: the tag walk only happens once the cheap gates have passed.
recipientTargets = { baseNote.author?.paymentTargets().orEmpty() },
)
}
@@ -0,0 +1,225 @@
/*
* 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
import kotlinx.coroutines.flow.update
import java.util.concurrent.ConcurrentHashMap
/** 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.** [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
* 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()
/**
* Decoded icons, kept across warms and keyed by package and size.
*
* [warm] runs every time the picker opens, so that resolution stays fresh when
* the user installs an app and comes back. Re-reading the APK's resources and
* re-rasterising the icon each of those times is the expensive half and answers
* the same thing, so only the cheap half repeats.
*/
private val icons = ConcurrentHashMap<String, ImageBitmap>()
/** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */
fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)]
/**
* 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
* the icon out of the composition path.
*/
fun warm(
context: Context,
targets: List<PaymentTarget>,
iconPx: Int,
) {
val pm = context.packageManager
val keys =
targets
.asSequence()
.map { it.type }
.filterNot { PaymentTargetTypes.isWalletCovered(it) }
.distinctBy { PaymentTargetTypes.probeKeyFor(it) }
.toList()
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()
val probed =
keys.associate { type ->
PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx)
}
state.update { it + probed }
}
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? {
val pkg = info.packageName() ?: return null
icons["$pkg@$px"]?.let { return it }
return runCatching {
info.loadIcon(pm).toBitmap(px, px).asImageBitmap()
}.onSuccess {
icons["$pkg@$px"] = it
}.onFailure {
Log.w("PayToAppAvailability", "Could not load icon for $pkg", it)
}.getOrNull()
}
@SuppressLint("QueryPermissionsNeeded")
private fun queryActivities(
pm: PackageManager,
intent: Intent,
): List<ResolveInfo> =
runCatching {
// MATCH_DEFAULT_ONLY mirrors startActivity, which implies CATEGORY_DEFAULT.
// Without it we would list activities the hand-off could never launch.
pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}.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()
/**
* Deliberately carries **no** category. `IntentFilter.matchCategories` returns
* the first category on the *intent* that the filter lacks, so every category
* added here narrows the match — a probe carrying `BROWSABLE` would miss any
* app whose filter declares only `DEFAULT`, and hide a chip that would have
* opened fine. Paired with `MATCH_DEFAULT_ONLY` in [queryActivities], this
* resolves exactly the set `startActivity` would.
*/
private fun viewIntent(uri: String) = Intent(Intent.ACTION_VIEW, uri.toUri())
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
@@ -118,6 +123,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.fork
import com.vitorpamplona.amethyst.commons.resources.nutzap
@@ -134,6 +140,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
@@ -153,6 +160,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
@@ -160,6 +168,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
@@ -197,6 +206,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
@@ -216,6 +226,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
@@ -2125,6 +2136,27 @@ fun observeZapRailCapability(
val showOnchainWallet by accountViewModel.settings.uiSettingsFlow.showOnchainWallet
.collectAsStateWithLifecycle()
// 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 recipientPayTo = author?.let { observeNoteEvent<PaymentTargetsEvent>(it.paymentTargetsNote, accountViewModel).value }
val payToApps by PayToAppAvailability.flow.collectAsStateWithLifecycle()
// 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(recipientPayTo, showPayToChip) {
val targets = recipientPayTo?.paymentTargets().orEmpty()
if (showPayToChip && targets.isNotEmpty()) {
withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, targets, iconPx) }
}
}
// Our own on-chain balance, so an amount we can't fund isn't offered at all.
// Cached account-wide and refreshed at most once a minute, so opening the
// picker again is instant; while it is still unknown (first open, explorer
@@ -2136,8 +2168,19 @@ fun observeZapRailCapability(
}
val onchainFunds by onchainWallet.funds.collectAsStateWithLifecycle()
return remember(baseNote, onchainEnabled, onchainFunds, cashuMints, cashuEntries, recipientInfo, nutzapInfo) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState)
return remember(
baseNote,
onchainEnabled,
onchainFunds,
cashuMints,
cashuEntries,
recipientInfo,
nutzapInfo,
showPayToChip,
recipientPayTo,
payToApps,
) {
val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip)
if (onchainEnabled) {
rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats)
} else {
@@ -2274,6 +2317,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 },
)
}
}
@@ -2289,6 +2335,7 @@ fun ZapAmountChoicePopupContent(
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
onHandedOff: () -> Unit = {},
) {
Box(HalfPadding, contentAlignment = Center) {
ElevatedCard(
@@ -2304,6 +2351,7 @@ fun ZapAmountChoicePopupContent(
onOnchainAmount = onOnchainAmount,
onChangeAmount = onChangeAmount,
onReloadNutzap = onReloadNutzap,
onHandedOff = onHandedOff,
)
}
}
@@ -2324,6 +2372,7 @@ fun ZapAmountChoiceGrid(
onOnchainAmount: (Long?) -> Unit,
onChangeAmount: () -> Unit,
onReloadNutzap: (Long) -> Unit = {},
onHandedOff: () -> Unit = {},
) {
FlowRow(
modifier = Modifier.padding(horizontal = 5.dp, vertical = 5.dp),
@@ -2342,6 +2391,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
@@ -2360,6 +2417,122 @@ 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 uri = remember(target) { PaymentTargetTypes.uriFor(target.type, target.authority) }
// Collected, not peeked once: a web target is offered before the probe has run
// (any browser opens https), so a snapshot taken at first composition would pin
// the fallback glyph and the real app icon would never arrive until the picker
// was closed and reopened.
val apps by PayToAppAvailability.flow.collectAsStateWithLifecycle()
val app = remember(apps, target.type) { apps[PaymentTargetTypes.probeKeyFor(target.type)] }
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
@@ -2626,6 +2799,24 @@ fun ZapAmountChoicePopupPreview() {
RailCapability(hasCashu = true, hasLightning = true, hasOnchain = true, cashuBestSingleMintSats = 10L, cashuTotalWalletSats = 1_000_000L),
amounts,
)
// The pay-to hand-off sits beside the amount pills, not inside them, so
// it renders once however many presets there are. No app is installed in
// a preview, so this also shows the brand-coloured glyph fallback the
// chip uses when no single app owns the URI.
ZapChipPreviewRow(
"Lightning + two pay-to hand-offs",
RailCapability(
hasCashu = false,
hasLightning = true,
hasOnchain = false,
payToTargets =
listOf(
PaymentTarget("venmo", "vitorpamplona"),
PaymentTarget("monero", "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRJ5AmD9"),
),
),
amounts,
)
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -55,6 +56,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -341,6 +343,38 @@ fun UpdateZapAmountContent(
)
}
// ── Section 3: Pay-to hand-off ───────────────────────────────────────
Text(
text = stringRes(R.string.zap_payto_section),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleSmall,
modifier = SettingsCategorySpacingModifier,
)
Text(
text = stringRes(R.string.zap_payto_section_explainer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 8.dp),
)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { postViewModel.showPayToChip = !postViewModel.showPayToChip },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(R.string.zap_payto_toggle),
modifier = Modifier.weight(1f),
)
Switch(
checked = postViewModel.showPayToChip,
onCheckedChange = { postViewModel.showPayToChip = it },
)
}
trailingContent()
Spacer(modifier = Modifier.height(16.dp))
@@ -46,6 +46,11 @@ class UpdateZapAmountViewModel : ViewModel() {
var walletConnectSecret by mutableStateOf(TextFieldValue(""))
var selectedZapType by mutableStateOf(LnZapEvent.ZapType.PRIVATE)
// A local UI preference rather than synced account state, but it is edited on
// this screen, so it follows this screen's Save/Cancel contract instead of
// applying instantly — a toggle that ignored Cancel would read as a bug.
var showPayToChip by mutableStateOf(false)
fun copyFromClipboard(text: String) {
if (text.isBlank()) {
return
@@ -61,6 +66,8 @@ class UpdateZapAmountViewModel : ViewModel() {
this.amountSet = accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value
this.selectedZapType = accountViewModel.account.settings.syncedSettings.zaps.defaultZapType.value
this.showPayToChip = uiSettings().showPayToZapChip.value
val nip47 = accountViewModel.account.settings.defaultZapPaymentRequest()
this.walletConnectPubkey = nip47?.pubKeyHex?.let { TextFieldValue(it) } ?: TextFieldValue("")
@@ -132,14 +139,18 @@ class UpdateZapAmountViewModel : ViewModel() {
}
accountViewModel.account.updateZapAmounts(amountSet, selectedZapType, nip47Update)
uiSettings().showPayToZapChip.tryEmit(showPayToChip)
nextAmount = TextFieldValue("")
}
fun cancel() {
nextAmount = TextFieldValue("")
showPayToChip = uiSettings().showPayToZapChip.value
}
private fun uiSettings() = accountViewModel.settings.uiSettingsFlow
fun hasChanged(): Boolean {
val defaultUri = accountViewModel.account.settings.defaultZapPaymentRequest()
return (
@@ -147,7 +158,8 @@ class UpdateZapAmountViewModel : ViewModel() {
amountSet != accountViewModel.account.settings.syncedSettings.zaps.zapAmountChoices.value ||
walletConnectPubkey.text != (defaultUri?.pubKeyHex ?: "") ||
walletConnectRelay.text != (defaultUri?.relayUri?.url ?: "") ||
walletConnectSecret.text != (defaultUri?.secret ?: "")
walletConnectSecret.text != (defaultUri?.secret ?: "") ||
showPayToChip != uiSettings().showPayToZapChip.value
)
}
@@ -691,6 +691,8 @@ private fun QuickZapAmountRow(
nav.nav(Route.UpdateZapAmount())
onDismiss()
},
// Hands off to another app; the sheet must not stay stacked behind it.
onHandedOff = onDismiss,
)
}
}
@@ -29,6 +29,7 @@ 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.User
import com.vitorpamplona.amethyst.commons.model.payments.PaymentTargetTypes
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -39,13 +40,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size16Modifier
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget
import com.vitorpamplona.quartz.nipBCOnchainZaps.taproot.SegwitAddress
/** 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
@@ -61,10 +56,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())
@@ -80,7 +75,7 @@ fun inAppPaymentRouteFor(
* payment-target dialog so the same pill hands off to the same app wherever
* it is tapped.
*/
fun paymentTargetUri(target: PaymentTarget): String = paymentTargetStyleFor(target.type).uriFor(target.authority)
fun paymentTargetUri(target: PaymentTarget): String = PaymentTargetTypes.uriFor(target.type, target.authority)
/**
* Chip for a NIP-A3 payment target. Rendered inside [DisplayPaymentRailChips]'s
@@ -108,7 +103,7 @@ fun PaymentTargetChip(
if (inAppRoute != null) {
nav.nav(inAppRoute)
} else {
runCatching { uriHandler.openUri(style.uriFor(target.authority)) }
runCatching { uriHandler.openUri(paymentTargetUri(target)) }
.onFailure {
accountViewModel.toastManager.toast(
R.string.error_dialog_payment_error,
@@ -150,52 +145,51 @@ fun PaymentTargetPill(
}
}
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)
}
}
}
+5 -1
View File
@@ -877,6 +877,10 @@
<string name="payment_targets_section_explainer">Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum).</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="zap_payto_section">Pay-to hand-off</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>
@@ -1306,7 +1310,7 @@
<string name="ui_preferences_search_keywords" translatable="false">dark mode, light mode, theme, font size, language, appearance</string>
<string name="notification_settings_search_keywords" translatable="false">push, alerts, sounds, vibration</string>
<string name="security_filters_search_keywords" translatable="false">spam, block, mute, filter, warnings</string>
<string name="zaps_search_keywords" translatable="false">lightning, sats, tips, wallet, amount</string>
<string name="zaps_search_keywords" translatable="false">lightning, sats, tips, wallet, amount, payto, pay-to, venmo, paypal, cash app, monero, iban, hand-off</string>
<string name="media_servers_search_keywords" translatable="false">blossom, uploads, images, photos, files, cdn, storage</string>
<string name="event_sync_search_keywords" translatable="false">negentropy, sync, reconcile, backfill</string>
<string name="import_follows_search_keywords" translatable="false">contacts, follows, follow list, import</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,82 @@
/*
* 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 hand off to when paying a note's
* author.
*
* 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 {
/**
* 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(recipientTargets: List<PaymentTarget>): List<PaymentTarget> {
if (recipientTargets.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) &&
seen.add(type)
}
}
/**
* 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. 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
* checks must run first. `peek` is on the one-tap zap path too.
*/
fun selectFor(
enabled: Boolean,
hasAuthor: Boolean,
hasZapSplit: Boolean,
canOpen: (PaymentTarget) -> Boolean,
recipientTargets: () -> List<PaymentTarget>,
): List<PaymentTarget> {
if (!enabled || !hasAuthor || hasZapSplit) return emptyList()
return match(recipientTargets()).filter(canOpen)
}
}
@@ -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,234 @@
/*
* 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 noRecipientTargetsMeansNoChips() {
assertEquals(emptyList(), PayToRailMatcher.match(emptyList()))
}
@Test
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() {
// 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 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"))
assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), PayToRailMatcher.match(recipient))
}
@Test
fun blankAuthoritiesAreSkipped() {
assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo", " "))))
}
@Test
fun recipientOrderIsPreserved() {
val recipient = listOf(t("monero", "m"), t("venmo", "v"))
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)
}
}
/** 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 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,
recipient: List<PaymentTarget> = theirs,
canOpen: (PaymentTarget) -> Boolean = anyAppOpens,
) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, canOpen) { recipient }
@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 everyOpenableTargetIsOfferedWithNoCap() {
val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban"))
assertEquals(many.size, select(recipient = many).size)
}
@Test
fun recipientTargetsAreNotReadWhenACheapGateAlreadyFailed() {
// peek() runs on the one-tap zap path too, and reading the recipient's
// kind:10133 walks its tag array. Nothing should touch it once a gate fails.
var reads = 0
val counted = {
reads++
theirs
}
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, anyAppOpens, counted)
assertEquals(1, reads)
}
@Test
fun lightningAndBitcoinStayWithTheirOwnRails() {
assertEquals(emptyList(), select(recipient = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"))))
}
}
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.quartz.experimental.nipA3
class PaymentTarget(
data class PaymentTarget(
val type: String,
val authority: String,
)