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'