diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx
index 5a97981e..a2d9982e 100644
--- a/src/components/PaytoDialog/index.tsx
+++ b/src/components/PaytoDialog/index.tsx
@@ -14,7 +14,6 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
-import type { PaytoPaymentOpenHandler } from '@/lib/payto'
import { ArrowRight, Copy, Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { closeModal } from '@getalby/bitcoin-connect-react'
@@ -23,8 +22,9 @@ import { toast } from 'sonner'
import { releaseBodyScrollLocks } from '@/lib/react-remove-scroll-body-cleanup'
import {
filterPaytoPaymentOpenHandlersForDevice,
- getPaytoPaymentOpenHandlers,
- getPaytoTypeInfo
+ getPaytoTypeInfo,
+ openPaytoPaymentTarget,
+ resolvePaytoPaymentOpenHandlers
} from '@/lib/payto'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
@@ -76,7 +76,7 @@ export default function PaytoDialog({
const openHandlers = useMemo(
() =>
filterPaytoPaymentOpenHandlersForDevice(
- getPaytoPaymentOpenHandlers(type, authority, { bolt11Invoice })
+ resolvePaytoPaymentOpenHandlers(type, authority, { bolt11Invoice })
),
[type, authority, bolt11Invoice]
)
@@ -99,17 +99,6 @@ export default function PaytoDialog({
[openHandlers, selectedOpenHandlerId]
)
- const openSelectedHandler = useCallback(
- (handler: PaytoPaymentOpenHandler) => {
- if (handler.isHttp) {
- window.open(handler.href, '_blank', 'noopener,noreferrer')
- return
- }
- window.location.assign(handler.href)
- },
- []
- )
-
const handleCopy = (text: string, copyLabel?: string) => {
navigator.clipboard.writeText(text)
toast.success(copyLabel ? t('Copied {{label}} address', { label: copyLabel }) : t('Copied to clipboard'))
@@ -241,7 +230,7 @@ export default function PaytoDialog({
: t('Open', { defaultValue: 'Open' })
}
onClick={() => {
- if (selectedOpenHandler) openSelectedHandler(selectedOpenHandler)
+ if (selectedOpenHandler) openPaytoPaymentTarget(selectedOpenHandler)
}}
>
diff --git a/src/data/payto-types.json b/src/data/payto-types.json
index 5aa2fe91..289889e6 100644
--- a/src/data/payto-types.json
+++ b/src/data/payto-types.json
@@ -7,7 +7,18 @@
"bolt11Invoice": {
"paytoType": "lightning",
"coinScheme": "lightning",
- "walletApps": ["phoenix", "zeus", "bluewallet"]
+ "walletApps": [
+ "phoenix",
+ "zeus",
+ "bluewallet",
+ "muun",
+ "walletofsatoshi",
+ "blink",
+ "breez",
+ "alby",
+ "bitkit",
+ "blockstream"
+ ]
}
},
"walletApps": {
@@ -19,17 +30,62 @@
"phoenix": {
"label": "Phoenix",
"mobileOnly": true,
- "uriTemplate": "phoenix:{coinScheme}:{authority}"
+ "builder": "phoenix"
},
"zeus": {
"label": "Zeus",
"mobileOnly": true,
- "uriTemplate": "zeusln:lightning:{authority}"
+ "builder": "zeus"
},
"bluewallet": {
"label": "BlueWallet",
"mobileOnly": true,
- "uriTemplate": "bluewallet:lightning:{authority}"
+ "builder": "bluewallet"
+ },
+ "muun": {
+ "label": "Muun",
+ "mobileOnly": true,
+ "uriTemplate": "muun:lightning:{authority}"
+ },
+ "walletofsatoshi": {
+ "label": "Wallet of Satoshi",
+ "mobileOnly": true,
+ "uriTemplate": "walletofsatoshi:lightning:{authority}"
+ },
+ "blink": {
+ "label": "Blink",
+ "mobileOnly": true,
+ "uriTemplate": "blink:lightning:{authority}"
+ },
+ "breez": {
+ "label": "Breez",
+ "mobileOnly": true,
+ "uriTemplate": "breez:{authority}"
+ },
+ "alby": {
+ "label": "Alby",
+ "mobileOnly": true,
+ "uriTemplate": "alby:lightning:{authority}"
+ },
+ "bitkit": {
+ "label": "Bitkit",
+ "mobileOnly": true,
+ "uriTemplate": "bitkit:lightning:{authority}"
+ },
+ "blockstream": {
+ "label": "Blockstream Green",
+ "mobileOnly": true,
+ "uriTemplate": "blockstream:lightning:{authority}"
+ },
+ "blixt": {
+ "label": "Blixt",
+ "mobileOnly": false,
+ "uriTemplate": "blixtwallet:{authority}"
+ },
+ "ledger": {
+ "label": "Ledger Live",
+ "mobileOnly": false,
+ "builder": "ledger"
}
},
"kind0CryptocurrencyAddresses": {
@@ -118,7 +174,7 @@
"logoAssetPath": "src/assets/payto_logos/Bitcoin.svg",
"walletOpen": {
"scheme": "bitcoin",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "bc1q…",
@@ -133,7 +189,7 @@
"walletOpen": {
"scheme": "bolt12",
"requirePrefix": "lno1",
- "walletApps": ["phoenix", "zeus"]
+ "walletApps": ["phoenix", "zeus", "blixt"]
},
"authority": {
"placeholder": "lno1…",
@@ -147,7 +203,7 @@
"walletOpen": {
"scheme": "lightning",
"requireAtSign": true,
- "walletApps": ["phoenix", "zeus"]
+ "walletApps": ["phoenix", "zeus", "muun", "blink", "walletofsatoshi", "breez", "alby", "bitkit"]
},
"authority": {
"placeholder": "user@example.com",
@@ -162,7 +218,7 @@
"walletOpen": {
"scheme": "bitcoin",
"requirePrefix": "sp1",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "sp1…",
@@ -210,7 +266,7 @@
"logoAssetPath": "src/assets/payto_logos/ethereum-eth-logo.svg",
"walletOpen": {
"scheme": "ethereum",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "0x…",
@@ -224,7 +280,7 @@
"logoAssetPath": "src/assets/payto_logos/Monero.png",
"walletOpen": {
"scheme": "monero",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "4… or 8…",
@@ -284,7 +340,7 @@
"logoAssetPath": "src/assets/payto_logos/bitcoin-cash-bch-logo.svg",
"walletOpen": {
"scheme": "bitcoincash",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "bitcoincash:… or q…",
@@ -298,7 +354,7 @@
"logoAssetPath": "src/assets/payto_logos/dogecoin-doge-logo.svg",
"walletOpen": {
"scheme": "dogecoin",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "D…",
@@ -312,7 +368,7 @@
"logoAssetPath": "src/assets/payto_logos/Litecoin.png",
"walletOpen": {
"scheme": "litecoin",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "ltc1q… or L… or M…",
@@ -366,7 +422,7 @@
"logoAssetPath": "src/assets/payto_logos/solana.png",
"walletOpen": {
"scheme": "solana",
- "walletApps": ["cakewallet"]
+ "walletApps": ["cakewallet", "ledger"]
},
"authority": {
"placeholder": "Base58 pubkey…",
diff --git a/src/lib/payto-registry.ts b/src/lib/payto-registry.ts
index b64815ee..1ae25e4b 100644
--- a/src/lib/payto-registry.ts
+++ b/src/lib/payto-registry.ts
@@ -1,11 +1,10 @@
/**
- * Loads payto type metadata from {@link ../data/payto-types.json}.
- * Edit that JSON to add types, editor order, hints, logos, and profile URL templates.
+ * Payto type metadata from {@link ../data/payto-types.json}.
+ * URI / “Open with” resolution lives in {@link ./payto-targets}.
*/
import paytoTypesCatalog from '@/data/payto-types.json'
import { resolvePaytoLogoAssetPath } from '@/lib/payto-logos'
-import { getPaytoPrimaryOpenUrl } from '@/lib/payto-wallet-open'
import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
export type PaytoCategory = 'bitcoin' | 'bitcoin-layer' | 'crypto' | 'stablecoin' | 'fiat' | 'tip'
@@ -15,6 +14,17 @@ export type PaytoAuthorityHelp = {
hint: string
}
+export type PaytoWalletAppBuilderId = 'phoenix' | 'zeus' | 'bluewallet' | 'ledger'
+
+export type PaytoWalletAppRecord = {
+ label: string
+ mobileOnly?: boolean
+ /** Used when {@link builder} is unset. Supports `{coinScheme}` and `{authority}`. */
+ uriTemplate?: string
+ /** Custom deep-link builder; takes precedence over {@link uriTemplate}. */
+ builder?: PaytoWalletAppBuilderId
+}
+
export type PaytoWalletOpenRow = {
scheme?: string
style?: 'path' | 'query'
@@ -23,45 +33,52 @@ export type PaytoWalletOpenRow = {
requireAtSign?: boolean
requirePrefix?: string
walletApps?: string[]
- /** When true, {@link walletApps} are hidden until PaytoDialog has a BOLT11 (see catalog `_openWith.bolt11Invoice`). */
+ /** When true, {@link walletApps} are hidden until PaytoDialog supplies a BOLT11 (see `_openWith.bolt11Invoice`). */
deferWalletAppsUntilBolt11?: boolean
}
+export type PaytoBolt11InvoiceOpenConfig = {
+ paytoType?: string
+ coinScheme?: string
+ walletApps: string[]
+}
+
+export type PaytoOpenWithConfig = {
+ bolt11Invoice?: PaytoBolt11InvoiceOpenConfig
+}
+
export type PaytoTypeRecord = {
label: string
symbol?: string
category: PaytoCategory
- /** Repo-relative path, e.g. `src/assets/payto_logos/ethereum-eth-logo.svg`. */
logoAssetPath?: string
profileUrlTemplate?: string
- /** PaytoDialog “Open with” mode; `paypal` uses the PayPal URL resolver only. */
+ /** PaytoDialog “Open with”: `paypal` uses the PayPal URL resolver only. */
paymentOpen?: 'paypal'
- /** Native wallet URI / app deep link (see {@link getPaytoPrimaryOpenUrl}). */
walletOpen?: PaytoWalletOpenRow
authority?: PaytoAuthorityHelp
}
-type PaytoTypesCatalogJson = {
+export type PaytoTypesCatalog = {
+ _openWith?: PaytoOpenWithConfig
+ walletApps?: Record
editorOrder: string[]
genericAuthorityHelp: PaytoAuthorityHelp
aliases: Record
types: Record
+ kind0CryptocurrencyAddresses?: Record
+ kind0RootPaymentFields?: Record
}
-const catalog = paytoTypesCatalog as PaytoTypesCatalogJson
+const catalog = paytoTypesCatalog as PaytoTypesCatalog
export const PAYTO_EDITOR_TYPE_ORDER: readonly string[] = catalog.editorOrder
-
-/** Select value: opens free-text payto type field (not published as this literal). */
export const PAYTO_EDITOR_OTHER_OPTION = '__other__'
const GENERIC_AUTHORITY_HELP: PaytoAuthorityHelp = catalog.genericAuthorityHelp
-
const PAYTO_TYPE_ALIASES: Record = catalog.aliases
-
const PAYTO_TYPES: Record = catalog.types
-/** UI summary per canonical type (label, symbol, category). */
export const PAYTO_KNOWN_TYPES: Record<
string,
{ label: string; symbol?: string; category: PaytoCategory }
@@ -72,6 +89,10 @@ export const PAYTO_KNOWN_TYPES: Record<
])
)
+export function getPaytoCatalog(): PaytoTypesCatalog {
+ return catalog
+}
+
export function getCanonicalPaytoType(type: string): string {
const key = type.toLowerCase().trim()
return PAYTO_TYPE_ALIASES[key] ?? key
@@ -81,6 +102,16 @@ export function getPaytoTypeRecord(type: string): PaytoTypeRecord | undefined {
return PAYTO_TYPES[getCanonicalPaytoType(type)]
}
+export function getWalletApp(appId: string): PaytoWalletAppRecord | undefined {
+ return catalog.walletApps?.[appId]
+}
+
+export function getBolt11InvoiceOpenConfig(): PaytoBolt11InvoiceOpenConfig | undefined {
+ const cfg = catalog._openWith?.bolt11Invoice
+ if (!cfg?.walletApps?.length) return undefined
+ return cfg
+}
+
export function getPaytoTypeInfo(type: string): (typeof PAYTO_KNOWN_TYPES)[string] | undefined {
return PAYTO_KNOWN_TYPES[getCanonicalPaytoType(type)]
}
@@ -98,55 +129,38 @@ export function getPaytoEditorTypeLabel(type: string): string {
return getPaytoTypeInfo(type)?.label ?? getCanonicalPaytoType(type)
}
-/** True when the row uses a custom payto type (Other selected or unknown type from JSON). */
export function isPaytoEditorCustomType(type: string): boolean {
const trimmed = type.trim()
if (!trimmed || trimmed === PAYTO_EDITOR_OTHER_OPTION) return true
return !isKnownPaytoType(trimmed)
}
-/** Dropdown options: catalog presets plus “Other”. */
export function paytoEditorSelectTypes(): string[] {
return [...PAYTO_EDITOR_TYPE_ORDER, PAYTO_EDITOR_OTHER_OPTION]
}
-/** Bundled asset URL for `
` (resolved from catalog `logoAssetPath`). */
export function getPaytoLogoPath(type: string): string | null {
return resolvePaytoLogoAssetPath(getPaytoTypeRecord(type)?.logoAssetPath)
}
-/** Same as {@link getPaytoLogoPath}; alias for callers that expect a URL field name. */
export function getPaytoLogoUrl(type: string): string | null {
return getPaytoLogoPath(type)
}
-export function getPaytoProfileUrl(type: string, authority: string): string | null {
- if (!authority.trim()) return null
-
- const canonical = getCanonicalPaytoType(type)
- if (canonical === 'paypal') {
- return resolvePaypalPaymentUrl(authority)
- }
-
- const fromWallet = getPaytoPrimaryOpenUrl(type, authority)
- if (fromWallet) return fromWallet
-
- const template = getPaytoTypeRecord(type)?.profileUrlTemplate
- if (!template) return null
- return template.replace('{authority}', encodeURIComponent(authority.trim()))
-}
-
export function getPaytoIconChar(type: string): string | null {
return getPaytoTypeRecord(type)?.symbol ?? null
}
-/** LUD-16 / LNURL lightning and BIP-353 DNS instructions — payment UI, not on-chain Bitcoin. */
export function isLightningPaytoType(type: string): boolean {
const canonical = getCanonicalPaytoType(type)
return canonical === 'lightning' || canonical === 'bip353'
}
-/** Lightning targets that support zaps (LUD-16 / LNURL only; BIP-353 is pay/copy, not zappable). */
export function isZappableLightningPaytoType(type: string): boolean {
return getCanonicalPaytoType(type) === 'lightning'
}
+
+/** Resolve PayPal targets; other types use {@link resolvePaytoProfileUrl} from payto-targets. */
+export function resolvePaypalProfileUrl(authority: string): string | null {
+ return resolvePaypalPaymentUrl(authority.trim())
+}
diff --git a/src/lib/payto-targets.test.ts b/src/lib/payto-targets.test.ts
new file mode 100644
index 00000000..66289a27
--- /dev/null
+++ b/src/lib/payto-targets.test.ts
@@ -0,0 +1,271 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildLedgerWalletHref,
+ buildPhoenixWalletHref,
+ filterPaytoPaymentOpenHandlersForDevice,
+ isPaytoHttpOpenUrl,
+ resolveNativeWalletUri,
+ resolvePaytoPaymentOpenHandlers,
+ resolvePaytoProfileUrl
+} from './payto-targets'
+
+describe('resolveNativeWalletUri', () => {
+ it('builds monero: URI for primary address', () => {
+ const addr =
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ expect(
+ resolveNativeWalletUri('monero', addr, { scheme: 'monero', walletApps: ['cakewallet'] })
+ ).toBe(`monero:${addr}`)
+ expect(resolvePaytoProfileUrl('monero', addr)).toBe(`monero:${addr}`)
+ })
+
+ it('builds bitcoin: URI', () => {
+ expect(
+ resolveNativeWalletUri('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh', {
+ scheme: 'bitcoin',
+ walletApps: ['cakewallet']
+ })
+ ).toBe('bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')
+ })
+
+ it('maps BIP-353 to lightning: URI', () => {
+ expect(
+ resolveNativeWalletUri('bip353', 'user@example.com', {
+ scheme: 'lightning',
+ requireAtSign: true,
+ walletApps: ['phoenix', 'zeus']
+ })
+ ).toBe('lightning:user@example.com')
+ })
+
+ it('requires lno1 prefix for bolt12', () => {
+ expect(
+ resolveNativeWalletUri('bolt12', 'lno1offer', {
+ scheme: 'bolt12',
+ requirePrefix: 'lno1',
+ walletApps: ['phoenix', 'zeus']
+ })
+ ).toBe('bolt12:lno1offer')
+ expect(
+ resolveNativeWalletUri('bolt12', 'bc1qinvalid', {
+ scheme: 'bolt12',
+ requirePrefix: 'lno1',
+ walletApps: ['phoenix']
+ })
+ ).toBeNull()
+ })
+})
+
+describe('resolvePaytoPaymentOpenHandlers', () => {
+ it('includes Cake Wallet and Ledger Live for monero', () => {
+ const addr =
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const handlers = resolvePaytoPaymentOpenHandlers('monero', addr)
+ const cake = handlers.find((h) => h.openTargetName === 'Cake Wallet')
+ expect(cake).toBeDefined()
+ expect(cake?.href).toBe(`cakewallet:monero?address=${addr}`)
+ expect(cake?.mobileOnly).toBe(true)
+ const ledger = handlers.find((h) => h.openTargetName === 'Ledger Live')
+ expect(ledger?.href).toBe(
+ `ledgerlive://send?currency=monero&recipient=${encodeURIComponent(addr)}`
+ )
+ expect(ledger?.mobileOnly).toBe(false)
+ })
+
+ it('builds Ledger Live send deeplink for on-chain types only', () => {
+ const btc = 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
+ expect(buildLedgerWalletHref('bitcoin', btc)).toBe(
+ `ledgerlive://send?currency=bitcoin&recipient=${encodeURIComponent(btc)}`
+ )
+ expect(buildLedgerWalletHref('lightning', 'lnbc1p0example')).toBeNull()
+ expect(
+ resolvePaytoPaymentOpenHandlers('lightning', 'user@example.com', {
+ bolt11Invoice: 'lnbc1p0example'
+ }).some((h) => h.openTargetName === 'Ledger Live')
+ ).toBe(false)
+ })
+
+ it('hides mobile-only handlers on desktop UA but keeps Ledger Live', () => {
+ const addr =
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const handlers = resolvePaytoPaymentOpenHandlers('monero', addr)
+ const prev = navigator.userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
+ configurable: true
+ })
+ try {
+ const visible = filterPaytoPaymentOpenHandlersForDevice(handlers)
+ expect(visible.some((h) => h.openTargetName === 'Cake Wallet')).toBe(false)
+ expect(visible.some((h) => h.openTargetName === 'Ledger Live')).toBe(true)
+ } finally {
+ Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
+ }
+ })
+
+ it('lists named apps only, not native coin schemes', () => {
+ const addr =
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const monero = resolvePaytoPaymentOpenHandlers('monero', addr)
+ expect(monero.some((h) => h.openTargetName === 'Cake Wallet')).toBe(true)
+ expect(monero.some((h) => h.href.startsWith('monero:'))).toBe(false)
+
+ const btc = resolvePaytoPaymentOpenHandlers('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')
+ expect(btc.find((h) => h.openTargetName === 'Cake Wallet')?.href).toBe(
+ 'cakewallet:bitcoin?address=bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
+ )
+
+ const sp = resolvePaytoPaymentOpenHandlers('bip352', 'sp1qxyz0123456789')
+ expect(sp.find((h) => h.openTargetName === 'Cake Wallet')?.href).toBe(
+ 'cakewallet:bitcoin?address=sp1qxyz0123456789'
+ )
+
+ const cash = resolvePaytoPaymentOpenHandlers('cashme', '$cashtag')
+ expect(cash).toHaveLength(1)
+ expect(cash[0].isHttp).toBe(true)
+ expect(cash[0].openTargetName).toBe('Cash App')
+ expect(cash[0].href).toBe('https://cash.app/%24cashtag')
+ })
+
+ it('builds bolt12 deep links from offer string', () => {
+ const offer =
+ 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs'
+ const handlers = resolvePaytoPaymentOpenHandlers('bolt12', offer)
+ expect(handlers.find((h) => h.openTargetName === 'Phoenix')?.href).toBe(`phoenix:bolt12:${offer}`)
+ expect(handlers.find((h) => h.openTargetName === 'Zeus')?.href).toBe(`zeusln:${offer}`)
+ expect(handlers.find((h) => h.openTargetName === 'Blixt')?.href).toBe(`blixtwallet:${offer}`)
+ })
+
+ it('builds Phoenix lightning deep link without pay?uri query', () => {
+ expect(buildPhoenixWalletHref('lightning', 'user@example.com')).toBe(
+ 'phoenix:lightning:user@example.com'
+ )
+ expect(buildPhoenixWalletHref('lightning', 'lnbc1p0example')).toBe('phoenix:lightning:lnbc1p0example')
+ expect(buildPhoenixWalletHref('lightning', 'lightning:lnbc1p0example')).toBe(
+ 'phoenix:lightning:lnbc1p0example'
+ )
+ })
+
+ it('omits mobile lightning wallets for lightning address until BOLT11 is supplied', () => {
+ const handlers = resolvePaytoPaymentOpenHandlers('lightning', 'user@example.com')
+ expect(handlers.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
+ expect(handlers.some((h) => h.openTargetName === 'Zeus')).toBe(false)
+ expect(handlers.some((h) => h.openTargetName === 'BlueWallet')).toBe(false)
+ })
+
+ it('includes lightning wallet apps on mobile only for bip353', () => {
+ const handlers = resolvePaytoPaymentOpenHandlers('bip353', 'user@example.com')
+ const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix')
+ const muun = handlers.find((h) => h.openTargetName === 'Muun')
+ expect(phoenix?.href).toBe('phoenix:lightning:user@example.com')
+ expect(muun?.href).toBe('muun:lightning:user@example.com')
+ expect(phoenix?.mobileOnly).toBe(true)
+ expect(muun?.mobileOnly).toBe(true)
+ expect(handlers.some((h) => h.openTargetName === 'Blink')).toBe(true)
+ expect(handlers.some((h) => h.openTargetName === 'Blixt')).toBe(false)
+
+ const prev = navigator.userAgent
+ Object.defineProperty(navigator, 'userAgent', {
+ value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
+ configurable: true
+ })
+ try {
+ expect(
+ filterPaytoPaymentOpenHandlersForDevice(handlers).some((h) => h.openTargetName === 'Phoenix')
+ ).toBe(false)
+ } finally {
+ Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
+ }
+ })
+
+ it('lists lightning wallet apps for a resolved BOLT11 invoice', () => {
+ const invoice = 'lnbc1p0example'
+ const handlers = resolvePaytoPaymentOpenHandlers('lightning', 'user@example.com', {
+ bolt11Invoice: invoice
+ })
+ const names = handlers.map((h) => h.openTargetName).sort()
+ expect(names).toEqual([
+ 'Alby',
+ 'Bitkit',
+ 'Blink',
+ 'Blockstream Green',
+ 'BlueWallet',
+ 'Breez',
+ 'Muun',
+ 'Phoenix',
+ 'Wallet of Satoshi',
+ 'Zeus'
+ ])
+ expect(handlers.find((h) => h.openTargetName === 'Phoenix')?.href).toBe(
+ `phoenix:lightning:${invoice}`
+ )
+ expect(handlers.find((h) => h.openTargetName === 'Zeus')?.href).toBe(`zeusln:lightning:${invoice}`)
+ expect(handlers.find((h) => h.openTargetName === 'BlueWallet')?.href).toBe(
+ `bluewallet:lightning:${invoice}`
+ )
+ expect(handlers.find((h) => h.openTargetName === 'Muun')?.href).toBe(`muun:lightning:${invoice}`)
+ expect(handlers.find((h) => h.openTargetName === 'Wallet of Satoshi')?.href).toBe(
+ `walletofsatoshi:lightning:${invoice}`
+ )
+ expect(handlers.find((h) => h.openTargetName === 'Blink')?.href).toBe(`blink:lightning:${invoice}`)
+ expect(handlers.find((h) => h.openTargetName === 'Breez')?.href).toBe(`breez:${invoice}`)
+ expect(handlers.find((h) => h.openTargetName === 'Alby')?.href).toBe(`alby:lightning:${invoice}`)
+ })
+
+ it('scopes lightning BOLT11 handlers to lightning payto only', () => {
+ const invoice = 'lnbc1p0example'
+ const lightning = resolvePaytoPaymentOpenHandlers('lightning', 'user@example.com', {
+ bolt11Invoice: invoice
+ })
+ expect(lightning.map((h) => h.openTargetName).sort()).toEqual([
+ 'Alby',
+ 'Bitkit',
+ 'Blink',
+ 'Blockstream Green',
+ 'BlueWallet',
+ 'Breez',
+ 'Muun',
+ 'Phoenix',
+ 'Wallet of Satoshi',
+ 'Zeus'
+ ])
+ expect(lightning.some((h) => h.openTargetName === 'PayPal')).toBe(false)
+
+ const moneroAddr =
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ const moneroWithBolt11 = resolvePaytoPaymentOpenHandlers('monero', moneroAddr, {
+ bolt11Invoice: invoice
+ })
+ expect(moneroWithBolt11.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
+ expect(moneroWithBolt11.some((h) => h.openTargetName === 'Zeus')).toBe(false)
+ expect(moneroWithBolt11.some((h) => h.openTargetName === 'BlueWallet')).toBe(false)
+
+ expect(
+ resolvePaytoPaymentOpenHandlers('paypal', 'someuser', { bolt11Invoice: invoice }).map(
+ (h) => h.openTargetName
+ )
+ ).toEqual(['PayPal'])
+ })
+
+ it('never mixes fiat web links or lightning wallets across payto types', () => {
+ const monero = resolvePaytoPaymentOpenHandlers(
+ 'monero',
+ '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
+ )
+ expect(monero.some((h) => h.openTargetName === 'PayPal')).toBe(false)
+ expect(monero.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
+ expect(monero.some((h) => h.openTargetName === 'Cake Wallet')).toBe(true)
+
+ const cash = resolvePaytoPaymentOpenHandlers('cashme', '$cashtag')
+ expect(cash).toHaveLength(1)
+ expect(cash[0].openTargetName).toBe('Cash App')
+ expect(cash.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
+ })
+})
+
+describe('isPaytoHttpOpenUrl', () => {
+ it('distinguishes https profile links from wallet schemes', () => {
+ expect(isPaytoHttpOpenUrl('https://paypal.me/foo')).toBe(true)
+ expect(isPaytoHttpOpenUrl('monero:4abc')).toBe(false)
+ })
+})
diff --git a/src/lib/payto-targets.ts b/src/lib/payto-targets.ts
new file mode 100644
index 00000000..1ba44b0e
--- /dev/null
+++ b/src/lib/payto-targets.ts
@@ -0,0 +1,324 @@
+/**
+ * Payto payment targets: wallet deep links, profile URLs, and PaytoDialog “Open with” handlers.
+ * Driven entirely by {@link ../data/payto-types.json} (see {@link ./payto-registry}).
+ */
+
+import {
+ getBolt11InvoiceOpenConfig,
+ getCanonicalPaytoType,
+ getPaytoTypeRecord,
+ getWalletApp,
+ resolvePaypalProfileUrl,
+ type PaytoWalletAppBuilderId,
+ type PaytoWalletOpenRow
+} from '@/lib/payto-registry'
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+/** Labeled target for PaytoDialog dropdown + open button. */
+export type PaytoPaymentOpenHandler = {
+ id: string
+ openTargetName: string
+ href: string
+ isHttp: boolean
+ mobileOnly?: boolean
+}
+
+export type PaytoPaymentOpenContext = {
+ /** LUD-16 lightning address resolved to BOLT11 in PaytoDialog. */
+ bolt11Invoice?: string | null
+}
+
+// ─── URI helpers ─────────────────────────────────────────────────────────────
+
+function trimAuthority(authority: string): string {
+ return authority.trim()
+}
+
+function substituteAuthority(template: string, authority: string): string {
+ return template.split('{authority}').join(authority)
+}
+
+function buildQueryUri(
+ scheme: string,
+ path: string,
+ query: Record,
+ authority: string
+): string {
+ const params = new URLSearchParams()
+ for (const [key, raw] of Object.entries(query)) {
+ params.set(key, substituteAuthority(raw, authority))
+ }
+ const base = path ? `${scheme}:${path}` : `${scheme}:`
+ const qs = params.toString()
+ return qs ? `${base}?${qs}` : base
+}
+
+/** Native `scheme:…` URI from a type’s `walletOpen` row (not app deep links). */
+export function resolveNativeWalletUri(
+ paytoType: string,
+ authority: string,
+ row: PaytoWalletOpenRow | undefined
+): string | null {
+ if (!row) return null
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ if (row.requireAtSign && !auth.includes('@')) return null
+ if (row.requirePrefix) {
+ const prefix = row.requirePrefix.toLowerCase()
+ if (!auth.toLowerCase().startsWith(prefix)) return null
+ }
+ if (/^https?:\/\//i.test(auth)) return auth
+
+ const scheme = (row.scheme ?? paytoType).toLowerCase()
+ if (row.style === 'query' && row.query) {
+ return buildQueryUri(scheme, row.path ?? '', row.query, auth)
+ }
+ const pathPart = row.path ? `${row.path}/` : ''
+ return `${scheme}:${pathPart}${auth}`
+}
+
+export function buildPhoenixWalletHref(coinScheme: string, authority: string): string | null {
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ const scheme = coinScheme.toLowerCase().trim()
+ if (!scheme) return null
+ const payload = auth.replace(/^lightning:/i, '')
+ return `phoenix:${scheme}:${payload}`
+}
+
+export function buildZeusWalletHref(coinScheme: string, authority: string): string | null {
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ const scheme = coinScheme.toLowerCase().trim()
+ if (!scheme) return null
+ const payload = auth.replace(/^lightning:/i, '')
+ if (scheme === 'bolt12' || /^lno1/i.test(payload)) {
+ return `zeusln:${payload}`
+ }
+ return `zeusln:lightning:${payload}`
+}
+
+export function buildBlueWalletWalletHref(authority: string): string | null {
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+ const payload = auth.replace(/^lightning:/i, '')
+ if (!payload) return null
+ return `bluewallet:lightning:${payload}`
+}
+
+/** Ledger Live `currency` query values for {@link ledgerlive://send}. */
+const LEDGER_SEND_CURRENCY: Record = {
+ bitcoin: 'bitcoin',
+ ethereum: 'ethereum',
+ monero: 'monero',
+ litecoin: 'litecoin',
+ dogecoin: 'dogecoin',
+ bitcoincash: 'bitcoin_cash',
+ solana: 'solana'
+}
+
+/** Opens Send with currency + recipient (desktop); mobile at least prefills currency. */
+export function buildLedgerWalletHref(coinScheme: string, authority: string): string | null {
+ const currency = LEDGER_SEND_CURRENCY[coinScheme.toLowerCase()]
+ if (!currency) return null
+ const recipient = trimAuthority(authority)
+ if (!recipient) return null
+ const params = new URLSearchParams({ currency, recipient })
+ return `ledgerlive://send?${params.toString()}`
+}
+
+const WALLET_APP_BUILDERS: Record<
+ PaytoWalletAppBuilderId,
+ (coinScheme: string, authority: string) => string | null
+> = {
+ phoenix: buildPhoenixWalletHref,
+ zeus: buildZeusWalletHref,
+ bluewallet: (_coinScheme, authority) => buildBlueWalletWalletHref(authority),
+ ledger: buildLedgerWalletHref
+}
+
+/** Deep link for a catalog `walletApps` entry and payto type context. */
+export function resolveWalletAppHref(
+ appId: string,
+ paytoType: string,
+ authority: string,
+ walletOpen: PaytoWalletOpenRow | undefined
+): string | null {
+ const app = getWalletApp(appId)
+ if (!app) return null
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+
+ const coinScheme = (walletOpen?.scheme ?? paytoType).toLowerCase()
+ if (app.builder) {
+ const build = WALLET_APP_BUILDERS[app.builder]
+ if (build) return build(coinScheme, auth)
+ }
+ if (!app.uriTemplate) return null
+ const payload =
+ coinScheme === 'lightning' || coinScheme === 'bolt12'
+ ? auth.replace(/^lightning:/i, '')
+ : auth
+ return substituteAuthority(app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme), payload)
+}
+
+/** Primary open URL: native wallet URI, then profile web template. */
+export function resolvePaytoProfileUrl(type: string, authority: string): string | null {
+ const auth = trimAuthority(authority)
+ if (!auth) return null
+
+ const canonical = getCanonicalPaytoType(type)
+ if (getPaytoTypeRecord(canonical)?.paymentOpen === 'paypal') {
+ return resolvePaypalProfileUrl(auth)
+ }
+
+ const record = getPaytoTypeRecord(canonical)
+ const fromWallet = resolveNativeWalletUri(canonical, auth, record?.walletOpen)
+ if (fromWallet) return fromWallet
+
+ const template = record?.profileUrlTemplate
+ if (!template) return null
+ return substituteAuthority(template, encodeURIComponent(auth))
+}
+
+export function isPaytoHttpOpenUrl(url: string | null | undefined): boolean {
+ return !!url && /^https?:\/\//i.test(url)
+}
+
+// ─── Payment open handlers (PaytoDialog) ─────────────────────────────────────
+
+function dedupeHandlers(handlers: PaytoPaymentOpenHandler[]): PaytoPaymentOpenHandler[] {
+ const seen = new Set()
+ return handlers.filter((h) => {
+ if (seen.has(h.href)) return false
+ seen.add(h.href)
+ return true
+ })
+}
+
+function handlerFromWalletApp(
+ appId: string,
+ paytoType: string,
+ authority: string,
+ walletOpen: PaytoWalletOpenRow | undefined,
+ idSuffix = ''
+): PaytoPaymentOpenHandler | null {
+ const app = getWalletApp(appId)
+ const href = resolveWalletAppHref(appId, paytoType, authority, walletOpen)
+ if (!app || !href) return null
+ const canonical = getCanonicalPaytoType(paytoType)
+ return {
+ id: `${canonical}-${appId}${idSuffix}`,
+ openTargetName: app.label,
+ href,
+ isHttp: false,
+ mobileOnly: app.mobileOnly !== false
+ }
+}
+
+function handlersForWalletAppIds(
+ appIds: string[],
+ paytoType: string,
+ authority: string,
+ walletOpen: PaytoWalletOpenRow | undefined,
+ idSuffix = ''
+): PaytoPaymentOpenHandler[] {
+ const handlers: PaytoPaymentOpenHandler[] = []
+ for (const appId of appIds) {
+ const h = handlerFromWalletApp(appId, paytoType, authority, walletOpen, idSuffix)
+ if (h) handlers.push(h)
+ }
+ return handlers
+}
+
+function handlersForBolt11Invoice(invoice: string): PaytoPaymentOpenHandler[] {
+ const cfg = getBolt11InvoiceOpenConfig()
+ if (!cfg) return []
+
+ const paytoType = getCanonicalPaytoType(cfg.paytoType ?? 'lightning')
+ const walletOpen = getPaytoTypeRecord(paytoType)?.walletOpen
+ return handlersForWalletAppIds(cfg.walletApps, paytoType, invoice, walletOpen, '-bolt11')
+}
+
+function handlerForProfileWeb(
+ canonical: string,
+ label: string,
+ authority: string,
+ template: string
+): PaytoPaymentOpenHandler {
+ return {
+ id: `${canonical}-web`,
+ openTargetName: label,
+ href: substituteAuthority(template, encodeURIComponent(authority)),
+ isHttp: true,
+ mobileOnly: false
+ }
+}
+
+/**
+ * “Open with” options for PaytoDialog for one payto type + authority.
+ * Only includes targets configured in the catalog for that type.
+ */
+export function resolvePaytoPaymentOpenHandlers(
+ type: string,
+ authority: string,
+ context?: PaytoPaymentOpenContext
+): PaytoPaymentOpenHandler[] {
+ const canonical = getCanonicalPaytoType(type)
+ const record = getPaytoTypeRecord(canonical)
+ const auth = trimAuthority(authority)
+ if (!auth || !record) return []
+
+ const handlers: PaytoPaymentOpenHandler[] = []
+
+ if (record.paymentOpen === 'paypal') {
+ const href = resolvePaypalProfileUrl(auth)
+ if (href) {
+ handlers.push({
+ id: 'paypal',
+ openTargetName: record.label,
+ href,
+ isHttp: true,
+ mobileOnly: false
+ })
+ }
+ return dedupeHandlers(handlers)
+ }
+
+ const walletOpen = record.walletOpen
+ if (walletOpen?.deferWalletAppsUntilBolt11) {
+ if (context?.bolt11Invoice) {
+ handlers.push(...handlersForBolt11Invoice(context.bolt11Invoice))
+ }
+ } else if (walletOpen?.walletApps?.length) {
+ handlers.push(...handlersForWalletAppIds(walletOpen.walletApps, canonical, auth, walletOpen))
+ }
+
+ if (record.profileUrlTemplate) {
+ handlers.push(handlerForProfileWeb(canonical, record.label, auth, record.profileUrlTemplate))
+ }
+
+ return dedupeHandlers(handlers)
+}
+
+export function isLikelyMobileWalletUserAgent(): boolean {
+ if (typeof navigator === 'undefined') return false
+ return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
+}
+
+export function filterPaytoPaymentOpenHandlersForDevice(
+ handlers: PaytoPaymentOpenHandler[]
+): PaytoPaymentOpenHandler[] {
+ if (isLikelyMobileWalletUserAgent()) return handlers
+ return handlers.filter((h) => !h.mobileOnly)
+}
+
+/** Open a resolved handler (new tab for https, assign for wallet schemes). */
+export function openPaytoPaymentTarget(handler: PaytoPaymentOpenHandler): void {
+ if (handler.isHttp) {
+ window.open(handler.href, '_blank', 'noopener,noreferrer')
+ return
+ }
+ window.location.assign(handler.href)
+}
diff --git a/src/lib/payto-wallet-open.test.ts b/src/lib/payto-wallet-open.test.ts
deleted file mode 100644
index 64e328a7..00000000
--- a/src/lib/payto-wallet-open.test.ts
+++ /dev/null
@@ -1,205 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { getPaytoProfileUrl } from '@/lib/payto-registry'
-import {
- buildBlueWalletWalletHref,
- buildPhoenixWalletHref,
- buildZeusWalletHref,
- filterPaytoPaymentOpenHandlersForDevice,
- filterWalletOpenActionsForDevice,
- getLightningInvoiceWalletPaymentHandlers,
- getPaytoPaymentOpenHandlers,
- getPaytoPrimaryOpenUrl,
- getPaytoWalletOpenActions,
- isPaytoHttpOpenUrl
-} from './payto-wallet-open'
-
-describe('getPaytoPrimaryOpenUrl', () => {
- it('builds monero: URI for primary address', () => {
- const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- expect(getPaytoPrimaryOpenUrl('monero', addr)).toBe(`monero:${addr}`)
- expect(getPaytoProfileUrl('monero', addr)).toBe(`monero:${addr}`)
- })
-
- it('builds bitcoin: URI', () => {
- expect(getPaytoPrimaryOpenUrl('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')).toBe(
- 'bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
- )
- })
-
- it('maps BIP-353 human-readable name to lightning: URI', () => {
- expect(getPaytoPrimaryOpenUrl('bip353', 'user@example.com')).toBe('lightning:user@example.com')
- })
-
- it('maps BIP-352 silent payment to bitcoin: URI', () => {
- const sp = 'sp1qxyz'
- expect(getPaytoPrimaryOpenUrl('bip352', sp)).toBe(`bitcoin:${sp}`)
- })
-
- it('requires lno1 prefix for bolt12', () => {
- expect(getPaytoPrimaryOpenUrl('bolt12', 'lno1offer')).toBe('bolt12:lno1offer')
- expect(getPaytoPrimaryOpenUrl('bolt12', 'bc1qinvalid')).toBeNull()
- })
-})
-
-describe('getPaytoWalletOpenActions', () => {
- it('includes Cake Wallet deep link for monero', () => {
- const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- const actions = getPaytoWalletOpenActions('monero', addr)
- expect(actions).toHaveLength(1)
- expect(actions[0].label).toBe('Cake Wallet')
- expect(actions[0].href).toBe(`cakewallet:monero?address=${addr}`)
- expect(actions[0].mobileOnly).toBe(true)
- })
-
- it('hides mobile-only actions on desktop UA', () => {
- const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- const actions = getPaytoWalletOpenActions('monero', addr)
- const prev = navigator.userAgent
- Object.defineProperty(navigator, 'userAgent', {
- value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
- configurable: true
- })
- try {
- expect(filterWalletOpenActionsForDevice(actions)).toHaveLength(0)
- } finally {
- Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
- }
- })
-})
-
-describe('getPaytoPaymentOpenHandlers', () => {
- it('lists named apps only, not native coin schemes', () => {
- const addr = '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- const monero = getPaytoPaymentOpenHandlers('monero', addr)
- expect(monero.some((h) => h.openTargetName === 'Cake Wallet')).toBe(true)
- expect(monero.some((h) => h.href.startsWith('monero:'))).toBe(false)
-
- const btc = getPaytoWalletOpenActions('bitcoin', 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh')
- expect(btc[0]?.href).toBe(
- 'cakewallet:bitcoin?address=bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh'
- )
-
- const sp = getPaytoWalletOpenActions('bip352', 'sp1qxyz0123456789')
- expect(sp[0]?.href).toBe('cakewallet:bitcoin?address=sp1qxyz0123456789')
-
- const cash = getPaytoPaymentOpenHandlers('cashme', '$cashtag')
- expect(cash).toHaveLength(1)
- expect(cash[0].isHttp).toBe(true)
- expect(cash[0].openTargetName).toBe('Cash App')
- expect(cash[0].href).toBe('https://cash.app/%24cashtag')
- })
-
- it('builds Phoenix and Zeus bolt12 deep links from offer string', () => {
- const offer = 'lno1pg257enxv4ezqcneype82um50ynhxgrwdajx283qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36ryg488qwlrnzyjczs'
- const actions = getPaytoWalletOpenActions('bolt12', offer)
- expect(actions).toHaveLength(2)
- expect(actions.map((a) => a.label).sort()).toEqual(['Phoenix', 'Zeus'])
- expect(actions.find((a) => a.label === 'Phoenix')?.href).toBe(`phoenix:bolt12:${offer}`)
- expect(actions.find((a) => a.label === 'Zeus')?.href).toBe(`zeusln:${offer}`)
- })
-
- it('builds Phoenix lightning deep link without pay?uri query', () => {
- expect(buildPhoenixWalletHref('lightning', 'user@example.com')).toBe(
- 'phoenix:lightning:user@example.com'
- )
- expect(buildPhoenixWalletHref('lightning', 'lnbc1p0example')).toBe('phoenix:lightning:lnbc1p0example')
- expect(buildPhoenixWalletHref('lightning', 'lightning:lnbc1p0example')).toBe(
- 'phoenix:lightning:lnbc1p0example'
- )
- })
-
- it('omits mobile lightning wallets for lightning address until BOLT11 is supplied separately', () => {
- const handlers = getPaytoPaymentOpenHandlers('lightning', 'user@example.com')
- expect(handlers.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
- expect(handlers.some((h) => h.openTargetName === 'Zeus')).toBe(false)
- expect(handlers.some((h) => h.openTargetName === 'BlueWallet')).toBe(false)
- })
-
- it('includes Phoenix and Zeus on mobile only for bip353', () => {
- const handlers = getPaytoPaymentOpenHandlers('bip353', 'user@example.com')
- const phoenix = handlers.find((h) => h.openTargetName === 'Phoenix')
- const zeus = handlers.find((h) => h.openTargetName === 'Zeus')
- expect(phoenix?.href).toBe('phoenix:lightning:user@example.com')
- expect(zeus?.href).toBe('zeusln:lightning:user@example.com')
- expect(phoenix?.mobileOnly).toBe(true)
- expect(zeus?.mobileOnly).toBe(true)
-
- const prev = navigator.userAgent
- Object.defineProperty(navigator, 'userAgent', {
- value: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0',
- configurable: true
- })
- try {
- expect(
- filterPaytoPaymentOpenHandlersForDevice(handlers).some((h) => h.openTargetName === 'Phoenix')
- ).toBe(false)
- } finally {
- Object.defineProperty(navigator, 'userAgent', { value: prev, configurable: true })
- }
- })
-
- it('builds Zeus and BlueWallet BOLT11 deep links', () => {
- expect(buildZeusWalletHref('lightning', 'lnbc1p0example')).toBe('zeusln:lightning:lnbc1p0example')
- expect(buildZeusWalletHref('lightning', 'lightning:lnbc1p0example')).toBe(
- 'zeusln:lightning:lnbc1p0example'
- )
- expect(buildBlueWalletWalletHref('lnbc1p0example')).toBe('bluewallet:lightning:lnbc1p0example')
- expect(buildBlueWalletWalletHref('lightning:lnbc1p0example')).toBe(
- 'bluewallet:lightning:lnbc1p0example'
- )
- })
-
- it('lists Phoenix, Zeus, and BlueWallet for a resolved BOLT11 invoice', () => {
- const invoice = 'lnbc1p0example'
- const handlers = getLightningInvoiceWalletPaymentHandlers(invoice)
- expect(handlers.map((h) => h.openTargetName).sort()).toEqual(['BlueWallet', 'Phoenix', 'Zeus'])
- expect(handlers.find((h) => h.openTargetName === 'Phoenix')?.href).toBe(
- `phoenix:lightning:${invoice}`
- )
- expect(handlers.find((h) => h.openTargetName === 'Zeus')?.href).toBe(`zeusln:lightning:${invoice}`)
- expect(handlers.find((h) => h.openTargetName === 'BlueWallet')?.href).toBe(
- `bluewallet:lightning:${invoice}`
- )
- })
-
- it('scopes lightning BOLT11 handlers to lightning payto only', () => {
- const invoice = 'lnbc1p0example'
- const lightning = getPaytoPaymentOpenHandlers('lightning', 'user@example.com', {
- bolt11Invoice: invoice
- })
- expect(lightning.map((h) => h.openTargetName).sort()).toEqual(['BlueWallet', 'Phoenix', 'Zeus'])
- expect(lightning.some((h) => h.openTargetName === 'PayPal')).toBe(false)
-
- const moneroAddr =
- '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- const moneroWithBolt11 = getPaytoPaymentOpenHandlers('monero', moneroAddr, { bolt11Invoice: invoice })
- expect(moneroWithBolt11.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
- expect(moneroWithBolt11.some((h) => h.openTargetName === 'Zeus')).toBe(false)
- expect(moneroWithBolt11.some((h) => h.openTargetName === 'BlueWallet')).toBe(false)
- expect(
- getPaytoPaymentOpenHandlers('paypal', 'someuser', { bolt11Invoice: invoice }).map((h) => h.openTargetName)
- ).toEqual(['PayPal'])
- })
-
- it('never mixes fiat web links or lightning wallets across payto types', () => {
- const monero = getPaytoPaymentOpenHandlers(
- 'monero',
- '4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgvYatVKtQNjUoNcknXV85jSp3wjUGpHbWfnqPm4WjwFGtW'
- )
- expect(monero.some((h) => h.openTargetName === 'PayPal')).toBe(false)
- expect(monero.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
- expect(monero.some((h) => h.openTargetName === 'Cake Wallet')).toBe(true)
-
- const cash = getPaytoPaymentOpenHandlers('cashme', '$cashtag')
- expect(cash).toHaveLength(1)
- expect(cash[0].openTargetName).toBe('Cash App')
- expect(cash.some((h) => h.openTargetName === 'Phoenix')).toBe(false)
- })
-})
-
-describe('isPaytoHttpOpenUrl', () => {
- it('distinguishes https profile links from wallet schemes', () => {
- expect(isPaytoHttpOpenUrl('https://paypal.me/foo')).toBe(true)
- expect(isPaytoHttpOpenUrl('monero:4abc')).toBe(false)
- })
-})
diff --git a/src/lib/payto-wallet-open.ts b/src/lib/payto-wallet-open.ts
deleted file mode 100644
index 87ddfce7..00000000
--- a/src/lib/payto-wallet-open.ts
+++ /dev/null
@@ -1,419 +0,0 @@
-/**
- * Wallet deep links and “open in app” targets driven by {@link ../data/payto-types.json}.
- */
-
-import paytoTypesCatalog from '@/data/payto-types.json'
-import type { PaytoWalletOpenRow } from '@/lib/payto-registry'
-import { resolvePaypalPaymentUrl } from '@/lib/payto-paypal-url'
-
-type PaytoBolt11InvoiceOpenConfig = {
- paytoType?: string
- coinScheme?: string
- walletApps: string[]
-}
-
-type PaytoTypeRecordWallet = {
- label?: string
- profileUrlTemplate?: string
- paymentOpen?: 'paypal'
- walletOpen?: PaytoWalletOpenRow
-}
-
-type PaytoWalletCatalogOpenWith = {
- bolt11Invoice?: PaytoBolt11InvoiceOpenConfig
-}
-
-/** Labeled “open in …” action shown inside {@link PaytoDialog}. */
-export type PaytoPaymentOpenHandler = {
- id: string
- /** App or service name for i18n: “Open in {{name}}”. */
- openTargetName: string
- href: string
- isHttp: boolean
- mobileOnly?: boolean
-}
-
-export type PaytoWalletOpenAction = {
- id: string
- label: string
- href: string
- /** Prefer showing on phones/tablets (e.g. Cake Wallet app scheme). */
- mobileOnly?: boolean
-}
-
-type WalletAppRowJson = {
- label: string
- mobileOnly?: boolean
- /** `cakewallet:{coinScheme}?address={authority}` — `{coinScheme}` from type's walletOpen.scheme or type id */
- uriTemplate: string
-}
-
-type PaytoWalletCatalogJson = {
- _openWith?: PaytoWalletCatalogOpenWith
- aliases?: Record
- types: Record
- walletApps?: Record
-}
-
-const walletCatalog = paytoTypesCatalog as PaytoWalletCatalogJson
-const PAYTO_ALIASES = walletCatalog.aliases ?? {}
-const PAYTO_TYPES = walletCatalog.types
-
-function getCanonicalPaytoType(type: string): string {
- const key = type.toLowerCase().trim()
- return PAYTO_ALIASES[key] ?? key
-}
-
-function getPaytoTypeRecord(type: string): PaytoTypeRecordWallet | undefined {
- return PAYTO_TYPES[getCanonicalPaytoType(type)]
-}
-
-function trimAuthority(authority: string): string {
- return authority.trim()
-}
-
-function substituteAuthority(template: string, authority: string): string {
- return template.split('{authority}').join(authority)
-}
-
-function buildQueryUri(scheme: string, path: string, query: Record, authority: string): string {
- const params = new URLSearchParams()
- for (const [key, raw] of Object.entries(query)) {
- params.set(key, substituteAuthority(raw, authority))
- }
- const base = path ? `${scheme}:${path}` : `${scheme}:`
- const qs = params.toString()
- return qs ? `${base}?${qs}` : base
-}
-
-function resolveWalletOpenRow(
- paytoType: string,
- authority: string,
- row: PaytoWalletOpenRow | undefined
-): string | null {
- if (!row) return null
- const auth = trimAuthority(authority)
- if (!auth) return null
- if (row.requireAtSign && !auth.includes('@')) return null
- if (row.requirePrefix) {
- const prefix = row.requirePrefix.toLowerCase()
- if (!auth.toLowerCase().startsWith(prefix)) return null
- }
- if (/^https?:\/\//i.test(auth)) return auth
-
- const scheme = (row.scheme ?? paytoType).toLowerCase()
- if (row.style === 'query' && row.query) {
- return buildQueryUri(scheme, row.path ?? '', row.query, auth)
- }
- const pathPart = row.path ? `${row.path}/` : ''
- return `${scheme}:${pathPart}${auth}`
-}
-
-/**
- * Phoenix strips a `phoenix:` prefix, then parses the remainder (e.g. `lightning:lnbc…`).
- * Do not use `phoenix:pay?uri=…` — the app does not treat that as a payment request.
- */
-export function buildPhoenixWalletHref(coinScheme: string, authority: string): string | null {
- const auth = trimAuthority(authority)
- if (!auth) return null
- const scheme = coinScheme.toLowerCase().trim()
- if (!scheme) return null
- const payload = auth.replace(/^lightning:/i, '')
- return `phoenix:${scheme}:${payload}`
-}
-
-/**
- * Zeus {@link https://github.com/ZeusLN/zeus/blob/master/utils/AddressUtils.ts processBIP21Uri}:
- * strips `zeusln:` then parses `lightning:` / bare `lno1` / `lnbc` payloads via handleAnything.
- */
-export function buildZeusWalletHref(coinScheme: string, authority: string): string | null {
- const auth = trimAuthority(authority)
- if (!auth) return null
- const scheme = coinScheme.toLowerCase().trim()
- if (!scheme) return null
- const payload = auth.replace(/^lightning:/i, '')
- if (scheme === 'bolt12' || /^lno1/i.test(payload)) {
- return `zeusln:${payload}`
- }
- return `zeusln:lightning:${payload}`
-}
-
-/**
- * BlueWallet wiki: wrap BOLT11 / lightning targets as `bluewallet:lightning:…`.
- * @see https://github.com/BlueWallet/BlueWallet/wiki/Deeplinking
- */
-export function buildBlueWalletWalletHref(authority: string): string | null {
- const auth = trimAuthority(authority)
- if (!auth) return null
- const payload = auth.replace(/^lightning:/i, '')
- if (!payload) return null
- return `bluewallet:lightning:${payload}`
-}
-
-const WALLET_APP_BUILDERS: Record<
- string,
- (coinScheme: string, authority: string) => string | null
-> = {
- phoenix: buildPhoenixWalletHref,
- zeus: buildZeusWalletHref,
- bluewallet: (_coinScheme, authority) => buildBlueWalletWalletHref(authority)
-}
-
-function walletAppMeta(appId: string): WalletAppRowJson | undefined {
- return walletCatalog.walletApps?.[appId]
-}
-
-function paymentOpenHandlerFromHref(
- appId: string,
- coinScheme: string,
- href: string
-): PaytoPaymentOpenHandler {
- const app = walletAppMeta(appId)
- return {
- id: `${appId}-${coinScheme}`,
- openTargetName: app?.label ?? appId,
- href,
- isHttp: false,
- mobileOnly: app?.mobileOnly !== false
- }
-}
-
-function resolveWalletAppUri(
- appId: string,
- paytoType: string,
- authority: string,
- row: PaytoWalletOpenRow | undefined
-): string | null {
- const app = walletCatalog.walletApps?.[appId]
- if (!app) return null
- const auth = trimAuthority(authority)
- if (!auth) return null
- const coinScheme = (row?.scheme ?? paytoType).toLowerCase()
- const customBuild = WALLET_APP_BUILDERS[appId]
- if (customBuild) {
- return customBuild(coinScheme, auth)
- }
- const href = substituteAuthority(
- app.uriTemplate.replace(/\{coinScheme\}/g, coinScheme),
- auth
- )
- return href
-}
-
-/** Mobile Phoenix deep link for a concrete payment target (BOLT11, offer, lightning address, …). */
-export function getPhoenixPaymentOpenHandler(
- coinScheme: string,
- authority: string
-): PaytoPaymentOpenHandler | null {
- const href = buildPhoenixWalletHref(coinScheme, authority)
- if (!href) return null
- return paymentOpenHandlerFromHref('phoenix', coinScheme, href)
-}
-
-export function getZeusPaymentOpenHandler(
- coinScheme: string,
- authority: string
-): PaytoPaymentOpenHandler | null {
- const href = buildZeusWalletHref(coinScheme, authority)
- if (!href) return null
- return paymentOpenHandlerFromHref('zeus', coinScheme, href)
-}
-
-/** BlueWallet BOLT11 handler (invoice must already be resolved). */
-export function getBlueWalletPaymentOpenHandler(
- coinScheme: string,
- authority: string
-): PaytoPaymentOpenHandler | null {
- const href = buildBlueWalletWalletHref(authority)
- if (!href) return null
- return paymentOpenHandlerFromHref('bluewallet', coinScheme, href)
-}
-
-function getBolt11InvoiceOpenConfig(): PaytoBolt11InvoiceOpenConfig | undefined {
- const cfg = walletCatalog._openWith?.bolt11Invoice
- if (!cfg?.walletApps?.length) return undefined
- return cfg
-}
-
-/** Build “Open with” handlers from catalog `_openWith.bolt11Invoice.walletApps`. */
-function getBolt11InvoiceOpenHandlers(invoice: string): PaytoPaymentOpenHandler[] {
- const cfg = getBolt11InvoiceOpenConfig()
- if (!cfg) return []
-
- const paytoType = getCanonicalPaytoType(cfg.paytoType ?? 'lightning')
- const row = getPaytoTypeRecord(paytoType)?.walletOpen
- const auth = trimAuthority(invoice)
- if (!auth) return []
-
- const handlers: PaytoPaymentOpenHandler[] = []
- const seen = new Set()
- for (const appId of cfg.walletApps) {
- const app = walletCatalog.walletApps?.[appId]
- const href = resolveWalletAppUri(appId, paytoType, auth, row)
- if (!app || !href || seen.has(href)) continue
- seen.add(href)
- handlers.push({
- id: `${paytoType}-${appId}-bolt11`,
- openTargetName: app.label,
- href,
- isHttp: false,
- mobileOnly: app.mobileOnly !== false
- })
- }
- return handlers
-}
-
-/** Handlers for a resolved BOLT11 shown after LNURL invoice creation (from {@link payto-types.json}). */
-export function getLightningInvoiceWalletPaymentHandlers(
- authority: string
-): PaytoPaymentOpenHandler[] {
- return getBolt11InvoiceOpenHandlers(authority)
-}
-
-export type PaytoPaymentOpenContext = {
- /** LUD-16 lightning address resolved to BOLT11 in {@link PaytoDialog}. */
- bolt11Invoice?: string | null
-}
-
-function walletActionToOpenHandler(action: PaytoWalletOpenAction): PaytoPaymentOpenHandler {
- return {
- id: action.id,
- openTargetName: action.label,
- href: action.href,
- isHttp: isPaytoHttpOpenUrl(action.href),
- mobileOnly: action.mobileOnly
- }
-}
-
-function dedupePaymentOpenHandlers(handlers: PaytoPaymentOpenHandler[]): PaytoPaymentOpenHandler[] {
- const seen = new Set()
- return handlers.filter((h) => {
- if (seen.has(h.href)) return false
- seen.add(h.href)
- return true
- })
-}
-
-/**
- * Primary browser/OS URL for this payto target (wallet URI or https).
- * Returns null when the type should use copy-only or zap (caller checks zappable lightning).
- */
-export function getPaytoPrimaryOpenUrl(type: string, authority: string): string | null {
- const canonical = getCanonicalPaytoType(type)
- const record = getPaytoTypeRecord(canonical)
- const auth = trimAuthority(authority)
- if (!auth || !record) return null
-
- const fromWallet = resolveWalletOpenRow(canonical, auth, record.walletOpen)
- if (fromWallet) return fromWallet
-
- const template = record.profileUrlTemplate
- if (template) {
- return substituteAuthority(template, encodeURIComponent(auth))
- }
-
- return null
-}
-
-/** Optional app-specific links (e.g. Cake Wallet on Android). */
-export function getPaytoWalletOpenActions(type: string, authority: string): PaytoWalletOpenAction[] {
- const canonical = getCanonicalPaytoType(type)
- const record = getPaytoTypeRecord(canonical)
- const row = record?.walletOpen
- if (!row?.walletApps?.length) return []
-
- const auth = trimAuthority(authority)
- if (!auth) return []
-
- const out: PaytoWalletOpenAction[] = []
- for (const appId of row.walletApps) {
- const app = walletCatalog.walletApps?.[appId]
- const href = resolveWalletAppUri(appId, canonical, auth, row)
- if (!app || !href) continue
- out.push({
- id: `${canonical}-${appId}`,
- label: app.label,
- href,
- /** App deep links are mobile-only unless catalog sets `mobileOnly: false`. */
- mobileOnly: app.mobileOnly !== false
- })
- }
- return out
-}
-
-export function isPaytoHttpOpenUrl(url: string | null | undefined): boolean {
- return !!url && /^https?:\/\//i.test(url)
-}
-
-export function isLikelyMobileWalletUserAgent(): boolean {
- if (typeof navigator === 'undefined') return false
- return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
-}
-
-export function filterWalletOpenActionsForDevice(
- actions: PaytoWalletOpenAction[]
-): PaytoWalletOpenAction[] {
- if (isLikelyMobileWalletUserAgent()) return actions
- return actions.filter((a) => !a.mobileOnly)
-}
-
-/**
- * “Open with” targets for {@link PaytoDialog} — scoped to the active payto type only.
- * Native coin schemes (monero:, bitcoin:, …) are omitted; users copy the payto URI instead.
- */
-export function getPaytoPaymentOpenHandlers(
- type: string,
- authority: string,
- context?: PaytoPaymentOpenContext
-): PaytoPaymentOpenHandler[] {
- const canonical = getCanonicalPaytoType(type)
- const record = getPaytoTypeRecord(canonical)
- const auth = trimAuthority(authority)
- if (!auth || !record) return []
-
- const handlers: PaytoPaymentOpenHandler[] = []
-
- if (record.paymentOpen === 'paypal') {
- const href = resolvePaypalPaymentUrl(auth)
- if (href) {
- handlers.push({
- id: 'paypal',
- openTargetName: record.label ?? 'PayPal',
- href,
- isHttp: true,
- mobileOnly: false
- })
- }
- return dedupePaymentOpenHandlers(handlers)
- }
-
- const walletRow = record.walletOpen
- if (walletRow?.deferWalletAppsUntilBolt11) {
- if (context?.bolt11Invoice) {
- handlers.push(...getBolt11InvoiceOpenHandlers(context.bolt11Invoice))
- }
- } else if (walletRow?.walletApps?.length) {
- for (const action of getPaytoWalletOpenActions(type, auth)) {
- handlers.push(walletActionToOpenHandler(action))
- }
- }
-
- if (record.profileUrlTemplate) {
- handlers.push({
- id: `${canonical}-web`,
- openTargetName: record.label ?? canonical,
- href: substituteAuthority(record.profileUrlTemplate, encodeURIComponent(auth)),
- isHttp: true,
- mobileOnly: false
- })
- }
-
- return dedupePaymentOpenHandlers(handlers)
-}
-
-export function filterPaytoPaymentOpenHandlersForDevice(
- handlers: PaytoPaymentOpenHandler[]
-): PaytoPaymentOpenHandler[] {
- if (isLikelyMobileWalletUserAgent()) return handlers
- return handlers.filter((h) => !h.mobileOnly)
-}
diff --git a/src/lib/payto.ts b/src/lib/payto.ts
index 2e2e9a55..9ba86f5e 100644
--- a/src/lib/payto.ts
+++ b/src/lib/payto.ts
@@ -1,6 +1,6 @@
/**
- * payto: URI handling (RFC-8905 / NIP-A3)
- * Type metadata lives in {@link ../data/payto-types.json} via {@link ./payto-registry}.
+ * payto: URI handling (RFC-8905 / NIP-A3) and payment targets.
+ * Type metadata: {@link ./payto-registry}. Open / wallet URIs: {@link ./payto-targets}.
*/
import { getCanonicalPaytoType } from '@/lib/payto-registry'
@@ -12,7 +12,6 @@ export {
getPaytoIconChar,
getPaytoLogoPath,
getPaytoLogoUrl,
- getPaytoProfileUrl,
getPaytoTypeInfo,
isKnownPaytoType,
isLightningPaytoType,
@@ -28,24 +27,15 @@ export {
} from '@/lib/payto-registry'
export {
- buildBlueWalletWalletHref,
- buildPhoenixWalletHref,
- buildZeusWalletHref,
- getBlueWalletPaymentOpenHandler,
- getLightningInvoiceWalletPaymentHandlers,
- getPhoenixPaymentOpenHandler,
- getZeusPaymentOpenHandler,
- getPaytoPrimaryOpenUrl,
- getPaytoPaymentOpenHandlers,
filterPaytoPaymentOpenHandlersForDevice,
- getPaytoWalletOpenActions,
- filterWalletOpenActionsForDevice,
+ openPaytoPaymentTarget,
+ resolvePaytoPaymentOpenHandlers,
+ resolvePaytoProfileUrl,
isPaytoHttpOpenUrl,
isLikelyMobileWalletUserAgent,
type PaytoPaymentOpenContext,
- type PaytoPaymentOpenHandler,
- type PaytoWalletOpenAction
-} from '@/lib/payto-wallet-open'
+ type PaytoPaymentOpenHandler
+} from '@/lib/payto-targets'
export const PAYTO_URI_REGEX = /payto:\/\/([a-z0-9-]+)\/([^\s\]\)\<\"']+)/gi
@@ -55,9 +45,6 @@ export interface ParsedPayto {
raw: string
}
-/**
- * Parse a payto URI into type and authority. Returns null if invalid.
- */
export function parsePaytoUri(uri: string): ParsedPayto | null {
const trimmed = uri.trim()
const m = /^payto:\/\/([a-z0-9-]+)\/(.+)$/i.exec(trimmed)
@@ -69,9 +56,6 @@ export function parsePaytoUri(uri: string): ParsedPayto | null {
return { type, authority, raw: trimmed }
}
-/**
- * Build payto URI from type and authority.
- */
export function buildPaytoUri(type: string, authority: string): string {
const t = type.toLowerCase().replace(/[^a-z0-9-]/g, '')
const a = encodeURIComponent(authority.trim())
@@ -90,6 +74,6 @@ export { extractKind0PaymentMethodsFromProfileJson, type Kind0ImportedPaymentMet
export {
extractAboutCoinPaymentMethods,
- parseAboutCoinLabelPaymentLines,
+ parseAboutContentWithCoinPayto,
type AboutCoinLineMatch
} from '@/lib/payto-about-coin-lines'