From af5f23109e598f4a8ec0f355abe709f8eb5fd905 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:46:46 +0000 Subject: [PATCH 1/8] docs(amethyst): plan NIP-A3 payment targets as a zap-chip rail Design doc for surfacing a NIP-A3 payment target as a selectable segment in the zap amount chip when the sender and recipient share a pay-to protocol and the note carries no NIP-57 zap split. Anchors the design on what is already in the tree: kind:10133 already rides in UserMetadataForKeyKinds beside kind:0, so no new subscription is needed; RailCapabilityResolver.peek already computes the zap splits the gate needs; and UnifiedZapAmountChip is already a segmented rail toggle. Calls out the constraints that shape it: there is no FX service in the repo, so the handoff segment carries no sat amount and emits no RFC-8905 amount=; lightning/bitcoin payto types must map onto the existing rails rather than render a second Bolt icon; ZapRail has to become a sealed interface to carry which target; and the handoff produces no kind:9735, so it must stay out of the zap state machine. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../2026-09-01-payment-targets-zap-rail.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 amethyst/plans/2026-09-01-payment-targets-zap-rail.md diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md new file mode 100644 index 0000000000..a487a55e2b --- /dev/null +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -0,0 +1,235 @@ +# NIP-A3 Payment Targets as a zap rail + +**Status:** proposal +**Modules:** `quartz`, `commons`, `amethyst` +**Goal:** when the sender and the recipient both publish a NIP-A3 payment +target of the *same* protocol, offer that protocol as a selectable segment in +the zap amount chip — but only on notes with no NIP-57 zap split. + +--- + +## 1. What already exists (survey) + +Almost all of the machinery is already in the tree. This feature is mostly +**wiring**, plus one small refactor and one honest UX decision. + +| Component | Where | Verdict | +|---|---|---| +| `PaymentTarget(type, authority)`, `PaymentTargetTag`, `PaymentTargetsEvent` (kind 10133) | `quartz/…/experimental/nipA3/` | **Reuse** (one tweak, §4.0) | +| Sender's own targets as a `StateFlow>` | `Account.paymentTargetsState.flow` (`Account.kt:902`) | **Reuse as-is** | +| **Recipient's** kind:10133 already co-loaded with kind:0 | `commons/…/watchers/FilterUserMetadataForKey.kt:50` | **Reuse — no new subscription** | +| Segmented multi-rail zap chip (CASHU/RELOAD/LIGHTNING/ONCHAIN toggle) | `ReactionsRow.kt:2362` `UnifiedZapAmountChip` | **Extend** | +| `RailCapability` + `RailCapabilityResolver.peek` (already reads `zapSplitSetup()`) | `amethyst/…/model/zap/RailCapability.kt` | **Extend** | +| `observeZapRailCapability` (async recompute keys) | `ReactionsRow.kt:2098` | **Extend** | +| `User.nutzapInfoNote` addressable-note accessor | `commons/…/model/User.kt:79` | **Pattern to copy** | +| `inAppPaymentRouteFor` (lightning/bitcoin targets → Send Payment screen) | `DisplayPaymentTargets.kt:82` | **Reuse** | +| `PaymentTargetsDialog` (list + copy + QR + pay) | `PaymentButton.kt:149` | **Reuse** for the overflow picker | +| `ReactionRowAction.Pay` — full target browser, `enabled = false` by default | `AccountSyncedSettingsInternal.kt:87`, `ReactionsRow.kt:371` | **Keep, don't duplicate** (§6.3) | +| Type-alias tables (`lightning/ln/lnurl`, `bitcoin/btc/onchain`, + the 15-entry style table) | `DisplayPaymentTargets.kt:67,70,190` | **Extract to `commons`** — currently duplicated twice inside one Android UI file | +| `showOnchainWallet` opt-out plumbing | `UiSettings.kt:62` → `UiSettingsFlow.kt:58` → `UISharedPreferences.kt:190` | **Pattern to copy** for the new setting | + +**Verified constraints:** + +- **There is no FX / bitcoin-price service anywhere in the repo.** Grepped + `commons`, `amethyst`, `quartz`. This is the single fact that shapes the + whole design (§3.2). +- Zap presets are **sats**. `MIN_ONCHAIN_ZAP_SATS = 1_000`, + `CASHU_PREFERRED_BELOW_SATS = 10`, `ONCHAIN_PREFERRED_ABOVE_SATS = 10_000`. +- `PaymentTarget` is a plain `class` with **no `equals`** — identity equality + only. This bites us in §4.0. + +--- + +## 2. What the obvious plan gets wrong + +The naive version is "add `PAYTO` to `enum class ZapRail` and a `hasPayTo` +boolean to `RailCapability`." That version ships five bugs: + +1. **It invents amounts that don't exist.** The chip is amount-first: every + segment shows `1000` and sends 1000 **sats**. Tapping a `venmo` or `iban` + segment cannot mean 1000 sats, and we have no rate to convert with. The + naive chip silently lies about how much money is moving. +2. **It double-renders Lightning and on-chain.** `lightning` / `ln` / `lnurl` + / `bitcoin` / `btc` / `onchain` are legal `payto` types and are exactly the + rails already on the chip. A naive match puts a second Bolt icon next to + the first one. +3. **A boolean can't say *which* target.** `ZapRail` is a payload-free enum; + `hasPayTo: Boolean` gets you a segment that doesn't know what to open. +4. **It corrupts the zap state machine.** A `payto://` handoff produces no + kind:9735 receipt, so `zappingProgress`, `zapStartingTime`, the "zapped by + you" icon and the counter must all stay untouched — a naive `send()` branch + wires it in beside `onLightningZap` and inherits all of them. +5. **It moves bank and Venmo handles into the feed by default.** Those carry + legal names. Today they sit behind an explicit tap on a profile. + +--- + +## 3. Design + +### 3.1 Two classes of `payto` type — only one becomes a segment + +Canonicalize the type, then split it: + +- **Wallet-covered types** — `lightning`/`ln`/`lnurl` and + `bitcoin`/`btc`/`onchain`. These **never** create a new segment; they are + the existing LIGHTNING and ONCHAIN rails. (Optional later: a `bitcoin` + target *enriches* ONCHAIN by supplying an explicit address instead of the + `TaprootAddress.fromPubKey` derivation — out of scope here.) This kills bug 2. +- **Handoff types** — everything else (`venmo`, `paypal`, `cashapp`, `iban`, + `upi`, `monero`, `ethereum`, …). These get **one** segment. + +### 3.2 The handoff segment carries no sat amount — and says so + +Because there is no FX service, the `PayTo` segment is the one segment that +does **not** display the amount when selected. It shows the protocol label and +the arrow (`VENMO →`), and the handoff URI is emitted **without** an RFC-8905 +`amount=` parameter. The user names the amount in their bank/Venmo app. + +This is deliberate. The alternatives were considered and rejected: + +- *Prefill `amount=:` from the sat preset* — requires a rate we do + not have, and would be wrong for every altcoin type too. +- *Restrict to BTC-denominated types only* — collapses the feature to almost + nothing (those are exactly the wallet-covered types). +- *Add an FX service* — a real feature with its own privacy (who do we query?), + Tor-routing and caching design. Not a prerequisite for this one. + +Consequence to document in the UI string: **the note's zap counter will not +move.** No 9735, no receipt, nothing to count. Calling it a "zap rail" in code +is a convenience; to the user it must read as *pay*, not *zap*. + +### 3.3 `ZapRail` becomes a sealed interface + +```kotlin +internal sealed interface ZapRail { + data object Cashu : ZapRail + data object Reload : ZapRail + data object Lightning : ZapRail + data object Onchain : ZapRail + data class PayTo(val target: PaymentTarget) : ZapRail +} +``` + +`PayTo` is **never** the `preferred` rail. The amount tiers +(cashu < 10 sats < lightning < 10 000 sats < on-chain) are untouched; `PayTo` +is always an explicit second tap. That keeps the one-tap muscle memory intact +and means an accidental tap never opens a banking app. + +### 3.4 Gates (all must hold) + +1. `showPayToZapRail` setting is on — **default off**, opt-in (§2 bug 5). +2. The note has **no** zap split: `baseNote.event?.zapSplitSetup().isNullOrEmpty()`. + `RailCapabilityResolver.peek` already computes `splits`; reuse it. +3. The recipient (note author) publishes ≥1 handoff-class target. +4. The sender publishes a target of the **same canonical type**. +5. Author pubkey exists (payto pays a person, not a split set). + +### 3.5 At most one segment, picker on overflow + +If more than one canonical type matches, render **one** segment (generic wallet +icon) whose tap opens `PaymentTargetsDialog` filtered to the matches — it +already does list + copy + QR + pay. Rendering N segments would make the chip +grow without bound; today's worst case is already 4. + +--- + +## 4. Implementation + +### 4.0 Phase 0 — prep, no behaviour change + +- **`quartz`**: `PaymentTarget` → `data class`. Without value equality, + `ZapRail.PayTo` compares by identity, so `remember(preferred, present)` in + `UnifiedZapAmountChip` resets `selectedRail` on every recompose that + re-derives the list. (It also fixes the hand-rolled field-by-field dedupe in + `PaymentTargetsViewModel.addTarget`.) +- **`commons`** — new `model/payments/PaymentTargetTypes.kt` (package already + exists, holds `PaymentSourceResolver`): + - `canonical(rawType): String` — `trim().lowercase()` + alias collapse. + - `isWalletCovered(canonical): Boolean` — lightning + bitcoin families. + - Move `LIGHTNING_TARGET_TYPES` / `BITCOIN_TARGET_TYPES` here and point + `inAppPaymentRouteFor` and `paymentTargetStyleFor` at them. Non-UI and + CLI-safe, per `commons/ARCHITECTURE.md`. +- **`commons`** — `User.paymentTargetsNote` + `paymentTargets()`, mirroring + `nutzapInfoNote` (`User.kt:79`), so the resolver can read the recipient + synchronously. + +### 4.1 Phase 1 — the matcher (pure, headless) + +`commons/…/model/payments/PayToRailMatcher.kt`: + +```kotlin +fun match( + senderTargets: List, + recipientTargets: List, +): List +``` + +Canonicalize both sides, drop wallet-covered types, keep recipient targets +whose type is in the sender's type set, de-dupe by canonical type keeping the +first. Pure function, no Android, no Compose — fully unit-testable. + +### 4.2 Phase 2 — capability + +- `RailCapability` += `payToTargets: List = emptyList()`. + Defaulted, so `RailCapabilityCashuStatusTest` and all existing call sites + compile unchanged. +- `RailCapabilityResolver.peek(..., senderTargets = emptyList(), payToEnabled = false)` + — **defaulted**, because `zapClick` (`ReactionsRow.kt:1464`) also calls + `peek` for the one-tap fast path and that path must stay Lightning-only. + Returns `emptyList()` when `splits.isNotEmpty()`. +- `observeZapRailCapability` adds three inputs, each both a subscription + trigger and a `remember` key (same contract as the existing four — see the + "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`): + `account.paymentTargetsState.flow`, the author's `paymentTargetsNote`, and + `uiSettingsFlow.showPayToZapRail`. + +### 4.3 Phase 3 — UI + +- `ZapRail` → sealed interface; update `present`, `preferred`, `selectedRail`, + `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor` and the settings + preview row. +- Selected `PayTo` renders the label + arrow, not the amount (§3.2). +- Action: try `inAppPaymentRouteFor` first (defensive — a bitcoin target that + slipped through), else `uriHandler.openUri("payto://$type/$authority")`, else + toast `no_payment_app_found_for_type` (string already exists). It must not + touch `zappingProgress` / `zapStartingTime` / `accountViewModel.zap`. +- No new icons: `AccountBalanceWallet` is already referenced in + `MaterialSymbols.kt`, so **no `subset.sh` run is needed**. + +### 4.4 Phase 4 — settings + docs + +`showPayToZapRail` through `UiSettings.kt` → `UiSettingsFlow.kt` → +`UISharedPreferences.kt` → `SettingsCatalogBuilder.kt`, mirroring +`showOnchainWallet`. New strings (+ `payment_targets_search_keywords`), and a +changelog entry. + +--- + +## 5. Tests + +| Level | Test | Asserts | +|---|---|---| +| `quartz/commonTest` | `PaymentTargetEqualityTest` | value equality; dedupe by value | +| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set | +| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` never matches (wallet-covered); `Venmo` vs `venmo` matches; multi-type dedupe | +| `amethyst/test` | extend `RailCapabilityCashuStatusTest` sibling | split present → `payToTargets` empty; setting off → empty; no author → empty; existing rails unaffected | +| Manual | | chip renders; tap opens the external app; counter does **not** move; a split note shows no payto segment | + +--- + +## 6. Open decisions for review + +1. **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* — but it is a deliberate divergence from the on-chain precedent. +2. **Default for `showPayToZapRail`.** Recommend **off**. It puts legal-name + handles one tap from every feed note; opt-in is the conservative call and + matches how `ReactionRowAction.Pay` already ships disabled. +3. **`ReactionRowAction.Pay` overlap.** Recommend keeping both and leaving + `Pay` disabled by default: `Pay` browses *all* of a recipient's targets, + the zap segment is the *matched* shortcut. Merging them is a bigger UX + change than this feature needs. +4. **Symmetry heuristic.** "Both parties declare the protocol" is exactly + right for closed loops (Venmo, Cash App, UPI) and arguably too strict for + open ones (Monero — a sender needs a wallet, not a published address). + Recommend shipping the strict rule first; relaxing it later is additive. From 8a4e37d41d6a9bdd86e9ae44812393c56f01a2a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 22:56:03 +0000 Subject: [PATCH 2/8] docs(amethyst): scope the payment-targets zap chip down to an amount-less v1 Drops amounts, in-app payment and receipts from the first cut. The chip carries no number and hands the amount to the external app, because there is no FX service in the repo to convert a sat preset into a fiat figure. Adds Intent-based discovery so only protocols an installed app can actually handle are offered. Records the constraint that decides it: targetSdk 37 means Android 11+ package visibility returns nothing from queryIntentActivities without a declaration, and the current block covers only nostrsigner, TTS, Health Connect and Tor. One payto entry covers every generic type, since unknown types all fall back to payto:///; https targets are exempt because a browser always resolves them. Keys the discovery cache on scheme+host rather than scheme, and warms it from the sender's own target list instead of lazily per post: the symmetry gate means only protocols the sender declares can ever be shown, so the probe set is a handful of entries and feed rendering never triggers one. The cache has to be a StateFlow, not a plain map, or the chip stays invisible until an unrelated recomposition. Moves the chip beside the amount pills instead of inside the per-amount rail toggle: an amount-less rail would repeat identically in every pill, and keeping it out of the toggle leaves ZapRail a plain enum, deleting the sealed-interface refactor and its recompose-key breakage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../2026-09-01-payment-targets-zap-rail.md | 411 ++++++++++-------- 1 file changed, 219 insertions(+), 192 deletions(-) diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md index a487a55e2b..80b88a063b 100644 --- a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -1,235 +1,262 @@ -# NIP-A3 Payment Targets as a zap rail +# NIP-A3 Payment Targets in the zap picker — v1 **Status:** proposal **Modules:** `quartz`, `commons`, `amethyst` -**Goal:** when the sender and the recipient both publish a NIP-A3 payment -target of the *same* protocol, offer that protocol as a selectable segment in -the zap amount chip — but only on notes with no NIP-57 zap split. +**Scope:** when the sender and recipient both publish a NIP-A3 payment target +of the same protocol, **an installed app can handle it**, and the note carries +no NIP-57 zap split — show one amount-less chip that hands off to that app. + +Deliberately excluded from v1: amounts, in-app payment, receipts, fiat +conversion, desktop. --- -## 1. What already exists (survey) +## 1. Why v1 has no amounts -Almost all of the machinery is already in the tree. This feature is mostly -**wiring**, plus one small refactor and one honest UX decision. +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. -| Component | Where | Verdict | -|---|---|---| -| `PaymentTarget(type, authority)`, `PaymentTargetTag`, `PaymentTargetsEvent` (kind 10133) | `quartz/…/experimental/nipA3/` | **Reuse** (one tweak, §4.0) | -| Sender's own targets as a `StateFlow>` | `Account.paymentTargetsState.flow` (`Account.kt:902`) | **Reuse as-is** | -| **Recipient's** kind:10133 already co-loaded with kind:0 | `commons/…/watchers/FilterUserMetadataForKey.kt:50` | **Reuse — no new subscription** | -| Segmented multi-rail zap chip (CASHU/RELOAD/LIGHTNING/ONCHAIN toggle) | `ReactionsRow.kt:2362` `UnifiedZapAmountChip` | **Extend** | -| `RailCapability` + `RailCapabilityResolver.peek` (already reads `zapSplitSetup()`) | `amethyst/…/model/zap/RailCapability.kt` | **Extend** | -| `observeZapRailCapability` (async recompute keys) | `ReactionsRow.kt:2098` | **Extend** | -| `User.nutzapInfoNote` addressable-note accessor | `commons/…/model/User.kt:79` | **Pattern to copy** | -| `inAppPaymentRouteFor` (lightning/bitcoin targets → Send Payment screen) | `DisplayPaymentTargets.kt:82` | **Reuse** | -| `PaymentTargetsDialog` (list + copy + QR + pay) | `PaymentButton.kt:149` | **Reuse** for the overflow picker | -| `ReactionRowAction.Pay` — full target browser, `enabled = false` by default | `AccountSyncedSettingsInternal.kt:87`, `ReactionsRow.kt:371` | **Keep, don't duplicate** (§6.3) | -| Type-alias tables (`lightning/ln/lnurl`, `bitcoin/btc/onchain`, + the 15-entry style table) | `DisplayPaymentTargets.kt:67,70,190` | **Extract to `commons`** — currently duplicated twice inside one Android UI file | -| `showOnchainWallet` opt-out plumbing | `UiSettings.kt:62` → `UiSettingsFlow.kt:58` → `UISharedPreferences.kt:190` | **Pattern to copy** for the new setting | - -**Verified constraints:** - -- **There is no FX / bitcoin-price service anywhere in the repo.** Grepped - `commons`, `amethyst`, `quartz`. This is the single fact that shapes the - whole design (§3.2). -- Zap presets are **sats**. `MIN_ONCHAIN_ZAP_SATS = 1_000`, - `CASHU_PREFERRED_BELOW_SATS = 10`, `ONCHAIN_PREFERRED_ABOVE_SATS = 10_000`. -- `PaymentTarget` is a plain `class` with **no `equals`** — identity equality - only. This bites us in §4.0. +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. What the obvious plan gets wrong +## 2. The layout decision — and the refactor it deletes -The naive version is "add `PAYTO` to `enum class ZapRail` and a `hasPayTo` -boolean to `RailCapability`." That version ships five bugs: +> This is the one place v1 diverges from the sketch, and the reason is that it +> makes the change roughly half the size. -1. **It invents amounts that don't exist.** The chip is amount-first: every - segment shows `1000` and sends 1000 **sats**. Tapping a `venmo` or `iban` - segment cannot mean 1000 sats, and we have no rate to convert with. The - naive chip silently lies about how much money is moving. -2. **It double-renders Lightning and on-chain.** `lightning` / `ln` / `lnurl` - / `bitcoin` / `btc` / `onchain` are legal `payto` types and are exactly the - rails already on the chip. A naive match puts a second Bolt icon next to - the first one. -3. **A boolean can't say *which* target.** `ZapRail` is a payload-free enum; - `hasPayTo: Boolean` gets you a segment that doesn't know what to open. -4. **It corrupts the zap state machine.** A `payto://` handoff produces no - kind:9735 receipt, so `zappingProgress`, `zapStartingTime`, the "zapped by - you" icon and the counter must all stay untouched — a naive `send()` branch - wires it in beside `onLightningZap` and inherits all of them. -5. **It moves bank and Venmo handles into the feed by default.** Those carry - legal names. Today they sit behind an explicit tap on a profile. +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. Design +## 3. Intent discovery — the constraint that decides it -### 3.1 Two classes of `payto` type — only one becomes a segment +`targetSdk = 37`. Under Android 11+ package visibility, +`queryIntentActivities` returns **empty** for any intent not covered by a +`` declaration — so *without a manifest change this feature silently +shows nothing on every modern device*. The existing `` block +(`AndroidManifest.xml:4`) covers only `nostrsigner`, TTS, Health Connect and +Tor. -Canonicalize the type, then split it: +### 3.1 Manifest -- **Wallet-covered types** — `lightning`/`ln`/`lnurl` and - `bitcoin`/`btc`/`onchain`. These **never** create a new segment; they are - the existing LIGHTNING and ONCHAIN rails. (Optional later: a `bitcoin` - target *enriches* ONCHAIN by supplying an explicit address instead of the - `TaprootAddress.fromPubKey` derivation — out of scope here.) This kills bug 2. -- **Handoff types** — everything else (`venmo`, `paypal`, `cashapp`, `iban`, - `upi`, `monero`, `ethereum`, …). These get **one** segment. +Add one `` per scheme we probe. The important economy: an arbitrary +user-typed type (`iban`, `upi`, `pix`, …) always falls back to +`payto:///`, so **one `payto` entry covers every generic +type**. Only the ~12 special-cased crypto schemes in `paymentTargetStyleFor` +(`DisplayPaymentTargets.kt:190`) need their own entries. -### 3.2 The handoff segment carries no sat amount — and says so - -Because there is no FX service, the `PayTo` segment is the one segment that -does **not** display the amount when selected. It shows the protocol label and -the arrow (`VENMO →`), and the handoff URI is emitted **without** an RFC-8905 -`amount=` parameter. The user names the amount in their bank/Venmo app. - -This is deliberate. The alternatives were considered and rejected: - -- *Prefill `amount=:` from the sat preset* — requires a rate we do - not have, and would be wrong for every altcoin type too. -- *Restrict to BTC-denominated types only* — collapses the feature to almost - nothing (those are exactly the wallet-covered types). -- *Add an FX service* — a real feature with its own privacy (who do we query?), - Tor-routing and caching design. Not a prerequisite for this one. - -Consequence to document in the UI string: **the note's zap counter will not -move.** No 9735, no receipt, nothing to count. Calling it a "zap rail" in code -is a convenience; to the user it must read as *pay*, not *zap*. - -### 3.3 `ZapRail` becomes a sealed interface - -```kotlin -internal sealed interface ZapRail { - data object Cashu : ZapRail - data object Reload : ZapRail - data object Lightning : ZapRail - data object Onchain : ZapRail - data class PayTo(val target: PaymentTarget) : ZapRail -} +```xml + + + + + ``` -`PayTo` is **never** the `preferred` rail. The amount tiers -(cashu < 10 sats < lightning < 10 000 sats < on-chain) are untouched; `PayTo` -is always an explicit second tap. That keeps the one-tap muscle memory intact -and means an accidental tap never opens a banking app. +Use **``, never `QUERY_ALL_PACKAGES`** — the latter is a +policy-restricted permission on Play and would need a declaration; specific +`` filters need nothing. On minSdk 26–29 `` is ignored and +everything resolves, which is a strict superset of the gated behaviour. -### 3.4 Gates (all must hold) +### 3.2 https targets are exempt -1. `showPayToZapRail` setting is on — **default off**, opt-in (§2 bug 5). -2. The note has **no** zap split: `baseNote.event?.zapSplitSetup().isNullOrEmpty()`. - `RailCapabilityResolver.peek` already computes `splits`; reuse it. -3. The recipient (note author) publishes ≥1 handoff-class target. -4. The sender publishes a target of the **same canonical type**. -5. Author pubkey exists (payto pays a person, not a split set). +`cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always +resolves — discovery would be a tautology. **Skip discovery for https +targets and always show them**: opening `venmo.com/` in a browser is a +legitimate way to pay, so nothing is broken. The sender-symmetry gate (§5) is +the real filter there. -### 3.5 At most one segment, picker on overflow +*(Refinement, not v1: to detect a genuine app handler behind an https target, +resolve a control `https:///` and treat the target as +app-backed only if its resolver set contains a package outside that control +set.)* -If more than one canonical type matches, render **one** segment (generic wallet -icon) whose tap opens `PaymentTargetsDialog` filtered to the matches — it -already does list + copy + QR + pay. Rendering N segments would make the chip -grow without bound; today's worst case is already 4. +### 3.3 The cache — keyed by scheme+host, warmed from the sender + +The naive cache is per-post and lazy. The better one falls out of the +symmetry gate: + +> **Only protocols the sender themself declares can ever be shown.** So the +> probe set is the *sender's own* target list — typically 1–5 entries — not +> anything derived from posts. + +- **Key:** `"://"`, e.g. `payto://iban`, `bitcoin://`. Scheme + alone is too coarse — an app may declare `android:scheme="payto" + android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi` + is handled. +- **Warm:** collect `account.paymentTargetsState.flow` (already an eagerly + started `StateFlow`, `Account.kt:902`); on each emission, probe the handful + of keys off the main thread. Feed rendering never triggers a probe. +- **Read:** synchronous map lookup — required, because + `RailCapabilityResolver.peek` is called from inside `remember {}`. +- **Recomposition:** the map must be a `MutableStateFlow>`, + 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. Implementation +## 4. Saying "the app decides the amount" -### 4.0 Phase 0 — prep, no behaviour change +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: -- **`quartz`**: `PaymentTarget` → `data class`. Without value equality, - `ZapRail.PayTo` compares by identity, so `remember(preferred, present)` in - `UnifiedZapAmountChip` resets `selectedRail` on every recompose that - re-derives the list. (It also fixes the hand-rolled field-by-field dedupe in - `PaymentTargetsViewModel.addTarget`.) -- **`commons`** — new `model/payments/PaymentTargetTypes.kt` (package already - exists, holds `PaymentSourceResolver`): - - `canonical(rawType): String` — `trim().lowercase()` + alias collapse. - - `isWalletCovered(canonical): Boolean` — lightning + bitcoin families. - - Move `LIGHTNING_TARGET_TYPES` / `BITCOIN_TARGET_TYPES` here and point - `inAppPaymentRouteFor` and `paymentTargetStyleFor` at them. Non-UI and - CLI-safe, per `commons/ARCHITECTURE.md`. -- **`commons`** — `User.paymentTargetsNote` + `paymentTargets()`, mirroring - `nutzapInfoNote` (`User.kt:79`), so the resolver can read the recipient - synchronously. +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. -### 4.1 Phase 1 — the matcher (pure, headless) +**Both icons are already in `MaterialSymbols.kt`** (`OpenInNew:280`, +`AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh` +run is needed.** -`commons/…/model/payments/PayToRailMatcher.kt`: - -```kotlin -fun match( - senderTargets: List, - recipientTargets: List, -): List -``` - -Canonicalize both sides, drop wallet-covered types, keep recipient targets -whose type is in the sender's type set, de-dupe by canonical type keeping the -first. Pure function, no Android, no Compose — fully unit-testable. - -### 4.2 Phase 2 — capability - -- `RailCapability` += `payToTargets: List = emptyList()`. - Defaulted, so `RailCapabilityCashuStatusTest` and all existing call sites - compile unchanged. -- `RailCapabilityResolver.peek(..., senderTargets = emptyList(), payToEnabled = false)` - — **defaulted**, because `zapClick` (`ReactionsRow.kt:1464`) also calls - `peek` for the one-tap fast path and that path must stay Lightning-only. - Returns `emptyList()` when `splits.isNotEmpty()`. -- `observeZapRailCapability` adds three inputs, each both a subscription - trigger and a `remember` key (same contract as the existing four — see the - "do NOT delete these as unused" comment at `ReactionsRow.kt:2105`): - `account.paymentTargetsState.flow`, the author's `paymentTargetsNote`, and - `uiSettingsFlow.showPayToZapRail`. - -### 4.3 Phase 3 — UI - -- `ZapRail` → sealed interface; update `present`, `preferred`, `selectedRail`, - `ZapRailIcon`, `previewPreferredRail`, `previewRailsFor` and the settings - preview row. -- Selected `PayTo` renders the label + arrow, not the amount (§3.2). -- Action: try `inAppPaymentRouteFor` first (defensive — a bitcoin target that - slipped through), else `uriHandler.openUri("payto://$type/$authority")`, else - toast `no_payment_app_found_for_type` (string already exists). It must not - touch `zappingProgress` / `zapStartingTime` / `accountViewModel.zap`. -- No new icons: `AccountBalanceWallet` is already referenced in - `MaterialSymbols.kt`, so **no `subset.sh` run is needed**. - -### 4.4 Phase 4 — settings + docs - -`showPayToZapRail` through `UiSettings.kt` → `UiSettingsFlow.kt` → -`UISharedPreferences.kt` → `SettingsCatalogBuilder.kt`, mirroring -`showOnchainWallet`. New strings (+ `payment_targets_search_keywords`), and a -changelog entry. +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. --- -## 5. Tests +## 5. Gates (all must hold) + +1. Setting `showPayToZapRail` — **default off**, opt-in. Fiat handles carry + legal names; this puts them one tap from every feed note. Mirrors + `showOnchainWallet` (`UiSettings.kt:62` → `UiSettingsFlow.kt:58` → + `UISharedPreferences.kt:190`). +2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't + fan out and returns no receipt. `RailCapabilityResolver.peek` **already + computes `splits`** — one-line reuse. +3. Recipient (note author) publishes ≥1 handoff-class target. +4. Sender publishes a target of the **same canonical type**. +5. §3 says an app can handle it (or it's https). +6. Cap at **2 chips**; with discovery filtering, 0–1 is the normal case, so v1 + needs no overflow picker. + +**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 +`` entries per §3.1. + +### 6.3 Capability +- `RailCapability` += `payToTargets: List = 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 | |---|---|---| -| `quartz/commonTest` | `PaymentTargetEqualityTest` | value equality; dedupe by value | -| `commons/commonTest` | `PaymentTargetTypesTest` | alias collapse, case/whitespace, wallet-covered set | -| `commons/commonTest` | `PayToRailMatcherTest` | empty sender → empty; no overlap → empty; `ln` vs `lightning` never matches (wallet-covered); `Venmo` vs `venmo` matches; multi-type dedupe | -| `amethyst/test` | extend `RailCapabilityCashuStatusTest` sibling | split present → `payToTargets` empty; setting off → empty; no author → empty; existing rails unaffected | -| Manual | | chip renders; tap opens the external app; counter does **not** move; a split note shows no payto segment | +| `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 | +| 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 | --- -## 6. Open decisions for review +## 8. Open decisions -1. **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* — but it is a deliberate divergence from the on-chain precedent. -2. **Default for `showPayToZapRail`.** Recommend **off**. It puts legal-name - handles one tap from every feed note; opt-in is the conservative call and - matches how `ReactionRowAction.Pay` already ships disabled. -3. **`ReactionRowAction.Pay` overlap.** Recommend keeping both and leaving - `Pay` disabled by default: `Pay` browses *all* of a recipient's targets, - the zap segment is the *matched* shortcut. Merging them is a bigger UX - change than this feature needs. -4. **Symmetry heuristic.** "Both parties declare the protocol" is exactly - right for closed loops (Venmo, Cash App, UPI) and arguably too strict for - open ones (Monero — a sender needs a wallet, not a published address). - Recommend shipping the strict rule first; relaxing it later is additive. +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. **Symmetry heuristic** — 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. From 4e067a13d713651bdf725c62ad712237111b6ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:34:09 +0000 Subject: [PATCH 3/8] docs(amethyst): take the pay-to chip's mark from the installed app Answers whether the chip can wear the icon of the app it hands off to: it can, and the codebase already does it. ExternalSignerButton renders installed NIP-55 signers from loadLabel/loadIcon off getExternalSignersInstalled, which is the same queryIntentActivities call discovery already makes, so the ResolveInfo we keep to answer "can anything open this?" also carries the mark and the label. Argues against bundling brand logos instead: a trademark question rather than a licence one, an unbounded free-text type space no bundled set can cover, and a call the codebase already made by pairing brand colours with a generic wallet glyph. Keeps that pairing as the fallback. Records four things the existing precedent gets away with and this would not: loadIcon is I/O and belongs in the off-main warm step caching an ImageBitmap rather than in a recomposing item; adaptive icons need sizing and a round mask or the logo floats in launcher bleed at 18dp; a multi-handler URI resolves to ResolverActivity and has no single app to name; and a full-colour raster cannot join the tinted glyph scheme. Promotes the https control probe from a later refinement into v1: it never gated the chip, but without it a browser-only Venmo target resolves to Chrome, and a Chrome icon on a Venmo chip is worse than no icon. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../2026-09-01-payment-targets-zap-rail.md | 97 ++++++++++++++++--- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md index 80b88a063b..a921a2dc1e 100644 --- a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -95,10 +95,12 @@ targets and always show them**: opening `venmo.com/` in a browser is a legitimate way to pay, so nothing is broken. The sender-symmetry gate (§5) is the real filter there. -*(Refinement, not v1: to detect a genuine app handler behind an https target, -resolve a control `https:///` and treat the target as -app-backed only if its resolver set contains a package outside that control -set.)* +**But §4.2 still needs the control probe here.** To tell a real app handler +from a browser, resolve a control `https:///` and treat the +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 sender @@ -132,7 +134,9 @@ of the UI file into `commons` (§6.0). --- -## 4. Saying "the app decides the amount" +## 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: @@ -143,14 +147,77 @@ it is visibly a *different kind of thing*. Three cues, no extra layout: 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 icons are already in `MaterialSymbols.kt`** (`OpenInNew:280`, +**Both glyphs are already in `MaterialSymbols.kt`** (`OpenInNew:280`, `AccountBalanceWallet:27`) — **no `tools/material-symbols-subset/subset.sh` -run is needed.** +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:///`, 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) @@ -201,7 +268,11 @@ Compose. ### 6.2 Discovery `amethyst/…/service/payments/PayToAppAvailability.kt` per §3.3 + the manifest -`` entries per §3.1. +`` 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 = emptyList()` — @@ -237,8 +308,8 @@ new strings; changelog. | `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 | -| 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 | +| `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 | --- @@ -255,7 +326,11 @@ new strings; changelog. 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. **Symmetry heuristic** — right for closed loops (Venmo, Cash App, UPI), +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** — 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 From 6cb485cda8f6e8754f31316d593a2c8f834a30c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:12:17 +0000 Subject: [PATCH 4/8] 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 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:///, so one payto entry covers the open-ended tail of the vocabulary. Specific 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 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- amethyst/src/main/AndroidManifest.xml | 73 ++++++ .../amethyst/model/UiSettings.kt | 5 + .../amethyst/model/UiSettingsFlow.kt | 9 + .../model/preferences/UISharedPreferences.kt | 3 + .../amethyst/model/zap/RailCapability.kt | 39 +++ .../service/payments/PayToAppAvailability.kt | 199 ++++++++++++++++ .../amethyst/ui/note/ReactionsRow.kt | 173 +++++++++++++- .../chats/feed/ChatMessageActionSheet.kt | 2 + .../profile/header/DisplayPaymentTargets.kt | 54 ++--- .../settings/ProfileUiSettingsScreen.kt | 9 + amethyst/src/main/res/values/strings.xml | 3 + .../amethyst/commons/model/User.kt | 10 + .../model/payments/PayToRailMatcher.kt | 99 ++++++++ .../model/payments/PaymentTargetTypes.kt | 146 ++++++++++++ .../model/payments/PaymentTargetTypesTest.kt | 222 ++++++++++++++++++ .../experimental/nipA3/PaymentTarget.kt | 2 +- 16 files changed, 1015 insertions(+), 33 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 0801863b12..1b1c5074e4 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -16,6 +16,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index c3b9d7a1ed..bd7a6d0057 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 8faab9450d..baa3d31268 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -56,6 +56,7 @@ class UiSettingsFlow( val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), val composeSignature: MutableStateFlow = MutableStateFlow(""), val showOnchainWallet: MutableStateFlow = MutableStateFlow(true), + val showPayToZapChip: MutableStateFlow = MutableStateFlow(false), ) { val listOfFlows: List> = listOf>( @@ -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), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index 01f955a8c9..e2371b53ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index ffc8de96d4..2a8fe43b08 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -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 = 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 = 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, + senderTargets: List, + enabled: Boolean, + ): List = + 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) + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt new file mode 100644 index 0000000000..8558b919be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -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 `` block declares the + * scheme. The declarations are deliberately `` filters rather than + * `QUERY_ALL_PACKAGES`, which is policy-restricted on Play. + * + * **Why this is not a per-post lookup.** The chip only ever appears for + * protocols the *sender themself* publishes, so [warm] probes the sender's own + * target list — a handful of entries, refreshed when that list changes or the + * app returns to the foreground. Feed rendering never triggers a probe; it only + * reads [peek]. + * + * 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>(emptyMap()) + val flow: StateFlow> = 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, + 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, + 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? { + 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 = 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 = 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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 6ba63fe27f..eb16082c3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -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(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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 263823e3e9..0e1fab3179 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt index 8d0b0193f5..5f3c3d30fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DisplayPaymentTargets.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index b3e10a008b..fdec1cd1b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1c2b7e3e82..e39c2d6819 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1866,6 +1866,9 @@ Delete payment target No app found to handle this payment. Please install a compatible wallet. No app installed to handle %1$s payments. Please install a compatible wallet. + Pay in %1$s — you choose the amount there + Pay-to shortcut in the zap picker + 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. Unable to open payment BOLT12 Offers diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt index e148a7b529..ac221fef95 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/User.kt @@ -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 = 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt new file mode 100644 index 0000000000..acb26e15f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -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, + recipientTargets: List, + ): List { + if (senderTargets.isEmpty() || recipientTargets.isEmpty()) return emptyList() + + val senderTypes = + senderTargets + .asSequence() + .map { PaymentTargetTypes.canonical(it.type) } + .filterNot { it.isEmpty() || PaymentTargetTypes.isWalletCovered(it) } + .toSet() + + if (senderTypes.isEmpty()) return emptyList() + + val seen = mutableSetOf() + 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, + recipientTargets: List, + canOpen: (PaymentTarget) -> Boolean, + ): List { + if (!enabled || !hasAuthor || hasZapSplit) return emptyList() + return match(senderTargets, recipientTargets).filter(canOpen).take(MAX_CHIPS) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt new file mode 100644 index 0000000000..e82aa23c43 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypes.kt @@ -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:///`. + * + * 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:///`, which is why a single `payto` entry in the + * manifest's `` 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, "") +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt new file mode 100644 index 0000000000..13e42dd133 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -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 = mine, + recipient: List = 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)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt index cea99d5b04..66e17243ca 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.nipA3 -class PaymentTarget( +data class PaymentTarget( val type: String, val authority: String, ) From 77b96c4ab7bdb80d3dfec13779884a1ce09eb402 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:33:29 +0000 Subject: [PATCH 5/8] fix(zap): correct the pay-to probe, and stop redoing its expensive half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the previous commit. Four findings, all reachable in normal use. The probe was stricter than the hand-off it predicts. It carried CATEGORY_BROWSABLE and queried with flags=0, while the hand-off goes through startActivity, which implies CATEGORY_DEFAULT and nothing else. IntentFilter.matchCategories returns the first category on the *intent* the filter lacks, so each category added to a query narrows the match: any app declaring only DEFAULT was invisible to the probe and its chip was hidden even though tapping it would have worked. The probe now carries no category and uses MATCH_DEFAULT_ONLY, resolving exactly the set startActivity would. The entries lose the category for the same reason — there it narrows package visibility itself. The chip snapshotted the probe result with remember(target.type), so it never saw the probe finish. A web target is offered before any probe runs, since any browser opens https, so that snapshot pinned the fallback glyph and the real app icon could not appear until the picker was closed and reopened — the Venmo and PayPal case the icon exists for. It now collects the availability flow. peek() built the recipient's target list eagerly, walking the kind:10133 tag array on every call, including the one-tap zap path with the feature switched off. selectFor now takes it as a lambda behind the cheap gates, pinned by a test that counts reads. warm() runs on each picker open so resolution stays fresh when the user installs an app and comes back, but it also re-read each APK's resources and re-rasterised its icon to answer the same question. Decoded icons are now kept across warms, keyed by package and size, and the browser control probe only runs when a web target is actually present. Also: the icon failure log kept its message but dropped the throwable; it now passes it. Removes the unused clear(). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- amethyst/src/main/AndroidManifest.xml | 18 ++----- .../amethyst/model/zap/RailCapability.kt | 3 +- .../service/payments/PayToAppAvailability.kt | 51 ++++++++++++++----- .../amethyst/ui/note/ReactionsRow.kt | 8 ++- .../model/payments/PayToRailMatcher.kt | 10 ++-- .../model/payments/PaymentTargetTypesTest.kt | 22 +++++++- 6 files changed, 78 insertions(+), 34 deletions(-) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 1b1c5074e4..2bf34594e9 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -23,70 +23,60 @@ modern device. Specific filters rather than QUERY_ALL_PACKAGES, which is policy-restricted on Play. Unknown target types all fall back to payto:///, - so the single payto entry covers the open-ended tail. --> + so the single payto entry covers the open-ended tail. + No : 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. --> - - - - - - - - - - - - - diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 2a8fe43b08..b2c744351f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -178,12 +178,13 @@ object RailCapabilityResolver { 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) }, + // Lazy: the tag walk only happens once the cheap gates have passed. + recipientTargets = { baseNote.author?.paymentTargets().orEmpty() }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 8558b919be..6ae52fd735 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap /** What the device can do with one `payto` target type. */ @Immutable @@ -74,6 +75,16 @@ object PayToAppAvailability { private val state = MutableStateFlow>(emptyMap()) val flow: StateFlow> = 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() + /** Synchronous read for `RailCapabilityResolver.peek`, which runs inside `remember {}`. */ fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)] @@ -103,18 +114,14 @@ object PayToAppAvailability { return } - val browsers = browserPackages(pm) + // Only https targets need the control probe; skip the extra query otherwise. + val browsers = if (keys.any(PaymentTargetTypes::isWebTarget)) browserPackages(pm) else emptySet() state.value = 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, @@ -173,27 +180,43 @@ object PayToAppAvailability { pm: PackageManager, info: ResolveInfo, px: Int, - ): ImageBitmap? = - runCatching { + ): 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 ${info.packageName()}: ${it.message}" } + Log.w("PayToAppAvailability", "Could not load icon for $pkg", it) }.getOrNull() + } @SuppressLint("QueryPermissionsNeeded") private fun queryActivities( pm: PackageManager, intent: Intent, - ): List = runCatching { pm.queryIntentActivities(intent, 0) }.getOrDefault(emptyList()) + ): List = + 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 = 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) - } + /** + * 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index eb16082c3e..d9af4afa4f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2427,9 +2427,15 @@ private fun PayToHandoffChip( 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) } + // 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt index acb26e15f0..fc51fb8d7c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -84,16 +84,20 @@ object PayToRailMatcher { * @param hasZapSplit a `payto` hand-off leaves with one authority and returns * no receipt, so it cannot honour a note that asks to divide the zap. * @param canOpen whether an installed app resolves this target's URI. + * @param 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, senderTargets: List, - recipientTargets: List, canOpen: (PaymentTarget) -> Boolean, + recipientTargets: () -> List, ): List { - if (!enabled || !hasAuthor || hasZapSplit) return emptyList() - return match(senderTargets, recipientTargets).filter(canOpen).take(MAX_CHIPS) + if (!enabled || !hasAuthor || hasZapSplit || senderTargets.isEmpty()) return emptyList() + return match(senderTargets, recipientTargets()).filter(canOpen).take(MAX_CHIPS) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt index 13e42dd133..3596f1cca5 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -173,7 +173,7 @@ class PayToRailGateTest { sender: List = mine, recipient: List = theirs, canOpen: (PaymentTarget) -> Boolean = anyAppOpens, - ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, recipient, canOpen) + ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, canOpen) { recipient } @Test fun offeredWhenEveryGatePasses() { @@ -214,6 +214,26 @@ class PayToRailGateTest { assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, 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, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, false, false, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, true, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, false, emptyList(), anyAppOpens, counted) + assertEquals(0, reads) + + PayToRailMatcher.selectFor(true, true, false, mine, anyAppOpens, counted) + assertEquals(1, reads) + } + @Test fun lightningAndBitcoinStayWithTheirOwnRails() { val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")) From 34e931a9b02c0fdc36b08881dcd9dd9c6dacce45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:15:57 +0000 Subject: [PATCH 6/8] refactor(settings): move the pay-to toggle to the Zaps screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was on Profile settings, under a section literally titled "profile sections" (badges, app recommendations, zap-received feed, followers feed). The toggle has no profile-visible effect at all — it decides whether a chip appears in the zap picker — so it was filed by association with showOnchainWallet, which sits there for the same weak reason but at least puts a chip on profiles. The Zaps screen is where it belongs: it is the zap picker's configuration surface, reached from the picker's own "change amount" action, and it already renders previewRailsFor for the very chip row this setting adds to. Wired through UpdateZapAmountViewModel rather than applied instantly, because that screen is a Save/Cancel form: load() reads it, hasChanged() reports it, sendPost() commits it and cancel() reverts it. An instant-apply switch on a form with a Cancel button that did not revert it would read as a bug. Strings move out of the profile_ui_ namespace to zap_payto_*, and the explainer now states the two things the chip does not do: the other app asks for the amount, and nothing is published, so the note's zap count is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../amethyst/ui/note/UpdateZapAmountDialog.kt | 34 +++++++++++++++++++ .../ui/note/UpdateZapAmountViewModel.kt | 14 +++++++- .../settings/ProfileUiSettingsScreen.kt | 9 ----- amethyst/src/main/res/values/strings.xml | 5 +-- 4 files changed, 50 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index bbdd92b223..a8e93f46f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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 @@ -326,6 +328,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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt index 0a5931f26b..b1b44291e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountViewModel.kt @@ -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 ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index fdec1cd1b5..b3e10a008b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -80,7 +80,6 @@ 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( @@ -126,14 +125,6 @@ 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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e39c2d6819..64123a8533 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1867,8 +1867,9 @@ No app found to handle this payment. Please install a compatible wallet. No app installed to handle %1$s payments. Please install a compatible wallet. Pay in %1$s — you choose the amount there - Pay-to shortcut in the zap picker - 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. + Pay-to hand-off + When you and the author both publish the same payment method (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change. + Offer shared payment methods Unable to open payment BOLT12 Offers From 2438ab16c6ce66f34b1aaaf87ea31e12714b18a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:32:30 +0000 Subject: [PATCH 7/8] fix(zap): make the pay-to setting findable, and preview the chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from moving the toggle to the Zaps screen. Settings search indexes the catalog entry's keywords, not the screen's contents, so after the move nothing matched "venmo", "payto" or "paypal" — the words someone would actually type to find this. Widened zaps_search_keywords. ZapAmountChoicePopupPreview exercises four rail combinations but never payToTargets, so the new chip had no preview at all in a file that otherwise covers this component carefully. Adds a row with two hand-offs; since no app resolves in a preview it also exercises the brand-coloured glyph fallback, which is what a device without the app installed shows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../amethyst/ui/note/ReactionsRow.kt | 18 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index d9af4afa4f..93e35494ed 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2777,6 +2777,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, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 64123a8533..d7b323ca63 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2484,7 +2484,7 @@ dark mode, light mode, theme, font size, language, appearance push, alerts, sounds, vibration spam, block, mute, filter, warnings - lightning, sats, tips, wallet, amount + lightning, sats, tips, wallet, amount, payto, pay-to, venmo, paypal, cash app, monero, iban, hand-off blossom, uploads, images, photos, files, cdn, storage negentropy, sync, reconcile, backfill contacts, follows, follow list, import From 12c6b7b08d5a3150bc2a8e7a3bb69e32806c3ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:50:04 +0000 Subject: [PATCH 8/8] feat(zap): gate the pay-to chip on discovery alone, default it on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip required the sender and the author to publish the same protocol, capped the row at two, and shipped opt-out. All three go. Symmetry was a proxy for "I can actually pay this way", and it is the wrong proxy: paying a Monero address needs a wallet, not a published address of one. What the sender happens to say about themselves never determined whether the hand-off would work — the installed-app probe does, and it was already running. So `PayToRailMatcher.match` no longer takes the sender's list, `selectFor` drops the `senderTargets` gate, and `canOpen` becomes the substantive filter with the rest as preconditions. Dropping symmetry moves the probe set. It used to be the sender's own target list, which is why `warm()` could replace the cache wholesale; it is now the targets of whichever author's picker is open. So `warm()` merges instead of replacing — replacing would evict what was learned about every other author the moment a second picker opened — and the `LaunchedEffect` keys on the author's observed kind:10133 rather than on `paymentTargetsState`. MAX_CHIPS existed because symmetry could pass several protocols at once with nothing else narrowing them. Discovery narrows them: a target with no installed app never reaches the picker, so the cap was bounding a row that discovery already bounds, and an arbitrary two-chip truncation would now hide a target the user can genuinely pay. `showPayToZapChip` defaults on for the same reason. The opt-out was justified by fiat handles carrying legal names, but the chip only ever surfaces a target its author chose to publish, to a device that can already open it. The setting's copy said "when you and the author both publish the same payment method" and the toggle read "Offer shared payment methods" — both described the gate that no longer exists, so both are rewritten. Tests follow the contract rather than the old shape: symmetry cases become capability cases, `everyOpenableTargetIsOfferedWithNoCap` replaces the cap assertion, and one new case pins the inverse of the rule that was removed — a target the sender does not publish is still offered. The lazy-read test keeps its guarantee, minus the sender-empty branch that no longer exists. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JXKZeV6FNhXF9BBjgEtfvS --- .../2026-09-01-payment-targets-zap-rail.md | 55 ++++++++------- .../amethyst/model/UiSettings.kt | 2 +- .../amethyst/model/UiSettingsFlow.kt | 2 +- .../model/preferences/UISharedPreferences.kt | 2 +- .../amethyst/model/zap/RailCapability.kt | 11 ++- .../service/payments/PayToAppAvailability.kt | 29 ++++---- .../amethyst/ui/note/ReactionsRow.kt | 18 +++-- amethyst/src/main/res/values/strings.xml | 4 +- .../model/payments/PayToRailMatcher.kt | 49 ++++--------- .../model/payments/PaymentTargetTypesTest.kt | 68 ++++++++----------- 10 files changed, 109 insertions(+), 131 deletions(-) diff --git a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md index a921a2dc1e..ab46a9115c 100644 --- a/amethyst/plans/2026-09-01-payment-targets-zap-rail.md +++ b/amethyst/plans/2026-09-01-payment-targets-zap-rail.md @@ -2,9 +2,16 @@ **Status:** proposal **Modules:** `quartz`, `commons`, `amethyst` -**Scope:** when the sender and recipient both publish a NIP-A3 payment target -of the same protocol, **an installed app can handle it**, and the note carries -no NIP-57 zap split — show one amount-less chip that hands off to that app. +**Scope:** when a note's author publishes a NIP-A3 payment target, **an +installed app can handle it**, and the note carries no NIP-57 zap split — show +one amount-less chip per such target that hands off to that app. + +> **Revised after the first implementation.** This document originally gated the +> chip on *symmetry* — both parties publishing the same protocol — and capped the +> row at two chips. Both are gone: the gate is capability alone (can anything on +> this phone open the URI), there is no cap, and the setting now defaults **on**. +> Sections below that argue for symmetry are kept for the reasoning, but §5 is +> the current rule. Deliberately excluded from v1: amounts, in-app payment, receipts, fiat conversion, desktop. @@ -92,8 +99,8 @@ everything resolves, which is a strict superset of the gated behaviour. `cashapp` / `venmo` / `paypal` map to `https://…`, which a browser always resolves — discovery would be a tautology. **Skip discovery for https targets and always show them**: opening `venmo.com/` in a browser is a -legitimate way to pay, so nothing is broken. The sender-symmetry gate (§5) is -the real filter there. +legitimate way to pay, so nothing is broken. For these three types the chip is +therefore gated only on the author having published one. **But §4.2 still needs the control probe here.** To tell a real app handler from a browser, resolve a control `https:///` and treat the @@ -102,22 +109,22 @@ control set. It never gates the chip — it decides whether the chip wears the app's icon or the brand-colour glyph, and a Chrome icon on a Venmo chip is worse than no icon at all. -### 3.3 The cache — keyed by scheme+host, warmed from the sender +### 3.3 The cache — keyed by scheme+host, warmed from the open picker -The naive cache is per-post and lazy. The better one falls out of the -symmetry gate: +The naive cache is per-post and lazy. With symmetry gone the probe set is the +author's target list, so: -> **Only protocols the sender themself declares can ever be shown.** So the -> probe set is the *sender's own* target list — typically 1–5 entries — not -> anything derived from posts. +> **Probe the targets of the one author whose picker is open** — typically 1–5 +> entries — and **merge** the answers into the cache. Merging matters: replacing +> would evict what was learned about every other author the moment a second +> picker opened. Feed rendering still never triggers a probe. - **Key:** `"://"`, e.g. `payto://iban`, `bitcoin://`. Scheme alone is too coarse — an app may declare `android:scheme="payto" android:host="iban"`, so a scheme-only hit would wrongly claim `payto://upi` is handled. -- **Warm:** collect `account.paymentTargetsState.flow` (already an eagerly - started `StateFlow`, `Account.kt:902`); on each emission, probe the handful - of keys off the main thread. Feed rendering never triggers a probe. +- **Warm:** a `LaunchedEffect` keyed on the author's observed kind:10133 probes + that handful of keys off the main thread when the picker opens. - **Read:** synchronous map lookup — required, because `RailCapabilityResolver.peek` is called from inside `remember {}`. - **Recomposition:** the map must be a `MutableStateFlow>`, @@ -222,18 +229,19 @@ its resolver set contains a package outside that control set) to decide ## 5. Gates (all must hold) -1. Setting `showPayToZapRail` — **default off**, opt-in. Fiat handles carry - legal names; this puts them one tap from every feed note. Mirrors - `showOnchainWallet` (`UiSettings.kt:62` → `UiSettingsFlow.kt:58` → - `UISharedPreferences.kt:190`). +1. Setting `showPayToZapChip` — **default on**. The chip only ever shows a + target its author chose to publish, to a device that can already open it, + so the discovery gate is doing the real narrowing (`UiSettings.kt:67` → + `UiSettingsFlow.kt:59` → `UISharedPreferences.kt:192`). 2. Note has **no** zap split: `zapSplitSetup().isNullOrEmpty()`. payto can't fan out and returns no receipt. `RailCapabilityResolver.peek` **already computes `splits`** — one-line reuse. 3. Recipient (note author) publishes ≥1 handoff-class target. -4. Sender publishes a target of the **same canonical type**. -5. §3 says an app can handle it (or it's https). -6. Cap at **2 chips**; with discovery filtering, 0–1 is the normal case, so v1 - needs no overflow picker. +4. §3 says an app can handle it (or it's https). **This is the substantive + gate**; everything else is a precondition. + +No cap: every openable target is offered. Discovery is what bounds the row — +a target with nothing to open it never reaches the picker. **Handoff-class** excludes the wallet-covered types — `lightning`/`ln`/`lnurl` and `bitcoin`/`btc`/`onchain` *are* the existing LIGHTNING and ONCHAIN rails. @@ -330,7 +338,8 @@ new strings; changelog. tinted, so the chip will be the one full-colour thing in the popup. Recommend **accepting** it as the "this leaves the app" signal — but it is a visible break from the rail iconography and worth an explicit yes. -6. **Symmetry heuristic** — right for closed loops (Venmo, Cash App, UPI), +6. ~~**Symmetry heuristic**~~ — *removed; see the note at the top.* It was + right for closed loops (Venmo, Cash App, UPI), arguably too strict for open ones (Monero: a sender needs a wallet, not a published address). Ship strict; relaxing later is additive. Note that intent discovery already covers much of what symmetry was proxying for, so diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index bd7a6d0057..c8998243e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -64,7 +64,7 @@ data class UiSettings( // and recipient share a payment protocol. Defaults to false: those targets can // be bank or Venmo handles carrying legal names, and this puts them one tap // from every note in the feed. - val showPayToZapChip: Boolean = false, + val showPayToZapChip: Boolean = true, ) enum class ThemeType( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index baa3d31268..d2528689c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -56,7 +56,7 @@ class UiSettingsFlow( val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), val composeSignature: MutableStateFlow = MutableStateFlow(""), val showOnchainWallet: MutableStateFlow = MutableStateFlow(true), - val showPayToZapChip: MutableStateFlow = MutableStateFlow(false), + val showPayToZapChip: MutableStateFlow = MutableStateFlow(true), ) { val listOfFlows: List> = listOf>( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index e2371b53ee..8d075bf688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -189,7 +189,7 @@ class UiSharedPreferences( fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "", showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true, - showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: false, + showPayToZapChip = preferences[UI_SHOW_PAYTO_ZAP_CHIP] ?: true, ) } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt index 6128cbbada..f53cc2144a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/zap/RailCapability.kt @@ -63,9 +63,9 @@ data class RailCapability( */ val onchainMaxSpendableSats: Long? = null, /** - * NIP-A3 targets the sender can hand off to: a protocol both parties publish, - * that no wallet rail already covers, that an installed app can open, on a note - * with no zap split. Empty by default so every existing caller is unchanged. + * NIP-A3 targets the sender can hand off to: the author's published targets + * that no wallet rail already covers and that an installed app can open, on a + * note with no zap split. Empty by default so every existing caller is unchanged. */ val payToTargets: List = emptyList(), ) { @@ -142,7 +142,6 @@ object RailCapabilityResolver { fun peek( baseNote: Note, cashuState: CashuWalletState, - senderPayToTargets: List = emptyList(), payToEnabled: Boolean = false, ): RailCapability { val author = baseNote.author?.pubkeyHex @@ -184,7 +183,7 @@ object RailCapabilityResolver { hasOnchain = hasOnchain, cashuBestSingleMintSats = cashuFunding?.bestSingleMintSats ?: 0L, cashuTotalWalletSats = cashuFunding?.totalWalletSats ?: 0L, - payToTargets = payToTargets(baseNote, splits, senderPayToTargets, payToEnabled), + payToTargets = payToTargets(baseNote, splits, payToEnabled), ) } @@ -196,14 +195,12 @@ object RailCapabilityResolver { private fun payToTargets( baseNote: Note, splits: List, - senderTargets: List, enabled: Boolean, ): List = PayToRailMatcher.selectFor( enabled = enabled, hasAuthor = baseNote.author != null, hasZapSplit = splits.isNotEmpty(), - senderTargets = senderTargets, // An unresolvable URI would open nothing, so the chip is not offered. // Web targets always resolve; there the probe only decides the icon. canOpen = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt index 6ae52fd735..4970e9aec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/payments/PayToAppAvailability.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap /** What the device can do with one `payto` target type. */ @@ -58,11 +59,10 @@ data class PayToAppInfo( * scheme. The declarations are deliberately `` filters rather than * `QUERY_ALL_PACKAGES`, which is policy-restricted on Play. * - * **Why this is not a per-post lookup.** The chip only ever appears for - * protocols the *sender themself* publishes, so [warm] probes the sender's own - * target list — a handful of entries, refreshed when that list changes or the - * app returns to the foreground. Feed rendering never triggers a probe; it only - * reads [peek]. + * **Why this is not a per-post lookup.** [warm] probes the targets of the one + * author whose zap picker is open — a handful of entries — and merges the answers + * into a cache keyed by scheme+host, so a type already probed for someone else is + * simply refreshed. Feed rendering never triggers a probe; it only reads [peek]. * * The result is a [StateFlow] rather than a plain map because a bare map write * is invisible to Compose: the chip would stay missing until some unrelated @@ -89,7 +89,12 @@ object PayToAppAvailability { fun peek(rawType: String): PayToAppInfo? = state.value[PaymentTargetTypes.probeKeyFor(rawType)] /** - * Probes every distinct type in [myTargets] and replaces the cache. + * Probes every distinct type in [targets] and merges the answers into the cache. + * + * Merging rather than replacing: the probe set is one author's target list, so + * replacing would evict what was learned about every other author the moment a + * second picker opened. Re-probing an already-known type is the point — that is + * how a newly installed app becomes visible — and the merge just overwrites it. * * Blocking: `loadIcon` reads the target APK's resources. Call from `Dispatchers.IO`. * [iconPx] is the size the chip draws at — decoding once here is what keeps @@ -97,29 +102,27 @@ object PayToAppAvailability { */ fun warm( context: Context, - myTargets: List, + targets: List, iconPx: Int, ) { val pm = context.packageManager val keys = - myTargets + targets .asSequence() .map { it.type } .filterNot { PaymentTargetTypes.isWalletCovered(it) } .distinctBy { PaymentTargetTypes.probeKeyFor(it) } .toList() - if (keys.isEmpty()) { - state.value = emptyMap() - return - } + if (keys.isEmpty()) return // Only https targets need the control probe; skip the extra query otherwise. val browsers = if (keys.any(PaymentTargetTypes::isWebTarget)) browserPackages(pm) else emptySet() - state.value = + val probed = keys.associate { type -> PaymentTargetTypes.probeKeyFor(type) to probe(pm, type, browsers, iconPx) } + state.update { it + probed } } private fun probe( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index b942707d59..8304775453 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -2142,19 +2142,18 @@ fun observeZapRailCapability( // round-trip and only says *when* to re-run the resolver. val showPayToChip by accountViewModel.settings.uiSettingsFlow.showPayToZapChip .collectAsStateWithLifecycle() - val myPayToTargets by accountViewModel.account.paymentTargetsState.flow - .collectAsStateWithLifecycle() val recipientPayTo = author?.let { observeNoteEvent(it.paymentTargetsNote, accountViewModel).value } val payToApps by PayToAppAvailability.flow.collectAsStateWithLifecycle() - // The probe set is the *sender's* target list, so this is bounded by how many - // ways the user says they can be paid — not by anything that grows with the - // feed. It runs when the picker opens, never while scrolling. + // The probe set is this one author's target list — a handful of entries, and + // only for the author whose picker is open. It runs when the picker opens, + // never while scrolling. val context = LocalContext.current val iconPx = with(LocalDensity.current) { PayToIconSize.roundToPx() } - LaunchedEffect(myPayToTargets, showPayToChip) { - if (showPayToChip && myPayToTargets.isNotEmpty()) { - withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, myPayToTargets, iconPx) } + LaunchedEffect(recipientPayTo, showPayToChip) { + val targets = recipientPayTo?.paymentTargets().orEmpty() + if (showPayToChip && targets.isNotEmpty()) { + withContext(Dispatchers.IO) { PayToAppAvailability.warm(context, targets, iconPx) } } } @@ -2178,11 +2177,10 @@ fun observeZapRailCapability( recipientInfo, nutzapInfo, showPayToChip, - myPayToTargets, recipientPayTo, payToApps, ) { - val rc = RailCapabilityResolver.peek(baseNote, cashuState, myPayToTargets, showPayToChip) + val rc = RailCapabilityResolver.peek(baseNote, cashuState, showPayToChip) if (onchainEnabled) { rc.copy(onchainMaxSpendableSats = onchainFunds?.maxSpendableSats) } else { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 32049f645e..31c37ef9e9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -879,8 +879,8 @@ No app installed to handle %1$s payments. Please install a compatible wallet. Pay in %1$s — you choose the amount there Pay-to hand-off - When you and the author both publish the same payment method (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change. - Offer shared payment methods + When the author publishes a payment method an app on this phone can open (Venmo, Monero, an IBAN…), the zap picker can offer it. Tapping opens that app, which asks for the amount — nothing is published to relays, so the note\'s zap count does not change. + Offer the author\'s payment methods Unable to open payment BOLT12 Offers diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt index fc51fb8d7c..fa8bccab2f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PayToRailMatcher.kt @@ -23,42 +23,25 @@ package com.vitorpamplona.amethyst.commons.model.payments import com.vitorpamplona.quartz.experimental.nipA3.PaymentTarget /** - * Picks the NIP-A3 payment targets a sender can plausibly use to pay a - * recipient: the recipient's targets whose protocol the sender also publishes. + * Picks the NIP-A3 payment targets a sender can hand off to when paying a note's + * author. * - * The symmetry rule is a proxy for "I can actually pay this way" and is exactly - * right for closed loops — both parties need Venmo accounts for a Venmo - * transfer to mean anything. It is arguably too strict for open protocols - * (paying a Monero address needs a wallet, not a published address of one), but - * it starts conservative: relaxing it later only ever adds chips. - * - * It also bounds the installed-app probe. Because only protocols the *sender* - * declares can ever be shown, the probe set is the sender's own target list — - * a handful of entries — rather than anything that grows with the feed. + * The rule is capability, not symmetry: a target is offered when something on + * this device can actually open its URI. Paying a Monero address needs a wallet, + * not a published address of one — so what the sender happens to publish about + * themselves says nothing about whether they can pay, and is not consulted. */ object PayToRailMatcher { /** - * Recipient targets payable by symmetry, de-duplicated by canonical type and - * in the recipient's published order. + * The recipient's payable targets, de-duplicated by canonical type and in the + * recipient's published order. * * Wallet-covered types (lightning, bitcoin) are dropped: those are the * picker's existing Lightning and on-chain rails, and re-offering them as a * hand-off would draw a second bolt icon beside the first. */ - fun match( - senderTargets: List, - recipientTargets: List, - ): List { - if (senderTargets.isEmpty() || recipientTargets.isEmpty()) return emptyList() - - val senderTypes = - senderTargets - .asSequence() - .map { PaymentTargetTypes.canonical(it.type) } - .filterNot { it.isEmpty() || PaymentTargetTypes.isWalletCovered(it) } - .toSet() - - if (senderTypes.isEmpty()) return emptyList() + fun match(recipientTargets: List): List { + if (recipientTargets.isEmpty()) return emptyList() val seen = mutableSetOf() return recipientTargets.filter { target -> @@ -66,14 +49,10 @@ object PayToRailMatcher { type.isNotEmpty() && target.authority.isNotBlank() && !PaymentTargetTypes.isWalletCovered(type) && - type in senderTypes && seen.add(type) } } - /** With discovery filtering, 0-1 is the normal case; the cap stops a wide popup. */ - const val MAX_CHIPS = 2 - /** * Every gate on the hand-off chip, as one pure decision. * @@ -83,7 +62,8 @@ object PayToRailMatcher { * @param hasAuthor a note with no author pubkey has nobody to pay. * @param hasZapSplit a `payto` hand-off leaves with one authority and returns * no receipt, so it cannot honour a note that asks to divide the zap. - * @param canOpen whether an installed app resolves this target's URI. + * @param canOpen whether an installed app resolves this target's URI. This is + * the substantive gate: everything else here is a precondition. * @param recipientTargets read lazily. Parsing the recipient's kind:10133 walks * its tag array and allocates, and the common case is that a gate has already * failed — the setting is off, or the note carries a split — so the cheap @@ -93,11 +73,10 @@ object PayToRailMatcher { enabled: Boolean, hasAuthor: Boolean, hasZapSplit: Boolean, - senderTargets: List, canOpen: (PaymentTarget) -> Boolean, recipientTargets: () -> List, ): List { - if (!enabled || !hasAuthor || hasZapSplit || senderTargets.isEmpty()) return emptyList() - return match(senderTargets, recipientTargets()).filter(canOpen).take(MAX_CHIPS) + if (!enabled || !hasAuthor || hasZapSplit) return emptyList() + return match(recipientTargets()).filter(canOpen) } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt index 3596f1cca5..3c63157b04 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/payments/PaymentTargetTypesTest.kt @@ -100,58 +100,54 @@ class PayToRailMatcherTest { authority: String = "handle", ) = PaymentTarget(type, authority) - @Test - fun noSenderTargetsMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(emptyList(), listOf(t("venmo")))) - } - @Test fun noRecipientTargetsMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), emptyList())) + assertEquals(emptyList(), PayToRailMatcher.match(emptyList())) } @Test - fun noSharedProtocolMeansNoChips() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("paypal")))) - } - - @Test - fun sharedProtocolMatchesAcrossCaseAndWhitespace() { - val out = PayToRailMatcher.match(listOf(t(" VENMO ")), listOf(t("venmo", "vitor"))) - assertEquals(listOf(t("venmo", "vitor")), out) + fun aTargetIsOfferedRegardlessOfWhatTheSenderPublishes() { + // Capability, not symmetry: paying a Monero address needs a wallet, not a + // published address of one, so the sender's own list is never consulted. + val out = PayToRailMatcher.match(listOf(t("monero", "theirs"))) + assertEquals(listOf(t("monero", "theirs")), out) } @Test fun walletCoveredTypesNeverProduceAChip() { - // Both sides publish lightning and bitcoin, but those ARE the existing - // rails — matching them would draw a second bolt beside the first. - val both = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z")) - assertEquals(emptyList(), PayToRailMatcher.match(both, both)) + // Those ARE the existing rails — matching them would draw a second bolt + // beside the first. + val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q"), t("ln", "x@y.z")) + assertEquals(emptyList(), PayToRailMatcher.match(wallets)) } @Test - fun aliasesOnEitherSideStillMatch() { - val out = PayToRailMatcher.match(listOf(t("xmr", "mine")), listOf(t("monero", "theirs"))) - assertEquals(listOf(t("monero", "theirs")), out) + fun aliasesCollapseToOneChip() { + val out = PayToRailMatcher.match(listOf(t("xmr", "first"), t("monero", "second"))) + assertEquals(listOf(t("xmr", "first")), out) } @Test fun oneChipPerProtocolKeepingTheFirst() { val recipient = listOf(t("venmo", "first"), t("venmo", "second"), t("monero", "xmr1")) - val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) - assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), out) + assertEquals(listOf(t("venmo", "first"), t("monero", "xmr1")), PayToRailMatcher.match(recipient)) } @Test fun blankAuthoritiesAreSkipped() { - assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo")), listOf(t("venmo", " ")))) + assertEquals(emptyList(), PayToRailMatcher.match(listOf(t("venmo", " ")))) } @Test fun recipientOrderIsPreserved() { val recipient = listOf(t("monero", "m"), t("venmo", "v")) - val out = PayToRailMatcher.match(listOf(t("venmo"), t("monero")), recipient) - assertEquals(listOf("monero", "venmo"), out.map { it.type }) + assertEquals(listOf("monero", "venmo"), PayToRailMatcher.match(recipient).map { it.type }) + } + + @Test + fun typesAreNormalisedBeforeDeduping() { + val out = PayToRailMatcher.match(listOf(t(" VENMO ", "first"), t("venmo", "second"))) + assertEquals(listOf(t(" VENMO ", "first")), out) } } @@ -162,7 +158,6 @@ class PayToRailGateTest { authority: String = "handle", ) = PaymentTarget(type, authority) - private val mine = listOf(t("venmo", "me"), t("monero", "myxmr")) private val theirs = listOf(t("venmo", "them"), t("monero", "theirxmr")) private val anyAppOpens: (PaymentTarget) -> Boolean = { true } @@ -170,10 +165,9 @@ class PayToRailGateTest { enabled: Boolean = true, hasAuthor: Boolean = true, hasZapSplit: Boolean = false, - sender: List = mine, recipient: List = theirs, canOpen: (PaymentTarget) -> Boolean = anyAppOpens, - ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, sender, canOpen) { recipient } + ) = PayToRailMatcher.selectFor(enabled, hasAuthor, hasZapSplit, canOpen) { recipient } @Test fun offeredWhenEveryGatePasses() { @@ -209,9 +203,9 @@ class PayToRailGateTest { } @Test - fun cappedSoThePopupCannotGrowWithoutBound() { + fun everyOpenableTargetIsOfferedWithNoCap() { val many = listOf(t("venmo"), t("monero"), t("pix"), t("upi"), t("iban")) - assertEquals(PayToRailMatcher.MAX_CHIPS, select(sender = many, recipient = many).size) + assertEquals(many.size, select(recipient = many).size) } @Test @@ -224,19 +218,17 @@ class PayToRailGateTest { theirs } - PayToRailMatcher.selectFor(false, true, false, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, false, false, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, true, true, mine, anyAppOpens, counted) - PayToRailMatcher.selectFor(true, true, false, emptyList(), anyAppOpens, counted) + PayToRailMatcher.selectFor(false, true, false, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, false, false, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, true, anyAppOpens, counted) assertEquals(0, reads) - PayToRailMatcher.selectFor(true, true, false, mine, anyAppOpens, counted) + PayToRailMatcher.selectFor(true, true, false, anyAppOpens, counted) assertEquals(1, reads) } @Test fun lightningAndBitcoinStayWithTheirOwnRails() { - val wallets = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")) - assertEquals(emptyList(), select(sender = wallets, recipient = wallets)) + assertEquals(emptyList(), select(recipient = listOf(t("lightning", "a@b.c"), t("btc", "bc1q")))) } }